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>
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py sensit_decoder.py ./
|
||||||
|
COPY static/ ./static/
|
||||||
|
|
||||||
|
ENV DB_PATH=/data/messages.db
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""SNEK Dashboard — Flask backend receiving SNEK callbacks."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request, send_from_directory, stream_with_context, Response
|
||||||
|
|
||||||
|
from sensit_decoder import decode as sensit_decode
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/data/messages.db")
|
||||||
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
app = Flask(__name__, static_folder="static", static_url_path="/static")
|
||||||
|
|
||||||
|
# --- SSE broadcast ---
|
||||||
|
_subscribers: list[queue.Queue] = []
|
||||||
|
_subscribers_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast(event: dict) -> None:
|
||||||
|
payload = json.dumps(event)
|
||||||
|
with _subscribers_lock:
|
||||||
|
dead = []
|
||||||
|
for q in _subscribers:
|
||||||
|
try:
|
||||||
|
q.put_nowait(payload)
|
||||||
|
except queue.Full:
|
||||||
|
dead.append(q)
|
||||||
|
for q in dead:
|
||||||
|
_subscribers.remove(q)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
def _init_db() -> None:
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts REAL NOT NULL,
|
||||||
|
device TEXT NOT NULL,
|
||||||
|
raw TEXT NOT NULL,
|
||||||
|
decoded TEXT NOT NULL,
|
||||||
|
rssi REAL,
|
||||||
|
snr REAL,
|
||||||
|
seq_number INTEGER
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_device_ts ON messages(device, ts DESC)")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
_init_db()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Routes ---
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
return send_from_directory("static", "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/webhook", methods=["POST"])
|
||||||
|
def webhook():
|
||||||
|
"""
|
||||||
|
SNEK callback endpoint.
|
||||||
|
SNEK sends JSON with: device, time, data, rssi, snr, seqNumber, etc.
|
||||||
|
"""
|
||||||
|
body = request.get_json(force=True, silent=True) or {}
|
||||||
|
device = (body.get("device") or "").lower()
|
||||||
|
raw = (body.get("data") or "").lower()
|
||||||
|
ts = float(body.get("time") or time.time())
|
||||||
|
rssi = body.get("rssi")
|
||||||
|
snr = body.get("snr")
|
||||||
|
seq_number = body.get("seqNumber")
|
||||||
|
|
||||||
|
if not device or not raw:
|
||||||
|
return jsonify({"error": "missing device or data"}), 400
|
||||||
|
|
||||||
|
decoded = sensit_decode(raw)
|
||||||
|
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO messages (ts, device, raw, decoded, rssi, snr, seq_number) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(ts, device, raw, json.dumps(decoded), rssi, snr, seq_number),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_broadcast({
|
||||||
|
"type": "message",
|
||||||
|
"ts": ts,
|
||||||
|
"device": device,
|
||||||
|
"raw": raw,
|
||||||
|
"decoded": decoded,
|
||||||
|
"rssi": rssi,
|
||||||
|
"snr": snr,
|
||||||
|
"seqNumber": seq_number,
|
||||||
|
})
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/devices")
|
||||||
|
def api_devices():
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT device, COUNT(*) c, MAX(ts) last_ts FROM messages GROUP BY device ORDER BY last_ts DESC"
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify([{"device": r[0], "count": r[1], "last_ts": r[2]} for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/messages")
|
||||||
|
def api_messages():
|
||||||
|
device = (request.args.get("device") or "").lower()
|
||||||
|
limit = int(request.args.get("limit") or 100)
|
||||||
|
with sqlite3.connect(DB_PATH) as conn:
|
||||||
|
if device:
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT ts, device, raw, decoded, rssi, snr, seq_number FROM messages "
|
||||||
|
"WHERE device = ? ORDER BY ts DESC LIMIT ?",
|
||||||
|
(device, limit),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur = conn.execute(
|
||||||
|
"SELECT ts, device, raw, decoded, rssi, snr, seq_number FROM messages "
|
||||||
|
"ORDER BY ts DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
return jsonify([
|
||||||
|
{
|
||||||
|
"ts": r[0],
|
||||||
|
"device": r[1],
|
||||||
|
"raw": r[2],
|
||||||
|
"decoded": json.loads(r[3]),
|
||||||
|
"rssi": r[4],
|
||||||
|
"snr": r[5],
|
||||||
|
"seqNumber": r[6],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/decode")
|
||||||
|
def api_decode():
|
||||||
|
"""Manual decode utility: /api/decode?hex=b60dc86e"""
|
||||||
|
h = request.args.get("hex", "")
|
||||||
|
return jsonify(sensit_decode(h))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/events")
|
||||||
|
def events():
|
||||||
|
"""SSE stream of live messages."""
|
||||||
|
def stream():
|
||||||
|
q: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
with _subscribers_lock:
|
||||||
|
_subscribers.append(q)
|
||||||
|
try:
|
||||||
|
yield "event: ping\ndata: connected\n\n"
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
payload = q.get(timeout=25)
|
||||||
|
yield f"data: {payload}\n\n"
|
||||||
|
except queue.Empty:
|
||||||
|
yield ": keepalive\n\n"
|
||||||
|
finally:
|
||||||
|
with _subscribers_lock:
|
||||||
|
if q in _subscribers:
|
||||||
|
_subscribers.remove(q)
|
||||||
|
|
||||||
|
return Response(stream_with_context(stream()), mimetype="text/event-stream")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/healthz")
|
||||||
|
def healthz():
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=3000)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
flask==3.0.3
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Sens'it Discovery payload decoder — all 6 modes."""
|
||||||
|
|
||||||
|
MODES = {
|
||||||
|
0: "Standby / Button",
|
||||||
|
1: "Temperature",
|
||||||
|
2: "Light",
|
||||||
|
3: "Door",
|
||||||
|
4: "Vibration",
|
||||||
|
5: "Magnet",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def decode(payload_hex: str) -> dict:
|
||||||
|
"""
|
||||||
|
Decode a 4-byte Sens'it Discovery payload.
|
||||||
|
|
||||||
|
Format (per Sens'it Discovery v3.1.0+ spec):
|
||||||
|
Byte 0: bits 7-3 = battery level, bits 2-0 = reserved (0b110)
|
||||||
|
Byte 1: bits 7-3 = mode, bits 2-1 = mode-specific / spare, bit 0 = button flag
|
||||||
|
Byte 2-3: mode-specific data
|
||||||
|
|
||||||
|
Battery formula: V = (raw × 0.05) + 2.7
|
||||||
|
"""
|
||||||
|
payload_hex = payload_hex.strip().lower().replace("0x", "").replace(" ", "")
|
||||||
|
if len(payload_hex) != 8:
|
||||||
|
return {"error": f"payload must be 4 bytes (8 hex chars), got '{payload_hex}'"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
b0, b1, b2, b3 = (int(payload_hex[i : i + 2], 16) for i in range(0, 8, 2))
|
||||||
|
except ValueError as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
# Common fields
|
||||||
|
battery_raw = (b0 >> 3) & 0x1F
|
||||||
|
battery_volts = round(battery_raw * 0.05 + 2.7, 2)
|
||||||
|
reserved = b0 & 0x07
|
||||||
|
mode = (b1 >> 3) & 0x1F
|
||||||
|
button_alert = bool(b1 & 0x01)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"raw": payload_hex,
|
||||||
|
"battery": battery_volts,
|
||||||
|
"battery_raw": battery_raw,
|
||||||
|
"reserved_ok": reserved == 0b110,
|
||||||
|
"mode": MODES.get(mode, f"Unknown ({mode})"),
|
||||||
|
"mode_id": mode,
|
||||||
|
"button_alert": button_alert,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mode-specific decoding
|
||||||
|
if mode == 1: # Temperature
|
||||||
|
temp_msb = (b1 >> 2) & 0x01
|
||||||
|
temp_raw = (temp_msb << 8) | b2
|
||||||
|
result["temperature_c"] = round((temp_raw - 200) / 8, 2)
|
||||||
|
result["humidity_pct"] = round(b3 / 2, 1)
|
||||||
|
|
||||||
|
elif mode == 2: # Light
|
||||||
|
brightness_msb = (b1 >> 2) & 0x01
|
||||||
|
brightness_raw = (brightness_msb << 8) | b2
|
||||||
|
result["brightness_lux"] = round(brightness_raw / 96, 2)
|
||||||
|
|
||||||
|
elif mode == 3: # Door
|
||||||
|
door_status_bits = (b1 >> 1) & 0x03
|
||||||
|
door_map = {0: "closed", 1: "opened", 2: "opening_alert", 3: "closing_alert"}
|
||||||
|
result["door_status"] = door_map.get(door_status_bits, "unknown")
|
||||||
|
result["event_count"] = (b2 << 8) | b3
|
||||||
|
|
||||||
|
elif mode == 4: # Vibration
|
||||||
|
vib_status_bits = (b1 >> 1) & 0x03
|
||||||
|
vib_map = {0: "no_vibration", 1: "vibration_detected"}
|
||||||
|
result["vibration_status"] = vib_map.get(vib_status_bits, "unknown")
|
||||||
|
result["event_count"] = (b2 << 8) | b3
|
||||||
|
|
||||||
|
elif mode == 5: # Magnet
|
||||||
|
mag_status_bits = (b1 >> 1) & 0x03
|
||||||
|
mag_map = {0: "no_magnet", 1: "magnet_detected"}
|
||||||
|
result["magnet_status"] = mag_map.get(mag_status_bits, "unknown")
|
||||||
|
result["event_count"] = (b2 << 8) | b3
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Sanity checks with the payloads we captured
|
||||||
|
for h in ("b60dc86e", "a60dc271", "b6043081"):
|
||||||
|
print(h, "→", decode(h))
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<!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>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: dashboard-data
|
||||||
|
namespace: snek
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: snek-dashboard
|
||||||
|
namespace: snek
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: snek-dashboard
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: snek-dashboard
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: dashboard
|
||||||
|
image: 192.168.1.100:30500/snek-dashboard:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
name: http
|
||||||
|
env:
|
||||||
|
- name: DB_PATH
|
||||||
|
value: /data/messages.db
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dashboard-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: snek-dashboard
|
||||||
|
namespace: snek
|
||||||
|
spec:
|
||||||
|
type: LoadBalancer
|
||||||
|
loadBalancerIP: 192.168.1.213
|
||||||
|
selector:
|
||||||
|
app: snek-dashboard
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 80
|
||||||
|
targetPort: 3000
|
||||||
Reference in New Issue
Block a user