feat: rewrite dashboard as Next.js pages
- Remove Go dashboard (replaced by Next.js) - Add API route /api/dashboard: Docker container list, Jaeger traces, Prometheus metrics, node resources - Add /dashboard page: React client component with inline SVG charts (donut, sparkline, gauge), auto-refresh every 15s - Same dark theme as original Go dashboard Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
91896b8fdf
commit
72f4759f8b
@@ -1,3 +0,0 @@
|
||||
module github.com/asepharyana/asepharyana-hub-hub/dashboard
|
||||
|
||||
go 1.24
|
||||
@@ -1,706 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var templateHTML string
|
||||
|
||||
var tmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"safeDur": func(us int64) string {
|
||||
if us < 1000 {
|
||||
return fmt.Sprintf("%dµs", us)
|
||||
} else if us < 1_000_000 {
|
||||
return fmt.Sprintf("%.1fms", float64(us)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.2fs", float64(us)/1_000_000)
|
||||
},
|
||||
}).Parse(templateHTML))
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type Service struct {
|
||||
Name string
|
||||
State string
|
||||
HasWeb bool // has traefik router label → eligible for quick link
|
||||
}
|
||||
|
||||
type Trace struct {
|
||||
Service string
|
||||
Operation string
|
||||
Duration int64
|
||||
Spans int
|
||||
HasError bool
|
||||
}
|
||||
|
||||
type NodeMetrics struct {
|
||||
CPU float64 // percent
|
||||
RAM float64 // percent
|
||||
Disk float64 // percent
|
||||
Load1 float64
|
||||
Load5 float64
|
||||
Load15 float64
|
||||
NetIn float64 // bytes/s
|
||||
NetOut float64 // bytes/s
|
||||
Online bool
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
Services []Service
|
||||
Running int
|
||||
Degraded int
|
||||
TraceCount int
|
||||
RPS []float64
|
||||
Latency []float64
|
||||
Errors []float64
|
||||
TraceVolume []float64
|
||||
TraceList []Trace
|
||||
Labels []string
|
||||
Node NodeMetrics
|
||||
Links []Link
|
||||
HasOTelData bool
|
||||
HasNodeData bool
|
||||
HealthSVG template.HTML
|
||||
RPSSVG template.HTML
|
||||
LatencySVG template.HTML
|
||||
ErrorSVG template.HTML
|
||||
TraceSVG template.HTML
|
||||
CPUSVG template.HTML
|
||||
RAMSVG template.HTML
|
||||
DiskSVG template.HTML
|
||||
SystemName string
|
||||
TotalUp int
|
||||
TotalDown int
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
URL string
|
||||
Label string
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
|
||||
type appState struct {
|
||||
mu sync.Mutex
|
||||
rps []float64
|
||||
latency []float64
|
||||
errs []float64
|
||||
traces []float64
|
||||
labels []string
|
||||
services []Service
|
||||
tracesL []Trace
|
||||
node NodeMetrics
|
||||
}
|
||||
|
||||
var state appState
|
||||
|
||||
const maxPts = 30
|
||||
|
||||
// ── HTTP clients ──
|
||||
|
||||
var dockerClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: func(_, _ string) (net.Conn, error) {
|
||||
return net.DialTimeout("unix", "/var/run/docker.sock", 5*time.Second)
|
||||
},
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
var composeProject string // auto-detected from Docker labels
|
||||
|
||||
func fetchJSON(url string, v interface{}) error {
|
||||
r, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// ── Docker service discovery ──
|
||||
|
||||
func fetchServices() []Service {
|
||||
r, err := dockerClient.Get("http://localhost/containers/json?all=true")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var raw []struct {
|
||||
Names []string `json:"Names"`
|
||||
State string `json:"State"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
NetworkSettings *struct {
|
||||
Networks map[string]any `json:"Networks"`
|
||||
} `json:"NetworkSettings"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&raw) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// auto-detect compose project name from first compose-managed container
|
||||
if composeProject == "" {
|
||||
for _, c := range raw {
|
||||
if p, ok := c.Labels["com.docker.compose.project"]; ok && p != "" {
|
||||
composeProject = p
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var svcs []Service
|
||||
for _, c := range raw {
|
||||
if c.NetworkSettings != nil {
|
||||
if _, ok := c.NetworkSettings.Networks["app-shared-net"]; ok {
|
||||
// only include containers from our compose project
|
||||
if c.Labels["com.docker.compose.project"] == composeProject {
|
||||
name := strings.TrimPrefix(c.Names[0], "/")
|
||||
// auto-detect web services by traefik label
|
||||
hasWeb := false
|
||||
for k := range c.Labels {
|
||||
if strings.HasPrefix(k, "traefik.http.routers.") && strings.HasSuffix(k, ".rule") {
|
||||
hasWeb = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// also flag containers that look like web services (named with trailing numbers or common patterns)
|
||||
// but don't hardcode names — trust only labels
|
||||
svcs = append(svcs, Service{Name: name, State: c.State, HasWeb: hasWeb})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return svcs
|
||||
}
|
||||
|
||||
// ── Jaeger ──
|
||||
|
||||
func jaegerServices() []string {
|
||||
var d struct{ Data []string }
|
||||
if fetchJSON("http://jaeger:16686/api/services", &d) != nil {
|
||||
return nil
|
||||
}
|
||||
return d.Data
|
||||
}
|
||||
|
||||
func jaegerTraces(service string) []Trace {
|
||||
now := time.Now().UnixMicro()
|
||||
start := now - 5*60*1_000_000
|
||||
u := fmt.Sprintf("http://jaeger:16686/api/traces?service=%s&start=%d&end=%d&limit=5&lookback=5m",
|
||||
url.QueryEscape(service), start, now)
|
||||
var d struct {
|
||||
Data []struct {
|
||||
Duration int64 `json:"duration"`
|
||||
Spans []struct {
|
||||
OperationName string `json:"operationName"`
|
||||
ProcessID string `json:"processID"`
|
||||
Tags []struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
} `json:"tags"`
|
||||
} `json:"spans"`
|
||||
Processes map[string]struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
} `json:"processes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if fetchJSON(u, &d) != nil {
|
||||
return nil
|
||||
}
|
||||
var tt []Trace
|
||||
for _, t := range d.Data {
|
||||
if len(t.Spans) == 0 {
|
||||
continue
|
||||
}
|
||||
s := t.Spans[0]
|
||||
svc := "unknown"
|
||||
if p, ok := t.Processes[s.ProcessID]; ok {
|
||||
svc = p.ServiceName
|
||||
}
|
||||
hasErr := false
|
||||
for _, sp := range t.Spans {
|
||||
for _, tag := range sp.Tags {
|
||||
if tag.Key == "error" && tag.Value == true {
|
||||
hasErr = true
|
||||
}
|
||||
}
|
||||
}
|
||||
tt = append(tt, Trace{Service: svc, Operation: s.OperationName, Duration: t.Duration, Spans: len(t.Spans), HasError: hasErr})
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
// ── Prometheus helpers ──
|
||||
|
||||
// promInstant returns a single float64 value from an instant query
|
||||
func promInstant(query string) (float64, bool) {
|
||||
u := fmt.Sprintf("http://prometheus:9090/api/v1/query?query=%s",
|
||||
url.QueryEscape(query))
|
||||
var d struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Value []any `json:"value"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if fetchJSON(u, &d) != nil || len(d.Data.Result) == 0 || len(d.Data.Result[0].Value) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
var f float64
|
||||
fmt.Sscanf(fmt.Sprint(d.Data.Result[0].Value[1]), "%f", &f)
|
||||
return f, true
|
||||
}
|
||||
|
||||
// promRange returns time-series values from a range query
|
||||
func promRange(query string) []float64 {
|
||||
now := time.Now().Unix()
|
||||
u := fmt.Sprintf("http://prometheus:9090/api/v1/query_range?query=%s&start=%d&end=%d&step=15",
|
||||
url.QueryEscape(query), now-300, now)
|
||||
var d struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Values [][]any `json:"values"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if fetchJSON(u, &d) != nil || len(d.Data.Result) == 0 {
|
||||
return nil
|
||||
}
|
||||
var vals []float64
|
||||
for _, v := range d.Data.Result[0].Values {
|
||||
if len(v) == 2 {
|
||||
var f float64
|
||||
fmt.Sscanf(fmt.Sprint(v[1]), "%f", &f)
|
||||
vals = append(vals, f)
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// ── Node metrics ──
|
||||
|
||||
func fetchNodeMetrics() NodeMetrics {
|
||||
n := NodeMetrics{}
|
||||
|
||||
if v, ok := promInstant(`100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)`); ok {
|
||||
n.CPU = v
|
||||
}
|
||||
if v, ok := promInstant(`(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100`); ok {
|
||||
n.RAM = v
|
||||
}
|
||||
if v, ok := promInstant(`(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100`); ok {
|
||||
n.Disk = v
|
||||
}
|
||||
if v, ok := promInstant(`node_load1`); ok {
|
||||
n.Load1 = v
|
||||
}
|
||||
if v, ok := promInstant(`node_load5`); ok {
|
||||
n.Load5 = v
|
||||
}
|
||||
if v, ok := promInstant(`node_load15`); ok {
|
||||
n.Load15 = v
|
||||
}
|
||||
if vIn, okIn := promInstant(`rate(node_network_receive_bytes_total{device!="lo"}[1m])`); okIn {
|
||||
n.NetIn = vIn
|
||||
}
|
||||
if vOut, okOut := promInstant(`rate(node_network_transmit_bytes_total{device!="lo"}[1m])`); okOut {
|
||||
n.NetOut = vOut
|
||||
}
|
||||
|
||||
// node is online if at least CPU or RAM returned data
|
||||
n.Online = n.CPU > 0 || n.RAM > 0
|
||||
return n
|
||||
}
|
||||
|
||||
// ── SVG Charts ──
|
||||
|
||||
func svgDonut(running, degraded int) string {
|
||||
total := running + degraded
|
||||
if total == 0 {
|
||||
return noDataSVG(200, 210)
|
||||
}
|
||||
const cx, cy, R = 100, 90, 60
|
||||
const circ = 2 * math.Pi * R
|
||||
type seg struct{ n int; c, l string }
|
||||
segs := []seg{{running, "#3fb950", "Running"}, {degraded, "#d29922", "Degraded"}}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<svg width="200" height="210" viewBox="0 0 200 210" xmlns="http://www.w3.org/2000/svg">`)
|
||||
b.WriteString(`<style>.sl{font-family:system-ui,sans-serif;font-size:10px;fill:#8b949e}</style>`)
|
||||
|
||||
var off float64
|
||||
for _, s := range segs {
|
||||
if s.n == 0 {
|
||||
continue
|
||||
}
|
||||
frac := float64(s.n) / float64(total)
|
||||
ln := frac * circ
|
||||
b.WriteString(fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="none" stroke="%s" stroke-width="14" stroke-dasharray="%.1f %.1f" stroke-dashoffset="%.1f" transform="rotate(-90 %d %d)"/>`,
|
||||
cx, cy, R, s.c, ln, circ-ln, -off, cx, cy))
|
||||
off += ln
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700" font-family="system-ui,sans-serif">%d</text>`, cx, cy-4, total))
|
||||
b.WriteString(fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" class="sl">total</text>`, cx, cy+14))
|
||||
|
||||
y := 165
|
||||
for _, s := range segs {
|
||||
if s.n == 0 {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, `<circle cx="16" cy="%d" r="4" fill="%s"/><text x="26" y="%d" class="sl">%s: %d</text>`, y, s.c, y+3, s.l, s.n)
|
||||
y += 16
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func svgLine(data []float64, color string) string {
|
||||
w, h := 300.0, 160.0
|
||||
pl, pt, pr, pb := 45.0, 20.0, 10.0, 25.0
|
||||
vw := w - pl - pr
|
||||
vh := h - pt - pb
|
||||
|
||||
if len(data) == 0 {
|
||||
return noDataSVG(int(w), int(h))
|
||||
}
|
||||
|
||||
maxV := 0.0
|
||||
for _, v := range data {
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
}
|
||||
if maxV <= 0 {
|
||||
maxV = 1
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg">`, w, h, w, h)
|
||||
b.WriteString(`<style>.ax{font-family:system-ui,sans-serif;font-size:9px;fill:#6e7681}</style>`)
|
||||
|
||||
for i := 0; i <= 4; i++ {
|
||||
y := pt + vh*float64(i)/4
|
||||
val := maxV * (1 - float64(i)/4)
|
||||
fmt.Fprintf(&b, `<line x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#21262d" stroke-width="1"/>`, pl, y, pl+vw, y)
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="end" class="ax">%.0f</text>`, pl-6, y+3, val)
|
||||
}
|
||||
|
||||
n := len(data)
|
||||
if n > 1 {
|
||||
pts := make([]string, n)
|
||||
for i, v := range data {
|
||||
x := pl + vw*float64(i)/float64(n-1)
|
||||
y := pt + vh*(1-v/maxV)
|
||||
pts[i] = fmt.Sprintf("%.1f,%.1f", x, y)
|
||||
}
|
||||
area := fmt.Sprintf("M%.1f,%.1f L%s L%.1f,%.1f Z", pl, pt+vh, strings.Join(pts, " L"), pl+vw, pt+vh)
|
||||
fmt.Fprintf(&b, `<path d="%s" fill="%s" opacity="0.15"/>`, area, color)
|
||||
fmt.Fprintf(&b, `<polyline points="%s" fill="none" stroke="%s" stroke-width="2" stroke-linejoin="round"/>`,
|
||||
strings.Join(pts, " "), color)
|
||||
|
||||
lv := data[n-1]
|
||||
ly := pt + vh*(1-lv/maxV)
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="end" fill="%s" font-size="11" font-weight="600" font-family="system-ui,sans-serif">%.1f</text>`,
|
||||
pl+vw, ly-10, color, lv)
|
||||
}
|
||||
|
||||
step := n / 5
|
||||
if step < 1 {
|
||||
step = 1
|
||||
}
|
||||
for i := step; i < n; i += step {
|
||||
x := pl + vw*float64(i)/float64(n-1)
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="middle" class="ax">%d</text>`, x, pt+vh+16, i+1)
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func svgGauge(pct float64, color, label, unit string) string {
|
||||
if pct <= 0 {
|
||||
return noDataSVG(220, 100)
|
||||
}
|
||||
w, h := 220.0, 100.0
|
||||
bw, bh := 200.0, 14.0
|
||||
bx, by := (w-bw)/2, 30.0
|
||||
fw := bw * math.Min(pct/100, 1)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, `<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg">`, w, h, w, h)
|
||||
b.WriteString(`<style>.gt{font-family:system-ui,sans-serif;font-size:11px;fill:#8b949e;text-anchor:middle}</style>`)
|
||||
|
||||
// background bar
|
||||
fmt.Fprintf(&b, `<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="7" ry="7" fill="#1c2333"/>`, bx, by, bw, bh)
|
||||
// fill bar
|
||||
if fw > 0 {
|
||||
fmt.Fprintf(&b, `<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="7" ry="7" fill="%s" opacity="0.85"/>`, bx, by, fw, bh, color)
|
||||
}
|
||||
// label
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="14" class="gt" font-weight="600" fill="%s">%s</text>`, w/2, color, label)
|
||||
// value
|
||||
fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" class="gt" fill="#e6edf3" font-size="14" font-weight="700">%.1f%s</text>`, w/2, by+bh+24, pct, unit)
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func noDataSVG(w, h int) string {
|
||||
return fmt.Sprintf(`<svg width="%d" height="%d" viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg"><text x="%d" y="%d" text-anchor="middle" fill="#6e7681" font-size="12" font-family="system-ui,sans-serif">No data</text></svg>`,
|
||||
w, h, w, h, w/2, h/2)
|
||||
}
|
||||
|
||||
// ── Refresh ──
|
||||
|
||||
func refresh() {
|
||||
svcs := fetchServices()
|
||||
|
||||
// Merge Jaeger-discovered services
|
||||
jaegerS := jaegerServices()
|
||||
for _, s := range jaegerS {
|
||||
found := false
|
||||
for _, sv := range svcs {
|
||||
if sv.Name == s {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
svcs = append(svcs, Service{Name: s, State: "jaeger"})
|
||||
}
|
||||
}
|
||||
sort.Slice(svcs, func(i, j int) bool {
|
||||
o := map[string]int{"running": 0, "jaeger": 1, "exited": 2, "restarting": 3}
|
||||
oi, oj := o[svcs[i].State], o[svcs[j].State]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return svcs[i].Name < svcs[j].Name
|
||||
})
|
||||
|
||||
var traces []Trace
|
||||
for _, s := range svcs {
|
||||
if s.State == "running" {
|
||||
t := jaegerTraces(s.Name)
|
||||
traces = append(traces, t...)
|
||||
}
|
||||
}
|
||||
sort.Slice(traces, func(i, j int) bool { return traces[i].Duration > traces[j].Duration })
|
||||
if len(traces) > 20 {
|
||||
traces = traces[:20]
|
||||
}
|
||||
|
||||
// Prometheus OTel metrics
|
||||
rps := promRange("rate(otelcol_receiver_accepted_spans[1m])")
|
||||
lat := promRange("otelcol_receiver_accepted_spans")
|
||||
err := promRange("rate(otelcol_receiver_refused_spans[1m])")
|
||||
|
||||
// Node metrics
|
||||
node := fetchNodeMetrics()
|
||||
|
||||
state.mu.Lock()
|
||||
state.services = svcs
|
||||
state.tracesL = traces
|
||||
state.node = node
|
||||
|
||||
now := time.Now().Format("15:04:05")
|
||||
state.labels = append(state.labels, now)
|
||||
if len(state.labels) > maxPts {
|
||||
state.labels = state.labels[len(state.labels)-maxPts:]
|
||||
}
|
||||
|
||||
add := func(dst *[]float64, src []float64) {
|
||||
v := 0.0
|
||||
if len(src) > 0 {
|
||||
v = src[len(src)-1]
|
||||
}
|
||||
*dst = append(*dst, v)
|
||||
if len(*dst) > maxPts {
|
||||
*dst = (*dst)[len(*dst)-maxPts:]
|
||||
}
|
||||
}
|
||||
add(&state.rps, rps)
|
||||
add(&state.latency, lat)
|
||||
add(&state.errs, err)
|
||||
state.traces = append(state.traces, float64(len(traces)))
|
||||
if len(state.traces) > maxPts {
|
||||
state.traces = state.traces[len(state.traces)-maxPts:]
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
// autoDetectLinks generates links for all services with traefik labels
|
||||
func autoDetectLinks(svcs []Service) []Link {
|
||||
var links []Link
|
||||
links = append(links, Link{"/jaeger", "Jaeger UI"})
|
||||
|
||||
hasProm := false
|
||||
for _, s := range svcs {
|
||||
if s.Name == "prometheus" {
|
||||
hasProm = true
|
||||
}
|
||||
}
|
||||
if hasProm {
|
||||
links = append(links, Link{"/api/prometheus/targets", "Prometheus"})
|
||||
}
|
||||
links = append(links, Link{"https://github.com/asepharyana/asepharyana-hub", "GitHub"})
|
||||
|
||||
// auto-generate links for services with traefik labels
|
||||
domains := []string{"asepharyana.my.id", "asepharyana.web.id"}
|
||||
for _, s := range svcs {
|
||||
if s.HasWeb && s.State == "running" {
|
||||
for _, d := range domains {
|
||||
links = append(links, Link{fmt.Sprintf("https://%s.%s", s.Name, d), s.Name})
|
||||
break // one link per service
|
||||
}
|
||||
}
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
// ── Dashboard Handler ──
|
||||
|
||||
func dashboard(w http.ResponseWriter, _ *http.Request) {
|
||||
state.mu.Lock()
|
||||
svcs := append([]Service{}, state.services...)
|
||||
tr := append([]Trace{}, state.tracesL...)
|
||||
rps := append([]float64{}, state.rps...)
|
||||
lat := append([]float64{}, state.latency...)
|
||||
ers := append([]float64{}, state.errs...)
|
||||
trc := append([]float64{}, state.traces...)
|
||||
labels := append([]string{}, state.labels...)
|
||||
node := state.node
|
||||
state.mu.Unlock()
|
||||
|
||||
running, degraded := 0, 0
|
||||
for _, s := range svcs {
|
||||
if s.State == "running" {
|
||||
running++
|
||||
} else {
|
||||
degraded++
|
||||
}
|
||||
}
|
||||
|
||||
cpuColor, ramColor, diskColor := "#3fb950", "#3fb950", "#3fb950"
|
||||
if node.CPU > 80 {
|
||||
cpuColor = "#f85149"
|
||||
} else if node.CPU > 60 {
|
||||
cpuColor = "#d29922"
|
||||
}
|
||||
if node.RAM > 80 {
|
||||
ramColor = "#f85149"
|
||||
} else if node.RAM > 60 {
|
||||
ramColor = "#d29922"
|
||||
}
|
||||
if node.Disk > 80 {
|
||||
diskColor = "#f85149"
|
||||
} else if node.Disk > 60 {
|
||||
diskColor = "#d29922"
|
||||
}
|
||||
|
||||
data := DashboardData{
|
||||
Services: svcs, Running: running, Degraded: degraded,
|
||||
TraceCount: len(tr), RPS: rps, Latency: lat, Errors: ers,
|
||||
TraceVolume: trc, TraceList: tr, Labels: labels,
|
||||
SystemName: composeProject, TotalUp: running, TotalDown: degraded,
|
||||
Links: autoDetectLinks(svcs),
|
||||
HasOTelData: len(rps) > 0 || len(lat) > 0, // actual OTel collector metrics
|
||||
HasNodeData: node.Online,
|
||||
Node: node,
|
||||
HealthSVG: template.HTML(svgDonut(running, degraded)),
|
||||
RPSSVG: template.HTML(svgLine(rps, "#58a6ff")),
|
||||
LatencySVG: template.HTML(svgLine(lat, "#bc8cff")),
|
||||
ErrorSVG: template.HTML(svgLine(ers, "#f85149")),
|
||||
TraceSVG: template.HTML(svgLine(trc, "#3fb950")),
|
||||
CPUSVG: template.HTML(svgGauge(node.CPU, cpuColor, "CPU Usage", "%")),
|
||||
RAMSVG: template.HTML(svgGauge(node.RAM, ramColor, "Memory Usage", "%")),
|
||||
DiskSVG: template.HTML(svgGauge(node.Disk, diskColor, "Disk Usage", "%")),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Proxy ──
|
||||
|
||||
func proxy(target string) http.Handler {
|
||||
u, _ := url.Parse(target)
|
||||
return httputil.NewSingleHostReverseProxy(u)
|
||||
}
|
||||
|
||||
func dockerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/docker/containers/json", "/api/docker/version":
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api/docker")
|
||||
resp, err := dockerClient.Get("http://localhost" + r.URL.String() + "?" + r.URL.RawQuery)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
default:
|
||||
http.Error(w, "Forbidden", 403)
|
||||
}
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
resp, err := http.Get("http://otel-collector:13133/")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
refresh()
|
||||
go func() {
|
||||
for range time.Tick(15 * time.Second) {
|
||||
refresh()
|
||||
}
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", dashboard)
|
||||
mux.Handle("/api/jaeger/", http.StripPrefix("/api/jaeger", proxy("http://jaeger:16686/")))
|
||||
mux.Handle("/api/prometheus/", http.StripPrefix("/api/prometheus", proxy("http://prometheus:9090/")))
|
||||
mux.HandleFunc("/api/health", healthHandler)
|
||||
mux.HandleFunc("/api/docker/", dockerHandler)
|
||||
mux.Handle("/jaeger/", http.StripPrefix("/jaeger", proxy("http://jaeger:16686/")))
|
||||
|
||||
log.Printf("Dashboard listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, mux))
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hub Dashboard</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0d1117;color:#e6edf3;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
|
||||
/* ── Header ── */
|
||||
.hdr{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;border-bottom:1px solid #30363d;background:rgba(22,27,34,.92);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);position:sticky;top:0;z-index:100;gap:8px}
|
||||
.hl{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.hl .l{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,#58a6ff,#bc8cff);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:12px;color:#fff;flex-shrink:0}
|
||||
.hl h1{font-size:16px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.hl .s{color:#8b949e;font-size:11px;display:none}
|
||||
.hr{display:flex;align-items:center;gap:10px;flex-shrink:0}
|
||||
.hb{display:flex;align-items:center;gap:5px;font-size:11px;padding:4px 12px;border-radius:20px;font-weight:500;white-space:nowrap}
|
||||
.hb.g{background:rgba(63,185,80,.12);color:#3fb950}
|
||||
.hb.y{background:rgba(210,153,34,.12);color:#d29922}
|
||||
.hb.r{background:rgba(248,81,73,.12);color:#f85149}
|
||||
.hb .d{width:7px;height:7px;border-radius:50%}
|
||||
.hb.g .d{background:#3fb950}.hb.y .d{background:#d29922}.hb.r .d{background:#f85149}
|
||||
.up{color:#6e7681;font-size:10px;white-space:nowrap}
|
||||
|
||||
/* ── Container ── */
|
||||
.c{max-width:1440px;margin:0 auto;padding:12px 16px}
|
||||
|
||||
/* ── Grid — fully responsive ── */
|
||||
.g{display:grid;gap:10px}
|
||||
@media(min-width:481px){.g{gap:12px;padding:0 4px}}
|
||||
@media(min-width:769px){.g{grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px;padding:0 8px}}
|
||||
|
||||
/* ── Cards ── */
|
||||
.cd{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:14px;transition:border-color .2s,box-shadow .2s}
|
||||
.cd:hover{border-color:#3d444d;box-shadow:0 0 0 1px transparent,0 1px 3px rgba(0,0,0,.3)}
|
||||
@media(max-width:480px){.cd{padding:12px;border-radius:6px}}
|
||||
|
||||
.ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:8px}
|
||||
.ch h2{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ch .b{font-size:9px;background:#1c2333;padding:2px 8px;border-radius:8px;color:#8b949e;white-space:nowrap;flex-shrink:0;line-height:1.6}
|
||||
.cg{display:flex;justify-content:center;align-items:center;width:100%}
|
||||
.cg svg{max-width:100%;height:auto}
|
||||
|
||||
/* ── Service tiles ── */
|
||||
.sg{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.st{display:inline-flex;align-items:center;gap:5px;padding:5px 8px;border:1px solid #30363d;border-radius:5px;font-size:11px;transition:all .15s;cursor:default}
|
||||
.st:hover{background:#1c2333;border-color:#3d444d}
|
||||
.st .i{width:6px;height:6px;border-radius:50%;flex-shrink:0}
|
||||
.st .i.g{background:#3fb950}.st .i.r{background:#f85149}.st .i.y{background:#d29922}.st .i.b{background:#58a6ff}
|
||||
.st .n{font-family:'SF Mono','Fira Code',Consolas,monospace;font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* ── Stat boxes ── */
|
||||
.sg2{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.sb{background:#1c2333;border-radius:6px;padding:10px 6px;text-align:center}
|
||||
.sv{font-size:20px;font-weight:700;font-family:'SF Mono','Fira Code',monospace;line-height:1.2}
|
||||
.sl2{font-size:9px;color:#8b949e;margin-top:2px;text-transform:uppercase;letter-spacing:.04em}
|
||||
.sv.a{color:#58a6ff}.sv.g{color:#3fb950}.sv.r{color:#f85149}.sv.y{color:#d29922}
|
||||
|
||||
/* ── System resource gauges ── */
|
||||
.sg3{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:6px}
|
||||
.ns{font-size:9px;color:#6e7681;text-align:center;margin-top:2px;padding-top:4px;border-top:1px solid #21262d}
|
||||
|
||||
/* ── Links ── */
|
||||
.lk{display:flex;flex-wrap:wrap;gap:4px}
|
||||
.lk a{color:#58a6ff;text-decoration:none;font-size:10px;padding:4px 8px;border:1px solid #30363d;border-radius:5px;transition:all .15s;line-height:1.4}
|
||||
.lk a:hover{background:#1c2333;border-color:#58a6ff}
|
||||
@media(max-width:480px){.lk a{padding:6px 10px;font-size:11px}}
|
||||
|
||||
/* ── Traces ── */
|
||||
.tl{list-style:none}
|
||||
.ti{padding:6px 0;border-bottom:1px solid #21262d;font-size:11px;display:flex;justify-content:space-between;align-items:center;gap:6px}
|
||||
.ti:last-child{border-bottom:none}
|
||||
.ts{font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.to{color:#8b949e;font-size:10px;font-family:'SF Mono','Fira Code',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px}
|
||||
.tt{display:flex;gap:8px;font-size:10px;color:#8b949e;flex-shrink:0;align-items:center}
|
||||
.td{font-family:'SF Mono',monospace;font-weight:500;color:#58a6ff}
|
||||
.te{color:#f85149;font-size:9px;padding:1px 5px;border-radius:3px;background:rgba(248,81,73,.1)}
|
||||
|
||||
.em{text-align:center;padding:16px;color:#6e7681;font-size:11px}
|
||||
|
||||
/* ── Scaling SVGs ── */
|
||||
.cg svg{width:100%;height:auto;max-width:340px}
|
||||
@media(min-width:481px){.cg svg{max-width:400px}}
|
||||
@media(max-width:480px){.cg svg{max-width:100%}}
|
||||
.sg3 .cg svg{max-width:100%}
|
||||
|
||||
/* ── Animations ── */
|
||||
.g>.cd{animation:fadeIn .3s ease}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||||
|
||||
/* ── Mobile first (single column) ── */
|
||||
@media(min-width:481px){
|
||||
.c{padding:16px 20px}
|
||||
.hdr{padding:14px 24px}
|
||||
.g{gap:12px;grid-template-columns:1fr}
|
||||
}
|
||||
@media(min-width:640px){
|
||||
.g{grid-template-columns:repeat(2,1fr)}
|
||||
.hl .s{display:inline}
|
||||
}
|
||||
@media(min-width:1024px){
|
||||
.g{grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
|
||||
.c{padding:20px 28px}
|
||||
.sg3{grid-template-columns:repeat(3,1fr)}
|
||||
}
|
||||
@media(min-width:1280px){
|
||||
.g{gap:16px;grid-template-columns:repeat(auto-fill,minmax(350px,1fr))}
|
||||
.c{padding:24px 32px}
|
||||
}
|
||||
|
||||
/* ── Dark scrollbar ── */
|
||||
::-webkit-scrollbar{width:8px;height:8px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:#30363d;border-radius:4px}
|
||||
::-webkit-scrollbar-thumb:hover{background:#3d444d}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hdr">
|
||||
<div class="hl">
|
||||
<div class="l">H</div>
|
||||
<div>
|
||||
<h1>{{.SystemName}}</h1>
|
||||
<div class="s">System Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hr">
|
||||
{{if eq .TotalDown 0}}
|
||||
<span class="hb g"><span class="d"></span> <span class="hbl">All Systems Operational</span></span>
|
||||
{{else if lt .TotalDown 3}}
|
||||
<span class="hb y"><span class="d"></span> {{.TotalDown}} degraded</span>
|
||||
{{else}}
|
||||
<span class="hb r"><span class="d"></span> {{.TotalDown}} services down</span>
|
||||
{{end}}
|
||||
<span class="up" id="ts"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c">
|
||||
<div class="g">
|
||||
|
||||
<!-- Services -->
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Services</h2><span class="b">{{len .Services}}</span></div>
|
||||
<div class="sg">
|
||||
{{range .Services}}
|
||||
<span class="st">
|
||||
<span class="i {{if eq .State "running"}}g{{else if eq .State "jaeger"}}b{{else}}r{{end}}"></span>
|
||||
<span class="n">{{.Name}}</span>
|
||||
</span>
|
||||
{{else}}<div class="em">No services detected</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Overview</h2></div>
|
||||
<div class="sg2">
|
||||
<div class="sb"><div class="sv a">{{len .Services}}</div><div class="sl2">Total</div></div>
|
||||
<div class="sb"><div class="sv a">{{.Running}}</div><div class="sl2">Healthy</div></div>
|
||||
<div class="sb"><div class="sv g">{{.TraceCount}}</div><div class="sl2">Traces</div></div>
|
||||
<div class="sb"><div class="sv r">{{if .Errors}}{{printf "%.0f" (index .Errors (sub (len .Errors) 1))}}{{else}}0{{end}}</div><div class="sl2">Errors</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Donut -->
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Health</h2></div>
|
||||
<div class="cg">{{.HealthSVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Links -->
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Links</h2></div>
|
||||
<div class="lk">
|
||||
{{range .Links}}<a href="{{.URL}}" target="_blank">{{.Label}}</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Resources (auto jika ada data) -->
|
||||
{{if .HasNodeData}}
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>System Resources</h2>
|
||||
<span class="b">{{printf "%.2f" .Node.Load1}} {{printf "%.2f" .Node.Load5}} {{printf "%.2f" .Node.Load15}}</span>
|
||||
</div>
|
||||
<div class="sg3">
|
||||
<div class="cg">{{.CPUSVG}}</div>
|
||||
<div class="cg">{{.RAMSVG}}</div>
|
||||
<div class="cg">{{.DiskSVG}}</div>
|
||||
</div>
|
||||
{{if .Node.NetIn}}<div class="ns">↑ {{printf "%.1f" .Node.NetOut}} B/s ↓ {{printf "%.1f" .Node.NetIn}} B/s</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Request Rate (auto jika OTel aktif) -->
|
||||
{{if .HasOTelData}}
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Request Rate</h2>
|
||||
<span class="b">{{if .RPS}}{{printf "%.1f" (index .RPS (sub (len .RPS) 1))}}/s{{else}}-{{end}}</span>
|
||||
</div>
|
||||
<div class="cg">{{.RPSSVG}}</div>
|
||||
</div>
|
||||
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Latency</h2>
|
||||
<span class="b">{{if .Latency}}{{printf "%.0f" (index .Latency (sub (len .Latency) 1))}}ms{{else}}-{{end}}</span>
|
||||
</div>
|
||||
<div class="cg">{{.LatencySVG}}</div>
|
||||
</div>
|
||||
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Error Rate</h2>
|
||||
<span class="b">{{if .Errors}}{{printf "%.1f" (index .Errors (sub (len .Errors) 1))}}/s{{else}}-{{end}}</span>
|
||||
</div>
|
||||
<div class="cg">{{.ErrorSVG}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Trace Volume -->
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Trace Volume</h2><span class="b">{{.TraceCount}} traces</span></div>
|
||||
<div class="cg">{{.TraceSVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Traces -->
|
||||
<div class="cd">
|
||||
<div class="ch"><h2>Recent Traces</h2><span class="b">{{.TraceCount}}</span></div>
|
||||
{{if .TraceList}}
|
||||
<div class="tl">
|
||||
{{range .TraceList}}
|
||||
<div class="ti">
|
||||
<div style="overflow:hidden;min-width:0">
|
||||
<div class="ts">{{.Service}}</div>
|
||||
<div class="to">{{.Operation}}</div>
|
||||
</div>
|
||||
<div class="tt">
|
||||
<span class="td">{{safeDur .Duration}}</span>
|
||||
<span>{{.Spans}}</span>
|
||||
{{if .HasError}}<span class="te">err</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="em">No traces — data appears once services send OTel telemetry</div>{{end}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
var ts = document.getElementById('ts');
|
||||
function tick(){ ts.textContent = new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }
|
||||
tick();
|
||||
setInterval(tick, 10000);
|
||||
setTimeout(function(){ location.reload(); }, 15000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import http from 'node:http';
|
||||
|
||||
const JAEGER = 'http://jaeger:16686';
|
||||
const PROMETHEUS = 'http://prometheus:9090';
|
||||
const DOCKER_SOCK = '/var/run/docker.sock';
|
||||
|
||||
interface Container {
|
||||
Names: string[];
|
||||
State: string;
|
||||
Labels: Record<string, string>;
|
||||
NetworkSettings?: { Networks?: Record<string, unknown> };
|
||||
}
|
||||
|
||||
interface Trace {
|
||||
service: string;
|
||||
operation: string;
|
||||
duration: number;
|
||||
spans: number;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
state: string;
|
||||
hasWeb: boolean;
|
||||
}
|
||||
|
||||
function dockerFetch(path: string): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get({ socketPath: DOCKER_SOCK, path }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (c: string) => (data += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJSON(url: string): Promise<unknown> {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
return res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseServices(containers: Container[]): Service[] {
|
||||
const project = containers.find((c) => c.Labels['com.docker.compose.project'])
|
||||
?.Labels['com.docker.compose.project'];
|
||||
|
||||
return containers
|
||||
.filter((c) => {
|
||||
if (!c.NetworkSettings?.Networks?.['app-shared-net']) return false;
|
||||
if (project && c.Labels['com.docker.compose.project'] !== project) return false;
|
||||
return true;
|
||||
})
|
||||
.map((c) => ({
|
||||
name: c.Names[0].replace(/^\//, ''),
|
||||
state: c.State,
|
||||
hasWeb: Object.keys(c.Labels).some(
|
||||
(k) => k.startsWith('traefik.http.routers.') && k.endsWith('.rule'),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchTraces(): Promise<Trace[]> {
|
||||
const svcRes = await fetchJSON(`${JAEGER}/api/services`);
|
||||
if (!svcRes || !Array.isArray((svcRes as { data?: string[] }).data)) return [];
|
||||
|
||||
const now = Date.now() * 1000;
|
||||
const start = now - 5 * 60 * 1_000_000;
|
||||
const all: Trace[] = [];
|
||||
|
||||
for (const service of (svcRes as { data: string[] }).data) {
|
||||
const d = await fetchJSON(
|
||||
`${JAEGER}/api/traces?service=${encodeURIComponent(service)}&start=${start}&end=${now}&limit=5&lookback=5m`,
|
||||
);
|
||||
if (!d || !Array.isArray((d as { data?: unknown[] }).data)) continue;
|
||||
|
||||
for (const t of (d as { data: { duration: number; spans: { operationName: string; processID: string; tags: { key: string; value: unknown }[] }[]; processes: Record<string, { serviceName: string }> }[] }).data) {
|
||||
if (!t.spans?.length) continue;
|
||||
const span = t.spans[0];
|
||||
const svc = t.processes[span.processID]?.serviceName || 'unknown';
|
||||
const hasError = t.spans.some((s) =>
|
||||
s.tags?.some((tag) => tag.key === 'error' && tag.value === true),
|
||||
);
|
||||
all.push({
|
||||
service: svc,
|
||||
operation: span.operationName,
|
||||
duration: t.duration,
|
||||
spans: t.spans.length,
|
||||
hasError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return all.sort((a, b) => b.duration - a.duration).slice(0, 20);
|
||||
}
|
||||
|
||||
async function promQuery(query: string): Promise<number | null> {
|
||||
const d = await fetchJSON(
|
||||
`${PROMETHEUS}/api/v1/query?query=${encodeURIComponent(query)}`,
|
||||
);
|
||||
const result = (d as { data?: { result?: { value?: unknown[] }[] } })?.data?.result;
|
||||
if (!result?.length) return null;
|
||||
const val = result[0].value?.[1];
|
||||
return val ? parseFloat(val as string) : null;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
services: Service[];
|
||||
traces: Trace[];
|
||||
node: {
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
disk: number | null;
|
||||
load1: number | null;
|
||||
load5: number | null;
|
||||
load15: number | null;
|
||||
netIn: number | null;
|
||||
netOut: number | null;
|
||||
};
|
||||
links: { url: string; label: string }[];
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const [containersRaw] = await Promise.all([
|
||||
dockerFetch('/containers/json?all=true') as Promise<Container[]>,
|
||||
]);
|
||||
|
||||
const containers = Array.isArray(containersRaw) ? containersRaw : [];
|
||||
const services = parseServices(containers);
|
||||
|
||||
const [traces, cpu, ram, disk, load1, load5, load15, netIn, netOut] =
|
||||
await Promise.all([
|
||||
fetchTraces(),
|
||||
promQuery(`100 - (avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)`),
|
||||
promQuery(`(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100`),
|
||||
promQuery(`(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!="tmpfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!="tmpfs"}) * 100`),
|
||||
promQuery('node_load1'),
|
||||
promQuery('node_load5'),
|
||||
promQuery('node_load15'),
|
||||
promQuery(`rate(node_network_receive_bytes_total{device!="lo"}[1m])`),
|
||||
promQuery(`rate(node_network_transmit_bytes_total{device!="lo"}[1m])`),
|
||||
]);
|
||||
|
||||
const links: { url: string; label: string }[] = [
|
||||
{ url: '/jaeger', label: 'Jaeger UI' },
|
||||
];
|
||||
if (containers.some((c) => c.Names?.some((n) => n.includes('prometheus')))) {
|
||||
links.push({ url: '/api/prometheus/targets', label: 'Prometheus' });
|
||||
}
|
||||
links.push({ url: 'https://github.com/asepharyana/asepharyana-hub', label: 'GitHub' });
|
||||
|
||||
const domains = ['asepharyana.my.id', 'asepharyana.web.id'];
|
||||
for (const s of services) {
|
||||
if (s.hasWeb && s.state === 'running') {
|
||||
links.push({ url: `https://${s.name}.${domains[0]}`, label: s.name });
|
||||
}
|
||||
}
|
||||
|
||||
const data: DashboardData = {
|
||||
services,
|
||||
traces,
|
||||
node: { cpu, ram, disk, load1, load5, load15, netIn, netOut },
|
||||
links,
|
||||
};
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Service {
|
||||
name: string;
|
||||
state: string;
|
||||
hasWeb: boolean;
|
||||
}
|
||||
|
||||
interface Trace {
|
||||
service: string;
|
||||
operation: string;
|
||||
duration: number;
|
||||
spans: number;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface DashboardData {
|
||||
services: Service[];
|
||||
traces: Trace[];
|
||||
node: {
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
disk: number | null;
|
||||
load1: number | null;
|
||||
load5: number | null;
|
||||
load15: number | null;
|
||||
netIn: number | null;
|
||||
netOut: number | null;
|
||||
};
|
||||
links: { url: string; label: string }[];
|
||||
}
|
||||
|
||||
function safeDur(us: number): string {
|
||||
if (us < 1000) return `${us}µs`;
|
||||
if (us < 1_000_000) return `${(us / 1000).toFixed(1)}ms`;
|
||||
return `${(us / 1_000_000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function Donut({ running, degraded }: { running: number; degraded: number }) {
|
||||
const total = running + degraded;
|
||||
if (!total) return <NoData />;
|
||||
|
||||
const cx = 100, cy = 90, R = 60, circ = 2 * Math.PI * R;
|
||||
const segs = [
|
||||
{ n: running, c: '#3fb950', l: 'Running' },
|
||||
{ n: degraded, c: '#d29922', l: 'Degraded' },
|
||||
];
|
||||
|
||||
let off = 0;
|
||||
return (
|
||||
<svg width={200} height={210} viewBox="0 0 200 210">
|
||||
<style>{`.sl{font-family:system-ui,sans-serif;font-size:10px;fill:#8b949e}`}</style>
|
||||
{segs.map((s) => {
|
||||
if (!s.n) return null;
|
||||
const frac = s.n / total;
|
||||
const ln = frac * circ;
|
||||
const el = (
|
||||
<circle key={s.l} cx={cx} cy={cy} r={R} fill="none" stroke={s.c} strokeWidth={14}
|
||||
strokeDasharray={`${ln} ${circ - ln}`} strokeDashoffset={-off}
|
||||
transform={`rotate(-90 ${cx} ${cy})`} />
|
||||
);
|
||||
off += ln;
|
||||
return el;
|
||||
})}
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fill="#e6edf3" fontSize={26} fontWeight={700} fontFamily="system-ui,sans-serif">{total}</text>
|
||||
<text x={cx} y={cy + 14} textAnchor="middle" className="sl">total</text>
|
||||
{segs.filter(s => s.n).map((s, i) => (
|
||||
<g key={s.l}>
|
||||
<circle cx={16} cy={165 + i * 16} r={4} fill={s.c} />
|
||||
<text x={26} y={168 + i * 16} className="sl">{s.l}: {s.n}</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Sparkline({ data, color }: { data: number[]; color: string }) {
|
||||
const w = 300, h = 160, pl = 45, pt = 20, pr = 10, pb = 25;
|
||||
const vw = w - pl - pr, vh = h - pt - pb;
|
||||
if (!data.length) return <NoData />;
|
||||
|
||||
const maxV = Math.max(...data.map(Math.abs), 1);
|
||||
|
||||
const pts = data.map((v, i) =>
|
||||
`${(pl + vw * i / (data.length - 1)).toFixed(1)},${(pt + vh * (1 - v / maxV)).toFixed(1)}`
|
||||
).join(' ');
|
||||
|
||||
const area = `M${pl},${pt + vh} L${pts} L${pl + vw},${pt + vh} Z`;
|
||||
const lv = data[data.length - 1];
|
||||
const ly = pt + vh * (1 - lv / maxV);
|
||||
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
||||
<style>{`.ax{font-family:system-ui,sans-serif;font-size:9px;fill:#6e7681}`}</style>
|
||||
{[0, 1, 2, 3, 4].map(i => {
|
||||
const y = pt + vh * i / 4;
|
||||
return (
|
||||
<g key={i}>
|
||||
<line x1={pl} y1={y} x2={pl + vw} y2={y} stroke="#21262d" strokeWidth={1} />
|
||||
<text x={pl - 6} y={y + 3} textAnchor="end" className="ax">{(maxV * (1 - i / 4)).toFixed(0)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<path d={area} fill={color} opacity={0.15} />
|
||||
<polyline points={pts} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" />
|
||||
<text x={pl + vw} y={ly - 10} textAnchor="end" fill={color} fontSize={11} fontWeight={600}
|
||||
fontFamily="system-ui,sans-serif">{lv.toFixed(1)}</text>
|
||||
{data.length > 5 && [...Array(5)].map((_, i) => {
|
||||
const idx = Math.floor((i + 1) * (data.length - 1) / 5);
|
||||
const x = pl + vw * idx / (data.length - 1);
|
||||
return <text key={i} x={x} y={pt + vh + 16} textAnchor="middle" className="ax">{idx + 1}</text>;
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ pct, color, label, unit }: { pct: number | null; color: string; label: string; unit: string }) {
|
||||
if (pct === null || pct <= 0) return <NoData />;
|
||||
const w = 220, h = 100, bw = 200, bh = 14;
|
||||
const bx = (w - bw) / 2, by = 30;
|
||||
const fw = bw * Math.min(pct / 100, 1);
|
||||
|
||||
return (
|
||||
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
||||
<style>{`.gt{font-family:system-ui,sans-serif;font-size:11px;fill:#8b949e;text-anchor:middle}`}</style>
|
||||
<rect x={bx} y={by} width={bw} height={bh} rx={7} ry={7} fill="#1c2333" />
|
||||
{fw > 0 && <rect x={bx} y={by} width={fw} height={bh} rx={7} ry={7} fill={color} opacity={0.85} />}
|
||||
<text x={w / 2} y={14} className="gt" fontWeight={600} fill={color}>{label}</text>
|
||||
<text x={w / 2} y={by + bh + 24} className="gt" fill="#e6edf3" fontSize={14} fontWeight={700}>
|
||||
{pct.toFixed(1)}{unit}
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function NoData() {
|
||||
return (
|
||||
<svg width={200} height={100} viewBox="0 0 200 100">
|
||||
<text x={100} y={50} textAnchor="middle" fill="#6e7681" fontSize={12} fontFamily="system-ui,sans-serif">No data</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({ state }: { state: string }) {
|
||||
const dot: Record<string, string> = { running: '#3fb950', jaeger: '#58a6ff', exited: '#f85149', restarting: '#d29922' };
|
||||
return <span style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 8px', border: '1px solid #30363d', borderRadius: 5, fontSize: 11, background: '#0d1117' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: dot[state] || '#8b949e', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: "'SF Mono','Fira Code',monospace", fontSize: 10 }}>{state}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [time, setTime] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard');
|
||||
if (res.ok) setData(await res.json());
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
fetchData();
|
||||
const id = setInterval(fetchData, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setTime(new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }));
|
||||
tick();
|
||||
const id = setInterval(tick, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const running = data?.services.filter(s => s.state === 'running').length ?? 0;
|
||||
const degraded = (data?.services.length ?? 0) - running;
|
||||
const hasNode = data?.node.cpu !== null || data?.node.ram !== null;
|
||||
const hasOTel = false;
|
||||
const node = data?.node;
|
||||
const gaugeColor = (v: number | null) => {
|
||||
if (v === null) return '#3fb950';
|
||||
if (v > 80) return '#f85149';
|
||||
if (v > 60) return '#d29922';
|
||||
return '#3fb950';
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ background: '#0d1117', color: '#e6edf3', minHeight: '100vh', fontFamily: "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif", lineHeight: 1.5 }}>
|
||||
<style>{`
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
@media(min-width:769px){.grid{grid-template-columns:repeat(auto-fill,minmax(320px,1fr))}}
|
||||
.card{border:1px solid #30363d;border-radius:8px;padding:14px;background:#161b22;transition:border-color .2s}
|
||||
.card:hover{border-color:#3d444d}
|
||||
@media(max-width:480px){.card{padding:12px;border-radius:6px}}
|
||||
.card-h{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:8px}
|
||||
.card-h h2{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#8b949e}
|
||||
.card-h .badge{font-size:9px;background:#1c2333;padding:2px 8px;border-radius:8px;color:#8b949e;white-space:nowrap}
|
||||
.card-c{display:flex;justify-content:center;align-items:center;width:100%}
|
||||
::-webkit-scrollbar{width:8px}
|
||||
::-webkit-scrollbar-thumb{background:#30363d;border-radius:4px}
|
||||
.link{color:#58a6ff;text-decoration:none;font-size:10px;padding:4px 8px;border:1px solid #30363d;border-radius:5px;transition:all .15s;line-height:1.4}
|
||||
.link:hover{background:#1c2333;border-color:#58a6ff}
|
||||
.stat-box{background:#1c2333;border-radius:6px;padding:10px 6px;text-align:center}
|
||||
.stat-val{font-size:20px;font-weight:700;font-family:'SF Mono','Fira Code',monospace;line-height:1.2}
|
||||
.stat-lbl{font-size:9px;color:#8b949e;margin-top:2px;text-transform:uppercase;letter-spacing:.04em}
|
||||
`}</style>
|
||||
|
||||
<header style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '12px 20px', borderBottom: '1px solid #30363d',
|
||||
background: 'rgba(22,27,34,.92)', backdropFilter: 'blur(12px)',
|
||||
position: 'sticky', top: 0, zIndex: 100, gap: 8,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{
|
||||
width: 28, height: 28, borderRadius: 6,
|
||||
background: 'linear-gradient(135deg,#58a6ff,#bc8cff)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontWeight: 700, fontSize: 12, color: '#fff', flexShrink: 0,
|
||||
}}>H</span>
|
||||
<div>
|
||||
<h1 style={{ fontSize: 16, fontWeight: 600 }}>Hub Dashboard</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, fontSize: 11,
|
||||
padding: '4px 12px', borderRadius: 20, fontWeight: 500,
|
||||
background: degraded ? 'rgba(210,153,34,.12)' : 'rgba(63,185,80,.12)',
|
||||
color: degraded ? '#d29922' : '#3fb950',
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: degraded ? '#d29922' : '#3fb950' }} />
|
||||
{degraded ? `${degraded} degraded` : 'All Systems Operational'}
|
||||
</span>
|
||||
<span style={{ color: '#6e7681', fontSize: 10 }}>{time}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 1440, margin: '0 auto', padding: '12px 16px' }}>
|
||||
<div className="grid" style={{ display: 'grid', gap: 10 }}>
|
||||
{/* Services */}
|
||||
<div className="card">
|
||||
<div className="card-h"><h2>Services</h2><span className="badge">{data?.services.length ?? 0}</span></div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{data?.services.map(s => (
|
||||
<span key={s.name} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '5px 8px', border: '1px solid #30363d', borderRadius: 5, fontSize: 11,
|
||||
}}>
|
||||
<span style={{
|
||||
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
|
||||
background: s.state === 'running' ? '#3fb950' : s.state === 'jaeger' ? '#58a6ff' : '#f85149',
|
||||
}} />
|
||||
<span style={{ fontFamily: "'SF Mono','Fira Code',monospace", fontSize: 10 }}>{s.name}</span>
|
||||
</span>
|
||||
)) || <span style={{ color: '#6e7681', padding: 16, textAlign: 'center', width: '100%', fontSize: 11 }}>No services detected</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<div className="card">
|
||||
<div className="card-h"><h2>Overview</h2></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
||||
<div className="stat-box"><div className="stat-val" style={{ color: '#58a6ff' }}>{data?.services.length ?? '-'}</div><div className="stat-lbl">Total</div></div>
|
||||
<div className="stat-box"><div className="stat-val" style={{ color: '#58a6ff' }}>{running}</div><div className="stat-lbl">Healthy</div></div>
|
||||
<div className="stat-box"><div className="stat-val" style={{ color: '#3fb950' }}>{data?.traces.length ?? 0}</div><div className="stat-lbl">Traces</div></div>
|
||||
<div className="stat-box"><div className="stat-val" style={{ color: '#f85149' }}>0</div><div className="stat-lbl">Errors</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health */}
|
||||
<div className="card">
|
||||
<div className="card-h"><h2>Health</h2></div>
|
||||
<div className="card-c"><Donut running={running} degraded={degraded} /></div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="card">
|
||||
<div className="card-h"><h2>Links</h2></div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{data?.links.map(l => (
|
||||
<a key={l.url} href={l.url} target="_blank" className="link">{l.label}</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Node Resources */}
|
||||
{hasNode && (
|
||||
<div className="card">
|
||||
<div className="card-h">
|
||||
<h2>System Resources</h2>
|
||||
<span className="badge">{node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)} {node?.load15?.toFixed(2)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit,minmax(160px,1fr))', gap: 6 }}>
|
||||
<div className="card-c"><Gauge pct={node?.cpu ?? null} color={gaugeColor(node?.cpu ?? null)} label="CPU Usage" unit="%" /></div>
|
||||
<div className="card-c"><Gauge pct={node?.ram ?? null} color={gaugeColor(node?.ram ?? null)} label="Memory Usage" unit="%" /></div>
|
||||
<div className="card-c"><Gauge pct={node?.disk ?? null} color={gaugeColor(node?.disk ?? null)} label="Disk Usage" unit="%" /></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traces */}
|
||||
<div className="card">
|
||||
<div className="card-h"><h2>Recent Traces</h2><span className="badge">{data?.traces.length ?? 0}</span></div>
|
||||
{data?.traces.length ? (
|
||||
<ul style={{ listStyle: 'none' }}>
|
||||
{data.traces.map((t, i) => (
|
||||
<li key={i} style={{
|
||||
padding: '6px 0', borderBottom: '1px solid #21262d',
|
||||
fontSize: 11, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 6,
|
||||
}}>
|
||||
<div style={{ overflow: 'hidden', minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.service}</div>
|
||||
<div style={{ color: '#8b949e', fontSize: 10, fontFamily: "'SF Mono','Fira Code',monospace", overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 200 }}>{t.operation}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 10, color: '#8b949e', flexShrink: 0, alignItems: 'center' }}>
|
||||
<span style={{ fontFamily: "'SF Mono',monospace", fontWeight: 500, color: '#58a6ff' }}>{safeDur(t.duration)}</span>
|
||||
<span>{t.spans}</span>
|
||||
{t.hasError && <span style={{ color: '#f85149', padding: '1px 5px', borderRadius: 3, background: 'rgba(248,81,73,.1)', fontSize: 9 }}>err</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : <div style={{ textAlign: 'center', padding: 16, color: '#6e7681', fontSize: 11 }}>No traces — data appears once services send OTel telemetry</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user