add diagnostic dashboard served from prometheus-exporter
- dashboard.html: standalone HTML, fetches :9090/metrics, auto-refresh - Exporter serves dashboard at /dashboard and /dashboard.html - Cards: Pipeline, Audio, Mixer, MIDI Bridge, LED Viz - Live gauges, counters, service status dots, last-activity timestamps
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>uno-q Audio Synth — Diagnostics</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f1117;
|
||||
color: #e0e0e0;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; font-weight: 600; color: #fff; }
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background: #1c1f2e;
|
||||
border: 1px solid #333;
|
||||
color: #e0e0e0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #2d5a8a;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
button:hover { background: #3d7ab8; }
|
||||
button.active { background: #2563eb; }
|
||||
|
||||
.refresh-info {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #1a1d2e;
|
||||
border: 1px solid #2a2d3e;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #2a2d3e;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric-name {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
flex: 1;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #fff;
|
||||
text-align: right;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.dot-ok { background: #22c55e; box-shadow: 0 0 6px #22c55e; }
|
||||
.dot-warn { background: #eab308; box-shadow: 0 0 6px #eab308; }
|
||||
.dot-err { background: #ef4444; box-shadow: 0 0 6px #ef4444; }
|
||||
.dot-unknown { background: #555; }
|
||||
|
||||
.status-text { font-size: 0.85rem; }
|
||||
|
||||
.status-ok { color: #22c55e; }
|
||||
.status-warn { color: #eab308; }
|
||||
.status-err { color: #ef4444; }
|
||||
|
||||
.last-update {
|
||||
font-size: 0.75rem;
|
||||
color: #555;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.gauge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.gauge-bar {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: #2a2d3e;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gauge-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.gauge-fill.ok { background: #22c55e; }
|
||||
.gauge-fill.warn { background: #eab308; }
|
||||
.gauge-fill.err { background: #ef4444; }
|
||||
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
color: #555;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.error-box {
|
||||
background: #1a1d2e;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
color: #ef4444;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-box small { color: #888; display: block; margin-top: 4px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>uno-q Audio Synth — Diagnostics</h1>
|
||||
<div class="controls">
|
||||
<input type="text" id="board" value="192.168.9.167" placeholder="board IP">
|
||||
<button id="refreshBtn" onclick="fetchMetrics()">Refresh</button>
|
||||
<button id="autoBtn" onclick="toggleAuto()">Auto: OFF</button>
|
||||
<span class="refresh-info" id="refreshInfo">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard" id="dashboard">
|
||||
<div class="error-box" id="errorBox">
|
||||
Enter board IP above and click Refresh
|
||||
<small>Dashboard will auto-fetch on load if IP is set</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="last-update" id="lastUpdate"></p>
|
||||
|
||||
<script>
|
||||
let autoInterval = null;
|
||||
let autoOn = false;
|
||||
|
||||
const boardInput = document.getElementById('board');
|
||||
const dashboard = document.getElementById('dashboard');
|
||||
const errorBox = document.getElementById('errorBox');
|
||||
const autoBtn = document.getElementById('autoBtn');
|
||||
const refreshInfo = document.getElementById('refreshInfo');
|
||||
const lastUpdate = document.getElementById('lastUpdate');
|
||||
|
||||
function getBoard() {
|
||||
return boardInput.value.trim() || '192.168.9.167';
|
||||
}
|
||||
|
||||
function toggleAuto() {
|
||||
autoOn = !autoOn;
|
||||
autoBtn.textContent = autoOn ? 'Auto: ON' : 'Auto: OFF';
|
||||
autoBtn.classList.toggle('active', autoOn);
|
||||
if (autoOn) {
|
||||
fetchMetrics();
|
||||
autoInterval = setInterval(fetchMetrics, 2000);
|
||||
} else {
|
||||
clearInterval(autoInterval);
|
||||
autoInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMetrics() {
|
||||
const board = getBoard();
|
||||
refreshInfo.textContent = 'fetching...';
|
||||
try {
|
||||
const resp = await fetch(`http://${board}:9090/metrics`);
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const text = await resp.text();
|
||||
refreshInfo.textContent = `fetched ${new Date().toLocaleTimeString()}`;
|
||||
lastUpdate.textContent = `Last updated: ${new Date().toLocaleString()}`;
|
||||
render(text);
|
||||
errorBox.style.display = 'none';
|
||||
} catch (e) {
|
||||
errorBox.style.display = 'block';
|
||||
errorBox.innerHTML = `Failed to fetch metrics from ${board}:9090<small>${e.message}<br>Check board IP and ensure prometheus-exporter is running</small>`;
|
||||
refreshInfo.textContent = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
function parseMetrics(text) {
|
||||
const metrics = {};
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const idx = line.indexOf(' ');
|
||||
if (idx < 0) continue;
|
||||
const name = line.slice(0, idx);
|
||||
const value = line.slice(idx + 1).trim();
|
||||
const num = parseFloat(value);
|
||||
metrics[name] = isNaN(num) ? value : num;
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function render(text) {
|
||||
const m = parseMetrics(text);
|
||||
|
||||
const cards = [];
|
||||
|
||||
// ── Pipeline Services ──
|
||||
cards.push(makeCard('Pipeline', `
|
||||
${serviceRow('Pd Synth', m['synth_pd_running'], m['synth_pd_running'] === 1)}
|
||||
${serviceRow('MIDI Bridge', m['synth_midi_bridge_running'], m['synth_midi_bridge_running'] === 1)}
|
||||
${serviceRow('LED Viz', m['synth_viz_running'], m['synth_viz_running'] === 1)}
|
||||
${serviceRow('ALSA Device', m['synth_alsa_running'], m['synth_alsa_running'] === 1)}
|
||||
${serviceRow('Exporter', m['synth_up'], m['synth_up'] === 1)}
|
||||
`));
|
||||
|
||||
// ── Audio ──
|
||||
const hwRate = m['synth_hw_ptr_bytes_per_sec'] || 0;
|
||||
const hwOk = hwRate > 1000;
|
||||
cards.push(makeCard('Audio', `
|
||||
${gaugeRow('ALSA hw_ptr rate', formatBytes(hwRate) + '/s', hwRate, 0, 500000, hwOk)}
|
||||
${metricRow('ALSA hw_ptr', m['synth_alsa_hw_ptr'] || 0)}
|
||||
`));
|
||||
|
||||
// ── Mixer (Headphone) ──
|
||||
cards.push(makeCard('Headphone Mixer', `
|
||||
${mixerRow('HPH Left Switch', m['synth_hphl_switch'], m['synth_hphl_switch'] === 1)}
|
||||
${mixerRow('HPH Right Switch', m['synth_hphr_switch'], m['synth_hphr_switch'] === 1)}
|
||||
${gaugeRow('HPH Left Vol', `${m['synth_hphl_volume'] || 0}/20`, (m['synth_hphl_volume'] || 0) / 20, 0, 1, (m['synth_hphl_volume'] || 0) > 10)}
|
||||
${gaugeRow('HPH Right Vol', `${m['synth_hphr_volume'] || 0}/20`, (m['synth_hphr_volume'] || 0) / 20, 0, 1, (m['synth_hphr_volume'] || 0) > 10)}
|
||||
`));
|
||||
|
||||
// ── MIDI Bridge ──
|
||||
const midiMsgs = m['midi_messages_total'] || 0;
|
||||
const midiNotesOn = m['midi_notes_on_total'] || 0;
|
||||
const midiErrors = m['midi_errors_total'] || 0;
|
||||
const midiTime = m['midi_last_time_unix'] || 0;
|
||||
const lastNoteAgo = midiTime > 0 ? timeAgo(midiTime) : 'never';
|
||||
cards.push(makeCard('MIDI Bridge', `
|
||||
${metricRow('Messages received', midiMsgs)}
|
||||
${metricRow('Note-on count', midiNotesOn)}
|
||||
${metricRow('Note-off count', m['midi_notes_off_total'] || 0)}
|
||||
${metricRow('Parse errors', midiErrors, midiErrors > 0 ? 'err' : 'ok')}
|
||||
${metricRow('Last note', midiNotesOn > 0 ? `note ${m['midi_last_note']} vel ${m['midi_last_velocity']}` : '—')}
|
||||
${metricRow('Last activity', lastNoteAgo)}
|
||||
`));
|
||||
|
||||
// ── Viz ──
|
||||
const vizSent = m['viz_frames_sent_total'] || 0;
|
||||
const vizDropped = m['viz_frames_dropped_total'] || 0;
|
||||
const vizQueue = m['viz_pending_queue'] || 0;
|
||||
const vizFull = m['viz_queue_full'] || 0;
|
||||
cards.push(makeCard('LED Matrix Viz', `
|
||||
${metricRow('Frames sent', vizSent)}
|
||||
${metricRow('Frames dropped', vizDropped, vizDropped > 0 ? 'warn' : 'ok')}
|
||||
${metricRow('Queue depth', `${vizQueue}/5`)}
|
||||
${metricRow('Buffer full event', vizFull > 0 ? 'YES' : 'no', vizFull > 0 ? 'warn' : 'ok')}
|
||||
`));
|
||||
|
||||
dashboard.innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
function makeCard(title, content) {
|
||||
return `<div class="card">
|
||||
<div class="card-title">${title}</div>
|
||||
${content}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function serviceRow(name, value, ok) {
|
||||
const dot = ok ? 'dot-ok' : 'dot-err';
|
||||
const cls = ok ? 'status-ok' : 'status-err';
|
||||
return `<div class="metric">
|
||||
<span class="metric-name">${name}</span>
|
||||
<span class="metric-value ${cls}"><span class="status-dot ${dot}"></span>${ok ? 'UP' : 'DOWN'}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function metricRow(name, value, type) {
|
||||
const cls = type ? `status-${type}` : '';
|
||||
return `<div class="metric">
|
||||
<span class="metric-name">${name}</span>
|
||||
<span class="metric-value ${cls}">${value}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function gaugeRow(name, label, fraction, min, max, ok) {
|
||||
const pct = Math.min(100, Math.max(0, ((fraction - min) / (max - min)) * 100));
|
||||
const cls = ok ? 'ok' : 'warn';
|
||||
return `<div class="metric">
|
||||
<span class="metric-name">${name}</span>
|
||||
<div class="gauge">
|
||||
<div class="gauge-bar"><div class="gauge-fill ${cls}" style="width:${pct}%"></div></div>
|
||||
<span class="metric-value">${label}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function mixerRow(name, value, on) {
|
||||
return `<div class="metric">
|
||||
<span class="metric-name">${name}</span>
|
||||
<span class="metric-value ${on ? 'status-ok' : 'status-err'}">${on ? 'ON' : 'OFF'}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function formatBytes(b) {
|
||||
if (b >= 1000000) return (b / 1000000).toFixed(1) + ' MB';
|
||||
if (b >= 1000) return (b / 1000).toFixed(0) + ' KB';
|
||||
return b + ' B';
|
||||
}
|
||||
|
||||
function timeAgo(unixTs) {
|
||||
const secs = Date.now() / 1000 - unixTs;
|
||||
if (secs < 2) return 'just now';
|
||||
if (secs < 60) return Math.floor(secs) + 's ago';
|
||||
if (secs < 3600) return Math.floor(secs / 60) + 'm ago';
|
||||
return Math.floor(secs / 3600) + 'h ago';
|
||||
}
|
||||
|
||||
// auto-fetch on page load
|
||||
boardInput.addEventListener('keydown', e => { if (e.key === 'Enter') fetchMetrics(); });
|
||||
window.addEventListener('load', fetchMetrics);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -236,6 +236,9 @@ def format_metrics():
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
DASHBOARD_PATH = os.path.join(os.path.dirname(__file__), "dashboard.html")
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/metrics":
|
||||
@@ -252,6 +255,18 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
elif self.path in ("/dashboard", "/dashboard.html"):
|
||||
try:
|
||||
with open(DASHBOARD_PATH) as f:
|
||||
body = f.read().encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except OSError:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
@@ -268,6 +283,7 @@ def main():
|
||||
t.start()
|
||||
server = http.server.HTTPServer(("0.0.0.0", PORT), Handler)
|
||||
print(f"[metrics] Listening on :{PORT}/metrics")
|
||||
print(f"[metrics] Dashboard: http://0.0.0.0:{PORT}/dashboard")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user