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 ``
+ 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(`