feat(devices): lan_devices repo (upsert/absent/prune/promote)

This commit is contained in:
root
2026-06-08 20:58:08 +10:00
parent 0083e80dc7
commit 2ca2adc485
2 changed files with 136 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { pool } from '../pool.js';
const COLS = 'mac, ip, vendor, name, grp, note, status, randomized, flagged, first_seen, last_seen, present';
export async function listKnown() {
const { rows } = await pool.query(
`SELECT ${COLS} FROM lan_devices WHERE status='known' ORDER BY grp, name NULLS LAST, ip`);
return rows;
}
export async function listDiscovered() {
const { rows } = await pool.query(
`SELECT ${COLS} FROM lan_devices WHERE status='new' ORDER BY last_seen DESC`);
return rows;
}
export async function get(mac) {
const { rows: [r] } = await pool.query(`SELECT ${COLS} FROM lan_devices WHERE mac=$1`, [mac]);
return r || null;
}
// Insert unseen MACs as status='new'; for existing, refresh ip/vendor/last_seen/present
// WITHOUT touching owner-curated name/grp/status/flagged.
export async function upsertScan(rows) {
for (const r of rows) {
await pool.query(
`INSERT INTO lan_devices (mac, ip, vendor, randomized, status, present, first_seen, last_seen)
VALUES ($1,$2,$3,$4,'new',true,now(),now())
ON CONFLICT (mac) DO UPDATE SET
ip = EXCLUDED.ip,
vendor = COALESCE(NULLIF(EXCLUDED.vendor,''), lan_devices.vendor),
last_seen = now(), present = true`,
[r.mac, r.ip ?? null, r.vendor ?? null, !!r.randomized]);
}
return rows.length;
}
// Mark devices not in the latest scan as absent. Empty input is a no-op so a
// failed/empty scan can never blanket-mark everything offline.
export async function markAbsent(seenMacs) {
if (!seenMacs || !seenMacs.length) return 0;
const { rowCount } = await pool.query(
`UPDATE lan_devices SET present=false WHERE present=true AND NOT (mac = ANY($1::text[]))`,
[seenMacs]);
return rowCount;
}
// Reap unreviewed + absent rows past their TTL. Never touches known/ignored.
export async function prune() {
const { rowCount } = await pool.query(
`DELETE FROM lan_devices WHERE status='new' AND present=false AND (
(randomized AND last_seen < now() - interval '24 hours') OR
(NOT randomized AND last_seen < now() - interval '14 days'))`);
return rowCount;
}
const PATCHABLE = ['name', 'grp', 'note', 'status', 'flagged'];
export async function update(mac, patch) {
const sets = [], vals = [];
for (const k of PATCHABLE) {
if (patch[k] !== undefined) { vals.push(patch[k]); sets.push(`${k}=$${vals.length}`); }
}
if (!sets.length) return get(mac);
vals.push(mac);
const { rows: [r] } = await pool.query(
`UPDATE lan_devices SET ${sets.join(', ')} WHERE mac=$${vals.length} RETURNING ${COLS}`, vals);
return r || null;
}
export async function remove(mac) {
const { rowCount } = await pool.query(`DELETE FROM lan_devices WHERE mac=$1`, [mac]);
return rowCount > 0;
}

View File

@@ -0,0 +1,63 @@
// tests/repos/lan_devices.test.js
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { resetDb } from '../helpers/db.js';
import { migrateUp } from '../../lib/db/migrate.js';
import { pool } from '../../lib/db/pool.js';
import * as repo from '../../lib/db/repos/lan_devices.js';
beforeAll(async () => { await resetDb(); await migrateUp(); });
beforeEach(async () => { await resetDb(); await migrateUp(); });
describe('lan_devices repo', () => {
it('seed: 17 known, 1 discovered (ASUS)', async () => {
expect(await repo.listKnown()).toHaveLength(17);
const disc = await repo.listDiscovered();
expect(disc).toHaveLength(1);
expect(disc[0].mac).toBe('24:4b:fe:8e:09:a4');
expect(disc[0].flagged).toBe(true);
});
it('upsertScan inserts unseen as new, updates known IP without clobbering name', async () => {
await repo.upsertScan([
{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: 'NewCo', randomized: false }, // new
{ mac: 'bc:a5:11:3e:06:88', ip: '192.168.1.77', vendor: 'Netgear', randomized: false } // known Orbi, IP changed
]);
const orbi = await repo.get('bc:a5:11:3e:06:88');
expect(orbi.ip).toBe('192.168.1.77'); // ip updated
expect(orbi.name).toBe('Orbi Satellite'); // name preserved
expect(orbi.status).toBe('known'); // status preserved
expect(orbi.present).toBe(true);
const fresh = await repo.get('aa:bb:cc:dd:ee:ff');
expect(fresh.status).toBe('new');
});
it('markAbsent flips present for unseen; empty list is a no-op', async () => {
await repo.upsertScan([{ mac: 'aa:bb:cc:dd:ee:ff', ip: '192.168.1.99', vendor: '', randomized: false }]);
await repo.markAbsent(['aa:bb:cc:dd:ee:ff']); // only this one seen
expect((await repo.get('bc:a5:11:3e:06:88')).present).toBe(false); // seeded device now absent
expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(true);
const before = (await repo.get('aa:bb:cc:dd:ee:ff')).present;
expect(await repo.markAbsent([])).toBe(0); // guard: no-op
expect((await repo.get('aa:bb:cc:dd:ee:ff')).present).toBe(before);
});
it('prune deletes stale new+absent (randomized >24h, others >14d); keeps known', async () => {
await pool.query(`INSERT INTO lan_devices (mac, status, randomized, present, last_seen)
VALUES ('11:11:11:11:11:11','new',true,false, now()-interval '2 days'),
('22:22:22:22:22:22','new',false,false, now()-interval '20 days'),
('33:33:33:33:33:33','new',true,false, now()-interval '1 hour'),
('44:44:44:44:44:44','known',true,false, now()-interval '99 days')`);
const n = await repo.prune();
expect(n).toBe(2); // the two stale 'new'
expect(await repo.get('33:33:33:33:33:33')).not.toBeNull(); // recent kept
expect(await repo.get('44:44:44:44:44:44')).not.toBeNull(); // known kept
});
it('update promotes + names a discovered device', async () => {
await repo.update('24:4b:fe:8e:09:a4', { name: 'ASUS RT-AX88U', grp: 'Network', status: 'known', flagged: false });
expect(await repo.listDiscovered()).toHaveLength(0);
const d = await repo.get('24:4b:fe:8e:09:a4');
expect(d.name).toBe('ASUS RT-AX88U');
expect(d.status).toBe('known');
});
});