diff --git a/infra/dashboard/go.mod b/infra/dashboard/go.mod new file mode 100644 index 0000000..c584376 --- /dev/null +++ b/infra/dashboard/go.mod @@ -0,0 +1,3 @@ +module github.com/asepharyana/asepharyana-hub/infra/dashboard + +go 1.26.5 diff --git a/infra/dashboard/index.html b/infra/dashboard/index.html deleted file mode 100644 index 01faf47..0000000 --- a/infra/dashboard/index.html +++ /dev/null @@ -1,671 +0,0 @@ - - - - - -Hub Dashboard - - - - - - -
-
- -
-

asepharyana-hub

-
System Dashboard
-
-
-
- - Loading... - - - -
-
- -
-
- - -
-
-

Services

- 0 -
-
-
Loading...
-
-
- - -
-

Overview

-
-
-
-
-
Services
-
-
-
-
-
Healthy
-
-
-
-
-
Traces (5m)
-
-
-
-
-
Errors (5m)
-
-
-
- - -
-

Health Distribution

-
-
- - -
-

Quick Links

- -
- - -
-

Request Rate

-
-
-
- - -
-

Latency

-
-
-
- - -
-

Error Rate

-
-
-
- - -
-

Trace Volume

0/min
-
-
- - -
-

Recent Traces

0
-
Loading...
-
- -
-
- - - - diff --git a/infra/dashboard/main.go b/infra/dashboard/main.go new file mode 100644 index 0000000..f902f42 --- /dev/null +++ b/infra/dashboard/main.go @@ -0,0 +1,536 @@ +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 }, + "divF": func(a, b float64) float64 { return a / b }, + "hasSuffix": strings.HasSuffix, + "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 +} + +type Trace struct { + Service string + Operation string + Duration int64 + Spans int + HasError bool +} + +type DashboardData struct { + Services []Service + Running int + Down int + OTelOnly int + TraceCount int + RPS []float64 + Latency []float64 + Errors []float64 + Traces []float64 + Labels []string + HealthSVG template.HTML + RPSSVG template.HTML + LatencySVG template.HTML + ErrorSVG template.HTML + TraceSVG template.HTML + SystemName string + Error string + TotalUp int + TotalDown int +} + +// ── State ── + +type appState struct { + mu sync.Mutex + rps []float64 + latency []float64 + errs []float64 + traces []float64 + labels []string + services []Service + tracesL []Trace +} + +var state appState + +const maxPts = 30 + +// ── Docker socket client ── + +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} + +// ── Helpers ── + +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 ── + +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"` + NetworkSettings *struct { + Networks map[string]any `json:"Networks"` + } `json:"NetworkSettings"` + } + if json.NewDecoder(r.Body).Decode(&raw) != nil { + return nil + } + var svcs []Service + for _, c := range raw { + if c.NetworkSettings != nil { + if _, ok := c.NetworkSettings.Networks["app-shared-net"]; ok { + svcs = append(svcs, Service{Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State}) + } + } + } + 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 ── + +func promQuery(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 +} + +// ── SVG Charts ── + +func svgDonut(running, down, otel int) string { + total := running + down + otel + if total == 0 { + return `No data` + } + 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"}, {down, "#f85149", "Down"}, {otel, "#58a6ff", "OTel"}} + + var b strings.Builder + b.WriteString(fmt.Sprintf(``)) + 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 + } + + // Center + b.WriteString(fmt.Sprintf(`%d`, cx, cy-4, total)) + b.WriteString(fmt.Sprintf(`total`, cx, cy+14)) + + // Legend + y := 165 + for _, s := range segs { + pct := 0.0 + if total > 0 { + pct = float64(s.n) / float64(total) * 100 + } + if s.n > 0 || s.l == "Running" { + b.WriteString(fmt.Sprintf(``, y, s.c)) + b.WriteString(fmt.Sprintf(`%s: %d (%.0f%%)`, y+3, s.l, s.n, pct)) + 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 fmt.Sprintf(` + No data`, + w, h, w, h, w/2, h/2) + } + + maxV := 0.0 + for _, v := range data { + if v > maxV { + maxV = v + } + } + if maxV <= 0 { + maxV = 1 + } + + var b strings.Builder + b.WriteString(fmt.Sprintf(``, w, h, w, h)) + b.WriteString(``) + + // Grid + for i := 0; i <= 4; i++ { + y := pt + vh*float64(i)/4 + val := maxV * (1 - float64(i)/4) + b.WriteString(fmt.Sprintf(``, pl, y, pl+vw, y)) + b.WriteString(fmt.Sprintf(`%.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 + area := fmt.Sprintf("M%.1f,%.1f L%s L%.1f,%.1f Z", pl, pt+vh, strings.Join(pts, " L"), pl+vw, pt+vh) + b.WriteString(fmt.Sprintf(``, area, color)) + + // Line + b.WriteString(fmt.Sprintf(``, + strings.Join(pts, " "), color)) + + // Last value + lv := data[n-1] + ly := pt + vh*(1-lv/maxV) + b.WriteString(fmt.Sprintf(`%.1f`, + pl+vw, ly-10, color, lv)) + } + + // X labels + step := n / 5 + if step < 1 { + step = 1 + } + for i := step; i < n; i += step { + x := pl + vw*float64(i)/float64(n-1) + b.WriteString(fmt.Sprintf(`%d`, x, pt+vh+16, i+1)) + } + + b.WriteString(``) + return b.String() +} + +// ── Refresh ── + +func refresh() { + svcs := fetchServices() + + 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, "paused": 2, "exited": 3, "restarting": 4} + 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 + rps := promQuery("rate(otelcol_receiver_accepted_spans[1m])") + lat := promQuery("otelcol_receiver_accepted_spans") + err := promQuery("rate(otelcol_receiver_refused_spans[1m])") + + state.mu.Lock() + state.services = svcs + state.tracesL = traces + + 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() +} + +// ── 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...) + state.mu.Unlock() + + running, down, otel := 0, 0, 0 + for _, s := range svcs { + switch s.State { + case "running": + running++ + case "jaeger": + otel++ + default: + down++ + } + } + + maxLat := 0.0 + for _, v := range lat { + if v > maxLat { + maxLat = v + } + } + + data := DashboardData{ + Services: svcs, Running: running, Down: down, OTelOnly: otel, + TraceCount: len(tr), RPS: rps, Latency: lat, Errors: ers, Traces: trc, Labels: labels, + SystemName: "asepharyana-hub", TotalUp: running, TotalDown: down + otel, + HealthSVG: template.HTML(svgDonut(running, down, otel)), + RPSSVG: template.HTML(svgLine(rps, "#58a6ff")), + LatencySVG: template.HTML(svgLine(lat, "#bc8cff")), + ErrorSVG: template.HTML(svgLine(ers, "#f85149")), + TraceSVG: template.HTML(svgLine(trc, "#3fb950")), + } + + 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/infra/dashboard/nginx.conf b/infra/dashboard/nginx.conf deleted file mode 100644 index 5e45673..0000000 --- a/infra/dashboard/nginx.conf +++ /dev/null @@ -1,111 +0,0 @@ -# Dashboard nginx — runs as root to access Docker socket -user root; -worker_processes auto; -pid /var/run/nginx.pid; -pcre_jit on; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - access_log /var/log/nginx/access.log; - sendfile on; - tcp_nopush on; - keepalive_timeout 65; - - # Gzip - gzip on; - gzip_types text/html text/css application/javascript application/json; - - server { - listen 8080; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - # Security headers - add_header X-Frame-Options DENY; - add_header X-Content-Type-Options nosniff; - add_header Referrer-Policy same-origin; - - # Jaeger API proxy - location /api/jaeger/ { - proxy_pass http://jaeger:16686/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_http_version 1.1; - proxy_read_timeout 30s; - } - - # Jaeger UI - location /jaeger/ { - proxy_pass http://jaeger:16686/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400s; - } - - # OTel collector prometheus metrics - location /api/metrics { - proxy_pass http://otel-collector:8889/metrics; - proxy_set_header Host $host; - proxy_read_timeout 10s; - } - - # OTel collector health - location /api/health { - proxy_pass http://otel-collector:13133/; - proxy_read_timeout 5s; - } - - # Prometheus API (read-only queries) - location /api/prometheus/ { - proxy_pass http://prometheus:9090/; - proxy_set_header Host $host; - proxy_read_timeout 15s; - } - - # Docker API proxy — strict whitelist (read-only Unix socket) - # Uses exact match (=) to avoid regex+proxy_pass URI limitation. - location = /api/docker/containers/json { - proxy_pass http://unix:/var/run/docker.sock:/containers/json; - proxy_set_header Host $host; - proxy_read_timeout 10s; - } - - location = /api/docker/version { - proxy_pass http://unix:/var/run/docker.sock:/version; - proxy_set_header Host $host; - proxy_read_timeout 10s; - } - - # Deny all other Docker API access - location /api/docker/ { - deny all; - return 403; - } - - # Static files - location / { - try_files $uri $uri/ /index.html; - expires 5s; - add_header Cache-Control "public, must-revalidate"; - } - - # Deny hidden files - location ~ /\. { - deny all; - access_log off; - log_not_found off; - } - - error_page 404 /index.html; - } -} diff --git a/infra/dashboard/template.html b/infra/dashboard/template.html new file mode 100644 index 0000000..2998d41 --- /dev/null +++ b/infra/dashboard/template.html @@ -0,0 +1,220 @@ + + + + + +Hub Dashboard + + + + +
+
+ +
+

