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:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
Reference in New Issue
Block a user