Track per-device traffic totals and recover inventory state
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 15:24:54 +03:00
parent e774486b99
commit 307ad02cd7
13 changed files with 842 additions and 96 deletions
+92
View File
@@ -123,6 +123,7 @@ test('traffic service preserves active rules and snapshot when replacement fails
});
const first = await service.refresh();
assert.equal(first.epoch, 'boot');
assert.equal(first.generation, 'rules-a');
assert.deepEqual(first.devices, [{
ip: '192.168.50.7',
@@ -175,3 +176,94 @@ test('traffic service preserves active rules and snapshot when replacement fails
assert.deepEqual(timedOut.devices, first.devices);
assert.ok(calls.every(([, , options]) => options.timeout === 2_000));
});
test('traffic service finalizes a detached slot once and keeps epoch totals monotonic', async () => {
const firstObservation = observation('192.168.50.7', '00:11:22:33:44:55');
const secondObservation = observation('192.168.50.8', '00:11:22:33:44:66');
const firstDevice = selectTrafficDevices([firstObservation])[0];
const secondDevice = selectTrafficDevices([secondObservation])[0];
let observed = {
observedAt: '2026-08-07T12:00:00.000Z',
observations: [firstObservation],
error: null,
};
const values = {
A: { upload: '100', download: '200' },
B: { upload: '5', download: '7' },
};
const keys = { A: firstDevice.key, B: secondDevice.key };
let failNextCounterRead = false;
const run = (command, args) => {
if (command !== 'iptables-save') return { status: 0, stdout: '', stderr: '' };
if (failNextCounterRead) {
failNextCounterRead = false;
return { status: null, stdout: '', stderr: '', error: new Error('retired slot read failed') };
}
const direction = args.includes('raw') ? 'upload' : 'download';
const tableChain = args.includes('raw') ? uploadChain : downloadChain;
return {
status: 0,
stdout: ['A', 'B'].map((slot) => (
`[1:${values[slot][direction]}] -A ${tableChain}_${slot} -m comment --comment "harbor-traffic:${keys[slot]}:${direction}" -j RETURN`
)).join('\n'),
stderr: '',
};
};
const generations = ['epoch-1', 'rules-a', 'rules-b'];
const service = createDeviceTrafficService({
observe: async () => observed,
uploadChain,
downloadChain,
bypassCidrs: [],
run,
nextGeneration: () => generations.shift(),
});
const first = await service.refresh();
assert.equal(first.epoch, 'epoch-1');
assert.equal(first.generation, 'rules-a');
assert.deepEqual(first.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
mac, uploadBytes, downloadBytes,
})), [{
mac: firstObservation.mac,
uploadBytes: '100',
downloadBytes: '200',
}]);
values.A = { upload: '130', download: '240' };
observed = {
observedAt: '2026-08-07T12:01:00.000Z',
observations: [secondObservation],
error: null,
};
failNextCounterRead = true;
const pending = await service.refresh();
assert.equal(pending.epoch, 'epoch-1');
assert.equal(pending.generation, 'rules-b');
assert.match(pending.source.error, /retired slot read failed/);
assert.deepEqual(pending.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
mac, uploadBytes, downloadBytes,
})), [
{ mac: firstObservation.mac, uploadBytes: '100', downloadBytes: '200' },
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
]);
const finalized = await service.refresh();
assert.equal(finalized.generation, 'rules-b');
assert.equal(finalized.source.error, null);
assert.deepEqual(finalized.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
mac, uploadBytes, downloadBytes,
})), [
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
{ mac: secondObservation.mac, uploadBytes: '5', downloadBytes: '7' },
]);
values.B = { upload: '15', download: '17' };
const polled = await service.refresh();
assert.deepEqual(polled.devices.map(({ mac, uploadBytes, downloadBytes }) => ({
mac, uploadBytes, downloadBytes,
})), [
{ mac: firstObservation.mac, uploadBytes: '130', downloadBytes: '240' },
{ mac: secondObservation.mac, uploadBytes: '15', downloadBytes: '17' },
]);
});