diff --git a/dashboard/go.mod b/dashboard/go.mod deleted file mode 100644 index d69ac54..0000000 --- a/dashboard/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/asepharyana/asepharyana-hub-hub/dashboard - -go 1.24 diff --git a/dashboard/main.go b/dashboard/main.go deleted file mode 100644 index 72a1007..0000000 --- a/dashboard/main.go +++ /dev/null @@ -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(``) - b.WriteString(``) - - 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(``, - cx, cy, R, s.c, ln, circ-ln, -off, cx, cy)) - off += ln - } - - b.WriteString(fmt.Sprintf(`%d`, cx, cy-4, total)) - b.WriteString(fmt.Sprintf(`total`, cx, cy+14)) - - y := 165 - for _, s := range segs { - if s.n == 0 { - continue - } - fmt.Fprintf(&b, `%s: %d`, y, s.c, y+3, s.l, s.n) - y += 16 - } - b.WriteString(``) - 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, ``, w, h, w, h) - b.WriteString(``) - - for i := 0; i <= 4; i++ { - y := pt + vh*float64(i)/4 - val := maxV * (1 - float64(i)/4) - fmt.Fprintf(&b, ``, pl, y, pl+vw, y) - fmt.Fprintf(&b, `%.0f`, 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, ``, area, color) - fmt.Fprintf(&b, ``, - strings.Join(pts, " "), color) - - lv := data[n-1] - ly := pt + vh*(1-lv/maxV) - fmt.Fprintf(&b, `%.1f`, - 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, `%d`, x, pt+vh+16, i+1) - } - b.WriteString(``) - 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, ``, w, h, w, h) - b.WriteString(``) - - // background bar - fmt.Fprintf(&b, ``, bx, by, bw, bh) - // fill bar - if fw > 0 { - fmt.Fprintf(&b, ``, bx, by, fw, bh, color) - } - // label - fmt.Fprintf(&b, `%s`, w/2, color, label) - // value - fmt.Fprintf(&b, `%.1f%s`, w/2, by+bh+24, pct, unit) - b.WriteString(``) - return b.String() -} - -func noDataSVG(w, h int) string { - return fmt.Sprintf(`No data`, - 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)) -} diff --git a/dashboard/template.html b/dashboard/template.html deleted file mode 100644 index 6c8db54..0000000 --- a/dashboard/template.html +++ /dev/null @@ -1,262 +0,0 @@ - - - - - -Hub Dashboard - - - - -
-
-
H
-
-

{{.SystemName}}

-
System Dashboard
-
-
-
- {{if eq .TotalDown 0}} - All Systems Operational - {{else if lt .TotalDown 3}} - {{.TotalDown}} degraded - {{else}} - {{.TotalDown}} services down - {{end}} - -
-
- -
-
- - -
-

Services

{{len .Services}}
-
- {{range .Services}} - - - {{.Name}} - - {{else}}
No services detected
{{end}} -
-
- - -
-

Overview

-
-
{{len .Services}}
Total
-
{{.Running}}
Healthy
-
{{.TraceCount}}
Traces
-
{{if .Errors}}{{printf "%.0f" (index .Errors (sub (len .Errors) 1))}}{{else}}0{{end}}
Errors
-
-
- - -
-

Health

-
{{.HealthSVG}}
-
- - -
-

Links

-
- {{range .Links}}{{.Label}}{{end}} -
-
- - - {{if .HasNodeData}} -
-

System Resources

- {{printf "%.2f" .Node.Load1}} {{printf "%.2f" .Node.Load5}} {{printf "%.2f" .Node.Load15}} -
-
-
{{.CPUSVG}}
-
{{.RAMSVG}}
-
{{.DiskSVG}}
-
- {{if .Node.NetIn}}
↑ {{printf "%.1f" .Node.NetOut}} B/s  ↓ {{printf "%.1f" .Node.NetIn}} B/s
{{end}} -
- {{end}} - - - {{if .HasOTelData}} -
-

Request Rate

- {{if .RPS}}{{printf "%.1f" (index .RPS (sub (len .RPS) 1))}}/s{{else}}-{{end}} -
-
{{.RPSSVG}}
-
- -
-

Latency