{{.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 found
+ {{end}} +
+
+ + +
+

Overview

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

Health

+
{{.HealthSVG}}
+
+ + +
+

Links

+ +
+ + +
+

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}}
+
+ + +
+

Trace Volume

{{.TraceCount}} traces
+
{{.TraceSVG}}
+
+ + +
+

Recent Traces

{{.TraceCount}}
+
+ {{if .Traces}} +
+ {{range .Traces}} +
+
+
{{.Service}}
+
{{.Operation}}
+
+
+ {{safeDur .Duration}} + {{.Spans}} spans + {{if .HasError}}error{{end}} +
+
+ {{end}} +
+ {{else}} +
No traces in the last 5 minutes
+ {{end}} +
+
+ +
+
+ + + + diff --git a/infra/docker/dashboard.Dockerfile b/infra/docker/dashboard.Dockerfile index e38cc5b..67ce95a 100644 --- a/infra/docker/dashboard.Dockerfile +++ b/infra/docker/dashboard.Dockerfile @@ -1,6 +1,11 @@ -FROM nginx:alpine -# Replace default nginx.conf with our custom config (runs as root for Docker socket access) -COPY infra/dashboard/nginx.conf /etc/nginx/nginx.conf -COPY infra/dashboard/index.html /usr/share/nginx/html/index.html +# ── Build stage ── +FROM golang:1.24-alpine AS builder +WORKDIR /build +COPY infra/dashboard/ ./ +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o dashboard . + +# ── Runtime (scratch — ~7MB total) ── +FROM scratch +COPY --from=builder /build/dashboard /dashboard EXPOSE 8080 -CMD ["nginx", "-g", "daemon off;"] +CMD ["/dashboard"]