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
This commit is contained in:
asepharyana
2026-07-23 05:31:05 +07:00
parent 4f15cafc02
commit 9a48a49f21
4 changed files with 330 additions and 159 deletions
+17
View File
@@ -56,6 +56,23 @@ services:
- ../../infra/otel/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ../../infra/otel/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- /prometheus - /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) ── # ── Custom Dashboard (nginx:alpine) ──
dashboard: dashboard:
container_name: dashboard container_name: dashboard
+241 -77
View File
@@ -24,8 +24,6 @@ var templateHTML string
var tmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{ var tmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{
"sub": func(a, b int) int { return a - b }, "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 { "safeDur": func(us int64) string {
if us < 1000 { if us < 1000 {
return fmt.Sprintf("%dµs", us) return fmt.Sprintf("%dµs", us)
@@ -39,8 +37,9 @@ var tmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{
// ── Types ── // ── Types ──
type Service struct { type Service struct {
Name string Name string
State string State string
HasWeb bool // has traefik router label → eligible for quick link
} }
type Trace struct { type Trace struct {
@@ -51,11 +50,22 @@ type Trace struct {
HasError bool 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 { type DashboardData struct {
Services []Service Services []Service
Running int Running int
Down int Degraded int
OTelOnly int
TraceCount int TraceCount int
RPS []float64 RPS []float64
Latency []float64 Latency []float64
@@ -63,17 +73,28 @@ type DashboardData struct {
TraceVolume []float64 TraceVolume []float64
TraceList []Trace TraceList []Trace
Labels []string Labels []string
Node NodeMetrics
Links []Link
HasOTelData bool
HasNodeData bool
HealthSVG template.HTML HealthSVG template.HTML
RPSSVG template.HTML RPSSVG template.HTML
LatencySVG template.HTML LatencySVG template.HTML
ErrorSVG template.HTML ErrorSVG template.HTML
TraceSVG template.HTML TraceSVG template.HTML
CPUSVG template.HTML
RAMSVG template.HTML
DiskSVG template.HTML
SystemName string SystemName string
Error string
TotalUp int TotalUp int
TotalDown int TotalDown int
} }
type Link struct {
URL string
Label string
}
// ── State ── // ── State ──
type appState struct { type appState struct {
@@ -85,13 +106,14 @@ type appState struct {
labels []string labels []string
services []Service services []Service
tracesL []Trace tracesL []Trace
node NodeMetrics
} }
var state appState var state appState
const maxPts = 30 const maxPts = 30
// ── Docker socket client ── // ── HTTP clients ──
var dockerClient = &http.Client{ var dockerClient = &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
@@ -104,7 +126,7 @@ var dockerClient = &http.Client{
var httpClient = &http.Client{Timeout: 10 * time.Second} var httpClient = &http.Client{Timeout: 10 * time.Second}
// ── Helpers ── var composeProject string // auto-detected from Docker labels
func fetchJSON(url string, v interface{}) error { func fetchJSON(url string, v interface{}) error {
r, err := httpClient.Get(url) r, err := httpClient.Get(url)
@@ -115,7 +137,7 @@ func fetchJSON(url string, v interface{}) error {
return json.NewDecoder(r.Body).Decode(v) return json.NewDecoder(r.Body).Decode(v)
} }
// ── Docker ── // ── Docker service discovery ──
func fetchServices() []Service { func fetchServices() []Service {
r, err := dockerClient.Get("http://localhost/containers/json?all=true") r, err := dockerClient.Get("http://localhost/containers/json?all=true")
@@ -124,9 +146,9 @@ func fetchServices() []Service {
} }
defer r.Body.Close() defer r.Body.Close()
var raw []struct { var raw []struct {
Names []string `json:"Names"` Names []string `json:"Names"`
State string `json:"State"` State string `json:"State"`
Labels map[string]string `json:"Labels"` Labels map[string]string `json:"Labels"`
NetworkSettings *struct { NetworkSettings *struct {
Networks map[string]any `json:"Networks"` Networks map[string]any `json:"Networks"`
} `json:"NetworkSettings"` } `json:"NetworkSettings"`
@@ -134,13 +156,35 @@ func fetchServices() []Service {
if json.NewDecoder(r.Body).Decode(&raw) != nil { if json.NewDecoder(r.Body).Decode(&raw) != nil {
return 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 var svcs []Service
for _, c := range raw { for _, c := range raw {
if c.NetworkSettings != nil { if c.NetworkSettings != nil {
if _, ok := c.NetworkSettings.Networks["app-shared-net"]; ok { if _, ok := c.NetworkSettings.Networks["app-shared-net"]; ok {
// Only include containers from the hub compose project // only include containers from our compose project
if c.Labels["com.docker.compose.project"] == "compose" { if c.Labels["com.docker.compose.project"] == composeProject {
svcs = append(svcs, Service{Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State}) 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 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() now := time.Now().Unix()
u := fmt.Sprintf("http://prometheus:9090/api/v1/query_range?query=%s&start=%d&end=%d&step=15", u := fmt.Sprintf("http://prometheus:9090/api/v1/query_range?query=%s&start=%d&end=%d&step=15",
url.QueryEscape(query), now-300, now) url.QueryEscape(query), now-300, now)
@@ -232,20 +296,55 @@ func promQuery(query string) []float64 {
return vals 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 ── // ── SVG Charts ──
func svgDonut(running, down, otel int) string { func svgDonut(running, degraded int) string {
total := running + down + otel total := running + degraded
if total == 0 { if total == 0 {
return `<svg width="200" height="210" viewBox="0 0 200 210"><text x="100" y="105" text-anchor="middle" fill="#6e7681" font-size="12" font-family="system-ui,sans-serif">No data</text></svg>` return noDataSVG(200, 210)
} }
const cx, cy, R = 100, 90, 60 const cx, cy, R = 100, 90, 60
const circ = 2 * math.Pi * R const circ = 2 * math.Pi * R
type seg struct{ n int; c, l string } 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 var b strings.Builder
b.WriteString(fmt.Sprintf(`<svg width="200" height="210" viewBox="0 0 200 210" xmlns="http://www.w3.org/2000/svg">`)) b.WriteString(`<svg width="200" height="210" viewBox="0 0 200 210" xmlns="http://www.w3.org/2000/svg">`)
b.WriteString(`<style>.sl{font-family:system-ui,sans-serif;font-size:10px;fill:#8b949e}</style>`) b.WriteString(`<style>.sl{font-family:system-ui,sans-serif;font-size:10px;fill:#8b949e}</style>`)
var off float64 var off float64
@@ -260,22 +359,16 @@ func svgDonut(running, down, otel int) string {
off += ln off += ln
} }
// Center
b.WriteString(fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700" font-family="system-ui,sans-serif">%d</text>`, cx, cy-4, total)) b.WriteString(fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" fill="#e6edf3" font-size="26" font-weight="700" font-family="system-ui,sans-serif">%d</text>`, cx, cy-4, total))
b.WriteString(fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" class="sl">total</text>`, cx, cy+14)) b.WriteString(fmt.Sprintf(`<text x="%d" y="%d" text-anchor="middle" class="sl">total</text>`, cx, cy+14))
// Legend
y := 165 y := 165
for _, s := range segs { for _, s := range segs {
pct := 0.0 if s.n == 0 {
if total > 0 { continue
pct = float64(s.n) / float64(total) * 100
}
if s.n > 0 || s.l == "Running" {
b.WriteString(fmt.Sprintf(`<circle cx="16" cy="%d" r="4" fill="%s"/>`, y, s.c))
b.WriteString(fmt.Sprintf(`<text x="26" y="%d" class="sl">%s: %d (%.0f%%)</text>`, y+3, s.l, s.n, pct))
y += 16
} }
fmt.Fprintf(&b, `<circle cx="16" cy="%d" r="4" fill="%s"/><text x="26" y="%d" class="sl">%s: %d</text>`, y, s.c, y+3, s.l, s.n)
y += 16
} }
b.WriteString(`</svg>`) b.WriteString(`</svg>`)
return b.String() return b.String()
@@ -288,9 +381,7 @@ func svgLine(data []float64, color string) string {
vh := h - pt - pb vh := h - pt - pb
if len(data) == 0 { if len(data) == 0 {
return fmt.Sprintf(`<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg"> return noDataSVG(int(w), int(h))
<text x="%.0f" y="%.0f" text-anchor="middle" fill="#6e7681" font-size="11" font-family="system-ui,sans-serif">No data</text></svg>`,
w, h, w, h, w/2, h/2)
} }
maxV := 0.0 maxV := 0.0
@@ -304,15 +395,14 @@ func svgLine(data []float64, color string) string {
} }
var b strings.Builder var b strings.Builder
b.WriteString(fmt.Sprintf(`<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg">`, w, h, w, h)) fmt.Fprintf(&b, `<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg">`, w, h, w, h)
b.WriteString(`<style>.ax{font-family:system-ui,sans-serif;font-size:9px;fill:#6e7681}</style>`) b.WriteString(`<style>.ax{font-family:system-ui,sans-serif;font-size:9px;fill:#6e7681}</style>`)
// Grid
for i := 0; i <= 4; i++ { for i := 0; i <= 4; i++ {
y := pt + vh*float64(i)/4 y := pt + vh*float64(i)/4
val := maxV * (1 - float64(i)/4) val := maxV * (1 - float64(i)/4)
b.WriteString(fmt.Sprintf(`<line x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#21262d" stroke-width="1"/>`, pl, y, pl+vw, y)) fmt.Fprintf(&b, `<line x1="%.0f" y1="%.0f" x2="%.0f" y2="%.0f" stroke="#21262d" stroke-width="1"/>`, pl, y, pl+vw, y)
b.WriteString(fmt.Sprintf(`<text x="%.0f" y="%.0f" text-anchor="end" class="ax">%.0f</text>`, pl-6, y+3, val)) fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="end" class="ax">%.0f</text>`, pl-6, y+3, val)
} }
n := len(data) n := len(data)
@@ -323,41 +413,67 @@ func svgLine(data []float64, color string) string {
y := pt + vh*(1-v/maxV) y := pt + vh*(1-v/maxV)
pts[i] = fmt.Sprintf("%.1f,%.1f", x, y) 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) 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(`<path d="%s" fill="%s" opacity="0.15"/>`, area, color)) fmt.Fprintf(&b, `<path d="%s" fill="%s" opacity="0.15"/>`, area, color)
fmt.Fprintf(&b, `<polyline points="%s" fill="none" stroke="%s" stroke-width="2" stroke-linejoin="round"/>`,
strings.Join(pts, " "), color)
// Line
b.WriteString(fmt.Sprintf(`<polyline points="%s" fill="none" stroke="%s" stroke-width="2" stroke-linejoin="round"/>`,
strings.Join(pts, " "), color))
// Last value
lv := data[n-1] lv := data[n-1]
ly := pt + vh*(1-lv/maxV) ly := pt + vh*(1-lv/maxV)
b.WriteString(fmt.Sprintf(`<text x="%.0f" y="%.0f" text-anchor="end" fill="%s" font-size="11" font-weight="600" font-family="system-ui,sans-serif">%.1f</text>`, fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="end" fill="%s" font-size="11" font-weight="600" font-family="system-ui,sans-serif">%.1f</text>`,
pl+vw, ly-10, color, lv)) pl+vw, ly-10, color, lv)
} }
// X labels
step := n / 5 step := n / 5
if step < 1 { if step < 1 {
step = 1 step = 1
} }
for i := step; i < n; i += step { for i := step; i < n; i += step {
x := pl + vw*float64(i)/float64(n-1) x := pl + vw*float64(i)/float64(n-1)
b.WriteString(fmt.Sprintf(`<text x="%.0f" y="%.0f" text-anchor="middle" class="ax">%d</text>`, x, pt+vh+16, i+1)) fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" text-anchor="middle" class="ax">%d</text>`, x, pt+vh+16, i+1)
} }
b.WriteString(`</svg>`) b.WriteString(`</svg>`)
return b.String() 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, `<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg">`, w, h, w, h)
b.WriteString(`<style>.gt{font-family:system-ui,sans-serif;font-size:11px;fill:#8b949e;text-anchor:middle}</style>`)
// background bar
fmt.Fprintf(&b, `<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="7" ry="7" fill="#1c2333"/>`, bx, by, bw, bh)
// fill bar
if fw > 0 {
fmt.Fprintf(&b, `<rect x="%.0f" y="%.0f" width="%.0f" height="%.0f" rx="7" ry="7" fill="%s" opacity="0.85"/>`, bx, by, fw, bh, color)
}
// label
fmt.Fprintf(&b, `<text x="%.0f" y="14" class="gt" font-weight="600" fill="%s">%s</text>`, w/2, color, label)
// value
fmt.Fprintf(&b, `<text x="%.0f" y="%.0f" class="gt" fill="#e6edf3" font-size="14" font-weight="700">%.1f%s</text>`, w/2, by+bh+24, pct, unit)
b.WriteString(`</svg>`)
return b.String()
}
func noDataSVG(w, h int) string {
return fmt.Sprintf(`<svg width="%d" height="%d" viewBox="0 0 %d %d" xmlns="http://www.w3.org/2000/svg"><text x="%d" y="%d" text-anchor="middle" fill="#6e7681" font-size="12" font-family="system-ui,sans-serif">No data</text></svg>`,
w, h, w, h, w/2, h/2)
}
// ── Refresh ── // ── Refresh ──
func refresh() { func refresh() {
svcs := fetchServices() svcs := fetchServices()
// Merge Jaeger-discovered services
jaegerS := jaegerServices() jaegerS := jaegerServices()
for _, s := range jaegerS { for _, s := range jaegerS {
found := false found := false
@@ -372,7 +488,7 @@ func refresh() {
} }
} }
sort.Slice(svcs, func(i, j int) bool { 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] oi, oj := o[svcs[i].State], o[svcs[j].State]
if oi != oj { if oi != oj {
return oi < oj return oi < oj
@@ -392,14 +508,18 @@ func refresh() {
traces = traces[:20] traces = traces[:20]
} }
// Prometheus // Prometheus OTel metrics
rps := promQuery("rate(otelcol_receiver_accepted_spans[1m])") rps := promRange("rate(otelcol_receiver_accepted_spans[1m])")
lat := promQuery("otelcol_receiver_accepted_spans") lat := promRange("otelcol_receiver_accepted_spans")
err := promQuery("rate(otelcol_receiver_refused_spans[1m])") err := promRange("rate(otelcol_receiver_refused_spans[1m])")
// Node metrics
node := fetchNodeMetrics()
state.mu.Lock() state.mu.Lock()
state.services = svcs state.services = svcs
state.tracesL = traces state.tracesL = traces
state.node = node
now := time.Now().Format("15:04:05") now := time.Now().Format("15:04:05")
state.labels = append(state.labels, now) state.labels = append(state.labels, now)
@@ -427,6 +547,35 @@ func refresh() {
state.mu.Unlock() 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 ── // ── Dashboard Handler ──
func dashboard(w http.ResponseWriter, _ *http.Request) { func dashboard(w http.ResponseWriter, _ *http.Request) {
@@ -438,37 +587,52 @@ func dashboard(w http.ResponseWriter, _ *http.Request) {
ers := append([]float64{}, state.errs...) ers := append([]float64{}, state.errs...)
trc := append([]float64{}, state.traces...) trc := append([]float64{}, state.traces...)
labels := append([]string{}, state.labels...) labels := append([]string{}, state.labels...)
node := state.node
state.mu.Unlock() state.mu.Unlock()
running, down, otel := 0, 0, 0 running, degraded := 0, 0
for _, s := range svcs { for _, s := range svcs {
switch s.State { if s.State == "running" {
case "running":
running++ running++
case "jaeger": } else {
otel++ degraded++
default:
down++
} }
} }
maxLat := 0.0 cpuColor, ramColor, diskColor := "#3fb950", "#3fb950", "#3fb950"
for _, v := range lat { if node.CPU > 80 {
if v > maxLat { cpuColor = "#f85149"
maxLat = v } 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{ 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, TraceCount: len(tr), RPS: rps, Latency: lat, Errors: ers,
TraceVolume: trc, TraceList: tr, Labels: labels, TraceVolume: trc, TraceList: tr, Labels: labels,
SystemName: "asepharyana-hub", TotalUp: running, TotalDown: down + otel, SystemName: composeProject, TotalUp: running, TotalDown: degraded,
HealthSVG: template.HTML(svgDonut(running, down, otel)), Links: autoDetectLinks(svcs),
RPSSVG: template.HTML(svgLine(rps, "#58a6ff")), HasOTelData: len(rps) > 0 || len(lat) > 0 || len(tr) > 0,
LatencySVG: template.HTML(svgLine(lat, "#bc8cff")), HasNodeData: node.Online,
ErrorSVG: template.HTML(svgLine(ers, "#f85149")), Node: node,
TraceSVG: template.HTML(svgLine(trc, "#3fb950")), 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") w.Header().Set("Content-Type", "text/html; charset=utf-8")
+67 -82
View File
@@ -8,38 +8,34 @@
*{margin:0;padding:0;box-sizing:border-box} *{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} 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{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 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} .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{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.g{background:rgba(63,185,80,0.12);color:#3fb950}
.hb.y{background:rgba(210,153,34,0.12);color:#d29922} .hb.y{background:rgba(210,153,34,0.12);color:#d29922}
.hb.r{background:rgba(248,81,73,0.12);color:#f85149} .hb.r{background:rgba(248,81,73,0.12);color:#f85149}
.hb .dot{width:8px;height:8px;border-radius:50%} .hb .d{width:8px;height:8px;border-radius:50%}
.hb.g .dot{background:#3fb950} .hb.g .d{background:#3fb950}.hb.y .d{background:#d29922}.hb.r .d{background:#f85149}
.hb.y .dot{background:#d29922} .up{color:#6e7681;font-size:11px}
.hb.r .dot{background:#f85149}
.upd{color:#6e7681;font-size:11px}
.c{max-width:1440px;margin:0 auto;padding:16px 24px} .c{max-width:1440px;margin:0 auto;padding:16px 24px}
.g{display:grid;gap:14px;grid-template-columns:repeat(12,1fr)} .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{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 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} .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))} .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:hover{background:#1c2333}
.st .i{width:7px;height:7px;border-radius:50%;flex-shrink:0} .st .i{width:7px;height:7px;border-radius:50%;flex-shrink:0}
.st .i.g{background:#3fb950} .st .i.g{background:#3fb950}.st .i.r{background:#f85149}.st .i.y{background:#d29922}.st .i.b{background:#58a6ff}
.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} .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} .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} .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} .sv.a{color:#58a6ff}.sv.g{color:#3fb950}.sv.r{color:#f85149}.sv.y{color:#d29922}
.links{display:flex;flex-wrap:wrap;gap:5px} .sg3{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.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} .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} .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{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} .ti:last-child{border-bottom:none}
.tl .ts{font-weight:500} .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} .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} .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} .td{font-family:'SF Mono',monospace;font-weight:500;color:#58a6ff}
.te{color:#f85149} .te{color:#f85149}
.chart{display:flex;justify-content:center} .nl{display:grid;grid-template-columns:1fr 1fr 1fr;gap:0;justify-items:center}
.no-data{text-align:center;padding:30px 0;color:#6e7681;font-size:13px} .ns{font-size:11px;color:#8b949e;text-align:center;margin-top:4px}
.em{text-align:center;padding:20px;color:#6e7681;font-size:12px} .em{text-align:center;padding:20px;color:#6e7681;font-size:12px}
@media(max-width:900px){ @media(max-width:900px){
.g{grid-template-columns:1fr} .g{grid-template-columns:1fr}
.sg2{grid-template-columns:1fr 1fr} .sg2{grid-template-columns:1fr 1fr}
.sg3{grid-template-columns:1fr}
.sg{grid-template-columns:repeat(auto-fill,minmax(120px,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}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="header"> <div class="hdr">
<div class="hl"> <div class="hl">
<div class="logo">H</div> <div class="l">H</div>
<div> <div>
<h1>{{.SystemName}}</h1> <h1>{{.SystemName}}</h1>
<div class="sub">System Dashboard</div> <div class="s">System Dashboard</div>
</div> </div>
</div> </div>
<div class="hr"> <div class="hr">
{{if eq .TotalDown 0}} {{if eq .TotalDown 0}}
<span class="hb g"><span class="dot"></span> All Systems Operational</span> <span class="hb g"><span class="d"></span> All Systems Operational</span>
{{else if lt .TotalDown 3}} {{else if lt .TotalDown 3}}
<span class="hb y"><span class="dot"></span> {{.TotalDown}} degraded</span> <span class="hb y"><span class="d"></span> {{.TotalDown}} degraded</span>
{{else}} {{else}}
<span class="hb r"><span class="dot"></span> {{.TotalDown}} services down</span> <span class="hb r"><span class="d"></span> {{.TotalDown}} services down</span>
{{end}} {{end}}
<span class="upd" id="ts"></span> <span class="up" id="ts"></span>
</div> </div>
</div> </div>
@@ -100,7 +100,7 @@
<div class="g"> <div class="g">
<!-- Services --> <!-- Services -->
<div class="card" style="grid-column:span 4"> <div class="cd" style="grid-column:span 4">
<div class="ch"><h2>Services</h2><span class="b">{{len .Services}}</span></div> <div class="ch"><h2>Services</h2><span class="b">{{len .Services}}</span></div>
<div class="sg"> <div class="sg">
{{range .Services}} {{range .Services}}
@@ -108,81 +108,80 @@
<span class="i {{if eq .State "running"}}g{{else if eq .State "jaeger"}}b{{else}}r{{end}}"></span> <span class="i {{if eq .State "running"}}g{{else if eq .State "jaeger"}}b{{else}}r{{end}}"></span>
<span class="n">{{.Name}}</span> <span class="n">{{.Name}}</span>
</div> </div>
{{else}} {{else}}<div class="em">No services</div>{{end}}
<div class="em">No services found</div>
{{end}}
</div> </div>
</div> </div>
<!-- Overview --> <!-- Overview -->
<div class="card" style="grid-column:span 2"> <div class="cd" style="grid-column:span 2">
<div class="ch"><h2>Overview</h2></div> <div class="ch"><h2>Overview</h2></div>
<div class="sg2"> <div class="sg2">
<div class="sb"><div class="sv a">{{len .Services}}</div><div class="sl">Total</div></div> <div class="sb"><div class="sv a">{{len .Services}}</div><div class="sl">Total</div></div>
<div class="sb"><div class="sv a">{{.Running}}</div><div class="sl">Healthy</div></div> <div class="sb"><div class="sv a">{{.Running}}</div><div class="sl">Healthy</div></div>
<div class="sb"><div class="sv g">{{if .TraceVolume}}{{printf "%.0f" (index .TraceVolume (sub (len .TraceVolume) 1))}}{{else}}0{{end}}</div><div class="sl">Traces</div></div> <div class="sb"><div class="sv g">{{.TraceCount}}</div><div class="sl">Traces</div></div>
<div class="sb"><div class="sv r">{{if .Errors}}{{printf "%.0f" (index .Errors (sub (len .Errors) 1))}}{{else}}0{{end}}</div><div class="sl">Errors</div></div> <div class="sb"><div class="sv r">{{if .Errors}}{{printf "%.0f" (index .Errors (sub (len .Errors) 1))}}{{else}}0{{end}}</div><div class="sl">Errors</div></div>
</div> </div>
</div> </div>
<!-- Health Donut (server-side SVG) --> <!-- Health Donut -->
<div class="card" style="grid-column:span 3"> <div class="cd" style="grid-column:span 3">
<div class="ch"><h2>Health</h2></div> <div class="ch"><h2>Health</h2></div>
<div class="chart">{{.HealthSVG}}</div> <div class="cg">{{.HealthSVG}}</div>
</div> </div>
<!-- Quick Links --> <!-- Links (auto-generated) -->
<div class="card" style="grid-column:span 3"> <div class="cd" style="grid-column:span 3">
<div class="ch"><h2>Links</h2></div> <div class="ch"><h2>Links</h2></div>
<div class="links"> <div class="lk">
<a href="/jaeger" target="_blank">Jaeger UI</a> {{range .Links}}<a href="{{.URL}}" target="_blank">{{.Label}}</a>{{end}}
<a href="/api/prometheus/targets" target="_blank">Prometheus</a>
<a href="https://github.com/asepharyana/asepharyana-hub" target="_blank">GitHub</a>
{{$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"))}}
<a href="https://{{.Name}}.{{$domain}}" target="_blank">{{.Name}}</a>
{{end}}
{{end}}
</div> </div>
</div> </div>
<!-- Node: CPU / RAM / Disk (auto-detected via Prometheus) -->
{{if .HasNodeData}}
<div class="cd" style="grid-column:span 6">
<div class="ch"><h2>System Resources</h2><span class="b">load: {{printf "%.2f" .Node.Load1}} {{printf "%.2f" .Node.Load5}} {{printf "%.2f" .Node.Load15}}</span></div>
<div class="sg3">
<div class="cg">{{.CPUSVG}}</div>
<div class="cg">{{.RAMSVG}}</div>
<div class="cg">{{.DiskSVG}}</div>
</div>
{{if .Node.NetIn}}<div class="ns">Net: {{printf "%.1f" .Node.NetIn}} B/s in / {{printf "%.1f" .Node.NetOut}} B/s out</div>{{end}}
</div>
{{end}}
<!-- Request Rate --> <!-- Request Rate -->
<div class="card" style="grid-column:span 4"> <div class="cd" style="grid-column:span 4">
<div class="ch"><h2>Request Rate</h2><span class="b">{{if .RPS}}{{printf "%.1f" (index .RPS (sub (len .RPS) 1))}}/s{{else}}-{{end}}</span></div> <div class="ch"><h2>Request Rate</h2><span class="b">{{if .RPS}}{{printf "%.1f" (index .RPS (sub (len .RPS) 1))}}/s{{else}}-{{end}}</span></div>
<div class="chart">{{.RPSSVG}}</div> <div class="cg">{{.RPSSVG}}</div>
</div> </div>
<!-- Latency --> <!-- Latency -->
<div class="card" style="grid-column:span 4"> <div class="cd" style="grid-column:span 4">
<div class="ch"><h2>Latency</h2><span class="b">{{if .Latency}}{{printf "%.0f" (index .Latency (sub (len .Latency) 1))}}ms{{else}}-{{end}}</span></div> <div class="ch"><h2>Latency</h2><span class="b">{{if .Latency}}{{printf "%.0f" (index .Latency (sub (len .Latency) 1))}}ms{{else}}-{{end}}</span></div>
<div class="chart">{{.LatencySVG}}</div> <div class="cg">{{.LatencySVG}}</div>
</div> </div>
<!-- Error Rate --> <!-- Error Rate -->
<div class="card" style="grid-column:span 4"> <div class="cd" style="grid-column:span 4">
<div class="ch"><h2>Error Rate</h2><span class="b">{{if .Errors}}{{printf "%.1f" (index .Errors (sub (len .Errors) 1))}}/s{{else}}-{{end}}</span></div> <div class="ch"><h2>Error Rate</h2><span class="b">{{if .Errors}}{{printf "%.1f" (index .Errors (sub (len .Errors) 1))}}/s{{else}}-{{end}}</span></div>
<div class="chart">{{.ErrorSVG}}</div> <div class="cg">{{.ErrorSVG}}</div>
</div> </div>
<!-- Trace Volume --> <!-- Trace Volume -->
<div class="card" style="grid-column:span 6"> <div class="cd" style="grid-column:span 6">
<div class="ch"><h2>Trace Volume</h2><span class="b">{{.TraceCount}} traces</span></div> <div class="ch"><h2>Trace Volume</h2><span class="b">{{.TraceCount}} traces</span></div>
<div class="chart">{{.TraceSVG}}</div> <div class="cg">{{.TraceSVG}}</div>
</div> </div>
<!-- Recent Traces --> <!-- Recent Traces -->
<div class="card" style="grid-column:span 6"> <div class="cd" style="grid-column:span 6">
<div class="ch"><h2>Recent Traces</h2><span class="b">{{.TraceCount}}</span></div> <div class="ch"><h2>Recent Traces</h2><span class="b">{{.TraceCount}}</span></div>
{{if .TraceList}}
<div class="tl"> <div class="tl">
{{if .TraceList}}
<div id="tl">
{{range .TraceList}} {{range .TraceList}}
<div class="ti"> <div class="ti">
<div> <div><div class="ts">{{.Service}}</div><div class="to">{{.Operation}}</div></div>
<div class="ts">{{.Service}}</div>
<div class="to">{{.Operation}}</div>
</div>
<div class="tt"> <div class="tt">
<span class="td">{{safeDur .Duration}}</span> <span class="td">{{safeDur .Duration}}</span>
<span>{{.Spans}} spans</span> <span>{{.Spans}} spans</span>
@@ -190,31 +189,17 @@
</div> </div>
</div> </div>
{{end}} {{end}}
</div>
{{else}}
<div class="em">No traces in the last 5 minutes</div>
{{end}}
</div> </div>
{{else}}<div class="em">No traces. Data appears once services send OTel traces.</div>{{end}}
</div> </div>
</div> </div>
</div> </div>
<script> <script>
// Auto-refresh every 15s (no JS charts — server-side SVGs)
function refresh() {
fetch('/', {headers:{'X-Requested-With':'XMLHttpRequest'}})
.then(r => r.text())
.then(html => {
document.open();
document.write(html);
document.close();
})
.catch(() => {});
}
document.getElementById('ts').textContent = 'updated ' + new Date().toLocaleTimeString(); document.getElementById('ts').textContent = 'updated ' + new Date().toLocaleTimeString();
setInterval(() => { document.getElementById('ts').textContent = 'updated ' + new Date().toLocaleTimeString(); }, 1000); setInterval(function(){ document.getElementById('ts').textContent = 'updated ' + new Date().toLocaleTimeString(); }, 1000);
setTimeout(() => location.reload(), 15000); setTimeout(function(){ location.reload(); }, 15000);
</script> </script>
</body> </body>
</html> </html>
+5
View File
@@ -10,3 +10,8 @@ scrape_configs:
- targets: ['otel-collector:8889'] - targets: ['otel-collector:8889']
labels: labels:
service: otel-collector service: otel-collector
- job_name: 'node'
scrape_interval: 15s
static_configs:
- targets: ['node-exporter:9100']