Add LAN device discovery (ping sweep + ARP, known/unknown labeling)
CI / web (push) Successful in 17s
CI / api (push) Successful in 23s

Runs as a host-level systemd timer on CT122 (scripts/discover-devices.sh)
rather than inside the api container, since real ARP entries live in the
host's network namespace, not Docker's bridge network. See
docs/device-discovery.md for the full writeup, including why literal
passive-only ARP reading was dropped (near-empty result in practice).

API reads the resulting JSON file each poll cycle, cross-references
config/hosts.yaml's knownDevices list by IP, and serves /api/devices.
Dashboard gets a new "Network Devices" table.

Closes #9.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 20:49:22 -06:00
parent d75bd58792
commit 04282232cc
15 changed files with 434 additions and 8 deletions
+50
View File
@@ -36,6 +36,14 @@ export function openDb(path: string): Database.Database {
);
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,
first_seen TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
return db;
}
@@ -100,3 +108,45 @@ export function pruneOldSnapshots(db: Database.Database, retentionHours: number)
cutoff: `-${retentionHours} hours`,
});
}
export interface DiscoveredDevice {
ip: string;
mac: string;
knownName: string | null;
}
export function upsertDevices(db: Database.Database, devices: DiscoveredDevice[]): void {
const upsert = db.prepare(`
INSERT INTO devices (ip, mac, known_name)
VALUES (@ip, @mac, @knownName)
ON CONFLICT(ip) DO UPDATE SET
mac = excluded.mac,
known_name = excluded.known_name,
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;
first_seen: string;
last_seen: string;
}
// 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.
export function getRecentDevices(db: Database.Database, sinceHours = 24): DeviceRow[] {
return db
.prepare(
`SELECT ip, mac, known_name, first_seen, last_seen FROM devices
WHERE last_seen > datetime('now', @cutoff)
ORDER BY known_name IS NULL, ip`
)
.all({ cutoff: `-${sinceHours} hours` }) as DeviceRow[];
}