feat(devices): hourly scan-cycle orchestration + cron
This commit is contained in:
@@ -5,6 +5,7 @@ import { enqueue } from '../jobs/queue.js';
|
|||||||
import { checkAll } from '../health/checker.js';
|
import { checkAll } from '../health/checker.js';
|
||||||
import * as statusRepo from '../db/repos/service_status.js';
|
import * as statusRepo from '../db/repos/service_status.js';
|
||||||
import * as services from '../db/repos/monitored_services.js';
|
import * as services from '../db/repos/monitored_services.js';
|
||||||
|
import { runDeviceScanCycle } from '../infra/scan_cycle.js';
|
||||||
|
|
||||||
export function startCron() {
|
export function startCron() {
|
||||||
// Daily at 03:00 local time
|
// Daily at 03:00 local time
|
||||||
@@ -35,5 +36,11 @@ export function startCron() {
|
|||||||
} catch (e) { log.error({ err: e }, 'health check failed'); }
|
} catch (e) { log.error({ err: e }, 'health check failed'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hourly LAN device scan (staggered off the :00 speedtest)
|
||||||
|
cron.schedule('7 * * * *', async () => {
|
||||||
|
try { await runDeviceScanCycle(); }
|
||||||
|
catch (e) { log.error({ err: e }, 'device scan cycle failed'); }
|
||||||
|
});
|
||||||
|
|
||||||
log.info('cron started');
|
log.info('cron started');
|
||||||
}
|
}
|
||||||
|
|||||||
19
lib/infra/scan_cycle.js
Normal file
19
lib/infra/scan_cycle.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// One discovery cycle: scan → upsert → mark-absent → prune. Deps injected for
|
||||||
|
// tests. Prune only runs after a successful, non-empty scan, so a failed scan
|
||||||
|
// can never reap rows.
|
||||||
|
import { runScan } from './scan.js';
|
||||||
|
import * as devices from '../db/repos/lan_devices.js';
|
||||||
|
import { log } from '../log.js';
|
||||||
|
|
||||||
|
export async function runDeviceScanCycle({ scan = runScan, repo = devices } = {}) {
|
||||||
|
const rows = await scan();
|
||||||
|
if (!rows.length) {
|
||||||
|
log.warn('device scan returned no hosts; skipping upsert/prune');
|
||||||
|
return { seen: 0 };
|
||||||
|
}
|
||||||
|
await repo.upsertScan(rows);
|
||||||
|
await repo.markAbsent(rows.map(r => r.mac));
|
||||||
|
const pruned = await repo.prune();
|
||||||
|
log.info({ seen: rows.length, pruned }, 'device scan cycle complete');
|
||||||
|
return { seen: rows.length, pruned };
|
||||||
|
}
|
||||||
32
tests/infra/scan_cycle.test.js
Normal file
32
tests/infra/scan_cycle.test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// tests/infra/scan_cycle.test.js
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { runDeviceScanCycle } from '../../lib/infra/scan_cycle.js';
|
||||||
|
|
||||||
|
function fakeRepo() {
|
||||||
|
return {
|
||||||
|
calls: [],
|
||||||
|
upsertScan: vi.fn(async r => r.length),
|
||||||
|
markAbsent: vi.fn(async () => 1),
|
||||||
|
prune: vi.fn(async () => 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runDeviceScanCycle', () => {
|
||||||
|
it('scan→upsert→markAbsent→prune on a non-empty scan', async () => {
|
||||||
|
const repo = fakeRepo();
|
||||||
|
const scan = vi.fn(async () => [{ mac: 'aa:bb:cc:dd:ee:ff', ip: '1.2.3.4', vendor: 'x', randomized: false }]);
|
||||||
|
const res = await runDeviceScanCycle({ scan, repo });
|
||||||
|
expect(repo.upsertScan).toHaveBeenCalledOnce();
|
||||||
|
expect(repo.markAbsent).toHaveBeenCalledWith(['aa:bb:cc:dd:ee:ff']);
|
||||||
|
expect(repo.prune).toHaveBeenCalledOnce();
|
||||||
|
expect(res).toEqual({ seen: 1, pruned: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips upsert/prune when the scan returns nothing', async () => {
|
||||||
|
const repo = fakeRepo();
|
||||||
|
const res = await runDeviceScanCycle({ scan: async () => [], repo });
|
||||||
|
expect(repo.upsertScan).not.toHaveBeenCalled();
|
||||||
|
expect(repo.prune).not.toHaveBeenCalled();
|
||||||
|
expect(res).toEqual({ seen: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user