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:
2026-06-24 02:43:31 -07:00
parent 74e0d3fa03
commit 2e10839fc9
2 changed files with 404 additions and 0 deletions
+388
View File
@@ -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>