Files
snek/dashboard/static/index.html
T
tarcourt f728adb76d ui: proper login page + inline SVG favicon
Replaced window.prompt() with a real login screen:
- Centered card, brand mark, hidden password-type input
- Inline error state on invalid token
- Overlay shown until token validated, then app rendered

Favicon: inline data-URI SVG with concentric radio-wave rings,
zero external requests, no /favicon.ico 404 in logs.
2026-07-03 18:28:45 +02:00

399 lines
16 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>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='7' fill='%230f172a'/><circle cx='16' cy='16' r='3' fill='%2338bdf8'/><circle cx='16' cy='16' r='7' fill='none' stroke='%2338bdf8' stroke-width='1.5' opacity='0.6'/><circle cx='16' cy='16' r='11' fill='none' stroke='%2338bdf8' stroke-width='1' opacity='0.35'/></svg>">
<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">
<!-- Login overlay -->
<div id="login-overlay" class="hidden fixed inset-0 bg-slate-100 z-50 items-center justify-center px-4">
<div class="w-full max-w-sm bg-white border border-slate-200 rounded-xl p-8 shadow-sm">
<div class="flex items-center gap-3 mb-6">
<svg viewBox="0 0 32 32" class="w-8 h-8"><rect width="32" height="32" rx="7" fill="#0f172a"/><circle cx="16" cy="16" r="3" fill="#38bdf8"/><circle cx="16" cy="16" r="7" fill="none" stroke="#38bdf8" stroke-width="1.5" opacity="0.6"/><circle cx="16" cy="16" r="11" fill="none" stroke="#38bdf8" stroke-width="1" opacity="0.35"/></svg>
<div>
<div class="text-sm font-semibold text-slate-900">SNEK Dashboard</div>
<div class="text-xs text-slate-500">Sign in with your access token</div>
</div>
</div>
<form id="login-form" class="space-y-4">
<div>
<label for="login-token" class="block text-xs font-medium text-slate-700 mb-1.5">Access token</label>
<input id="login-token" type="password" autocomplete="off" required
class="mono w-full text-sm border border-slate-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-slate-900/10 focus:border-slate-400" />
</div>
<div id="login-error" class="hidden text-xs text-rose-600 bg-rose-50 border border-rose-200 rounded-md px-3 py-2"></div>
<button type="submit" class="w-full bg-slate-900 text-white text-sm font-medium py-2 rounded-md hover:bg-slate-800 transition">
Continue
</button>
</form>
</div>
</div>
<div id="app" 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 id="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 id="chart-subtitle" class="text-xs text-slate-500 mt-0.5"></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 &amp; Discovery v3
</footer>
</div>
<script>
// --- Token gate --------------------------------------------------------------
const TOKEN_KEY = 'snek_dashboard_token';
let token = localStorage.getItem(TOKEN_KEY) || '';
async function ensureToken() {
const probe = await fetch('/api/devices', { headers: authHeaders() });
if (probe.ok) return;
if (probe.status !== 401) return;
const overlay = document.getElementById('login-overlay');
const form = document.getElementById('login-form');
const input = document.getElementById('login-token');
const error = document.getElementById('login-error');
const app = document.getElementById('app');
overlay.className = 'fixed inset-0 bg-slate-100 z-50 flex items-center justify-center px-4';
app.classList.add('hidden');
input.focus();
return new Promise((resolve) => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const t = input.value.trim();
error.classList.add('hidden');
const r = await fetch('/api/devices', { headers: { Authorization: 'Bearer ' + t } });
if (r.ok) {
token = t;
localStorage.setItem(TOKEN_KEY, token);
overlay.className = 'hidden';
app.classList.remove('hidden');
resolve();
} else {
error.textContent = 'Invalid token. Please try again.';
error.classList.remove('hidden');
input.value = '';
input.focus();
}
});
});
}
function authHeaders() {
return token ? { Authorization: 'Bearer ' + token } : {};
}
function authQuery() {
return token ? '?token=' + encodeURIComponent(token) : '';
}
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 ('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('');
}
const chartPresets = {
Temperature: {
subtitle: 'Temperature & humidity over time',
datasets: (msgs) => [
makeDs('Temperature °C', msgs.map(m => m.decoded?.temperature_c ?? null), '#2563eb', 'y'),
makeDs('Humidity %', msgs.map(m => m.decoded?.humidity_pct ?? null), '#059669', 'y1'),
],
},
Light: {
subtitle: 'Brightness over time',
datasets: (msgs) => [
makeDs('Brightness (lux)', msgs.map(m => m.decoded?.brightness_lux ?? null), '#d97706', 'y'),
],
},
Door: {
subtitle: 'Cumulative openings',
datasets: (msgs) => [
makeDs('Event count', msgs.map(m => m.decoded?.event_count ?? null), '#7c3aed', 'y'),
],
},
Vibration: {
subtitle: 'Cumulative vibration events',
datasets: (msgs) => [
makeDs('Event count', msgs.map(m => m.decoded?.event_count ?? null), '#e11d48', 'y'),
],
},
Move: {
subtitle: 'Cumulative movement events',
datasets: (msgs) => [
makeDs('Event count', msgs.map(m => m.decoded?.event_count ?? null), '#e11d48', 'y'),
],
},
Magnet: {
subtitle: 'Cumulative magnet events',
datasets: (msgs) => [
makeDs('Event count', msgs.map(m => m.decoded?.event_count ?? null), '#7c3aed', 'y'),
],
},
'Reed Switch': {
subtitle: 'Cumulative reed switch events',
datasets: (msgs) => [
makeDs('Event count', msgs.map(m => m.decoded?.event_count ?? null), '#7c3aed', 'y'),
],
},
};
function makeDs(label, data, color, yID) {
return {
label, data, borderColor: color, backgroundColor: color + '14',
yAxisID: yID, tension: 0.35, borderWidth: 2, fill: true,
spanGaps: true, pointRadius: 2, pointHoverRadius: 4,
};
}
function renderChart() {
const dev = state.selectedDevice || document.getElementById('chart-device').value;
const section = document.getElementById('chart-section');
const subtitle = document.getElementById('chart-subtitle');
if (!dev) { section.style.display = 'none'; return; }
const msgs = (state.messagesByDevice[dev] || []).slice().reverse();
const latestMode = msgs.length ? msgs[msgs.length - 1].decoded?.mode : null;
const preset = chartPresets[latestMode];
if (!preset) {
section.style.display = 'none';
return;
}
section.style.display = '';
subtitle.textContent = preset.subtitle;
const labels = msgs.map(m => fmtShortTime(m.ts));
const datasets = preset.datasets(msgs);
const hasSecondAxis = datasets.some(ds => ds.yAxisID === 'y1');
const ctx = document.getElementById('chart').getContext('2d');
if (state.chart) state.chart.destroy();
state.chart = new Chart(ctx, {
type: 'line',
data: { labels, datasets },
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: datasets[0].borderColor, font: { size: 11 } }, grid: { color: '#f1f5f9' }, position: 'left', border: { display: false } },
...(hasSecondAxis ? { y1: { ticks: { color: datasets[1].borderColor, font: { size: 11 } }, grid: { drawOnChartArea: false }, position: 'right', border: { display: false } } } : {}),
}
}
});
}
async function bootstrap() {
const r = await fetch('/api/messages?limit=200', { headers: authHeaders() });
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' + authQuery());
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();
});
ensureToken().then(bootstrap).then(connectSSE);
</script>
</body>
</html>