Files
snek/dashboard/static/index.html
T
tarcourt a77776d6d8 feat: web dashboard for live device data
Small companion service that receives SNEK callbacks, decodes Sens'it
Discovery payloads for all 6 modes (Standby, Temperature, Light, Door,
Vibration, Magnet), stores in SQLite, and serves a responsive dashboard.

Backend (Python Flask):
- POST /webhook: receive SNEK callback
- GET /api/devices, /api/messages, /api/decode
- GET /events: Server-Sent Events for live push
- SQLite persistence at /data/messages.db

Frontend:
- Tailwind CSS via CDN, Chart.js for temp/humidity graph
- Live updates via SSE
- Device cards with icons per mode
- Message log with human-readable summaries

Deploy:
- Separate deployment (snek-dashboard) with own PVC
- LoadBalancer on 192.168.1.213:80
- SNEK callback URL: http://snek-dashboard.snek.svc.cluster.local/webhook

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 17:43:11 +02:00

211 lines
8.3 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>
<style>
html,body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.fade-in { animation: fade 0.4s ease-out; }
@keyframes fade { from { opacity:0; transform: translateY(4px); } to { opacity:1; transform: translateY(0); } }
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen">
<div class="max-w-6xl mx-auto p-4 md:p-8">
<header class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl md:text-3xl font-bold">🛰️ SNEK Dashboard</h1>
<p class="text-slate-400 text-sm">Sens'it Discovery — live decoding</p>
</div>
<div id="conn-badge" class="text-xs px-3 py-1 rounded-full bg-slate-800 text-slate-400">connecting…</div>
</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-slate-900 rounded-2xl p-4 md:p-6 mb-8 border border-slate-800">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Historique</h2>
<select id="chart-device" class="bg-slate-800 border border-slate-700 rounded-lg px-3 py-1.5 text-sm"></select>
</div>
<canvas id="chart" height="120"></canvas>
</section>
<!-- Messages log -->
<section class="bg-slate-900 rounded-2xl p-4 md:p-6 border border-slate-800">
<h2 class="text-lg font-semibold mb-4">Messages</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-slate-400 text-left">
<tr>
<th class="pb-2 pr-3">Time</th>
<th class="pb-2 pr-3">Device</th>
<th class="pb-2 pr-3">Mode</th>
<th class="pb-2 pr-3">Data</th>
<th class="pb-2 pr-3">RSSI</th>
<th class="pb-2 pr-3">Payload</th>
</tr>
</thead>
<tbody id="msgs" class="divide-y divide-slate-800"></tbody>
</table>
</div>
</section>
</div>
<script>
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR');
const fmtShortTime = ts => new Date(ts * 1000).toLocaleTimeString('fr-FR');
const modeIcon = mode => ({
'Temperature': '🌡️',
'Light': '💡',
'Door': '🚪',
'Vibration': '📳',
'Magnet': '🧲',
'Standby / Button': '⏸️',
}[mode] || '❓');
function summaryLine(d) {
if (!d) return '-';
const parts = [];
if ('temperature_c' in d) parts.push(`${d.temperature_c}°C`);
if ('humidity_pct' in d) parts.push(`${d.humidity_pct}%`);
if ('brightness_lux' in d) parts.push(`${d.brightness_lux} lux`);
if ('door_status' in d) parts.push(`porte: ${d.door_status}`);
if ('vibration_status' in d) parts.push(`vibration: ${d.vibration_status}`);
if ('magnet_status' in d) parts.push(`aimant: ${d.magnet_status}`);
if ('event_count' in d && d.mode_id > 2) parts.push(`events: ${d.event_count}`);
if (parts.length === 0) parts.push('heartbeat');
return parts.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-slate-900 rounded-2xl p-4 border border-slate-800 fade-in';
card.innerHTML = `
<div class="flex items-center justify-between mb-2">
<div>
<div class="text-xs text-slate-500">${dev.toUpperCase()}</div>
<div class="text-sm font-semibold">${modeIcon(d.mode)} ${d.mode || 'Unknown'}</div>
</div>
<div class="text-right">
<div class="text-xs text-slate-500">Battery</div>
<div class="text-sm font-mono">${d.battery ?? '?'} V</div>
</div>
</div>
<div class="text-2xl md:text-3xl font-bold text-blue-400 mb-1">${summaryLine(d)}</div>
<div class="text-xs text-slate-500">${fmtTime(last.ts)}</div>
${last.rssi != null ? `<div class="text-xs text-slate-500 mt-1">RSSI ${last.rssi.toFixed?.(1) ?? last.rssi} dBm</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="hover:bg-slate-800/50 fade-in">
<td class="py-2 pr-3 text-slate-400">${fmtShortTime(m.ts)}</td>
<td class="py-2 pr-3 font-mono text-blue-400">${m.device.toUpperCase()}</td>
<td class="py-2 pr-3">${modeIcon(m.decoded?.mode)} ${m.decoded?.mode || '?'}</td>
<td class="py-2 pr-3 text-slate-300">${summaryLine(m.decoded)}</td>
<td class="py-2 pr-3 text-slate-500">${m.rssi?.toFixed?.(0) ?? '-'}</td>
<td class="py-2 pr-3 text-slate-500 font-mono 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: '#60a5fa', backgroundColor: 'rgba(96,165,250,0.15)', yAxisID: 'y', tension: 0.3, fill: true, spanGaps: true },
{ label: 'Humidity %', data: humi, borderColor: '#34d399', backgroundColor: 'rgba(52,211,153,0.15)', yAxisID: 'y1', tension: 0.3, fill: true, spanGaps: true },
]
},
options: {
responsive: true,
plugins: { legend: { labels: { color: '#e2e8f0' } } },
scales: {
x: { ticks: { color: '#94a3b8' }, grid: { color: '#1e293b' } },
y: { ticks: { color: '#60a5fa' }, grid: { color: '#1e293b' }, position: 'left', title: { text:'°C', display:true, color:'#60a5fa' } },
y1: { ticks: { color: '#34d399' }, grid: { drawOnChartArea: false }, position: 'right', title: { text:'%', display:true, color:'#34d399' } },
}
}
});
}
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 badge = document.getElementById('conn-badge');
const es = new EventSource('/events');
es.onopen = () => { badge.textContent = 'live'; badge.className = 'text-xs px-3 py-1 rounded-full bg-emerald-900/50 text-emerald-400'; };
es.onerror = () => { badge.textContent = 'reconnecting…'; badge.className = 'text-xs px-3 py-1 rounded-full bg-red-900/50 text-red-400'; };
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>