2e10839fc9
- 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
388 lines
11 KiB
HTML
388 lines
11 KiB
HTML
<!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> |