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
+243
View File
@@ -7,6 +7,8 @@ import { parseNeighborSnapshot, readNeighborSnapshot } from '../../src/server/ad
import {
createDeviceInventoryService,
createVendorLookup,
DEVICE_INVENTORY_SCHEMA_VERSION,
migrateDeviceInventoryState,
} from '../../src/server/services/deviceInventoryService.js';
import { createJsonStore } from '../../src/server/services/stateStore.js';
@@ -100,3 +102,244 @@ test('device inventory discovers, merges, persists metadata and expires anonymou
assert.deepEqual(failed.observations, []);
assert.match(failed.error, /not available/);
});
test('device traffic totals persist exact deltas across polls and process epochs', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-traffic-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const store = createJsonStore({
filePath: path.join(directory, 'devices.json'),
defaultValue: {},
migrate: migrateDeviceInventoryState,
});
const observedAt = '2026-08-07T12:00:00.000Z';
const mac = '00:11:22:33:44:55';
const neighbor = {
observedAt,
observations: [{
ip: '192.168.50.7',
mac,
interface: 'eth0',
observedAt,
active: true,
}],
error: null,
};
let traffic = {
epoch: 'epoch-a',
generation: 'rules-a',
observedAt,
source: { error: null },
devices: [{
ip: '192.168.50.7',
mac,
interface: 'eth0',
uploadBytes: '9007199254740993',
downloadBytes: '100',
}],
};
let trafficError = null;
const createService = () => createDeviceInventoryService({
store,
observe: () => neighbor,
observeTraffic: () => {
if (trafficError) throw trafficError;
return traffic;
},
});
let service = createService();
let snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
assert.equal(snapshot.devices[0].downloadBytes, '100');
assert.equal(snapshot.devices[0].trafficObservedAt, observedAt);
assert.deepEqual(snapshot.source.traffic, { lastObservedAt: observedAt, error: null });
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740993');
assert.equal(snapshot.devices[0].downloadBytes, '100');
traffic = {
...traffic,
devices: [{ ...traffic.devices[0], uploadBytes: '9007199254740995', downloadBytes: '150' }],
};
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
assert.equal(snapshot.devices[0].downloadBytes, '150');
service = createService();
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254740995');
assert.equal(snapshot.devices[0].downloadBytes, '150');
traffic = {
...traffic,
epoch: 'epoch-b',
generation: 'rules-b',
devices: [{ ...traffic.devices[0], uploadBytes: '10', downloadBytes: '20' }],
};
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.equal(snapshot.devices[0].downloadBytes, '170');
traffic = {
...traffic,
devices: [{ ...traffic.devices[0], uploadBytes: '9', downloadBytes: '20' }],
};
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.match(snapshot.source.traffic.error, /уменьшился/);
trafficError = new Error('traffic unavailable');
snapshot = await service.refresh();
assert.equal(snapshot.devices[0].uploadBytes, '9007199254741005');
assert.equal(snapshot.source.traffic.lastObservedAt, observedAt);
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
});
test('device inventory v1 migration creates a versioned backup', (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filePath = path.join(directory, 'devices.json');
fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, revision: 4, devices: [] }));
const store = createJsonStore({
filePath,
defaultValue: {},
migrate: migrateDeviceInventoryState,
backupWhen: (before, after) => before?.schemaVersion !== after.schemaVersion,
});
const migrated = store.read();
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
assert.deepEqual(migrated.traffic.baselinesByMac, {});
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
assert.ok(fs.existsSync(store.migration.backupPath));
});
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filePath = path.join(directory, 'devices.json');
const observedAt = '2026-08-07T12:00:00.000Z';
const macs = [
'00:11:22:33:44:55',
'00:11:22:33:44:66',
'00:11:22:33:44:77',
'00:11:22:33:44:88',
'00:11:22:33:44:99',
];
const [firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac] = macs;
const devices = macs.map((mac, index) => ({
id: `device-${index}`,
alias: '',
pinned: false,
hostname: null,
manufacturer: null,
mac,
ip: `192.168.50.${index + 7}`,
interface: 'eth0',
firstSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
lastSeenAt: mac === expiredMac ? '2026-06-01T12:00:00.000Z' : observedAt,
source: 'neighbor',
confidence: 'high',
}));
fs.writeFileSync(filePath, JSON.stringify({
schemaVersion: 2,
revision: 4,
lastObservedAt: observedAt,
lastError: null,
devices,
traffic: {
epoch: 'epoch-a',
generation: 'rules-a',
lastObservedAt: observedAt,
lastError: null,
baselinesByMac: {
[firstMac]: { epoch: 'epoch-a', uploadBytes: 'broken', downloadBytes: '100' },
[secondMac]: { epoch: 'epoch-a', uploadBytes: '50', downloadBytes: '60' },
[missingTotalMac]: { epoch: 'epoch-a', uploadBytes: '90', downloadBytes: '100' },
[expiredMac]: { epoch: 'epoch-a', uploadBytes: '110', downloadBytes: '120' },
},
totalsByMac: {
[firstMac]: { uploadBytes: '500', downloadBytes: '600', observedAt },
[secondMac]: { uploadBytes: 'broken', downloadBytes: '700', observedAt },
[missingBaselineMac]: { uploadBytes: '800', downloadBytes: '900', observedAt },
[expiredMac]: { uploadBytes: 'broken', downloadBytes: '1000', observedAt },
},
},
}));
const store = createJsonStore({
filePath,
defaultValue: {},
migrate: migrateDeviceInventoryState,
backupWhen: () => true,
});
const migrated = store.read();
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
assert.ok(fs.existsSync(store.migration.backupPath));
assert.deepEqual(migrated.traffic.totalsByMac[firstMac], {
uploadBytes: '500',
downloadBytes: '600',
observedAt,
});
assert.equal(migrated.traffic.totalsByMac[secondMac], undefined);
assert.deepEqual(
new Set(migrated.traffic.rebaselineMacs),
new Set([firstMac, secondMac, missingBaselineMac, missingTotalMac, expiredMac]),
);
let counters = [
{ mac: firstMac, uploadBytes: '200', downloadBytes: '300' },
{ mac: secondMac, uploadBytes: '70', downloadBytes: '80' },
{ mac: missingBaselineMac, uploadBytes: '110', downloadBytes: '120' },
{ mac: missingTotalMac, uploadBytes: '130', downloadBytes: '140' },
];
const service = createDeviceInventoryService({
store,
observe: () => ({
observedAt,
error: null,
observations: devices.filter(({ mac }) => mac !== expiredMac)
.map(({ ip, mac, interface: deviceInterface }) => ({
ip,
mac,
interface: deviceInterface,
observedAt,
active: true,
})),
}),
observeTraffic: () => ({
epoch: 'epoch-a',
generation: 'rules-a',
observedAt,
source: { error: null },
devices: counters,
}),
});
let snapshot = await service.refresh();
let byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
assert.equal(byMac.get(firstMac).uploadBytes, '500');
assert.equal(byMac.get(secondMac).uploadBytes, '0');
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '800');
assert.equal(byMac.get(missingTotalMac).uploadBytes, '0');
assert.equal(byMac.has(expiredMac), false);
assert.deepEqual(store.read().traffic.rebaselineMacs, []);
assert.equal(snapshot.source.traffic.error, null);
counters = [
{ mac: firstMac, uploadBytes: '250', downloadBytes: '330' },
{ mac: secondMac, uploadBytes: '75', downloadBytes: '90' },
{ mac: missingBaselineMac, uploadBytes: '115', downloadBytes: '125' },
{ mac: missingTotalMac, uploadBytes: '150', downloadBytes: '160' },
];
snapshot = await service.refresh();
byMac = new Map(snapshot.devices.map((device) => [device.mac, device]));
assert.equal(byMac.get(firstMac).uploadBytes, '550');
assert.equal(byMac.get(firstMac).downloadBytes, '630');
assert.equal(byMac.get(secondMac).uploadBytes, '5');
assert.equal(byMac.get(secondMac).downloadBytes, '10');
assert.equal(byMac.get(missingBaselineMac).uploadBytes, '805');
assert.equal(byMac.get(missingBaselineMac).downloadBytes, '905');
assert.equal(byMac.get(missingTotalMac).uploadBytes, '20');
assert.equal(byMac.get(missingTotalMac).downloadBytes, '20');
});
+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' },
]);
});
+28 -1
View File
@@ -3,7 +3,11 @@ import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { formatLastSeen } from '../../src/web/utils/format.js';
import {
formatByteString,
formatLastSeen,
sortDevicesByTraffic,
} from '../../src/web/utils/format.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const overview = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
@@ -17,6 +21,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /api\.devices\.list\(\)/);
assert.match(panel, /api\.devices\.refresh\(\)/);
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
assert.match(panel, /requestError\.code !== 'STATE_CONFLICT'[\s\S]*api\.devices\.list\(\)[\s\S]*Object\.keys\(patch\)\.some\(\(key\) => latestDevice\[key\] !== device\[key\]\)[\s\S]*api\.devices\.update\(device\.id, patch, latest\.revision\)/);
assert.match(panel, /deviceNodes\.current\.get\(id\)\?\.animate/);
assert.match(panel, /prefers-reduced-motion: reduce/);
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
@@ -27,6 +32,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /client-device-addresses/);
assert.doesNotMatch(panel, /device\.interface/);
assert.match(panel, /device\.confidence === 'ambiguous'/);
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
assert.match(panel, /Трафик временно не обновляется/);
assert.match(panel, /Получено \$\{download\}, отдано \$\{upload\}/);
assert.match(panel, /client-device-traffic/);
assert.match(panel, /client-drawer client-instructions client-devices/);
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
@@ -36,6 +46,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-pin-wrap\.client-tooltip-anchor:hover > \.client-tooltip[\s\S]*translate\(0, 0\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
});
test('device last-seen copy is compact with precise accessible and relative forms', () => {
@@ -50,3 +62,18 @@ test('device last-seen copy is compact with precise accessible and relative form
},
);
});
test('device traffic formatting and sorting preserve uint64 precision and canonical ties', () => {
assert.equal(formatByteString('9007199254740993'), '8,0 ПБ');
assert.equal(formatByteString('1536'), '1,5 КБ');
assert.equal(formatByteString('invalid'), '0 Б');
const devices = [
{ id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0' },
{ id: 'b', uploadBytes: '9007199254740992', downloadBytes: '2' },
{ id: 'c', uploadBytes: '10', downloadBytes: '10' },
{ id: 'd', uploadBytes: '15', downloadBytes: '5' },
];
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['b', 'a', 'c', 'd']);
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['c', 'd', 'a', 'b']);
});
+13 -4
View File
@@ -83,6 +83,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
test('secondary menus share one right rail and both drawers open from the right', () => {
const disabledRulesLabel = rule('.client-local-rules-toggle:disabled span');
const zIndex = (selector) => Number(/z-index:\s*(\d+)/.exec(rule(selector))?.[1]);
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
@@ -92,10 +93,18 @@ test('secondary menus share one right rail and both drawers open from the right'
assert.match(disabledRulesLabel, /opacity:\s*0/);
assert.match(disabledRulesLabel, /filter:\s*blur\(5px\)/);
assert.match(styles, /\.client-local-rules-toggle:disabled:hover span\s*\{[\s\S]*opacity:\s*1/);
assert.match(rule('.client-instructions'), /inset:\s*0 0 0 auto/);
assert.match(rule('.client-instructions'), /transform:\s*translateX\(104%\)/);
assert.match(rule('.client-local-rules'), /inset:\s*0 0 0 auto/);
assert.match(rule('.client-local-rules'), /transform:\s*translateX\(104%\)/);
assert.match(rule('.client-drawer'), /inset:\s*0 0 0 auto/);
assert.match(rule('.client-drawer'), /transform:\s*translateX\(104%\)/);
assert.match(rule('.client-drawer'), /z-index:\s*50/);
assert.match(rule('.client-drawer'), /box-shadow:/);
assert.deepEqual(
['.client-confirmation-popup', '.client-secondary-menu', '.client-drawer', '.harbor-versions'].map(zIndex),
[100, 60, 50, 40],
);
assert.match(rule('.client-instructions'), /width:\s*min\(470px, 100vw\)/);
assert.match(rule('.client-local-rules'), /width:\s*min\(480px, 100vw\)/);
assert.match(component, /className={`client-drawer client-instructions/);
assert.match(component, /className={`client-drawer client-local-rules/);
});
test('duration and Gateway access keep stable geometry without tabs', () => {