Add device traffic reset with outbound baselines
This commit is contained in:
@@ -517,7 +517,9 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
||||
let trackedDirect = ['10', '20'];
|
||||
let unknown = ['1', '2'];
|
||||
let directIpv4 = ['40', '60'];
|
||||
const service = createDeviceInventoryService({
|
||||
let gateway = ['100', '200'];
|
||||
let proxyIngress = ['10', '20'];
|
||||
const createService = () => createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
@@ -531,7 +533,8 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
||||
source: { error: null },
|
||||
direct: { uploadBytes: directIpv4[0], downloadBytes: directIpv4[1] },
|
||||
devices: [{
|
||||
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: '100', downloadBytes: '200',
|
||||
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: gateway[0], downloadBytes: gateway[1],
|
||||
proxyUploadBytes: proxyIngress[0], proxyDownloadBytes: proxyIngress[1],
|
||||
directUploadBytes: directIpv4[0], directDownloadBytes: directIpv4[1],
|
||||
}],
|
||||
}),
|
||||
@@ -547,6 +550,7 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
||||
series: [],
|
||||
}),
|
||||
});
|
||||
let service = createService();
|
||||
|
||||
let snapshot = await service.refresh();
|
||||
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||
@@ -582,7 +586,64 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
||||
unknownBytes: '9',
|
||||
}]);
|
||||
|
||||
const globalBeforeReset = structuredClone(snapshot.traffic);
|
||||
const resetRevision = snapshot.revision;
|
||||
snapshot = await service.resetTraffic(resetRevision);
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '0');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '0');
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '0');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '0');
|
||||
assert.deepEqual(snapshot.devices[0].trafficHistory, []);
|
||||
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||
observedAt,
|
||||
singboxObservedAt: observedAt,
|
||||
directIpv4ObservedAt: observedAt,
|
||||
vpnBytes: '0',
|
||||
directTrackedBytes: '0',
|
||||
directIpv4Bytes: '0',
|
||||
unknownBytes: '0',
|
||||
});
|
||||
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, []);
|
||||
assert.deepEqual(snapshot.traffic, globalBeforeReset);
|
||||
assert.deepEqual(store.read().traffic.outboundBaselinesByDeviceId[id], {
|
||||
routeEpoch: 'epoch-a',
|
||||
directEpoch: 'epoch-a',
|
||||
vpnBytes: '400',
|
||||
directTrackedBytes: '80',
|
||||
directIpv4Bytes: '160',
|
||||
unknownBytes: '12',
|
||||
});
|
||||
assert.equal(service.metricsSnapshot().domainTraffic.routes[0].uploadBytes, '150');
|
||||
assert.equal(service.metricsSnapshot().directTraffic.series[0].uploadBytes, '70');
|
||||
await assert.rejects(
|
||||
service.resetTraffic(resetRevision),
|
||||
(error) => error.code === 'STATE_CONFLICT',
|
||||
);
|
||||
|
||||
observedAt = '2026-08-12T12:00:30.000Z';
|
||||
gateway = ['115', '225'];
|
||||
proxyIngress = ['13', '24'];
|
||||
vpn = ['160', '260'];
|
||||
trackedDirect = ['35', '55'];
|
||||
unknown = ['4', '8'];
|
||||
directIpv4 = ['75', '95'];
|
||||
service = createService();
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].uploadBytes, '15');
|
||||
assert.equal(snapshot.devices[0].downloadBytes, '25');
|
||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '3');
|
||||
assert.equal(snapshot.devices[0].proxyDownloadBytes, '4');
|
||||
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||
observedAt,
|
||||
singboxObservedAt: observedAt,
|
||||
directIpv4ObservedAt: observedAt,
|
||||
vpnBytes: '20',
|
||||
directTrackedBytes: '10',
|
||||
directIpv4Bytes: '10',
|
||||
unknownBytes: '0',
|
||||
});
|
||||
|
||||
observedAt = '2026-08-12T12:00:45.000Z';
|
||||
epoch = 'epoch-b';
|
||||
vpn = ['3', '4'];
|
||||
trackedDirect = ['1', '2'];
|
||||
|
||||
@@ -34,9 +34,13 @@ function createHarness({ inventory = {}, body = {} } = {}) {
|
||||
calls.push(['update', ...args]);
|
||||
return inventory.update ?? { revision: 3 };
|
||||
},
|
||||
resetTraffic: async (...args) => {
|
||||
calls.push(['resetTraffic', ...args]);
|
||||
return inventory.resetTraffic ?? { revision: 4 };
|
||||
},
|
||||
setPolicy: async (...args) => {
|
||||
calls.push(['setPolicy', ...args]);
|
||||
return inventory.setPolicy ?? { revision: 4 };
|
||||
return inventory.setPolicy ?? { revision: 5 };
|
||||
},
|
||||
};
|
||||
const route = createDeviceInventoryRoute({
|
||||
@@ -97,13 +101,23 @@ test('device route forwards metadata patch and policy arguments without coercion
|
||||
url: `/api/devices/${deviceId}/policy?source=ui`,
|
||||
}, policyResponse), true);
|
||||
assert.deepEqual(policy.calls, [['setPolicy', deviceId, 42, '8']]);
|
||||
assert.deepEqual(policyResponse.payload, { revision: 4 });
|
||||
assert.deepEqual(policyResponse.payload, { revision: 5 });
|
||||
|
||||
const reset = createHarness({ body: { expectedRevision: '9', ignored: true } });
|
||||
const resetResponse = response();
|
||||
assert.equal(await reset.route.handle({
|
||||
method: 'DELETE',
|
||||
url: '/api/devices/traffic?source=ui',
|
||||
}, resetResponse), true);
|
||||
assert.deepEqual(reset.calls, [['resetTraffic', '9']]);
|
||||
assert.deepEqual(resetResponse.payload, { revision: 4 });
|
||||
});
|
||||
|
||||
test('device route preserves endpoint gating and strict lowercase IDs', async () => {
|
||||
for (const [method, url] of [
|
||||
['POST', '/api/devices'],
|
||||
['GET', '/api/devices/refresh'],
|
||||
['GET', '/api/devices/traffic'],
|
||||
['GET', `/api/devices/${deviceId}`],
|
||||
['POST', `/api/devices/${deviceId}/policy`],
|
||||
]) {
|
||||
@@ -130,6 +144,7 @@ test('device route preserves endpoint gating and strict lowercase IDs', async ()
|
||||
for (const [method, url] of [
|
||||
['GET', '/api/devices'],
|
||||
['POST', '/api/devices/refresh'],
|
||||
['DELETE', '/api/devices/traffic'],
|
||||
['PUT', `/api/devices/${deviceId}`],
|
||||
['PUT', `/api/devices/${deviceId}/policy`],
|
||||
]) {
|
||||
@@ -149,6 +164,7 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
||||
snapshot: () => { throw syncError; },
|
||||
refresh: async () => ({}),
|
||||
update: () => ({}),
|
||||
resetTraffic: async () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
readBody: async () => ({}),
|
||||
@@ -164,6 +180,7 @@ test('device route propagates synchronous and asynchronous service errors unchan
|
||||
snapshot: () => ({}),
|
||||
refresh: async () => { throw asyncError; },
|
||||
update: () => ({}),
|
||||
resetTraffic: async () => ({}),
|
||||
setPolicy: async () => ({}),
|
||||
},
|
||||
readBody: async () => ({}),
|
||||
|
||||
@@ -102,6 +102,9 @@ test('typed endpoint facade preserves exact request contracts and raw payload id
|
||||
}],
|
||||
[() => api.devices.list(), '/api/devices', {}],
|
||||
[() => api.devices.refresh(), '/api/devices/refresh', { method: 'POST' }],
|
||||
[() => api.devices.resetTraffic(8), '/api/devices/traffic', {
|
||||
method: 'DELETE', body: JSON.stringify({ expectedRevision: 8 }),
|
||||
}],
|
||||
[() => api.devices.update('dev_1', { alias: 'TV' }, 8), '/api/devices/dev_1', {
|
||||
method: 'PUT', body: JSON.stringify({ alias: 'TV', expectedRevision: 8 }),
|
||||
}],
|
||||
|
||||
@@ -13,7 +13,7 @@ const routing = source('features/routing/RoutingFeature.tsx');
|
||||
const diagnostics = source('features/diagnostics/ConnectivityDiagnosticsPanel.tsx');
|
||||
|
||||
test('App owns one stable mapping from typed transport to component actions', () => {
|
||||
assert.match(app, /const componentActions = \{[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
|
||||
assert.match(app, /const componentActions = \{[\s\S]*listDevices: api\.devices\.list[\s\S]*refreshDevices: api\.devices\.refresh[\s\S]*resetDeviceTraffic: api\.devices\.resetTraffic[\s\S]*updateDevice: api\.devices\.update[\s\S]*setDevicePolicy: api\.devices\.setPolicy[\s\S]*pingServers: api\.servers\.ping[\s\S]*runConnectivityDiagnostics: api\.diagnostics\.connectivity[\s\S]*\};/);
|
||||
assert.doesNotMatch(app, /validateSubscription: api\.subscription\.validate/);
|
||||
assert.equal((app.match(/actions=\{componentActions\}/g) || []).length, 1);
|
||||
assert.doesNotMatch(app, /componentActions\s*=\s*useMemo|componentActions\s*=\s*\([^)]*\)\s*=>/);
|
||||
@@ -25,6 +25,7 @@ test('presentational components use only injected narrow actions', () => {
|
||||
assert.match(subscription, /isSubscriptionUrlValid\(normalizedUrl\)/);
|
||||
assert.doesNotMatch(subscription, /validateSubscription\(|AbortController/);
|
||||
assert.match(overview, /refreshDevices: actions\.refreshDevices/);
|
||||
assert.match(overview, /resetDeviceTraffic: actions\.resetDeviceTraffic/);
|
||||
assert.match(deviceFeature, /discover \? refreshDevices\(\) : listDevices\(\)/);
|
||||
assert.match(overview, /<ServerPicker[\s\S]*pingServers=\{actions\.pingServers\}/);
|
||||
assert.match(overview, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy/);
|
||||
|
||||
@@ -44,9 +44,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.doesNotMatch(panel, /revision >= current\.revision/);
|
||||
assert.match(panel, /prefers-reduced-motion: reduce/);
|
||||
assert.match(api, /refresh: \(\) => request\('\/api\/devices\/refresh', \{ method: 'POST' \}\)/);
|
||||
assert.match(api, /resetTraffic:[\s\S]*request\('\/api\/devices\/traffic'[\s\S]*method: 'DELETE'/);
|
||||
assert.match(api, /setPolicy:[\s\S]*`\/api\/devices\/\$\{id\}\/policy`/);
|
||||
assert.match(server, /createDeviceInventoryRoute\(\{/);
|
||||
assert.match(deviceRoute, /pathname === '\/api\/devices\/refresh'[\s\S]*deviceInventory\.refresh\(\)/);
|
||||
assert.match(deviceRoute, /pathname === '\/api\/devices\/traffic'[\s\S]*deviceInventory\.resetTraffic/);
|
||||
assert.match(deviceRoute, /DEVICE_POLICY_PATH[\s\S]*deviceInventory\.setPolicy/);
|
||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||
assert.match(panel, /copyText\(value\)/);
|
||||
@@ -198,6 +200,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(styles, /\.client-device-traffic-plot svg \{[\s\S]*height: 100%;[\s\S]*overflow: hidden/);
|
||||
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||
assert.match(panel, /className="client-devices-reset"[\s\S]*Сбросить данные/);
|
||||
assert.match(styles, /\.client-devices-reset \{[\s\S]*oklch\(0\.68 0\.15 28\)[\s\S]*\.client-devices-reset:hover:not\(:disabled\) svg[\s\S]*rotate\(-360deg\)/);
|
||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-identity-details[\s\S]*\.client-device-identity-copy[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value[\s\S]*transition: none;[\s\S]*animation: none/);
|
||||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||
|
||||
@@ -20,7 +20,7 @@ test('devices feature is the sole public owner and legacy component paths are go
|
||||
assert.equal((page.match(/<DevicesToggle/g) || []).length, 1);
|
||||
assert.equal((page.match(/<GatewayTrafficSummary/g) || []).length, 1);
|
||||
assert.equal((page.match(/<DevicesPanel/g) || []).length, 1);
|
||||
assert.match(page, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy[\s\S]*\}\)/);
|
||||
assert.match(page, /useDevicesFeature\(\{[\s\S]*listDevices: actions\.listDevices[\s\S]*refreshDevices: actions\.refreshDevices[\s\S]*resetDeviceTraffic: actions\.resetDeviceTraffic[\s\S]*updateDevice: actions\.updateDevice[\s\S]*setDevicePolicy: actions\.setDevicePolicy[\s\S]*\}\)/);
|
||||
assert.match(page, /<DevicesPanel feature=\{devicesFeature\} \/>/);
|
||||
assert.doesNotMatch(page, /DEVICE_AUTO_REFRESH_MS|deviceSnapshot|deviceStatus|deviceError|devicesRefreshing|deviceRefreshCycle|devicesPanelRef|devicesToggleRef|devicesCloseRef|function loadDevices|client-devices-toggle|className="client-gateway-summary"/);
|
||||
assert.doesNotMatch([feature, panel].join('\n'), /from ['"][^'"]*\/api\/|ConnectionPanel|SubscriptionFeature|RoutingFeature|DiagnosticsPanel/);
|
||||
@@ -34,6 +34,9 @@ test('device controller preserves Gateway-only polling, monotonic publication an
|
||||
assert.match(feature, /setSnapshot\(\(current\) => !current \|\| next\.revision > current\.revision \? next : current\)/);
|
||||
assert.match(feature, /finally \{[\s\S]*setRefreshing\(false\)[\s\S]*setRefreshCycle/);
|
||||
assert.match(feature, /closeRef\.current\?\.focus\(\)[\s\S]*panelRef\.current\?\.contains[\s\S]*toggleRef\.current\?\.focus\(\)/);
|
||||
assert.match(feature, /resetOpen[\s\S]*if \(resetOpen\) return/);
|
||||
assert.match(feature, /resetDeviceTraffic\(snapshot\.revision\)[\s\S]*STATE_CONFLICT[\s\S]*resetDeviceTraffic\(latest\.revision\)/);
|
||||
assert.match(panel, /<ConfirmationDialog[\s\S]*title="Сбросить статистику\?"[\s\S]*confirmLabel="Сбросить"[\s\S]*busy=\{resetting\}/);
|
||||
assert.match(page, /<DevicesToggle[\s\S]*devicesFeature\.toggle\(\)/);
|
||||
assert.match(page, /<GatewayTrafficSummary feature=\{devicesFeature\} now=\{now\}/);
|
||||
});
|
||||
|
||||
@@ -37,26 +37,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 813,
|
||||
cascadeEdges: 815,
|
||||
customProperties: 103,
|
||||
declarations: 3270,
|
||||
declarations: 3299,
|
||||
important: 0,
|
||||
keyframes: 56,
|
||||
media: 13,
|
||||
rules: 941,
|
||||
variableReferences: 812,
|
||||
rules: 947,
|
||||
variableReferences: 815,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: '38c9f42cdc5839efbfc06fd12e21d943742fd71a7170b239059c9041e675697d',
|
||||
cascadeEdges: '8ed02f069be17d132a23eed9e9480b31fa583f91771dffbe4d44b485042035e0',
|
||||
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
|
||||
declarations: '8ed706131420b5e15efcaaffa64282d4d44e6bcf8473938c16ec55f375013661',
|
||||
declarations: '0f20800db4323aac387f740f9b55ef8d7b3b2e78bac8765941dbda4f1e832499',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
||||
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
|
||||
ruleDeclarationSequences: 'f054d14a153e93f07aebc28ada717e004ac8618b9f766d4701cc748a271fad9a',
|
||||
selectors: '4e8f1d8af4830eb6ca40a5b94d3b1b234817f22eb7073c643b4773ee36f8249c',
|
||||
variableReferences: 'd3a740a583156df0b42ce0f52687617a5b5507b394251070c63d355560921f03',
|
||||
witnesses: '834f4fcda87e58c8cd49011d6d8f3e88393c09a88b1d255f3e8d43ccb00ea3e6',
|
||||
ruleDeclarationSequences: 'bc194eaaf4351ea21cefa140fe084e8a086720dc0fc1b0159dad623e76a0d4e6',
|
||||
selectors: 'ddb869866dc84197f7a6bef2e8d07310ec2e9fcc6822b18952de800f4c5d0d59',
|
||||
variableReferences: '2ccbb06fe9f4d0e00dc40aa117add2bed00321b92beee7c581436ecc8c9da4ca',
|
||||
witnesses: 'd8f04b8250b68423a593cb471e535712cb5b9f7d85d4ebd8d34c44f2855432da',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
||||
|
||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||
const witnesses = readStyleWitnesses(root);
|
||||
assert.equal(witnesses.length, 787);
|
||||
assert.equal(witnesses.length, 807);
|
||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||
@@ -405,8 +405,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||
|
||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||
assert.deepEqual(assets, ['index-BT8lRf1r.css']);
|
||||
assert.deepEqual(assets, ['index-Cen_yNNI.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 126715);
|
||||
assert.equal(sha256(built), '0d1d0c03b2d89265a373ebd8e80aa5f5b2a37bb1dd4d5cb350802dcb7ddf673a');
|
||||
assert.equal(built.byteLength, 127660);
|
||||
assert.equal(sha256(built), 'f0f09ebbe31597130121828157e9e68fc77141db5975d32f9e3062b70e5f894b');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user