Files
homelab-monitor/apps/api/src/db/index.ts
T
jhodgkin 5329136bad
CI / web (pull_request) Successful in 24s
CI / api (pull_request) Successful in 31s
Show memory and disk capacity (used/total) on dashboard tiles
Previously the Mem/Disk bars only showed a percentage. Plumbed the
underlying byte totals through the whole stack -- collectors, DB
(with a migration for the already-deployed CT122 instance), API, and
the web tile -- so each card also shows e.g. "19.6/32.0 GB".

Verified end-to-end with a throwaway local instance (seeded snapshot,
logged in, screenshotted the rendered tile).

Note: the SSH-collected hosts (omv, ripper) assume the remote
monitor-readonly.sh script emits raw bytes for MEMLINE/DISK_, matching
Proxmox's convention -- unverified since that script only lives on
those two hosts, not in this repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:36:09 +00:00

303 lines
12 KiB
TypeScript

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;
}