From 9a48a49f214f01d59f655bbf2996054c8f8023c5 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 23 Jul 2026 05:30:55 +0700 Subject: [PATCH] feat(infra): auto-discovered node metrics (CPU, RAM, Disk) and fully dynamic dashboard - Add node-exporter container for host metrics (CPU, RAM, Disk) - SVG gauge charts rendered server-side from Prometheus data - Auto-detect compose project name from Docker labels - Auto-detect web services via traefik.* labels for quick links - Remove all hardcoded service name filters - Dynamic system name based on compose project --- infra/compose/observability.yml | 17 ++ infra/dashboard/main.go | 318 ++++++++++++++++++++++++-------- infra/dashboard/template.html | 149 +++++++-------- infra/otel/prometheus.yml | 5 + 4 files changed, 330 insertions(+), 159 deletions(-) diff --git a/infra/compose/observability.yml b/infra/compose/observability.yml index 8ad8dc9..b6ea785 100644 --- a/infra/compose/observability.yml +++ b/infra/compose/observability.yml @@ -56,6 +56,23 @@ services: - ../../infra/otel/prometheus.yml:/etc/prometheus/prometheus.yml:ro - /prometheus + # ── Node Exporter (Host Metrics: CPU, RAM, Disk) ── + node-exporter: + container_name: node-exporter + image: prom/node-exporter:latest + restart: always + networks: + app-shared-net: + aliases: + - node-exporter + command: + - '--web.listen-address=0.0.0.0:9100' + - '--path.rootfs=/host' + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + volumes: + - /:/host:ro,rslave + # ── Custom Dashboard (nginx:alpine) ── dashboard: container_name: dashboard diff --git a/infra/dashboard/main.go b/infra/dashboard/main.go index e4b6331..4dda333 100644 --- a/infra/dashboard/main.go +++ b/infra/dashboard/main.go @@ -24,8 +24,6 @@ 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) @@ -39,8 +37,9 @@ var tmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{ // ── Types ── type Service struct { - Name string - State string + Name string + State string + HasWeb bool // has traefik router label → eligible for quick link } type Trace struct { @@ -51,11 +50,22 @@ type Trace struct { 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 - Down int - OTelOnly int + Degraded int TraceCount int RPS []float64 Latency []float64 @@ -63,17 +73,28 @@ type DashboardData struct { 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 - Error string TotalUp int TotalDown int } +type Link struct { + URL string + Label string +} + // ── State ── type appState struct { @@ -85,13 +106,14 @@ type appState struct { labels []string services []Service tracesL []Trace + node NodeMetrics } var state appState const maxPts = 30 -// ── Docker socket client ── +// ── HTTP clients ── var dockerClient = &http.Client{ Transport: &http.Transport{ @@ -104,7 +126,7 @@ var dockerClient = &http.Client{ var httpClient = &http.Client{Timeout: 10 * time.Second} -// ── Helpers ── +var composeProject string // auto-detected from Docker labels func fetchJSON(url string, v interface{}) error { r, err := httpClient.Get(url) @@ -115,7 +137,7 @@ func fetchJSON(url string, v interface{}) error { return json.NewDecoder(r.Body).Decode(v) } -// ── Docker ── +// ── Docker service discovery ── func fetchServices() []Service { r, err := dockerClient.Get("http://localhost/containers/json?all=true") @@ -124,9 +146,9 @@ func fetchServices() []Service { } defer r.Body.Close() var raw []struct { - Names []string `json:"Names"` - State string `json:"State"` - Labels map[string]string `json:"Labels"` + Names []string `json:"Names"` + State string `json:"State"` + Labels map[string]string `json:"Labels"` NetworkSettings *struct { Networks map[string]any `json:"Networks"` } `json:"NetworkSettings"` @@ -134,13 +156,35 @@ func fetchServices() []Service { 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 the hub compose project - if c.Labels["com.docker.compose.project"] == "compose" { - svcs = append(svcs, Service{Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State}) + // 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}) } } } @@ -205,9 +249,29 @@ func jaegerTraces(service string) []Trace { return tt } -// ── Prometheus ── +// ── Prometheus helpers ── -func promQuery(query string) []float64 { +// 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) @@ -232,20 +296,55 @@ func promQuery(query string) []float64 { 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, down, otel int) string { - total := running + down + otel +func svgDonut(running, degraded int) string { + total := running + degraded if total == 0 { - return `No data` + 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"}, {down, "#f85149", "Down"}, {otel, "#58a6ff", "OTel"}} + segs := []seg{{running, "#3fb950", "Running"}, {degraded, "#d29922", "Degraded"}} var b strings.Builder - b.WriteString(fmt.Sprintf(``)) + b.WriteString(``) b.WriteString(``) var off float64 @@ -260,22 +359,16 @@ func svgDonut(running, down, otel int) string { 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 + 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() @@ -288,9 +381,7 @@ func svgLine(data []float64, color string) string { vh := h - pt - pb if len(data) == 0 { - return fmt.Sprintf(` - No data`, - w, h, w, h, w/2, h/2) + return noDataSVG(int(w), int(h)) } maxV := 0.0 @@ -304,15 +395,14 @@ func svgLine(data []float64, color string) string { } var b strings.Builder - b.WriteString(fmt.Sprintf(``, w, h, w, h)) + fmt.Fprintf(&b, ``, 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)) + fmt.Fprintf(&b, ``, pl, y, pl+vw, y) + fmt.Fprintf(&b, `%.0f`, pl-6, y+3, val) } n := len(data) @@ -323,41 +413,67 @@ func svgLine(data []float64, color string) string { 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)) + fmt.Fprintf(&b, ``, area, color) + fmt.Fprintf(&b, ``, + strings.Join(pts, " "), 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)) + fmt.Fprintf(&b, `%.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)) + 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 @@ -372,7 +488,7 @@ func refresh() { } } sort.Slice(svcs, func(i, j int) bool { - o := map[string]int{"running": 0, "jaeger": 1, "paused": 2, "exited": 3, "restarting": 4} + 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 @@ -392,14 +508,18 @@ func refresh() { 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])") + // 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) @@ -427,6 +547,35 @@ func refresh() { 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) { @@ -438,37 +587,52 @@ func dashboard(w http.ResponseWriter, _ *http.Request) { ers := append([]float64{}, state.errs...) trc := append([]float64{}, state.traces...) labels := append([]string{}, state.labels...) + node := state.node state.mu.Unlock() - running, down, otel := 0, 0, 0 + running, degraded := 0, 0 for _, s := range svcs { - switch s.State { - case "running": + if s.State == "running" { running++ - case "jaeger": - otel++ - default: - down++ + } else { + degraded++ } } - maxLat := 0.0 - for _, v := range lat { - if v > maxLat { - maxLat = v - } + 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, Down: down, OTelOnly: otel, + Services: svcs, Running: running, Degraded: degraded, TraceCount: len(tr), RPS: rps, Latency: lat, Errors: ers, TraceVolume: trc, TraceList: tr, 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")), + SystemName: composeProject, TotalUp: running, TotalDown: degraded, + Links: autoDetectLinks(svcs), + HasOTelData: len(rps) > 0 || len(lat) > 0 || len(tr) > 0, + 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") diff --git a/infra/dashboard/template.html b/infra/dashboard/template.html index e5e617a..6bd7d9e 100644 --- a/infra/dashboard/template.html +++ b/infra/dashboard/template.html @@ -8,38 +8,34 @@ *{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} - .header{display:flex;justify-content:space-between;align-items:center;padding:14px 24px;border-bottom:1px solid #30363d;background:#161b22;position:sticky;top:0;z-index:100} + .hdr{display:flex;justify-content:space-between;align-items:center;padding:14px 24px;border-bottom:1px solid #30363d;background:#161b22;position:sticky;top:0;z-index:100} .hl{display:flex;align-items:center;gap:12px} - .hl .logo{width:30px;height:30px;border-radius:7px;background:linear-gradient(135deg,#58a6ff,#bc8cff);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;color:#fff} + .hl .l{width:30px;height:30px;border-radius:7px;background:linear-gradient(135deg,#58a6ff,#bc8cff);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;color:#fff} .hl h1{font-size:17px;font-weight:600} - .hl .sub{color:#8b949e;font-size:12px} + .hl .s{color:#8b949e;font-size:12px} .hr{display:flex;align-items:center;gap:14px} .hb{display:flex;align-items:center;gap:6px;font-size:12px;padding:4px 14px;border-radius:20px;font-weight:500} .hb.g{background:rgba(63,185,80,0.12);color:#3fb950} .hb.y{background:rgba(210,153,34,0.12);color:#d29922} .hb.r{background:rgba(248,81,73,0.12);color:#f85149} - .hb .dot{width:8px;height:8px;border-radius:50%} - .hb.g .dot{background:#3fb950} - .hb.y .dot{background:#d29922} - .hb.r .dot{background:#f85149} - .upd{color:#6e7681;font-size:11px} + .hb .d{width:8px;height:8px;border-radius:50%} + .hb.g .d{background:#3fb950}.hb.y .d{background:#d29922}.hb.r .d{background:#f85149} + .up{color:#6e7681;font-size:11px} .c{max-width:1440px;margin:0 auto;padding:16px 24px} .g{display:grid;gap:14px;grid-template-columns:repeat(12,1fr)} - .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px} + .cd{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px} .ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px} .ch h2{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:#8b949e} .ch .b{font-size:10px;background:#1c2333;padding:2px 10px;border-radius:10px;color:#8b949e} + .cg{display:flex;justify-content:center} .sg{display:grid;gap:5px;grid-template-columns:repeat(auto-fill,minmax(150px,1fr))} - .st{display:flex;align-items:center;gap:7px;padding:7px 9px;border:1px solid #30363d;border-radius:6px;font-size:12px;transition:all .15s} + .st{display:flex;align-items:center;gap:7px;padding:7px 9px;border:1px solid #30363d;border-radius:6px;font-size:12px} .st:hover{background:#1c2333} .st .i{width:7px;height:7px;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 .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',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .sg2{display:grid;grid-template-columns:1fr 1fr;gap:7px} @@ -48,51 +44,55 @@ .sl{font-size:10px;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} - .links{display:flex;flex-wrap:wrap;gap:5px} - .links a{color:#58a6ff;text-decoration:none;font-size:11px;padding:4px 10px;border:1px solid #30363d;border-radius:6px;transition:all .15s} - .links a:hover{background:#1c2333;border-color:#58a6ff} + .sg3{display:grid;grid-template-columns:1fr 1fr;gap:8px} + + .lk{display:flex;flex-wrap:wrap;gap:5px} + .lk a{color:#58a6ff;text-decoration:none;font-size:11px;padding:4px 10px;border:1px solid #30363d;border-radius:6px} + .lk a:hover{background:#1c2333;border-color:#58a6ff} .tl{list-style:none} .ti{padding:7px 0;border-bottom:1px solid #30363d;font-size:12px;display:flex;justify-content:space-between;align-items:center} .ti:last-child{border-bottom:none} - .tl .ts{font-weight:500} - .tl .to{color:#8b949e;font-size:11px;font-family:'SF Mono','Fira Code',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px} + .ts{font-weight:500} + .to{color:#8b949e;font-size:11px;font-family:'SF Mono','Fira Code',monospace;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px} .tt{display:flex;gap:10px;font-size:11px;color:#8b949e;flex-shrink:0;align-items:center} .td{font-family:'SF Mono',monospace;font-weight:500;color:#58a6ff} .te{color:#f85149} - .chart{display:flex;justify-content:center} - .no-data{text-align:center;padding:30px 0;color:#6e7681;font-size:13px} + .nl{display:grid;grid-template-columns:1fr 1fr 1fr;gap:0;justify-items:center} + .ns{font-size:11px;color:#8b949e;text-align:center;margin-top:4px} .em{text-align:center;padding:20px;color:#6e7681;font-size:12px} @media(max-width:900px){ .g{grid-template-columns:1fr} .sg2{grid-template-columns:1fr 1fr} + .sg3{grid-template-columns:1fr} .sg{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))} - .header{flex-direction:column;align-items:flex-start;gap:8px} + .nl{grid-template-columns:1fr} + .hdr{flex-direction:column;align-items:flex-start;gap:8px} } -
+
- +
H

