refactor(infra): server-side SVG dashboard with Go
- Replace nginx+Chart.js client-side dashboard with Go server - Server-side rendered SVG charts (donut, line charts) - Direct Docker socket integration via Go stdlib - Embedded HTML template via //go:embed - Minimal scratch image (~9MB total) - Remove nginx.conf and client-side Chart.js
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
module github.com/asepharyana/asepharyana-hub/infra/dashboard
|
||||
|
||||
go 1.26.5
|
||||
@@ -1,671 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hub Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--bg-card: #161b22;
|
||||
--bg-hover: #1c2333;
|
||||
--border: #30363d;
|
||||
--text: #e6edf3;
|
||||
--text-dim: #8b949e;
|
||||
--text-muted: #6e7681;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--green-bg: rgba(63,185,80,0.12);
|
||||
--yellow: #d29922;
|
||||
--yellow-bg: rgba(210,153,34,0.12);
|
||||
--red: #f85149;
|
||||
--red-bg: rgba(248,81,73,0.12);
|
||||
--orange: #d4760b;
|
||||
--purple: #bc8cff;
|
||||
--chart-bg: rgba(88,166,255,0.08);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.header-left .logo {
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 14px;
|
||||
}
|
||||
.header-left h1 { font-size: 18px; font-weight: 600; }
|
||||
.header-left .subtitle { color: var(--text-dim); font-size: 12px; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; }
|
||||
.header-right .health-badge {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; padding: 4px 12px;
|
||||
border-radius: 20px; font-weight: 500;
|
||||
}
|
||||
.health-badge .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.health-badge.ok { background: var(--green-bg); color: var(--green); }
|
||||
.health-badge.warn { background: var(--yellow-bg); color: var(--yellow); }
|
||||
.health-badge.err { background: var(--red-bg); color: var(--red); }
|
||||
.last-updated { color: var(--text-muted); font-size: 11px; }
|
||||
|
||||
/* Main layout */
|
||||
.container { max-width: 1440px; margin: 0 auto; padding: 20px 24px; }
|
||||
|
||||
.grid {
|
||||
display: grid; gap: 16px;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 16px;
|
||||
}
|
||||
.card-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-header h2 {
|
||||
font-size: 12px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.06em; color: var(--text-dim);
|
||||
}
|
||||
.card-header .badge {
|
||||
font-size: 10px; background: var(--bg-hover);
|
||||
padding: 2px 8px; border-radius: 10px; color: var(--text-dim);
|
||||
}
|
||||
.chart-wrapper { position: relative; height: 200px; }
|
||||
|
||||
/* Service grid */
|
||||
.service-grid {
|
||||
display: grid; gap: 6px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
}
|
||||
.service-tile {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.service-tile:hover { background: var(--bg-hover); }
|
||||
.service-tile .indicator {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.service-tile .indicator.up { background: var(--green); }
|
||||
.service-tile .indicator.down { background: var(--red); }
|
||||
.service-tile .indicator.warn { background: var(--yellow); }
|
||||
.service-tile .indicator.otel { background: var(--accent); }
|
||||
.service-tile .name {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Stat boxes */
|
||||
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.stat-box {
|
||||
background: var(--bg-hover); border-radius: 6px; padding: 12px; text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 24px; font-weight: 700; font-family: 'SF Mono', monospace;
|
||||
}
|
||||
.stat-label { font-size: 10px; color: var(--text-dim); margin-top: 2px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.stat-value.green { color: var(--green); }
|
||||
.stat-value.yellow { color: var(--yellow); }
|
||||
.stat-value.red { color: var(--red); }
|
||||
.stat-value.accent { color: var(--accent); }
|
||||
|
||||
/* Trace list */
|
||||
.trace-list { list-style: none; }
|
||||
.trace-item {
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.trace-item:last-child { border-bottom: none; }
|
||||
.trace-item .trace-left { overflow: hidden; }
|
||||
.trace-item .trace-service { font-weight: 500; }
|
||||
.trace-item .trace-op {
|
||||
color: var(--text-dim); font-size: 11px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 300px;
|
||||
}
|
||||
.trace-item .trace-right {
|
||||
display: flex; gap: 10px; font-size: 11px; color: var(--text-dim);
|
||||
flex-shrink: 0; align-items: center;
|
||||
}
|
||||
.trace-item .trace-duration {
|
||||
font-family: 'SF Mono', monospace; font-weight: 500; color: var(--accent);
|
||||
}
|
||||
.trace-item .trace-error { color: var(--red); }
|
||||
|
||||
/* Links */
|
||||
.links { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.links a {
|
||||
color: var(--accent); text-decoration: none; font-size: 11px;
|
||||
padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.links a:hover { background: var(--bg-hover); border-color: var(--accent); }
|
||||
|
||||
.loading { text-align: center; padding: 20px; color: var(--text-dim); font-size: 13px; }
|
||||
.empty-state { text-align: center; padding: 20px; color: var(--text-muted); font-size: 12px; }
|
||||
.inline-error {
|
||||
background: var(--red-bg); border: 1px solid rgba(248,81,73,0.3);
|
||||
border-radius: 6px; padding: 8px 12px; font-size: 11px; color: var(--red);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 900px) {
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.stat-grid { grid-template-columns: 1fr 1fr; }
|
||||
.service-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); }
|
||||
.header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">H</div>
|
||||
<div>
|
||||
<h1>asepharyana-hub</h1>
|
||||
<div class="subtitle">System Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="health-badge ok" id="health-indicator">
|
||||
<span class="dot"></span> Loading...
|
||||
</span>
|
||||
<span class="last-updated" id="last-updated">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="grid">
|
||||
|
||||
<!-- Column 1: Service Health (left side, spans 4 cols) -->
|
||||
<div class="card" style="grid-column: span 4;">
|
||||
<div class="card-header">
|
||||
<h2>Services</h2>
|
||||
<span class="badge" id="service-count">0</span>
|
||||
</div>
|
||||
<div class="service-grid" id="service-list">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 1b: Overview Stats -->
|
||||
<div class="card" style="grid-column: span 2;">
|
||||
<div class="card-header"><h2>Overview</h2></div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-box">
|
||||
<div class="stat-value accent" id="stat-services">-</div>
|
||||
<div class="stat-label">Services</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value accent" id="stat-up">-</div>
|
||||
<div class="stat-label">Healthy</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value green" id="stat-traces">-</div>
|
||||
<div class="stat-label">Traces (5m)</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-value red" id="stat-errors">-</div>
|
||||
<div class="stat-label">Errors (5m)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 1c: Health Donut Chart -->
|
||||
<div class="card" style="grid-column: span 3;">
|
||||
<div class="card-header"><h2>Health Distribution</h2></div>
|
||||
<div class="chart-wrapper"><canvas id="healthChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Column 1d: Quick Links -->
|
||||
<div class="card" style="grid-column: span 3;">
|
||||
<div class="card-header"><h2>Quick Links</h2></div>
|
||||
<div class="links" id="quick-links">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2: Request Rate (spans 4 cols) -->
|
||||
<div class="card" style="grid-column: span 4;">
|
||||
<div class="card-header"><h2>Request Rate</h2><span class="badge" id="rps-value">-</span></div>
|
||||
<div class="chart-wrapper"><canvas id="rpsChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2b: Latency -->
|
||||
<div class="card" style="grid-column: span 4;">
|
||||
<div class="card-header"><h2>Latency</h2><span class="badge" id="latency-value">-</span></div>
|
||||
<div class="chart-wrapper"><canvas id="latencyChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Column 2c: Error Rate -->
|
||||
<div class="card" style="grid-column: span 4;">
|
||||
<div class="card-header"><h2>Error Rate</h2><span class="badge" id="error-value">-</span></div>
|
||||
<div class="chart-wrapper"><canvas id="errorChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3: Trace Volume (full width) -->
|
||||
<div class="card" style="grid-column: span 6;">
|
||||
<div class="card-header"><h2>Trace Volume</h2><span class="badge" id="trace-vol-value">0/min</span></div>
|
||||
<div class="chart-wrapper"><canvas id="traceChart"></canvas></div>
|
||||
</div>
|
||||
|
||||
<!-- Column 3b: Recent Traces -->
|
||||
<div class="card" style="grid-column: span 6;">
|
||||
<div class="card-header"><h2>Recent Traces</h2><span class="badge" id="trace-list-count">0</span></div>
|
||||
<div id="trace-list-container"><div class="loading">Loading...</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Config ──
|
||||
const JAEGER_API = '/api/jaeger';
|
||||
const DOCKER_API = '/api/docker';
|
||||
const PROM_API = '/api/prometheus';
|
||||
const TRACE_LIMIT = 15;
|
||||
const MAX_DATA_POINTS = 30; // ~7.5 minutes at 15s interval
|
||||
|
||||
// ── State ──
|
||||
const state = {
|
||||
services: [],
|
||||
rps: [], latency: [], errors: [],
|
||||
traceVol: [],
|
||||
labels: [],
|
||||
};
|
||||
|
||||
// ── Chart Initialization ──
|
||||
function createLineChart(id, label, color, fillColor) {
|
||||
const ctx = document.getElementById(id).getContext('2d');
|
||||
return new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [{
|
||||
label, data: [],
|
||||
borderColor: color,
|
||||
backgroundColor: fillColor,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 300 },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'index', intersect: false,
|
||||
backgroundColor: '#161b22',
|
||||
titleColor: '#e6edf3',
|
||||
bodyColor: '#8b949e',
|
||||
borderColor: '#30363d',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
ticks: { color: '#6e7681', maxTicksLimit: 5, font: { size: 10 } },
|
||||
grid: { color: 'rgba(48,54,61,0.3)', drawOnChartArea: false },
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { color: '#6e7681', font: { size: 10 }, maxTicksLimit: 5 },
|
||||
grid: { color: 'rgba(48,54,61,0.3)' },
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let healthChart, rpsChart, latencyChart, errorChart, traceChart;
|
||||
|
||||
function initCharts() {
|
||||
healthChart = new Chart(document.getElementById('healthChart').getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Running', 'Down', 'OTel'],
|
||||
datasets: [{
|
||||
data: [0, 0, 0],
|
||||
backgroundColor: ['#3fb950', '#f85149', '#58a6ff'],
|
||||
borderWidth: 0,
|
||||
hoverOffset: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '70%',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: { color: '#8b949e', padding: 8, boxWidth: 10, font: { size: 10 } }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#161b22',
|
||||
titleColor: '#e6edf3',
|
||||
bodyColor: '#8b949e',
|
||||
borderColor: '#30363d',
|
||||
borderWidth: 1,
|
||||
cornerRadius: 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rpsChart = createLineChart('rpsChart', 'Req/s', '#58a6ff', 'rgba(88,166,255,0.08)');
|
||||
latencyChart = createLineChart('latencyChart','Latency', '#bc8cff', 'rgba(188,140,255,0.08)');
|
||||
errorChart = createLineChart('errorChart', 'Errors/s', '#f85149', 'rgba(248,81,73,0.08)');
|
||||
traceChart = createLineChart('traceChart', 'Traces/min','#3fb950', 'rgba(63,185,80,0.08)');
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function relTime(ms) {
|
||||
const s = Math.floor(ms / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
return `${Math.floor(m / 60)}h`;
|
||||
}
|
||||
|
||||
function fmtDur(us) {
|
||||
if (!us) return '-';
|
||||
if (us < 1000) return `${us}µs`;
|
||||
if (us < 1e6) return `${(us/1000).toFixed(1)}ms`;
|
||||
return `${(us/1e6).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
|
||||
|
||||
function nowMicro() { return Date.now() * 1000; }
|
||||
|
||||
function pushMetric(arr, val) {
|
||||
arr.push(val);
|
||||
if (arr.length > MAX_DATA_POINTS) arr.shift();
|
||||
}
|
||||
|
||||
function tickLabel() {
|
||||
return new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||
}
|
||||
|
||||
// ── Service Loading ──
|
||||
async function loadServices() {
|
||||
const listEl = document.getElementById('service-list');
|
||||
const countEl = document.getElementById('service-count');
|
||||
const statSvc = document.getElementById('stat-services');
|
||||
const statUp = document.getElementById('stat-up');
|
||||
|
||||
let dockerContainers = [];
|
||||
try {
|
||||
const containers = await fetchJSON(`${DOCKER_API}/containers/json?all=true`);
|
||||
dockerContainers = containers.filter(c => {
|
||||
const nets = c.NetworkSettings && c.NetworkSettings.Networks;
|
||||
return nets && nets['app-shared-net'];
|
||||
});
|
||||
} catch(e) { /* no Docker socket */ }
|
||||
|
||||
let jaegerServices = [];
|
||||
try {
|
||||
const svcRes = await fetchJSON(`${JAEGER_API}/api/services`);
|
||||
jaegerServices = svcRes.data || [];
|
||||
} catch(e) { /* Jaeger not ready */ }
|
||||
|
||||
// Build service map
|
||||
const svcMap = new Map();
|
||||
for (const c of dockerContainers) {
|
||||
const name = (c.Names && c.Names[0]) ? c.Names[0].replace(/^\//, '') : c.Id.slice(0, 12);
|
||||
const state = c.State || 'unknown';
|
||||
svcMap.set(name, { name, state, source: 'docker' });
|
||||
}
|
||||
for (const svc of jaegerServices) {
|
||||
if (!svcMap.has(svc)) {
|
||||
svcMap.set(svc, { name: svc, state: 'jaeger', source: 'jaeger' });
|
||||
}
|
||||
}
|
||||
|
||||
const entries = Array.from(svcMap.values()).sort((a, b) => {
|
||||
const order = { running: 0, jaeger: 1, paused: 2, exited: 3, restarting: 3, unknown: 4 };
|
||||
return (order[a.state] || 4) - (order[b.state] || 4) || a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
state.services = entries;
|
||||
countEl.textContent = entries.length;
|
||||
statSvc.textContent = entries.length;
|
||||
|
||||
const running = entries.filter(e => e.state === 'running').length;
|
||||
statUp.textContent = running;
|
||||
|
||||
listEl.innerHTML = entries.map(s => {
|
||||
const cls = s.state === 'running' ? 'up' : s.state === 'jaeger' ? 'otel' : 'down';
|
||||
return `<div class="service-tile">
|
||||
<span class="indicator ${cls}"></span>
|
||||
<span class="name">${esc(s.name)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Update health donut
|
||||
const down = entries.filter(e => e.state !== 'running' && e.state !== 'jaeger').length;
|
||||
const otelOnly = entries.filter(e => e.state === 'jaeger').length;
|
||||
healthChart.data.datasets[0].data = [running, down, otelOnly];
|
||||
healthChart.update('none');
|
||||
}
|
||||
|
||||
// ── Quick Links ──
|
||||
async function loadLinks() {
|
||||
const el = document.getElementById('quick-links');
|
||||
try {
|
||||
const names = state.services.map(s => s.name);
|
||||
const links = [
|
||||
{ href: '/jaeger', label: 'Jaeger UI', icon: '🔍' },
|
||||
{ href: '/api/metrics', label: 'Raw Metrics', icon: '📊' },
|
||||
{ href: 'https://github.com/asepharyana/asepharyana-hub', label: 'GitHub', icon: '📦' },
|
||||
];
|
||||
const domain = 'asepharyana.my.id';
|
||||
for (const name of names) {
|
||||
if (name === 'jaeger' || name === 'traefik') continue;
|
||||
if (name.endsWith('-dapr') || ['redis','nats','dapr-placement','otel-collector','prometheus','dashboard','scraper-api-dapr'].includes(name)) continue;
|
||||
links.push({ href: `https://${name}.${domain}`, label: name, icon: '🔗' });
|
||||
}
|
||||
el.innerHTML = links.map(l => `<a href="${l.href}" target="_blank">${l.icon} ${esc(l.label)}</a>`).join('');
|
||||
} catch(e) {
|
||||
el.innerHTML = `<a href="/jaeger" target="_blank">Jaeger UI</a><a href="/api/metrics" target="_blank">Metrics</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Prometheus Metrics ──
|
||||
async function loadPromMetrics() {
|
||||
try {
|
||||
// Try to query Prometheus for RPS, latency, errors
|
||||
// Using Prometheus instant queries
|
||||
const range = '5m';
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const start = now - 300;
|
||||
|
||||
// Query RPS (using OTel's HTTP server received items)
|
||||
const rpsRes = await fetchJSON(
|
||||
`${PROM_API}api/v1/query_range?query=otelcol_receiver_accepted_spans_ratio&start=${start}&end=${now}&step=15`
|
||||
);
|
||||
if (rpsRes.data?.result?.length) {
|
||||
const vals = rpsRes.data.result[0].values;
|
||||
const lastVal = vals[vals.length-1][1];
|
||||
document.getElementById('rps-value').textContent = `${parseFloat(lastVal).toFixed(1)}/s`;
|
||||
}
|
||||
} catch(e) { /* Prometheus not ready */ }
|
||||
}
|
||||
|
||||
// ── Traces from Jaeger ──
|
||||
async function loadTraces() {
|
||||
const container = document.getElementById('trace-list-container');
|
||||
const countEl = document.getElementById('trace-list-count');
|
||||
const traceVolEl = document.getElementById('trace-vol-value');
|
||||
|
||||
try {
|
||||
const services = await fetchJSON(`${JAEGER_API}/api/services`);
|
||||
const svcs = services.data || [];
|
||||
const lookback = 5 * 60 * 1_000_000;
|
||||
const now = nowMicro();
|
||||
const start = now - lookback;
|
||||
|
||||
let allTraces = [];
|
||||
for (const svc of svcs.slice(0, 8)) {
|
||||
try {
|
||||
const data = await fetchJSON(
|
||||
`${JAEGER_API}/api/traces?service=${encodeURIComponent(svc)}&start=${start}&end=${now}&limit=10&lookback=5m`
|
||||
);
|
||||
if (data.data) allTraces = allTraces.concat(data.data);
|
||||
} catch(e) { /* skip */ }
|
||||
}
|
||||
|
||||
allTraces.sort((a, b) => (b.startTime || 0) - (a.startTime || 0));
|
||||
allTraces = allTraces.slice(0, TRACE_LIMIT);
|
||||
|
||||
countEl.textContent = allTraces.length;
|
||||
traceVolEl.textContent = `${allTraces.length}/5m`;
|
||||
|
||||
// Push trace volume to chart
|
||||
pushMetric(state.traceVol, allTraces.length);
|
||||
pushMetric(state.labels, tickLabel());
|
||||
|
||||
if (allTraces.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">No traces in the last 5 minutes. Data appears once services send OTel traces.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '<div class="trace-list">' + allTraces.map(t => {
|
||||
const dur = t.duration || 0;
|
||||
const span = (t.spans && t.spans[0]) || {};
|
||||
const svcName = (span.process && span.process.serviceName)
|
||||
|| (t.processes && Object.values(t.processes)[0]?.serviceName) || 'unknown';
|
||||
const opName = span.operationName || 'unknown';
|
||||
const spanCount = (t.spans && t.spans.length) || 0;
|
||||
const hasErr = t.spans && t.spans.some(s => (s.tags || []).some(tg => tg.key === 'error' && tg.value === true));
|
||||
|
||||
return `<div class="trace-item">
|
||||
<div class="trace-left">
|
||||
<div class="trace-service">${esc(svcName)}</div>
|
||||
<div class="trace-op">${esc(opName)}</div>
|
||||
</div>
|
||||
<div class="trace-right">
|
||||
<span class="trace-duration">${fmtDur(dur)}</span>
|
||||
<span>${spanCount} spans</span>
|
||||
${hasErr ? '<span class="trace-error">error</span>' : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
|
||||
// Update trace chart
|
||||
traceChart.data.labels = state.labels;
|
||||
traceChart.data.datasets[0].data = state.traceVol;
|
||||
traceChart.update('none');
|
||||
|
||||
} catch(e) {
|
||||
container.innerHTML = `<div class="inline-error">Traces unavailable: ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPS / Latency / Error simulation (will be replaced by real Prometheus data) ──
|
||||
function simulateMetrics() {
|
||||
pushMetric(state.rps, Math.random() * 10 + 2);
|
||||
pushMetric(state.latency, Math.random() * 200 + 50);
|
||||
pushMetric(state.errors, Math.random() * 2);
|
||||
|
||||
// Only update labels once (from loadTraces)
|
||||
if (state.labels.length <= state.rps.length) {
|
||||
// labels already handled by traces
|
||||
}
|
||||
|
||||
rpsChart.data.labels = state.labels;
|
||||
rpsChart.data.datasets[0].data = state.rps;
|
||||
rpsChart.update('none');
|
||||
|
||||
latencyChart.data.labels = state.labels;
|
||||
latencyChart.data.datasets[0].data = state.latency;
|
||||
latencyChart.update('none');
|
||||
|
||||
errorChart.data.labels = state.labels;
|
||||
errorChart.data.datasets[0].data = state.errors;
|
||||
errorChart.update('none');
|
||||
|
||||
document.getElementById('rps-value').textContent = `${state.rps[state.rps.length-1]?.toFixed(1) || '-'}/s`;
|
||||
document.getElementById('latency-value').textContent = `${Math.round(state.latency[state.latency.length-1] || 0)}ms`;
|
||||
document.getElementById('error-value').textContent = `${(state.errors[state.errors.length-1] || 0).toFixed(1)}/s`;
|
||||
}
|
||||
|
||||
// ── Health Check ──
|
||||
function setHealth(status, label) {
|
||||
const el = document.getElementById('health-indicator');
|
||||
el.className = `health-badge ${status}`;
|
||||
el.innerHTML = `<span class="dot"></span> ${label}`;
|
||||
}
|
||||
|
||||
// ── Main Refresh ──
|
||||
async function refresh() {
|
||||
try {
|
||||
const now = tickLabel();
|
||||
if (!state.labels.length || state.labels[state.labels.length-1] !== now) {
|
||||
pushMetric(state.labels, now);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadServices(),
|
||||
loadTraces(),
|
||||
loadLinks(),
|
||||
]);
|
||||
|
||||
simulateMetrics();
|
||||
|
||||
const down = state.services.filter(s => s.state !== 'running').length;
|
||||
if (down === 0) setHealth('ok', 'All Systems Operational');
|
||||
else if (down < 3) setHealth('warn', `${down} degraded`);
|
||||
else setHealth('err', `${down} services down`);
|
||||
} catch(e) {
|
||||
setHealth('err', 'Dashboard Error');
|
||||
}
|
||||
document.getElementById('last-updated').textContent = `updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// ── Init ──
|
||||
initCharts();
|
||||
refresh();
|
||||
setInterval(refresh, 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,536 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed template.html
|
||||
var templateHTML string
|
||||
|
||||
var tmpl = template.Must(template.New("dashboard").Funcs(template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"divF": func(a, b float64) float64 { return a / b },
|
||||
"hasSuffix": strings.HasSuffix,
|
||||
"safeDur": func(us int64) string {
|
||||
if us < 1000 {
|
||||
return fmt.Sprintf("%dµs", us)
|
||||
} else if us < 1_000_000 {
|
||||
return fmt.Sprintf("%.1fms", float64(us)/1000)
|
||||
}
|
||||
return fmt.Sprintf("%.2fs", float64(us)/1_000_000)
|
||||
},
|
||||
}).Parse(templateHTML))
|
||||
|
||||
// ── Types ──
|
||||
|
||||
type Service struct {
|
||||
Name string
|
||||
State string
|
||||
}
|
||||
|
||||
type Trace struct {
|
||||
Service string
|
||||
Operation string
|
||||
Duration int64
|
||||
Spans int
|
||||
HasError bool
|
||||
}
|
||||
|
||||
type DashboardData struct {
|
||||
Services []Service
|
||||
Running int
|
||||
Down int
|
||||
OTelOnly int
|
||||
TraceCount int
|
||||
RPS []float64
|
||||
Latency []float64
|
||||
Errors []float64
|
||||
Traces []float64
|
||||
Labels []string
|
||||
HealthSVG template.HTML
|
||||
RPSSVG template.HTML
|
||||
LatencySVG template.HTML
|
||||
ErrorSVG template.HTML
|
||||
TraceSVG template.HTML
|
||||
SystemName string
|
||||
Error string
|
||||
TotalUp int
|
||||
TotalDown int
|
||||
}
|
||||
|
||||
// ── State ──
|
||||
|
||||
type appState struct {
|
||||
mu sync.Mutex
|
||||
rps []float64
|
||||
latency []float64
|
||||
errs []float64
|
||||
traces []float64
|
||||
labels []string
|
||||
services []Service
|
||||
tracesL []Trace
|
||||
}
|
||||
|
||||
var state appState
|
||||
|
||||
const maxPts = 30
|
||||
|
||||
// ── Docker socket client ──
|
||||
|
||||
var dockerClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Dial: func(_, _ string) (net.Conn, error) {
|
||||
return net.DialTimeout("unix", "/var/run/docker.sock", 5*time.Second)
|
||||
},
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
func fetchJSON(url string, v interface{}) error {
|
||||
r, err := httpClient.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(v)
|
||||
}
|
||||
|
||||
// ── Docker ──
|
||||
|
||||
func fetchServices() []Service {
|
||||
r, err := dockerClient.Get("http://localhost/containers/json?all=true")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var raw []struct {
|
||||
Names []string `json:"Names"`
|
||||
State string `json:"State"`
|
||||
NetworkSettings *struct {
|
||||
Networks map[string]any `json:"Networks"`
|
||||
} `json:"NetworkSettings"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&raw) != nil {
|
||||
return nil
|
||||
}
|
||||
var svcs []Service
|
||||
for _, c := range raw {
|
||||
if c.NetworkSettings != nil {
|
||||
if _, ok := c.NetworkSettings.Networks["app-shared-net"]; ok {
|
||||
svcs = append(svcs, Service{Name: strings.TrimPrefix(c.Names[0], "/"), State: c.State})
|
||||
}
|
||||
}
|
||||
}
|
||||
return svcs
|
||||
}
|
||||
|
||||
// ── Jaeger ──
|
||||
|
||||
func jaegerServices() []string {
|
||||
var d struct{ Data []string }
|
||||
if fetchJSON("http://jaeger:16686/api/services", &d) != nil {
|
||||
return nil
|
||||
}
|
||||
return d.Data
|
||||
}
|
||||
|
||||
func jaegerTraces(service string) []Trace {
|
||||
now := time.Now().UnixMicro()
|
||||
start := now - 5*60*1_000_000
|
||||
u := fmt.Sprintf("http://jaeger:16686/api/traces?service=%s&start=%d&end=%d&limit=5&lookback=5m",
|
||||
url.QueryEscape(service), start, now)
|
||||
var d struct {
|
||||
Data []struct {
|
||||
Duration int64 `json:"duration"`
|
||||
Spans []struct {
|
||||
OperationName string `json:"operationName"`
|
||||
ProcessID string `json:"processID"`
|
||||
Tags []struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
} `json:"tags"`
|
||||
} `json:"spans"`
|
||||
Processes map[string]struct {
|
||||
ServiceName string `json:"serviceName"`
|
||||
} `json:"processes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if fetchJSON(u, &d) != nil {
|
||||
return nil
|
||||
}
|
||||
var tt []Trace
|
||||
for _, t := range d.Data {
|
||||
if len(t.Spans) == 0 {
|
||||
continue
|
||||
}
|
||||
s := t.Spans[0]
|
||||
svc := "unknown"
|
||||
if p, ok := t.Processes[s.ProcessID]; ok {
|
||||
svc = p.ServiceName
|
||||
}
|
||||
hasErr := false
|
||||
for _, sp := range t.Spans {
|
||||
for _, tag := range sp.Tags {
|
||||
if tag.Key == "error" && tag.Value == true {
|
||||
hasErr = true
|
||||
}
|
||||
}
|
||||
}
|
||||
tt = append(tt, Trace{Service: svc, Operation: s.OperationName, Duration: t.Duration, Spans: len(t.Spans), HasError: hasErr})
|
||||
}
|
||||
return tt
|
||||
}
|
||||
|
||||
// ── Prometheus ──
|
||||
|
||||
func promQuery(query string) []float64 {
|
||||
now := time.Now().Unix()
|
||||
u := fmt.Sprintf("http://prometheus:9090/api/v1/query_range?query=%s&start=%d&end=%d&step=15",
|
||||
url.QueryEscape(query), now-300, now)
|
||||
var d struct {
|
||||
Data struct {
|
||||
Result []struct {
|
||||
Values [][]any `json:"values"`
|
||||
} `json:"result"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if fetchJSON(u, &d) != nil || len(d.Data.Result) == 0 {
|
||||
return nil
|
||||
}
|
||||
var vals []float64
|
||||
for _, v := range d.Data.Result[0].Values {
|
||||
if len(v) == 2 {
|
||||
var f float64
|
||||
fmt.Sscanf(fmt.Sprint(v[1]), "%f", &f)
|
||||
vals = append(vals, f)
|
||||
}
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// ── SVG Charts ──
|
||||
|
||||
func svgDonut(running, down, otel int) string {
|
||||
total := running + down + otel
|
||||
if total == 0 {
|
||||
return `<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>`
|
||||
}
|
||||
const cx, cy, R = 100, 90, 60
|
||||
const circ = 2 * math.Pi * R
|
||||
type seg struct{ n int; c, l string }
|
||||
segs := []seg{{running, "#3fb950", "Running"}, {down, "#f85149", "Down"}, {otel, "#58a6ff", "OTel"}}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(`<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>`)
|
||||
|
||||
var off float64
|
||||
for _, s := range segs {
|
||||
if s.n == 0 {
|
||||
continue
|
||||
}
|
||||
frac := float64(s.n) / float64(total)
|
||||
ln := frac * circ
|
||||
b.WriteString(fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" fill="none" stroke="%s" stroke-width="14" stroke-dasharray="%.1f %.1f" stroke-dashoffset="%.1f" transform="rotate(-90 %d %d)"/>`,
|
||||
cx, cy, R, s.c, ln, circ-ln, -off, cx, cy))
|
||||
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" class="sl">total</text>`, 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(`<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
|
||||
}
|
||||
}
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func svgLine(data []float64, color string) string {
|
||||
w, h := 300.0, 160.0
|
||||
pl, pt, pr, pb := 45.0, 20.0, 10.0, 25.0
|
||||
vw := w - pl - pr
|
||||
vh := h - pt - pb
|
||||
|
||||
if len(data) == 0 {
|
||||
return fmt.Sprintf(`<svg width="%.0f" height="%.0f" viewBox="0 0 %.0f %.0f" xmlns="http://www.w3.org/2000/svg">
|
||||
<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
|
||||
for _, v := range data {
|
||||
if v > maxV {
|
||||
maxV = v
|
||||
}
|
||||
}
|
||||
if maxV <= 0 {
|
||||
maxV = 1
|
||||
}
|
||||
|
||||
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))
|
||||
b.WriteString(`<style>.ax{font-family:system-ui,sans-serif;font-size:9px;fill:#6e7681}</style>`)
|
||||
|
||||
// Grid
|
||||
for i := 0; i <= 4; i++ {
|
||||
y := pt + vh*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))
|
||||
b.WriteString(fmt.Sprintf(`<text x="%.0f" y="%.0f" text-anchor="end" class="ax">%.0f</text>`, pl-6, y+3, val))
|
||||
}
|
||||
|
||||
n := len(data)
|
||||
if n > 1 {
|
||||
pts := make([]string, n)
|
||||
for i, v := range data {
|
||||
x := pl + vw*float64(i)/float64(n-1)
|
||||
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(`<path d="%s" fill="%s" opacity="0.15"/>`, area, 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]
|
||||
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>`,
|
||||
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(`<text x="%.0f" y="%.0f" text-anchor="middle" class="ax">%d</text>`, x, pt+vh+16, i+1))
|
||||
}
|
||||
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Refresh ──
|
||||
|
||||
func refresh() {
|
||||
svcs := fetchServices()
|
||||
|
||||
jaegerS := jaegerServices()
|
||||
for _, s := range jaegerS {
|
||||
found := false
|
||||
for _, sv := range svcs {
|
||||
if sv.Name == s {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
svcs = append(svcs, Service{Name: s, State: "jaeger"})
|
||||
}
|
||||
}
|
||||
sort.Slice(svcs, func(i, j int) bool {
|
||||
o := map[string]int{"running": 0, "jaeger": 1, "paused": 2, "exited": 3, "restarting": 4}
|
||||
oi, oj := o[svcs[i].State], o[svcs[j].State]
|
||||
if oi != oj {
|
||||
return oi < oj
|
||||
}
|
||||
return svcs[i].Name < svcs[j].Name
|
||||
})
|
||||
|
||||
var traces []Trace
|
||||
for _, s := range svcs {
|
||||
if s.State == "running" {
|
||||
t := jaegerTraces(s.Name)
|
||||
traces = append(traces, t...)
|
||||
}
|
||||
}
|
||||
sort.Slice(traces, func(i, j int) bool { return traces[i].Duration > traces[j].Duration })
|
||||
if len(traces) > 20 {
|
||||
traces = traces[:20]
|
||||
}
|
||||
|
||||
// Prometheus
|
||||
rps := promQuery("rate(otelcol_receiver_accepted_spans[1m])")
|
||||
lat := promQuery("otelcol_receiver_accepted_spans")
|
||||
err := promQuery("rate(otelcol_receiver_refused_spans[1m])")
|
||||
|
||||
state.mu.Lock()
|
||||
state.services = svcs
|
||||
state.tracesL = traces
|
||||
|
||||
now := time.Now().Format("15:04:05")
|
||||
state.labels = append(state.labels, now)
|
||||
if len(state.labels) > maxPts {
|
||||
state.labels = state.labels[len(state.labels)-maxPts:]
|
||||
}
|
||||
|
||||
add := func(dst *[]float64, src []float64) {
|
||||
v := 0.0
|
||||
if len(src) > 0 {
|
||||
v = src[len(src)-1]
|
||||
}
|
||||
*dst = append(*dst, v)
|
||||
if len(*dst) > maxPts {
|
||||
*dst = (*dst)[len(*dst)-maxPts:]
|
||||
}
|
||||
}
|
||||
add(&state.rps, rps)
|
||||
add(&state.latency, lat)
|
||||
add(&state.errs, err)
|
||||
state.traces = append(state.traces, float64(len(traces)))
|
||||
if len(state.traces) > maxPts {
|
||||
state.traces = state.traces[len(state.traces)-maxPts:]
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
// ── Dashboard Handler ──
|
||||
|
||||
func dashboard(w http.ResponseWriter, _ *http.Request) {
|
||||
state.mu.Lock()
|
||||
svcs := append([]Service{}, state.services...)
|
||||
tr := append([]Trace{}, state.tracesL...)
|
||||
rps := append([]float64{}, state.rps...)
|
||||
lat := append([]float64{}, state.latency...)
|
||||
ers := append([]float64{}, state.errs...)
|
||||
trc := append([]float64{}, state.traces...)
|
||||
labels := append([]string{}, state.labels...)
|
||||
state.mu.Unlock()
|
||||
|
||||
running, down, otel := 0, 0, 0
|
||||
for _, s := range svcs {
|
||||
switch s.State {
|
||||
case "running":
|
||||
running++
|
||||
case "jaeger":
|
||||
otel++
|
||||
default:
|
||||
down++
|
||||
}
|
||||
}
|
||||
|
||||
maxLat := 0.0
|
||||
for _, v := range lat {
|
||||
if v > maxLat {
|
||||
maxLat = v
|
||||
}
|
||||
}
|
||||
|
||||
data := DashboardData{
|
||||
Services: svcs, Running: running, Down: down, OTelOnly: otel,
|
||||
TraceCount: len(tr), RPS: rps, Latency: lat, Errors: ers, Traces: trc, Labels: labels,
|
||||
SystemName: "asepharyana-hub", TotalUp: running, TotalDown: down + otel,
|
||||
HealthSVG: template.HTML(svgDonut(running, down, otel)),
|
||||
RPSSVG: template.HTML(svgLine(rps, "#58a6ff")),
|
||||
LatencySVG: template.HTML(svgLine(lat, "#bc8cff")),
|
||||
ErrorSVG: template.HTML(svgLine(ers, "#f85149")),
|
||||
TraceSVG: template.HTML(svgLine(trc, "#3fb950")),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Proxy ──
|
||||
|
||||
func proxy(target string) http.Handler {
|
||||
u, _ := url.Parse(target)
|
||||
return httputil.NewSingleHostReverseProxy(u)
|
||||
}
|
||||
|
||||
func dockerHandler(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/docker/containers/json", "/api/docker/version":
|
||||
r.URL.Path = strings.TrimPrefix(r.URL.Path, "/api/docker")
|
||||
resp, err := dockerClient.Get("http://localhost" + r.URL.String() + "?" + r.URL.RawQuery)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
for k, v := range resp.Header {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
io.Copy(w, resp.Body)
|
||||
default:
|
||||
http.Error(w, "Forbidden", 403)
|
||||
}
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
resp, err := http.Get("http://otel-collector:13133/")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// ── Main ──
|
||||
|
||||
func main() {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
refresh()
|
||||
go func() {
|
||||
for range time.Tick(15 * time.Second) {
|
||||
refresh()
|
||||
}
|
||||
}()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", dashboard)
|
||||
mux.Handle("/api/jaeger/", http.StripPrefix("/api/jaeger", proxy("http://jaeger:16686/")))
|
||||
mux.Handle("/api/prometheus/", http.StripPrefix("/api/prometheus", proxy("http://prometheus:9090/")))
|
||||
mux.HandleFunc("/api/health", healthHandler)
|
||||
mux.HandleFunc("/api/docker/", dockerHandler)
|
||||
mux.Handle("/jaeger/", http.StripPrefix("/jaeger", proxy("http://jaeger:16686/")))
|
||||
|
||||
log.Printf("Dashboard listening on :%s", port)
|
||||
log.Fatal(http.ListenAndServe(":"+port, mux))
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
# Dashboard nginx — runs as root to access Docker socket
|
||||
user root;
|
||||
worker_processes auto;
|
||||
pid /var/run/nginx.pid;
|
||||
pcre_jit on;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/html text/css application/javascript application/json;
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header Referrer-Policy same-origin;
|
||||
|
||||
# Jaeger API proxy
|
||||
location /api/jaeger/ {
|
||||
proxy_pass http://jaeger:16686/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# Jaeger UI
|
||||
location /jaeger/ {
|
||||
proxy_pass http://jaeger:16686/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
|
||||
# OTel collector prometheus metrics
|
||||
location /api/metrics {
|
||||
proxy_pass http://otel-collector:8889/metrics;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 10s;
|
||||
}
|
||||
|
||||
# OTel collector health
|
||||
location /api/health {
|
||||
proxy_pass http://otel-collector:13133/;
|
||||
proxy_read_timeout 5s;
|
||||
}
|
||||
|
||||
# Prometheus API (read-only queries)
|
||||
location /api/prometheus/ {
|
||||
proxy_pass http://prometheus:9090/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 15s;
|
||||
}
|
||||
|
||||
# Docker API proxy — strict whitelist (read-only Unix socket)
|
||||
# Uses exact match (=) to avoid regex+proxy_pass URI limitation.
|
||||
location = /api/docker/containers/json {
|
||||
proxy_pass http://unix:/var/run/docker.sock:/containers/json;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 10s;
|
||||
}
|
||||
|
||||
location = /api/docker/version {
|
||||
proxy_pass http://unix:/var/run/docker.sock:/version;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 10s;
|
||||
}
|
||||
|
||||
# Deny all other Docker API access
|
||||
location /api/docker/ {
|
||||
deny all;
|
||||
return 403;
|
||||
}
|
||||
|
||||
# Static files
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires 5s;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
|
||||
# Deny hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
error_page 404 /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hub Dashboard</title>
|
||||
<style>
|
||||
*{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}
|
||||
.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 h1{font-size:17px;font-weight:600}
|
||||
.hl .sub{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}
|
||||
|
||||
.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}
|
||||
.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}
|
||||
|
||||
.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: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 .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}
|
||||
.sb{background:#1c2333;border-radius:6px;padding:12px 8px;text-align:center}
|
||||
.sv{font-size:22px;font-weight:700;font-family:'SF Mono',monospace}
|
||||
.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}
|
||||
|
||||
.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}
|
||||
.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}
|
||||
|
||||
.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}
|
||||
.sg{grid-template-columns:repeat(auto-fill,minmax(120px,1fr))}
|
||||
.header{flex-direction:column;align-items:flex-start;gap:8px}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div class="hl">
|
||||
<div class="logo">H</div>
|
||||
<div>
|
||||
<h1>{{.SystemName}}</h1>
|
||||
<div class="sub">System Dashboard</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hr">
|
||||
{{if eq .TotalDown 0}}
|
||||
<span class="hb g"><span class="dot"></span> All Systems Operational</span>
|
||||
{{else if lt .TotalDown 3}}
|
||||
<span class="hb y"><span class="dot"></span> {{.TotalDown}} degraded</span>
|
||||
{{else}}
|
||||
<span class="hb r"><span class="dot"></span> {{.TotalDown}} services down</span>
|
||||
{{end}}
|
||||
<span class="upd" id="ts"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="c">
|
||||
<div class="g">
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card" style="grid-column:span 4">
|
||||
<div class="ch"><h2>Services</h2><span class="b">{{len .Services}}</span></div>
|
||||
<div class="sg">
|
||||
{{range .Services}}
|
||||
<div class="st">
|
||||
<span class="i {{if eq .State "running"}}g{{else if eq .State "jaeger"}}b{{else}}r{{end}}"></span>
|
||||
<span class="n">{{.Name}}</span>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="em">No services found</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview -->
|
||||
<div class="card" style="grid-column:span 2">
|
||||
<div class="ch"><h2>Overview</h2></div>
|
||||
<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">{{.Running}}</div><div class="sl">Healthy</div></div>
|
||||
<div class="sb"><div class="sv g">{{if .Traces}}{{printf "%.0f" (index .Traces (sub (len .Traces) 1))}}{{else}}0{{end}}</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>
|
||||
</div>
|
||||
|
||||
<!-- Health Donut (server-side SVG) -->
|
||||
<div class="card" style="grid-column:span 3">
|
||||
<div class="ch"><h2>Health</h2></div>
|
||||
<div class="chart">{{.HealthSVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div class="card" style="grid-column:span 3">
|
||||
<div class="ch"><h2>Links</h2></div>
|
||||
<div class="links">
|
||||
<a href="/jaeger" target="_blank">Jaeger UI</a>
|
||||
<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>
|
||||
|
||||
<!-- Request Rate -->
|
||||
<div class="card" 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="chart">{{.RPSSVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Latency -->
|
||||
<div class="card" 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="chart">{{.LatencySVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Rate -->
|
||||
<div class="card" 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="chart">{{.ErrorSVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Trace Volume -->
|
||||
<div class="card" style="grid-column:span 6">
|
||||
<div class="ch"><h2>Trace Volume</h2><span class="b">{{.TraceCount}} traces</span></div>
|
||||
<div class="chart">{{.TraceSVG}}</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Traces -->
|
||||
<div class="card" style="grid-column:span 6">
|
||||
<div class="ch"><h2>Recent Traces</h2><span class="b">{{.TraceCount}}</span></div>
|
||||
<div class="tl">
|
||||
{{if .Traces}}
|
||||
<div id="tl">
|
||||
{{range .Traces}}
|
||||
<div class="ti">
|
||||
<div>
|
||||
<div class="ts">{{.Service}}</div>
|
||||
<div class="to">{{.Operation}}</div>
|
||||
</div>
|
||||
<div class="tt">
|
||||
<span class="td">{{safeDur .Duration}}</span>
|
||||
<span>{{.Spans}} spans</span>
|
||||
{{if .HasError}}<span class="te">error</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="em">No traces in the last 5 minutes</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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();
|
||||
setInterval(() => { document.getElementById('ts').textContent = 'updated ' + new Date().toLocaleTimeString(); }, 1000);
|
||||
setTimeout(() => location.reload(), 15000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,11 @@
|
||||
FROM nginx:alpine
|
||||
# Replace default nginx.conf with our custom config (runs as root for Docker socket access)
|
||||
COPY infra/dashboard/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY infra/dashboard/index.html /usr/share/nginx/html/index.html
|
||||
# ── Build stage ──
|
||||
FROM golang:1.24-alpine AS builder
|
||||
WORKDIR /build
|
||||
COPY infra/dashboard/ ./
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o dashboard .
|
||||
|
||||
# ── Runtime (scratch — ~7MB total) ──
|
||||
FROM scratch
|
||||
COPY --from=builder /build/dashboard /dashboard
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["/dashboard"]
|
||||
|
||||
Reference in New Issue
Block a user