- {{if .Latency}}{{printf "%.0f" (index .Latency (sub (len .Latency) 1))}}ms{{else}}-{{end}} -
-
{{.LatencySVG}}
-
- -
-

Error Rate

- {{if .Errors}}{{printf "%.1f" (index .Errors (sub (len .Errors) 1))}}/s{{else}}-{{end}} -
-
{{.ErrorSVG}}
-
- {{end}} - - -
-

Trace Volume

{{.TraceCount}} traces
-
{{.TraceSVG}}
-
- - -
-

Recent Traces

{{.TraceCount}}
- {{if .TraceList}} -
- {{range .TraceList}} -
-
-
{{.Service}}
-
{{.Operation}}
-
-
- {{safeDur .Duration}} - {{.Spans}} - {{if .HasError}}err{{end}} -
-
- {{end}} -
- {{else}}
No traces — data appears once services send OTel telemetry
{{end}} -
- -
-
- - - - diff --git a/src/app/api/dashboard/route.ts b/src/app/api/dashboard/route.ts new file mode 100644 index 0000000..bc52828 --- /dev/null +++ b/src/app/api/dashboard/route.ts @@ -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; + NetworkSettings?: { Networks?: Record }; +} + +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 { + 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 { + 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 { + 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 }[] }).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 { + 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, + ]); + + 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); +} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx new file mode 100644 index 0000000..4a41457 --- /dev/null +++ b/src/app/dashboard/page.tsx @@ -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 ; + + 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 ( + + + {segs.map((s) => { + if (!s.n) return null; + const frac = s.n / total; + const ln = frac * circ; + const el = ( + + ); + off += ln; + return el; + })} + {total} + total + {segs.filter(s => s.n).map((s, i) => ( + + + {s.l}: {s.n} + + ))} + + ); +} + +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 ; + + 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 ( + + + {[0, 1, 2, 3, 4].map(i => { + const y = pt + vh * i / 4; + return ( + + + {(maxV * (1 - i / 4)).toFixed(0)} + + ); + })} + + + {lv.toFixed(1)} + {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 {idx + 1}; + })} + + ); +} + +function Gauge({ pct, color, label, unit }: { pct: number | null; color: string; label: string; unit: string }) { + if (pct === null || pct <= 0) return ; + 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 ( + + + + {fw > 0 && } + {label} + + {pct.toFixed(1)}{unit} + + + ); +} + +function NoData() { + return ( + + No data + + ); +} + +function Pill({ state }: { state: string }) { + const dot: Record = { running: '#3fb950', jaeger: '#58a6ff', exited: '#f85149', restarting: '#d29922' }; + return + + {state} + ; +} + +export default function Dashboard() { + const [data, setData] = useState(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 ( +
+ + +
+
+ H +
+

Hub Dashboard

+
+
+
+ + + {degraded ? `${degraded} degraded` : 'All Systems Operational'} + + {time} +
+
+ +
+
+ {/* Services */} +
+

Services

{data?.services.length ?? 0}
+
+ {data?.services.map(s => ( + + + {s.name} + + )) || No services detected} +
+
+ + {/* Overview */} +
+

Overview

+
+
{data?.services.length ?? '-'}
Total
+
{running}
Healthy
+
{data?.traces.length ?? 0}
Traces
+
0
Errors
+
+
+ + {/* Health */} +
+

Health

+
+
+ + {/* Links */} +
+

Links

+
+ {data?.links.map(l => ( + {l.label} + ))} +
+
+ + {/* Node Resources */} + {hasNode && ( +
+
+

System Resources

+ {node?.load1?.toFixed(2)} {node?.load5?.toFixed(2)} {node?.load15?.toFixed(2)} +
+
+
+
+
+
+
+ )} + + {/* Traces */} +
+

Recent Traces

{data?.traces.length ?? 0}
+ {data?.traces.length ? ( +
    + {data.traces.map((t, i) => ( +
  • +
    +
    {t.service}
    +
    {t.operation}
    +
    +
    + {safeDur(t.duration)} + {t.spans} + {t.hasError && err} +
    +
  • + ))} +
+ ) :
No traces — data appears once services send OTel telemetry
} +
+
+
+
+ ); +}