import Database from "better-sqlite3"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import type { MetricSnapshot } from "../collectors/types.js"; export function openDb(path: string): Database.Database { mkdirSync(dirname(path), { recursive: true }); const db = new Database(path); db.pragma("journal_mode = WAL"); db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS hosts ( host_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, group_name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS metric_snapshots ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id TEXT NOT NULL, ts TEXT NOT NULL DEFAULT (datetime('now')), status TEXT NOT NULL, cpu_pct REAL, mem_pct REAL, disk_pct REAL, mem_total_bytes REAL, mem_used_bytes REAL, disk_total_bytes REAL, disk_used_bytes REAL, mem_pressure_pct REAL, cpu_pressure_pct REAL, uptime_sec INTEGER, meta_json TEXT ); CREATE INDEX IF NOT EXISTS idx_snapshots_host_ts ON metric_snapshots(host_id, ts); CREATE TABLE IF NOT EXISTS devices ( ip TEXT PRIMARY KEY, mac TEXT NOT NULL, known_name TEXT, mdns_hostname TEXT, first_seen TEXT NOT NULL DEFAULT (datetime('now')), last_seen TEXT NOT NULL DEFAULT (datetime('now')) ); -- Manual admin-assigned names, keyed by MAC (not IP, which can change on -- DHCP renewal). Overrides both known_name and mdns_hostname when present. CREATE TABLE IF NOT EXISTS device_labels ( mac TEXT PRIMARY KEY, label TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); -- Insert-only, permanent record of the first time each MAC was ever seen. -- Deliberately separate from the devices table (keyed by IP, which churns -- on DHCP renewal) -- keying "have we ever seen this MAC" by IP would -- generate a false new-device alert every time an existing device's -- lease just happened to change. is_bootstrap marks rows seeded by the -- very first (empty-table) call -- without it, the "New" UI badge (which -- just checks "seen within 24h") would flag the entire pre-existing -- device population as new on the day this feature is first deployed, -- since bootstrap timestamps are, correctly, "now". Caught by actually -- checking the deployed API response, not just the notification-side unit test. CREATE TABLE IF NOT EXISTS seen_macs ( mac TEXT PRIMARY KEY, first_seen TEXT NOT NULL DEFAULT (datetime('now')), is_bootstrap INTEGER NOT NULL DEFAULT 0 ); `); // Migration for metric_snapshots pre-dating the raw byte capacity columns // (CREATE TABLE IF NOT EXISTS above doesn't touch already-existing tables). const snapshotColumns = (db.pragma("table_info(metric_snapshots)") as { name: string }[]).map( (c) => c.name ); for (const col of ["mem_total_bytes", "mem_used_bytes", "disk_total_bytes", "disk_used_bytes"]) { if (!snapshotColumns.includes(col)) db.exec(`ALTER TABLE metric_snapshots ADD COLUMN ${col} REAL`); } // Migration for the devices table pre-dating mdns_hostname (CREATE TABLE IF // NOT EXISTS above doesn't touch already-existing tables). SQLite's ALTER // TABLE ADD COLUMN has no IF NOT EXISTS clause (unlike CREATE TABLE/INDEX), // so check first -- confirmed the hard way, "ADD COLUMN IF NOT EXISTS" is a // syntax error even on a SQLite version otherwise new enough for it. const hasMdnsColumn = (db.pragma("table_info(devices)") as { name: string }[]).some( (c) => c.name === "mdns_hostname" ); if (!hasMdnsColumn) db.exec(`ALTER TABLE devices ADD COLUMN mdns_hostname TEXT`); // Same situation for seen_macs.is_bootstrap. Any rows that already existed // before this column was added were, by definition, from a table that had // no bootstrap tracking at all -- backfill them as bootstrap rows (the // true history) so they don't show up as "new" in the UI once this // migration lands on an already-deployed instance. const hasBootstrapColumn = (db.pragma("table_info(seen_macs)") as { name: string }[]).some( (c) => c.name === "is_bootstrap" ); if (!hasBootstrapColumn) { db.exec(`ALTER TABLE seen_macs ADD COLUMN is_bootstrap INTEGER NOT NULL DEFAULT 0`); db.exec(`UPDATE seen_macs SET is_bootstrap = 1`); } return db; } export function upsertSnapshots(db: Database.Database, snapshots: MetricSnapshot[]): void { const upsertHost = db.prepare(` INSERT INTO hosts (host_id, display_name, group_name) VALUES (@hostId, @displayName, @group) ON CONFLICT(host_id) DO UPDATE SET display_name = excluded.display_name, group_name = excluded.group_name `); const insertSnapshot = db.prepare(` INSERT INTO metric_snapshots (host_id, status, cpu_pct, mem_pct, disk_pct, mem_total_bytes, mem_used_bytes, disk_total_bytes, disk_used_bytes, mem_pressure_pct, cpu_pressure_pct, uptime_sec, meta_json) VALUES (@hostId, @status, @cpuPct, @memPct, @diskPct, @memTotalBytes, @memUsedBytes, @diskTotalBytes, @diskUsedBytes, @memPressurePct, @cpuPressurePct, @uptimeSec, @metaJson) `); const tx = db.transaction((items: MetricSnapshot[]) => { for (const s of items) { upsertHost.run(s); insertSnapshot.run({ ...s, metaJson: JSON.stringify(s.meta) }); } }); tx(snapshots); } export interface LatestRow { host_id: string; display_name: string; group_name: string; status: string; cpu_pct: number | null; mem_pct: number | null; disk_pct: number | null; mem_total_bytes: number | null; mem_used_bytes: number | null; disk_total_bytes: number | null; disk_used_bytes: number | null; mem_pressure_pct: number | null; cpu_pressure_pct: number | null; uptime_sec: number | null; ts: string; } export function getLatestSnapshots(db: Database.Database): LatestRow[] { return db .prepare( ` SELECT h.host_id, h.display_name, h.group_name, s.status, s.cpu_pct, s.mem_pct, s.disk_pct, s.mem_total_bytes, s.mem_used_bytes, s.disk_total_bytes, s.disk_used_bytes, s.mem_pressure_pct, s.cpu_pressure_pct, s.uptime_sec, s.ts FROM hosts h JOIN metric_snapshots s ON s.host_id = h.host_id WHERE s.id = ( SELECT id FROM metric_snapshots s2 WHERE s2.host_id = h.host_id ORDER BY s2.id DESC LIMIT 1 ) ORDER BY h.group_name, h.display_name ` ) .all() as LatestRow[]; } export interface HistoryPoint { host_id: string; ts: string; cpu_pct: number | null; mem_pct: number | null; disk_pct: number | null; } // Last N samples per host (not a time window) — at the default 30s poll interval // that's ~20 minutes of trend, enough for a sparkline without needing to // downsample. A window function keeps this to one query instead of one per host. export function getRecentHistory(db: Database.Database, samplesPerHost = 40): HistoryPoint[] { return db .prepare( ` SELECT host_id, ts, cpu_pct, mem_pct, disk_pct FROM ( SELECT host_id, ts, cpu_pct, mem_pct, disk_pct, ROW_NUMBER() OVER (PARTITION BY host_id ORDER BY id DESC) AS rn FROM metric_snapshots ) WHERE rn <= @samplesPerHost ORDER BY host_id, ts ASC ` ) .all({ samplesPerHost }) as HistoryPoint[]; } // Sparklines only ever read the last ~40 samples (see getRecentHistory), so // anything older than the retention window is just dead weight — trim it. export function pruneOldSnapshots(db: Database.Database, retentionHours: number): void { db.prepare(`DELETE FROM metric_snapshots WHERE ts < datetime('now', @cutoff)`).run({ cutoff: `-${retentionHours} hours`, }); } export interface DiscoveredDevice { ip: string; mac: string; knownName: string | null; mdnsHostname: string | null; } export function upsertDevices(db: Database.Database, devices: DiscoveredDevice[]): void { const upsert = db.prepare(` INSERT INTO devices (ip, mac, known_name, mdns_hostname) VALUES (@ip, @mac, @knownName, @mdnsHostname) ON CONFLICT(ip) DO UPDATE SET mac = excluded.mac, known_name = excluded.known_name, mdns_hostname = excluded.mdns_hostname, last_seen = datetime('now') `); const tx = db.transaction((items: DiscoveredDevice[]) => { for (const d of items) upsert.run(d); }); tx(devices); } export interface DeviceRow { ip: string; mac: string; known_name: string | null; mdns_hostname: string | null; manual_label: string | null; first_seen: string; last_seen: string; first_ever_seen: string | null; is_bootstrap: number | null; } // Devices not seen in the last 24h (unplugged, moved, DHCP lease expired) are // dropped from the list rather than shown as permanently "known but offline" — // there's no persistent per-device up/down state to track like hosts have. // Manual labels are joined by MAC (survives IP changes) and take priority — // see effectiveName() in routes/devices.ts for the full precedence order. // first_ever_seen comes from seen_macs (permanent, MAC-keyed), not // devices.first_seen (IP-keyed, resets on DHCP lease change) — see // recordSeenMacs()'s comment for why that distinction matters. export function getRecentDevices(db: Database.Database, sinceHours = 24): DeviceRow[] { return db .prepare( `SELECT d.ip, d.mac, d.known_name, d.mdns_hostname, l.label AS manual_label, d.first_seen, d.last_seen, s.first_seen AS first_ever_seen, s.is_bootstrap FROM devices d LEFT JOIN device_labels l ON l.mac = d.mac LEFT JOIN seen_macs s ON s.mac = d.mac WHERE d.last_seen > datetime('now', @cutoff) ORDER BY (l.label IS NULL AND d.known_name IS NULL), d.ip` ) .all({ cutoff: `-${sinceHours} hours` }) as DeviceRow[]; } export function getDeviceMac(db: Database.Database, ip: string): string | null { const row = db.prepare(`SELECT mac FROM devices WHERE ip = @ip`).get({ ip }) as | { mac: string } | undefined; return row?.mac ?? null; } export function setDeviceLabel(db: Database.Database, mac: string, label: string): void { db.prepare( `INSERT INTO device_labels (mac, label) VALUES (@mac, @label) ON CONFLICT(mac) DO UPDATE SET label = excluded.label, updated_at = datetime('now')` ).run({ mac, label }); } export function clearDeviceLabel(db: Database.Database, mac: string): void { db.prepare(`DELETE FROM device_labels WHERE mac = @mac`).run({ mac }); } // Records every MAC seen this discovery cycle, returning only the ones never // recorded before. On the very first run (empty table), the whole current // device population would otherwise look "new" and trigger an alert storm -- // that first call just seeds the baseline and reports nothing as new. export function recordSeenMacs(db: Database.Database, macs: string[]): string[] { const isBootstrap = (db.prepare(`SELECT COUNT(*) AS n FROM seen_macs`).get() as { n: number }).n === 0; const alreadySeen = new Set( (db.prepare(`SELECT mac FROM seen_macs`).all() as { mac: string }[]).map((r) => r.mac) ); const newMacs = macs.filter((mac) => !alreadySeen.has(mac)); const insert = db.prepare( `INSERT OR IGNORE INTO seen_macs (mac, is_bootstrap) VALUES (@mac, @isBootstrap)` ); const tx = db.transaction((items: string[]) => { for (const mac of items) insert.run({ mac, isBootstrap: isBootstrap ? 1 : 0 }); }); tx(macs); return isBootstrap ? [] : newMacs; }