Add outbound traffic breakdown to device charts
This commit is contained in:
@@ -504,6 +504,82 @@ test('device traffic history stays in bounded service memory and survives client
|
||||
assert.deepEqual(createService().snapshot().traffic.history, []);
|
||||
});
|
||||
|
||||
test('device outbound history keeps sing-box routes and kernel Direct on separate reset-safe series', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-outbound-history-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'devices.json');
|
||||
const store = createJsonStore({ filePath, defaultValue: {}, migrate: migrateDeviceInventoryState });
|
||||
const mac = '00:11:22:33:44:55';
|
||||
const id = deviceId(mac);
|
||||
let observedAt = '2026-08-12T12:00:00.000Z';
|
||||
let epoch = 'epoch-a';
|
||||
let vpn = ['100', '200'];
|
||||
let trackedDirect = ['10', '20'];
|
||||
let unknown = ['1', '2'];
|
||||
let directIpv4 = ['40', '60'];
|
||||
const service = createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({
|
||||
observedAt,
|
||||
error: null,
|
||||
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
||||
}),
|
||||
observeTraffic: () => ({
|
||||
epoch,
|
||||
generation: 'rules-a',
|
||||
observedAt,
|
||||
source: { error: null },
|
||||
direct: { uploadBytes: directIpv4[0], downloadBytes: directIpv4[1] },
|
||||
devices: [{
|
||||
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: '100', downloadBytes: '200',
|
||||
directUploadBytes: directIpv4[0], directDownloadBytes: directIpv4[1],
|
||||
}],
|
||||
}),
|
||||
observeDomainTraffic: () => ({
|
||||
epoch,
|
||||
observedAt,
|
||||
source: { error: null },
|
||||
routes: [
|
||||
{ deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: vpn[0], downloadBytes: vpn[1] },
|
||||
{ deviceId: id, source: 'proxy', outbound: 'direct', uploadBytes: trackedDirect[0], downloadBytes: trackedDirect[1] },
|
||||
{ deviceId: id, source: 'gateway', outbound: 'unknown', uploadBytes: unknown[0], downloadBytes: unknown[1] },
|
||||
],
|
||||
series: [],
|
||||
}),
|
||||
});
|
||||
|
||||
assert.deepEqual((await service.refresh()).devices[0].outboundTrafficHistory, []);
|
||||
observedAt = '2026-08-12T12:00:15.000Z';
|
||||
vpn = ['150', '250'];
|
||||
trackedDirect = ['30', '50'];
|
||||
unknown = ['4', '8'];
|
||||
directIpv4 = ['70', '90'];
|
||||
let snapshot = await service.refresh();
|
||||
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, [{
|
||||
observedAt,
|
||||
vpnBytes: '100',
|
||||
directTrackedBytes: '50',
|
||||
directIpv4Bytes: '60',
|
||||
unknownBytes: '9',
|
||||
}]);
|
||||
|
||||
observedAt = '2026-08-12T12:00:30.000Z';
|
||||
epoch = 'epoch-b';
|
||||
vpn = ['3', '4'];
|
||||
trackedDirect = ['1', '2'];
|
||||
unknown = ['0', '1'];
|
||||
directIpv4 = ['5', '6'];
|
||||
snapshot = await service.refresh();
|
||||
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory.at(-1), {
|
||||
observedAt,
|
||||
vpnBytes: '0',
|
||||
directTrackedBytes: '0',
|
||||
directIpv4Bytes: '0',
|
||||
unknownBytes: '0',
|
||||
});
|
||||
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTrafficHistory/);
|
||||
});
|
||||
|
||||
test('global traffic stays monotonic when a device expires and returns in the same epoch', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-global-traffic-retention-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
@@ -87,24 +87,26 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
||||
assert.match(panel, /const hasProxyTraffic = proxyTotal > 0n/);
|
||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && <span className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
|
||||
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity/);
|
||||
assert.match(panel, /TrafficChart[\s\S]*samples=\{trafficView === 'outbound' \? device\.outboundTrafficHistory \|\| \[\] : device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity[\s\S]*series=\{trafficView\}/);
|
||||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||
assert.match(panel, /useState<'outbound' \| 'inbound'>\('outbound'\)[\s\S]*aria-pressed=\{trafficView === 'outbound'\}[\s\S]*>Выход<\/button>[\s\S]*aria-pressed=\{trafficView === 'inbound'\}[\s\S]*>Вход<\/button>/);
|
||||
assert.match(chart, /function trafficPathAnimationSource[\s\S]*previousByTime[\s\S]*gatewayY: 100[\s\S]*attributeName="d"[\s\S]*dur="520ms"/);
|
||||
assert.match(chart, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /);
|
||||
assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio: number\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
|
||||
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/);
|
||||
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)[\s\S]*directIpv4Y: trafficChartY\(trafficScaleRatio\(directIpv4, max, scale\)\)[\s\S]*unknownY: trafficChartY\(trafficScaleRatio\(unknown, max, scale\)\)/);
|
||||
assert.match(chart, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
|
||||
assert.match(chart, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
|
||||
assert.doesNotMatch(panel, /key=\{latest\}/);
|
||||
assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
||||
assert.match(chart, /onPointerMove=\{trackPointer\}/);
|
||||
assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
||||
assert.match(chart, /client-device-traffic-lines[\s\S]*series === 'speed' \? 'is-download' : 'is-gateway'[\s\S]*series === 'speed' \? 'is-upload' : 'is-proxy'/);
|
||||
assert.match(chart, /client-device-traffic-cursor[\s\S]*`is-point \$\{series === 'speed' \? 'is-download' : 'is-gateway'\}`[\s\S]*`is-point \$\{series === 'speed' \? 'is-upload' : 'is-proxy'\}`/);
|
||||
assert.match(chart, /series === 'outbound'[\s\S]*'is-vpn'[\s\S]*'is-direct-tracked'[\s\S]*'is-direct-ipv4'[\s\S]*'is-unknown'/);
|
||||
assert.match(chart, /client-device-traffic-lines[\s\S]*visibleLines\.map[\s\S]*smoothTrafficPath\(previous, line\.yKey\)[\s\S]*client-device-traffic-cursor[\s\S]*visibleLines\.map/);
|
||||
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
|
||||
assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
|
||||
assert.match(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*series === 'speed' \? '↓ \/ ↑' : '15 с'/);
|
||||
assert.match(chart, /VPN · sing-box[\s\S]*Direct · sing-box[\s\S]*Direct · IPv4[\s\S]*Неизвестно · sing-box[\s\S]*Разные уровни учёта не складываются/);
|
||||
assert.match(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*client-device-traffic-legend[\s\S]*VPN[\s\S]*Direct · sing-box[\s\S]*Direct · IPv4[\s\S]*series === 'speed' \? '↓ \/ ↑' : 'Вход'/);
|
||||
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
|
||||
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
|
||||
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
|
||||
@@ -155,6 +157,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
||||
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
|
||||
assert.doesNotMatch(styles, /client-device-traffic-shift|client-device-traffic-line-draw/);
|
||||
assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/);
|
||||
assert.match(styles, /\.client-device-traffic-lines \.is-vpn[\s\S]*\.is-direct-tracked[\s\S]*\.is-direct-ipv4[\s\S]*\.is-unknown/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-plot-expand[\s\S]*scaleY\(0\.3448275862\)[\s\S]*scaleY\(1\)/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-plot-collapse[\s\S]*scaleY\(2\.9\)[\s\S]*scaleY\(1\)/);
|
||||
assert.match(styles, /@keyframes client-device-traffic-detail-in[\s\S]*opacity: 0[\s\S]*opacity: 1/);
|
||||
@@ -230,8 +233,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
|
||||
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
|
||||
});
|
||||
|
||||
test('traffic chart scale compares Gateway and Proxy before choosing the maximum', () => {
|
||||
assert.match(chart, /function trafficSeriesMax[\s\S]*proxy > gateway \? proxy : gateway[\s\S]*proxy > largest \? proxy : largest/);
|
||||
test('traffic chart scale compares every visible route before choosing the maximum', () => {
|
||||
assert.match(chart, /function trafficSeriesMax[\s\S]*Object\.values\(value\)\.reduce[\s\S]*item > current \? item : current/);
|
||||
});
|
||||
|
||||
test('device last-seen copy is compact with precise accessible and relative forms', () => {
|
||||
|
||||
@@ -62,6 +62,13 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
appliedPolicy: 'vpn',
|
||||
confidence: 'high',
|
||||
trafficHistory: [{ observedAt, gatewayBytes: '42', proxyBytes: '0' }],
|
||||
outboundTrafficHistory: [{
|
||||
observedAt,
|
||||
vpnBytes: '30',
|
||||
directTrackedBytes: '8',
|
||||
directIpv4Bytes: '4',
|
||||
unknownBytes: '0',
|
||||
}],
|
||||
}],
|
||||
trafficHistoryCapacity: 120,
|
||||
traffic: {
|
||||
@@ -109,6 +116,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
|
||||
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] },
|
||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTrafficHistory: [{ observedAt, vpnBytes: '1' }] }] },
|
||||
{ ...valid, traffic: { ...valid.traffic, totalBytes: -4 } },
|
||||
{ ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } },
|
||||
{ ...valid, source: { ...valid.source, kind: 'arp' } },
|
||||
|
||||
@@ -37,26 +37,26 @@ const expectedImports = [
|
||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||
const acceptedLedger = {
|
||||
counts: {
|
||||
cascadeEdges: 771,
|
||||
cascadeEdges: 829,
|
||||
customProperties: 103,
|
||||
declarations: 3247,
|
||||
declarations: 3268,
|
||||
important: 0,
|
||||
keyframes: 56,
|
||||
media: 13,
|
||||
rules: 922,
|
||||
variableReferences: 797,
|
||||
rules: 939,
|
||||
variableReferences: 812,
|
||||
},
|
||||
hashes: {
|
||||
cascadeEdges: 'e1b9f75210ef7258d9cb5b9e332de529b2ccceaf92b9b86eabcd0b62fefb5c98',
|
||||
cascadeEdges: '31e13ff6817e157b5ab4bb6a7caeb9f869da114e7ddb72a44d6972f3594f027a',
|
||||
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
|
||||
declarations: '6e30102fba87a4c28ea429159a13239a313ea8d18928423763bb166e0456f8cc',
|
||||
declarations: '9eee80b8494eab2d9015b797ad0ea7487bcc5b01c84ec90f00884ddf39396cd2',
|
||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
||||
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
|
||||
ruleDeclarationSequences: '9dacb5d7407717ae041cabf6e8e75af7cfccfb7b5e4c387880468db409e93fbd',
|
||||
selectors: '8456f64c608e0411bbc56c0c955d25383391f019fa5af0648a72d40564cfee1a',
|
||||
variableReferences: '42661286e6150115a367068908f79c79951e24fb3fcf8775c2b083bb2d520e38',
|
||||
witnesses: '7f44af307fd67fc88e274c1dcc6a21a5e064a8be3895d80fd640715e1af42419',
|
||||
ruleDeclarationSequences: '7fcacaa4386895d6eeb43678d151ec882959bbbd6815843a626f7cea7886a5f6',
|
||||
selectors: 'edf95ecfdb232b500674687f577a03884c399ec33a590ac73fc5145ec280ca38',
|
||||
variableReferences: '2035aa280c6096e2581bb9e06ef687fd80e4ce4ee5d8844ed16c96b3fa735ff9',
|
||||
witnesses: 'cf5d27b5fac96afae3c8f5c68f5787815679ef00c825d06d76ea3b27e4d6ead0',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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, 757);
|
||||
assert.equal(witnesses.length, 770);
|
||||
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-CGpprjFm.css']);
|
||||
assert.deepEqual(assets, ['index-DxMz3Vnp.css']);
|
||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||
assert.equal(built.byteLength, 125113);
|
||||
assert.equal(sha256(built), 'f9ddcf2a43d81f42abe53303087966b5ec4ecfd3c032bd98cf1dc2a805b47364');
|
||||
assert.equal(built.byteLength, 126501);
|
||||
assert.equal(sha256(built), '99f66c86e8a2bb8d0351b46cad8d16393ae92c1246b9027312e7b79756355a7d');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user