ui: light, professional dashboard redesign

- Light theme with slate palette (bg #f8fafc, cards white with borders)
- Inter font + JetBrains Mono for hex/device IDs
- Emojis removed, replaced with colored text badges (mode, format, alerts)
- Battery/RSSI shown with tone-based badges (green > amber > rose)
- Cleaner header, connection status pill with dot indicator
- Chart with cleaner grid, tooltip, and legend positioning
- Table with proper typography and hover states
- Footer with brief system info

Inspired by modern SaaS dashboards (Linear, Stripe, Cal.com).
This commit is contained in:
2026-07-03 18:05:13 +02:00
parent 1549d4a73e
commit d84da9f9ae
+150 -78
View File
@@ -6,89 +6,136 @@
<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>
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); } }
: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="bg-slate-950 text-slate-100 min-h-screen">
<div class="max-w-6xl mx-auto p-4 md:p-8">
<body class="min-h-screen">
<div class="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-10">
<header class="flex items-center justify-between mb-8">
<!-- Header -->
<header class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 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>
<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>
</div>
<div id="conn-badge" class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-md bg-slate-100 text-slate-600 border border-slate-200 w-fit">
<span id="conn-dot" class="w-1.5 h-1.5 rounded-full bg-slate-400"></span>
<span id="conn-text">connecting</span>
</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>
<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>
<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>
<!-- 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 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>
<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-800"></tbody>
<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>
const state = { messagesByDevice: {}, chart: null, selectedDevice: null };
const fmtTime = ts => new Date(ts * 1000).toLocaleString('fr-FR');
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');
const modeIcon = mode => ({
'Temperature': '🌡️',
'Light': '💡',
'Door': '🚪',
'Vibration': '📳',
'Move': '📳',
'Magnet': '🧲',
'Reed Switch': '🧲',
'Standby / Button': '⏸️',
'Button': '🔘',
}[mode] || '❓');
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 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 ('move_events' in d) parts.push(`move: ${d.move_events}`);
if ('reed_events' in d) parts.push(`reed: ${d.reed_events}`);
if ('fw_major' in d) parts.push(`fw v${d.fw_major}.${d.fw_minor}`);
if (parts.length === 0) parts.push('heartbeat');
return parts.join(' · ');
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}% RH`);
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() {
@@ -101,21 +148,27 @@ function renderDevices() {
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.className = 'bg-white rounded-lg border border-slate-200 p-5 fade-in hover:border-slate-300 transition';
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 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') : ''}
${d.button_alert ? badge('button', 'blue') : ''}
</div>
<div class="text-right">
</div>
<div class="text-right shrink-0">
<div class="text-xs text-slate-500">Battery</div>
<div class="text-sm font-mono">${d.battery ?? '?'} V</div>
<div class="mt-1">${badge(`${d.battery ?? ''} V`, batteryTone(d.battery))}</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>` : ''}
<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);
@@ -133,13 +186,14 @@ function renderMessages() {
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 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('');
}
@@ -159,17 +213,21 @@ function renderChart() {
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 },
{ 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,
plugins: { legend: { labels: { color: '#e2e8f0' } } },
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' }, 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' } },
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 } },
}
}
});
@@ -187,11 +245,25 @@ async function bootstrap() {
renderMessages();
}
function setConn(state) {
const dot = document.getElementById('conn-dot');
const txt = document.getElementById('conn-text');
const bg = document.getElementById('conn-badge');
const map = {
live: ['bg-emerald-500', 'live', 'bg-emerald-50 text-emerald-700 border-emerald-200'],
off: ['bg-rose-500', 'reconnecting', 'bg-rose-50 text-rose-700 border-rose-200'],
idle: ['bg-slate-400', 'connecting', 'bg-slate-100 text-slate-600 border-slate-200'],
};
const [d, t, b] = map[state] || map.idle;
dot.className = `w-1.5 h-1.5 rounded-full ${d}`;
txt.textContent = t;
bg.className = `inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-md border w-fit ${b}`;
}
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.onopen = () => setConn('live');
es.onerror = () => setConn('off');
es.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);