{{.SystemName}}

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

Services

{{len .Services}}
{{range .Services}} @@ -108,81 +108,80 @@ {{.Name}}
- {{else}} -
No services found
- {{end}} + {{else}}
No services
{{end}}
-
+

Overview

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

Health

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

Links

- + + {{if .HasNodeData}} +
+

System Resources

load: {{printf "%.2f" .Node.Load1}} {{printf "%.2f" .Node.Load5}} {{printf "%.2f" .Node.Load15}}
+
+
{{.CPUSVG}}
+
{{.RAMSVG}}
+
{{.DiskSVG}}
+
+ {{if .Node.NetIn}}
Net: {{printf "%.1f" .Node.NetIn}} B/s in / {{printf "%.1f" .Node.NetOut}} B/s out
{{end}} +
+ {{end}} + -
+

Request Rate

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

Latency

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

Error Rate

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

Trace Volume

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

Recent Traces

{{.TraceCount}}
+ {{if .TraceList}}
- {{if .TraceList}} -
{{range .TraceList}}
-
-
{{.Service}}
-
{{.Operation}}
-
+
{{.Service}}
{{.Operation}}
{{safeDur .Duration}} {{.Spans}} spans @@ -190,31 +189,17 @@
{{end}} -
- {{else}} -
No traces in the last 5 minutes
- {{end}}
+ {{else}}
No traces. Data appears once services send OTel traces.
{{end}}
diff --git a/infra/otel/prometheus.yml b/infra/otel/prometheus.yml index ba65be1..4a95860 100644 --- a/infra/otel/prometheus.yml +++ b/infra/otel/prometheus.yml @@ -10,3 +10,8 @@ scrape_configs: - targets: ['otel-collector:8889'] labels: service: otel-collector + + - job_name: 'node' + scrape_interval: 15s + static_configs: + - targets: ['node-exporter:9100']