Projects Web

Pulse — Realtime Ops Dashboard

A self-hosted dashboard that aggregates live metrics from my weather station, print farm, and home hub into one realtime view over server-sent events.

Shipped since Dec 2025 #sveltekit#server-sent-events#sqlite#self-hosted
9
Devices reporting
41,000
Metrics ingested/day
180 ms
Dashboard latency
99.7 %
Uptime

Pulse exists because I got tired of checking four different tabs to know what my hardware was doing. The weather station had its own page, the print farm had OctoPrint, the home hub logged to a file nobody read. I wanted one screen on the wall that answered “is everything okay?” at a glance.

Architecture

Devices POST JSON readings to a single ingest endpoint on a SvelteKit server running on my home server (an old ThinkCentre pulling 11 W idle). Readings land in SQLite via better-sqlite3 — synchronous writes, WAL mode, no ORM. The dashboard page opens one EventSource connection and the server fans out every new reading to connected clients. There is no message broker and no separate API service; the whole thing is one Node process behind Caddy.

Why SSE, not WebSockets

I started with WebSockets out of habit, then realized the data flow is strictly one-directional: server pushes, browser renders. SSE gave me automatic reconnection with Last-Event-ID for free, worked through Caddy with zero config, and deleted about 120 lines of heartbeat and reconnect code. The lesson: pick the transport that matches the traffic shape, not the one with the bigger feature list.

Time-series in SQLite

The naive schema — one row per reading — worked until the raw table hit 2 million rows and the 24-hour chart query took 900 ms. The fix was a rollup table populated on write:

INSERT INTO readings_hourly (device_id, metric, hour, min, max, avg, n)
VALUES (?, ?, strftime('%Y-%m-%dT%H', 'now'), ?, ?, ?, 1)
ON CONFLICT(device_id, metric, hour) DO UPDATE SET
  min = MIN(min, excluded.min),
  max = MAX(max, excluded.max),
  avg = (avg * n + excluded.avg) / (n + 1),
  n   = n + 1;

Charts longer than six hours read the rollups; only the live sparklines touch raw rows. Query time dropped to under 20 ms, and a nightly job deletes raw rows older than 90 days.

Keeping it up

Uptime is currently 99.7% over five months. The two outages were both mine: a full disk from forgetting WAL checkpointing, and a power cut the UPS I hadn’t bought yet would have absorbed. A systemd watchdog and a disk-space stat on the dashboard itself closed the loop — Pulse now monitors Pulse.

Development timeline

  1. 2025-12

    First ingest

    Weather station POSTing readings into a bare SQLite table.

  2. 2026-01

    SSE live view

    Dropped the 5-second polling loop for server-sent events.

  3. 2026-03

    Rollups and retention

    Hourly aggregation tables cut dashboard queries from 900 ms to under 20 ms.

  4. 2026-05

    Shipped

    Running unattended behind Caddy with systemd watchdog restarts.