Persist traffic settings and support multi-device traffic views
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import {
|
||||
assertLiveTrafficSnapshot,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||
import { HarborError } from '../../../shared/errors.js';
|
||||
import {
|
||||
normalizeTrafficSettings,
|
||||
type TrafficSettings,
|
||||
} from '../../../shared/trafficSettings.js';
|
||||
import { sendJson } from '../response.js';
|
||||
|
||||
interface LiveTrafficReader {
|
||||
@@ -15,6 +21,11 @@ interface DeviceInventoryReader {
|
||||
snapshot(): unknown;
|
||||
}
|
||||
|
||||
interface TrafficSettingsState {
|
||||
read(): { revision?: unknown; traffic?: TrafficSettings };
|
||||
update(mutator: (state: StoredState) => Record<string, unknown>): unknown;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
@@ -53,21 +64,52 @@ export function enrichLiveTrafficDeviceLabels(
|
||||
export function createLiveTrafficRoute({
|
||||
traffic,
|
||||
deviceInventory = null,
|
||||
settingsState = null,
|
||||
readBody = null,
|
||||
sendState = null,
|
||||
}: {
|
||||
traffic: LiveTrafficReader | null;
|
||||
deviceInventory?: DeviceInventoryReader | null;
|
||||
settingsState?: TrafficSettingsState | null;
|
||||
readBody?: ((req: IncomingMessage) => Promise<Record<string, unknown>>) | null;
|
||||
sendState?: ((res: ServerResponse) => Promise<void>) | null;
|
||||
}) {
|
||||
return {
|
||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||
if (pathname !== '/api/traffic/live') return false;
|
||||
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||
const enriched = deviceInventory
|
||||
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
||||
: snapshot;
|
||||
sendJson(res, 200, enriched);
|
||||
return true;
|
||||
if (pathname === '/api/traffic/live') {
|
||||
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||
const enriched = deviceInventory
|
||||
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
||||
: snapshot;
|
||||
sendJson(res, 200, enriched);
|
||||
return true;
|
||||
}
|
||||
if (pathname === '/api/traffic/settings') {
|
||||
if (req.method !== 'PUT' || !settingsState || !readBody || !sendState) {
|
||||
throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
}
|
||||
const body = await readBody(req);
|
||||
const expectedRevision = body.expectedRevision;
|
||||
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
let settings: TrafficSettings;
|
||||
try {
|
||||
settings = normalizeTrafficSettings(body.settings, { strict: true });
|
||||
} catch (cause) {
|
||||
throw new HarborError('REQUEST_INVALID', { cause });
|
||||
}
|
||||
const current = settingsState.read();
|
||||
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
if (!isDeepStrictEqual(current.traffic, settings)) {
|
||||
settingsState.update((state) => ({ ...state, traffic: settings }));
|
||||
}
|
||||
await sendState(res);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user