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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 ``
+ }
+ 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(``)
+ 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(``,
+ 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(``)
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
Services
{{len .Services}}
+
+ {{range .Services}}
+
+
+ {{.Name}}
+
+ {{else}}
+
No services found
+ {{end}}
+
+
+
+
+
+
Overview
+
+
+
+
{{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
+
+
Jaeger UI
+
Prometheus
+
GitHub
+ {{$domain := "asepharyana.my.id"}}
+ {{range .Services}}
+ {{if and (ne .Name "jaeger") (ne .Name "traefik") (ne .Name "dashboard") (ne .Name "redis") (ne .Name "nats") (ne .Name "prometheus") (ne .Name "otel-collector") (not (hasSuffix .Name "-dapr"))}}
+
{{.Name}}
+ {{end}}
+ {{end}}
+
+
+
+
+
+
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"]