265 lines
11 KiB
HTML
265 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<title>SNEK Dashboard</title>
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root { --border: #e5e7eb; --text: #0f172a; --muted: #64748b; --card-bg: #ffffff; }
|
|
html, body { font-family: 'Inter', ui-sans-serif, system-ui, sans-serif; background: #f8fafc; color: var(--text); }
|
|
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
|
|
.fade-in { animation: fade 0.3s ease-out; }
|
|
@keyframes fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
|
|
</style>
|
|
</head>
|
|
<body class="min-h-screen">
|
|
<div class="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-10">
|
|
|
|
<!-- Header -->
|
|
<header class="mb-8">
|
|
<h1 class="text-xl md:text-2xl font-semibold tracking-tight">SNEK Dashboard</h1>
|
|
<p class="text-sm text-slate-500 mt-0.5">Live Sens'it uplink decoding</p>
|
|
</header>
|
|
|
|
<!-- Devices -->
|
|
<section class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8" id="devices"></section>
|
|
|
|
<!-- Chart -->
|
|
<section class="bg-white rounded-lg border border-slate-200 mb-8 overflow-hidden">
|
|
<div class="px-5 py-4 border-b border-slate-200 flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-sm font-semibold text-slate-900">History</h2>
|
|
<p class="text-xs text-slate-500 mt-0.5">Temperature & humidity over time</p>
|
|
</div>
|
|
<select id="chart-device" class="text-sm bg-white border border-slate-200 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-slate-900/10"></select>
|
|
</div>
|
|
<div class="p-5">
|
|
<canvas id="chart" height="100"></canvas>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Messages -->
|
|
<section class="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
|
<div class="px-5 py-4 border-b border-slate-200">
|
|
<h2 class="text-sm font-semibold text-slate-900">Messages</h2>
|
|
<p class="text-xs text-slate-500 mt-0.5">Most recent uplinks first</p>
|
|
</div>
|
|
<div class="overflow-x-auto">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-xs uppercase tracking-wide text-slate-500 border-b border-slate-200">
|
|
<th class="px-5 py-3 font-medium">Time</th>
|
|
<th class="px-5 py-3 font-medium">Device</th>
|
|
<th class="px-5 py-3 font-medium">Format</th>
|
|
<th class="px-5 py-3 font-medium">Mode</th>
|
|
<th class="px-5 py-3 font-medium">Data</th>
|
|
<th class="px-5 py-3 font-medium text-right">RSSI</th>
|
|
<th class="px-5 py-3 font-medium">Payload</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="msgs" class="divide-y divide-slate-100"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<footer class="mt-8 text-center text-xs text-slate-400">
|
|
SQLite persistence · Auto-decoding Sens'it v2 & Discovery v3
|
|
</footer>
|
|
</div>
|
|
|
|
<script>
|
|
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
|
|
|
|
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
const fmtShortTime = ts => new Date(ts * 1000).toLocaleTimeString('fr-FR');
|
|
|
|
function badge(text, tone = 'slate') {
|
|
const tones = {
|
|
slate: 'bg-slate-100 text-slate-700 border-slate-200',
|
|
blue: 'bg-blue-50 text-blue-700 border-blue-200',
|
|
green: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
|
amber: 'bg-amber-50 text-amber-700 border-amber-200',
|
|
rose: 'bg-rose-50 text-rose-700 border-rose-200',
|
|
violet: 'bg-violet-50 text-violet-700 border-violet-200',
|
|
};
|
|
return `<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border ${tones[tone] || tones.slate}">${text}</span>`;
|
|
}
|
|
|
|
function modeTone(mode) {
|
|
return {
|
|
'Temperature': 'blue',
|
|
'Light': 'amber',
|
|
'Door': 'violet',
|
|
'Vibration': 'rose', 'Move': 'rose',
|
|
'Magnet': 'violet', 'Reed Switch': 'violet',
|
|
'Button': 'slate', 'Standby / Button': 'slate',
|
|
}[mode] || 'slate';
|
|
}
|
|
|
|
function batteryTone(v) {
|
|
if (v == null) return 'slate';
|
|
if (v < 3.0) return 'rose';
|
|
if (v < 3.4) return 'amber';
|
|
return 'green';
|
|
}
|
|
|
|
function rssiTone(rssi) {
|
|
if (rssi == null) return 'slate';
|
|
if (rssi > -70) return 'green';
|
|
if (rssi > -100) return 'amber';
|
|
return 'rose';
|
|
}
|
|
|
|
function summaryLine(d) {
|
|
if (!d) return '—';
|
|
const p = [];
|
|
if ('temperature_c' in d) p.push(`${d.temperature_c}°C`);
|
|
if ('humidity_pct' in d) p.push(`${d.humidity_pct}%`);
|
|
if ('brightness_lux' in d) p.push(`${d.brightness_lux} lux`);
|
|
if ('door_status' in d) p.push(`door: ${d.door_status}`);
|
|
if ('vibration_status' in d) p.push(`vibration: ${d.vibration_status}`);
|
|
if ('magnet_status' in d) p.push(`magnet: ${d.magnet_status}`);
|
|
if ('event_count' in d && d.mode_id > 2) p.push(`${d.event_count} events`);
|
|
if ('move_events' in d) p.push(`${d.move_events} moves`);
|
|
if ('reed_events' in d) p.push(`${d.reed_events} reed events`);
|
|
if ('fw_major' in d) p.push(`fw ${d.fw_major}.${d.fw_minor}`);
|
|
if (p.length === 0) p.push('heartbeat');
|
|
return p.join(' · ');
|
|
}
|
|
|
|
function renderDevices() {
|
|
const el = document.getElementById('devices');
|
|
el.innerHTML = '';
|
|
const sel = document.getElementById('chart-device');
|
|
const currentSel = sel.value;
|
|
sel.innerHTML = '';
|
|
Object.entries(state.messagesByDevice).forEach(([dev, msgs]) => {
|
|
const last = msgs[0];
|
|
const d = last.decoded || {};
|
|
const card = document.createElement('div');
|
|
card.className = 'bg-white rounded-lg border border-slate-200 p-5 fade-in hover:border-slate-300 transition';
|
|
card.innerHTML = `
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div class="min-w-0">
|
|
<div class="text-xs text-slate-500 mono uppercase tracking-wider">${dev.toUpperCase()}</div>
|
|
<div class="mt-1 flex items-center gap-2 flex-wrap">
|
|
${badge(d.mode || 'Unknown', modeTone(d.mode))}
|
|
${d.format ? badge(d.format.replace('sensit_','').toUpperCase(), 'slate') : ''}
|
|
</div>
|
|
</div>
|
|
<div class="text-right shrink-0">
|
|
<div class="text-xs text-slate-500">Battery</div>
|
|
<div class="mt-1">${badge(`${d.battery ?? '—'} V`, batteryTone(d.battery))}</div>
|
|
</div>
|
|
</div>
|
|
<div class="text-2xl font-semibold text-slate-900 tabular-nums">${summaryLine(d)}</div>
|
|
<div class="mt-3 flex items-center gap-3 text-xs text-slate-500">
|
|
<span>${fmtTime(last.ts)}</span>
|
|
${last.rssi != null ? `<span>·</span><span>RSSI ${Number(last.rssi).toFixed(0)} dBm</span>` : ''}
|
|
</div>
|
|
`;
|
|
el.appendChild(card);
|
|
|
|
const opt = document.createElement('option');
|
|
opt.value = dev; opt.textContent = dev.toUpperCase();
|
|
sel.appendChild(opt);
|
|
});
|
|
if (currentSel && state.messagesByDevice[currentSel]) sel.value = currentSel;
|
|
if (!state.selectedDevice && sel.options.length) state.selectedDevice = sel.value;
|
|
renderChart();
|
|
}
|
|
|
|
function renderMessages() {
|
|
const el = document.getElementById('msgs');
|
|
const all = Object.values(state.messagesByDevice).flat()
|
|
.sort((a,b) => b.ts - a.ts).slice(0, 50);
|
|
el.innerHTML = all.map(m => `
|
|
<tr class="fade-in hover:bg-slate-50">
|
|
<td class="px-5 py-3 text-slate-500 whitespace-nowrap">${fmtShortTime(m.ts)}</td>
|
|
<td class="px-5 py-3 mono text-slate-700">${m.device.toUpperCase()}</td>
|
|
<td class="px-5 py-3">${badge(m.decoded?.format?.replace('sensit_','').toUpperCase() || '?', 'slate')}</td>
|
|
<td class="px-5 py-3">${badge(m.decoded?.mode || '?', modeTone(m.decoded?.mode))}</td>
|
|
<td class="px-5 py-3 text-slate-700">${summaryLine(m.decoded)}</td>
|
|
<td class="px-5 py-3 text-right mono tabular-nums text-slate-500">${m.rssi?.toFixed?.(0) ?? '—'}</td>
|
|
<td class="px-5 py-3 mono text-slate-400 text-xs">${m.raw}</td>
|
|
</tr>
|
|
`).join('');
|
|
}
|
|
|
|
function renderChart() {
|
|
const dev = state.selectedDevice || document.getElementById('chart-device').value;
|
|
if (!dev) return;
|
|
const msgs = (state.messagesByDevice[dev] || []).slice().reverse();
|
|
const labels = msgs.map(m => fmtShortTime(m.ts));
|
|
const temps = msgs.map(m => m.decoded?.temperature_c ?? null);
|
|
const humi = msgs.map(m => m.decoded?.humidity_pct ?? null);
|
|
|
|
const ctx = document.getElementById('chart').getContext('2d');
|
|
if (state.chart) state.chart.destroy();
|
|
state.chart = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels,
|
|
datasets: [
|
|
{ label: 'Temperature °C', data: temps, borderColor: '#2563eb', backgroundColor: 'rgba(37,99,235,0.08)', yAxisID: 'y', tension: 0.35, borderWidth: 2, fill: true, spanGaps: true, pointRadius: 2, pointHoverRadius: 4 },
|
|
{ label: 'Humidity %', data: humi, borderColor: '#059669', backgroundColor: 'rgba(5,150,105,0.08)', yAxisID: 'y1', tension: 0.35, borderWidth: 2, fill: true, spanGaps: true, pointRadius: 2, pointHoverRadius: 4 },
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
interaction: { mode: 'index', intersect: false },
|
|
plugins: {
|
|
legend: { position: 'bottom', labels: { color: '#64748b', padding: 16, font: { size: 12 } } },
|
|
tooltip: { backgroundColor: '#0f172a', padding: 10, boxPadding: 4 },
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: '#94a3b8', font: { size: 11 } }, grid: { color: '#f1f5f9' } },
|
|
y: { ticks: { color: '#2563eb', font: { size: 11 } }, grid: { color: '#f1f5f9' }, position: 'left', border: { display: false } },
|
|
y1: { ticks: { color: '#059669', font: { size: 11 } }, grid: { drawOnChartArea: false }, position: 'right', border: { display: false } },
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function bootstrap() {
|
|
const r = await fetch('/api/messages?limit=200');
|
|
const msgs = await r.json();
|
|
msgs.forEach(m => {
|
|
if (!state.messagesByDevice[m.device]) state.messagesByDevice[m.device] = [];
|
|
state.messagesByDevice[m.device].push(m);
|
|
});
|
|
Object.values(state.messagesByDevice).forEach(a => a.sort((x,y) => y.ts - x.ts));
|
|
renderDevices();
|
|
renderMessages();
|
|
}
|
|
|
|
function connectSSE() {
|
|
const es = new EventSource('/events');
|
|
es.onmessage = (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data);
|
|
if (msg.type !== 'message') return;
|
|
if (!state.messagesByDevice[msg.device]) state.messagesByDevice[msg.device] = [];
|
|
state.messagesByDevice[msg.device].unshift(msg);
|
|
state.messagesByDevice[msg.device] = state.messagesByDevice[msg.device].slice(0, 200);
|
|
renderDevices();
|
|
renderMessages();
|
|
} catch (e) { console.error(e); }
|
|
};
|
|
}
|
|
|
|
document.getElementById('chart-device').addEventListener('change', (e) => {
|
|
state.selectedDevice = e.target.value;
|
|
renderChart();
|
|
});
|
|
|
|
bootstrap().then(connectSSE);
|
|
</script>
|
|
</body>
|
|
</html>
|