5329136bad
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>
303 lines
12 KiB
TypeScript
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;
|
|
}
|