From 9d43e74d973b31bedd57c08be46157f817f0ff60 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Wed, 12 Aug 2026 22:18:08 +0300 Subject: [PATCH] Add outbound traffic breakdown to device charts --- src/server/services/deviceInventoryService.ts | 95 ++++++++++++ src/shared/versions.ts | 6 +- src/web/features/devices/DevicesPanel.tsx | 8 +- src/web/features/devices/TrafficChart.tsx | 137 ++++++++++++------ src/web/features/devices/deviceSnapshot.ts | 25 +++- src/web/styles/features/devices.css | 72 +++++++++ test/server/device-inventory.test.js | 76 ++++++++++ test/web/device-inventory-contract.test.js | 17 ++- test/web/devices-feature-contract.test.js | 8 + test/web/style-boundaries.test.js | 28 ++-- 10 files changed, 403 insertions(+), 69 deletions(-) diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index f1a1b74..65496ed 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -141,6 +141,24 @@ interface TrafficCursor { download: bigint; } +interface OutboundTrafficSample { + observedAt: string; + vpnBytes: string; + directTrackedBytes: string; + directIpv4Bytes: string; + unknownBytes: string; +} + +interface OutboundTrafficCursor { + signature: string; + routeEpoch: string; + directEpoch: string; + vpn: bigint; + directTracked: bigint; + directIpv4: bigint; + unknown: bigint; +} + interface DeviceObservation { mac: string; ip: string; @@ -689,6 +707,8 @@ export function createDeviceInventoryService({ let policyQueue: Promise = Promise.resolve(); const trafficHistoryByMac = new Map(); const trafficCursorByMac = new Map(); + const outboundTrafficHistoryByDeviceId = new Map(); + const outboundTrafficCursorByDeviceId = new Map(); let globalTrafficHistory: TrafficSample[] = []; let globalTrafficCursor: TrafficCursor | null = null; const hostnameAttempts = new Map(); @@ -763,6 +783,79 @@ export function createDeviceInventoryService({ } } + function captureOutboundTrafficHistory(state: InventoryState) { + const routeEpoch = typeof domainTrafficSnapshot.epoch === 'string' ? domainTrafficSnapshot.epoch : ''; + const directEpoch = typeof directTrafficSnapshot.epoch === 'string' ? directTrafficSnapshot.epoch : ''; + const routeObservedAt = validTimestamp(domainTrafficSnapshot.observedAt) + ? String(domainTrafficSnapshot.observedAt) + : ''; + const directObservedAt = validTimestamp(directTrafficSnapshot.observedAt) + ? String(directTrafficSnapshot.observedAt) + : ''; + const signature = `${routeEpoch}|${routeObservedAt}|${directEpoch}|${directObservedAt}`; + if (signature === '|||') return; + + const totals = new Map>(); + const totalFor = (deviceId: string) => { + const existing = totals.get(deviceId) || { vpn: 0n, directTracked: 0n, directIpv4: 0n, unknown: 0n }; + totals.set(deviceId, existing); + return existing; + }; + for (const value of Array.isArray(domainTrafficSnapshot.routes) ? domainTrafficSnapshot.routes : []) { + const row = record(value); + const deviceId = String(row.deviceId || ''); + const outbound = String(row.outbound || ''); + const uploadBytes = String(row.uploadBytes || ''); + const downloadBytes = String(row.downloadBytes || ''); + if (!DEVICE_ID_PATTERN.test(deviceId) || !['vpn', 'direct', 'unknown'].includes(outbound) + || !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue; + const amount = BigInt(uploadBytes) + BigInt(downloadBytes); + const total = totalFor(deviceId); + if (outbound === 'vpn') total.vpn += amount; + else if (outbound === 'direct') total.directTracked += amount; + else total.unknown += amount; + } + for (const value of Array.isArray(directTrafficSnapshot.series) ? directTrafficSnapshot.series : []) { + const row = record(value); + const deviceId = String(row.deviceId || ''); + const uploadBytes = String(row.uploadBytes || ''); + const downloadBytes = String(row.downloadBytes || ''); + if (!DEVICE_ID_PATTERN.test(deviceId) + || !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue; + totalFor(deviceId).directIpv4 += BigInt(uploadBytes) + BigInt(downloadBytes); + } + + const knownIds = new Set(state.devices.map(({ id }) => id)); + const observedAt = [routeObservedAt, directObservedAt].filter(Boolean).sort().at(-1) || ''; + for (const device of state.devices) { + const total = totalFor(device.id); + const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total }; + const previous = outboundTrafficCursorByDeviceId.get(device.id); + outboundTrafficCursorByDeviceId.set(device.id, current); + if (!previous || previous.signature === signature || !observedAt) continue; + const routeDelta = (value: bigint, before: bigint) => ( + routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n + ); + const directDelta = directEpoch && directEpoch === previous.directEpoch && total.directIpv4 > previous.directIpv4 + ? total.directIpv4 - previous.directIpv4 + : 0n; + const samples = outboundTrafficHistoryByDeviceId.get(device.id) || []; + outboundTrafficHistoryByDeviceId.set(device.id, [...samples, { + observedAt, + vpnBytes: routeDelta(total.vpn, previous.vpn).toString(), + directTrackedBytes: routeDelta(total.directTracked, previous.directTracked).toString(), + directIpv4Bytes: directDelta.toString(), + unknownBytes: routeDelta(total.unknown, previous.unknown).toString(), + }].slice(-TRAFFIC_HISTORY_LIMIT)); + } + for (const deviceId of outboundTrafficCursorByDeviceId.keys()) { + if (!knownIds.has(deviceId)) { + outboundTrafficCursorByDeviceId.delete(deviceId); + outboundTrafficHistoryByDeviceId.delete(deviceId); + } + } + } + function serializePolicy(action: () => Promise | T): Promise { const result = policyQueue.then(() => action(), () => action()); policyQueue = result.catch(() => {}); @@ -826,6 +919,7 @@ export function createDeviceInventoryService({ proxyDownloadBytes: proxyTraffic?.downloadBytes || '0', proxyTrafficObservedAt: proxyTraffic?.observedAt || null, trafficHistory: trafficHistoryByMac.get(device.mac) || [], + outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [], desiredPolicy: policy.desired, appliedPolicy: policy.applied, policyStatus: policy.status, @@ -1375,6 +1469,7 @@ export function createDeviceInventoryService({ }; }); captureTrafficHistory(nextState); + captureOutboundTrafficHistory(nextState); if (typeof policyResult?.transportError === 'string') { commitPolicyFailure(new Error(policyResult.transportError)); } diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 699e7ff..d6f5136 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.2', - gatewayClient: '0.26.1', - gatewayBackend: '0.26.2', + macClient: '0.25.3', + gatewayClient: '0.26.2', + gatewayBackend: '0.26.3', }); export interface ParsedVersion { diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index e37880a..cb69cb4 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -79,6 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const [alias, setAlias] = useState(''); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear'); + const [trafficView, setTrafficView] = useState<'outbound' | 'inbound'>('outbound'); const [copyFeedback, setCopyFeedback] = useState>({}); const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null); const [pencilAnimationId, setPencilAnimationId] = useState(''); @@ -324,6 +325,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика + + + + @@ -581,10 +586,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { {deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'} {!compact && finishPinCollapse(device.id)} diff --git a/src/web/features/devices/TrafficChart.tsx b/src/web/features/devices/TrafficChart.tsx index db160d6..2645958 100644 --- a/src/web/features/devices/TrafficChart.tsx +++ b/src/web/features/devices/TrafficChart.tsx @@ -14,7 +14,7 @@ import { trafficBytesPerSecond, trafficScaleRatio, } from '../../utils/format.js'; -import type { TrafficSample, TrafficScale } from './deviceSnapshot.js'; +import type { OutboundTrafficSample, TrafficSample, TrafficScale } from './deviceSnapshot.js'; const TRAFFIC_CHART_HEADROOM = 10; const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM); @@ -25,13 +25,21 @@ function chartTime(value: string) { }); } +type ChartSample = TrafficSample | OutboundTrafficSample; +type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown'; +type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY'; + interface ChartPoint { - sample: TrafficSample; + sample: ChartSample; x: number; gateway: bigint; proxy: bigint; + directIpv4: bigint; + unknown: bigint; gatewayY: number; proxyY: number; + directIpv4Y: number; + unknownY: number; } interface HoveredPoint extends ChartPoint { @@ -39,7 +47,7 @@ interface HoveredPoint extends ChartPoint { clientY: number; } -function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') { +function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) { if (!points.length) return ''; return points.slice(1).reduce((path, point, index) => { const previous = points[index]; @@ -60,20 +68,22 @@ function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartP x: source.x, gatewayY: source.gatewayY, proxyY: source.proxyY, + directIpv4Y: source.directIpv4Y, + unknownY: source.unknownY, } : { ...point, gatewayY: 100, proxyY: 100, + directIpv4Y: 100, + unknownY: 100, }; }); } -function trafficSeriesMax(values: Array<{ gateway: bigint; proxy: bigint }>) { - return values.reduce((largest, { gateway, proxy }) => { - return gateway > largest - ? (proxy > gateway ? proxy : gateway) - : (proxy > largest ? proxy : largest); - }, 0n); +function trafficSeriesMax(values: Array>) { + return values.reduce((largest, value) => ( + Object.values(value).reduce((current, item) => item > current ? item : current, largest) + ), 0n); } function formatRate(value: bigint) { @@ -88,64 +98,101 @@ export function TrafficChart({ pinned = true, collapsing = false, onCollapseEnd, - series = 'routes', + series = 'inbound', }: { - samples: TrafficSample[]; + samples: ChartSample[]; scale?: TrafficScale; capacity: number; routeLabel: string; pinned?: boolean; collapsing?: boolean; onCollapseEnd?: () => void; - series?: 'routes' | 'speed'; + series?: 'inbound' | 'outbound' | 'speed'; }) { const [hovered, setHovered] = useState(null); const previousPoints = useRef([]); const previousSeries = useRef(series); const speedAvailable = samples.length > 1 && samples.every((sample) => ( - typeof sample.downloadBytes === 'string' && typeof sample.uploadBytes === 'string' + 'downloadBytes' in sample && typeof sample.downloadBytes === 'string' + && 'uploadBytes' in sample && typeof sample.uploadBytes === 'string' )); const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples; const values = visibleSamples.map((sample, index) => { - if (series === 'routes') { + if (series === 'inbound') { + const traffic = sample as TrafficSample; return { sample, - gateway: byteString(sample.gatewayBytes), - proxy: byteString(sample.proxyBytes), + gateway: byteString(traffic.gatewayBytes), + proxy: byteString(traffic.proxyBytes), + directIpv4: 0n, + unknown: 0n, }; } - const previous = samples[index]; + if (series === 'outbound') { + const outbound = sample as OutboundTrafficSample; + return { + sample, + gateway: byteString(outbound.vpnBytes), + proxy: byteString(outbound.directTrackedBytes), + directIpv4: byteString(outbound.directIpv4Bytes), + unknown: byteString(outbound.unknownBytes), + }; + } + const traffic = sample as TrafficSample; + const previous = samples[index] as TrafficSample; return { sample, - gateway: trafficBytesPerSecond(sample.downloadBytes, previous.observedAt, sample.observedAt), - proxy: trafficBytesPerSecond(sample.uploadBytes, previous.observedAt, sample.observedAt), + gateway: trafficBytesPerSecond(traffic.downloadBytes, previous.observedAt, traffic.observedAt), + proxy: trafficBytesPerSecond(traffic.uploadBytes, previous.observedAt, traffic.observedAt), + directIpv4: 0n, + unknown: 0n, }; }); - const max = trafficSeriesMax(values); + const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({ + gateway, proxy, directIpv4, unknown, + }))); const mid = trafficAxisMid(max, scale); const firstSlot = capacity - visibleSamples.length; - const points = values.map(({ sample, gateway, proxy }, index) => { + const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => { return { sample, x: (firstSlot + index) * 100 / Math.max(1, capacity - 1), gateway, proxy, + directIpv4, + unknown, gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)), proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)), + directIpv4Y: trafficChartY(trafficScaleRatio(directIpv4, max, scale)), + unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)), }; }); const previous = points.slice(0, -1); const penultimate = points.at(-2); const newest = points.at(-1); - const hasSecondary = points.some(({ proxy }) => proxy > 0n); + const lineDefinitions: Array<{ + valueKey: ChartValueKey; + yKey: ChartYKey; + }> = series === 'outbound' + ? [ + { valueKey: 'gateway', yKey: 'gatewayY' }, + { valueKey: 'proxy', yKey: 'proxyY' }, + { valueKey: 'directIpv4', yKey: 'directIpv4Y' }, + { valueKey: 'unknown', yKey: 'unknownY' }, + ] + : [ + { valueKey: 'gateway', yKey: 'gatewayY' }, + { valueKey: 'proxy', yKey: 'proxyY' }, + ]; + const visibleLines = lineDefinitions.filter(({ valueKey }) => points.some((point) => point[valueKey] > 0n)); const motionFrom = previousSeries.current === series ? previousPoints.current : []; const previousMotionFrom = trafficPathAnimationSource(previous, motionFrom); const newestMotionFrom = trafficPathAnimationSource( penultimate && newest ? [penultimate, newest] : [], motionFrom, ); - const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy }) => ( - `${sample.observedAt}:${gateway}:${proxy}` + const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy, directIpv4, unknown }) => ( + `${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}` )).join('|')}`; const animatePaths = points.length > 0 && !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); @@ -182,6 +229,12 @@ export function TrafficChart({ За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))} {routeLabel} {formatByteString(hovered.sample.gatewayBytes)} {byteString(hovered.sample.proxyBytes) > 0n && Proxy {formatByteString(hovered.sample.proxyBytes)}} + : series === 'outbound' ? <> + {hovered.gateway > 0n && VPN · sing-box {formatByteString(hovered.gateway)}} + {hovered.proxy > 0n && Direct · sing-box {formatByteString(hovered.proxy)}} + {hovered.directIpv4 > 0n && Direct · IPv4 {formatByteString(hovered.directIpv4)}} + {hovered.unknown > 0n && Неизвестно · sing-box {formatByteString(hovered.unknown)}} + Разные уровни учёта не складываются : <> Всего {formatByteString(hovered.gateway + hovered.proxy)} {routeLabel} {formatByteString(hovered.gateway)} @@ -194,7 +247,7 @@ export function TrafficChart({ return } - {previous.length > 0 && - {animatePaths && } - } - {hasSecondary && previous.length > 0 && - {animatePaths && } - } - {penultimate && newest && - {animatePaths && } - } - {hasSecondary && penultimate && newest && - {animatePaths && } - } - {!penultimate && newest && - {animatePaths && } - } + {visibleLines.map((line) => previous.length > 0 && + {animatePaths && } + )} + {visibleLines.map((line) => penultimate && newest && + {animatePaths && } + )} + {visibleLines.map((line) => !penultimate && newest && + {animatePaths && } + )} {hovered && - - {hovered.proxy > 0n && } + {visibleLines.map((line) => hovered[line.valueKey] > 0n && )} } {visibleSamples.length > 0 && } {tooltip} diff --git a/src/web/features/devices/deviceSnapshot.ts b/src/web/features/devices/deviceSnapshot.ts index 26aa714..4deea1c 100644 --- a/src/web/features/devices/deviceSnapshot.ts +++ b/src/web/features/devices/deviceSnapshot.ts @@ -13,6 +13,14 @@ export interface TrafficSample extends Record { downloadBytes?: ByteValue; } +export interface OutboundTrafficSample extends Record { + observedAt: string; + vpnBytes: ByteValue; + directTrackedBytes: ByteValue; + directIpv4Bytes: ByteValue; + unknownBytes: ByteValue; +} + export interface Device extends Record { id: string; alias: string | null; @@ -33,6 +41,7 @@ export interface Device extends Record { appliedPolicy: DevicePolicy; confidence: DeviceConfidence; trafficHistory: TrafficSample[]; + outboundTrafficHistory?: OutboundTrafficSample[]; } interface SnapshotSource extends Record { @@ -108,6 +117,19 @@ function validHistory(value: unknown): value is TrafficSample[] { return Array.isArray(value) && value.every(validTrafficSample); } +function validOutboundTrafficSample(value: unknown): value is OutboundTrafficSample { + return record(value) + && timestamp(value.observedAt) + && bytes(value.vpnBytes) + && bytes(value.directTrackedBytes) + && bytes(value.directIpv4Bytes) + && bytes(value.unknownBytes); +} + +function validOutboundHistory(value: unknown): value is OutboundTrafficSample[] { + return Array.isArray(value) && value.every(validOutboundTrafficSample); +} + function validDevice(value: unknown): value is Device { return record(value) && typeof value.id === 'string' @@ -132,7 +154,8 @@ function validDevice(value: unknown): value is Device { && (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct') && (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct') && (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous') - && validHistory(value.trafficHistory); + && validHistory(value.trafficHistory) + && (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory)); } function validSource(value: unknown): value is SnapshotSource { diff --git a/src/web/styles/features/devices.css b/src/web/styles/features/devices.css index 7169fa3..7c27d72 100644 --- a/src/web/styles/features/devices.css +++ b/src/web/styles/features/devices.css @@ -909,6 +909,25 @@ stroke-dasharray: 5 4; } +.client-device-traffic-lines .is-vpn { + stroke: var(--harbor-connect); +} + +.client-device-traffic-lines .is-direct-tracked { + stroke: var(--harbor-gateway); + stroke-dasharray: 5 4; +} + +.client-device-traffic-lines .is-direct-ipv4 { + stroke: var(--harbor-word); + stroke-dasharray: 2 3; +} + +.client-device-traffic-lines .is-unknown { + stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted)); + stroke-dasharray: 1 4; +} + .client-device-traffic-lines .is-point { stroke-width: 3; stroke-linecap: round; @@ -944,6 +963,22 @@ stroke: var(--harbor-gateway); } +.client-device-traffic-cursor .is-point.is-vpn { + stroke: var(--harbor-connect); +} + +.client-device-traffic-cursor .is-point.is-direct-tracked { + stroke: var(--harbor-gateway); +} + +.client-device-traffic-cursor .is-point.is-direct-ipv4 { + stroke: var(--harbor-word); +} + +.client-device-traffic-cursor .is-point.is-unknown { + stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted)); +} + .client-device-traffic-time { grid-column: 2; grid-row: 2; @@ -956,6 +991,27 @@ text-transform: var(--type-micro-transform); } +.client-device-traffic-legend { + display: flex; + gap: 7px; +} + +.client-device-traffic-legend .is-vpn { + color: var(--harbor-connect); +} + +.client-device-traffic-legend .is-direct-tracked { + color: var(--harbor-gateway); +} + +.client-device-traffic-legend .is-direct-ipv4 { + color: var(--harbor-word); +} + +.client-device-traffic-legend .is-unknown { + color: color-mix(in oklch, var(--client-text) 54%, var(--client-muted)); +} + .client-device-traffic-point-tooltip { position: fixed; z-index: 1002; @@ -1012,6 +1068,22 @@ color: oklch(0.79 0.11 72); } +.client-device-traffic-point-tooltip .is-vpn { + color: oklch(0.75 0.1 185); +} + +.client-device-traffic-point-tooltip .is-direct-tracked { + color: oklch(0.79 0.11 72); +} + +.client-device-traffic-point-tooltip .is-direct-ipv4 { + color: oklch(0.78 0.07 232); +} + +.client-device-traffic-point-tooltip .is-unknown { + color: oklch(0.68 0.012 145); +} + .client-device-traffic-point-tooltip .is-interval, .client-device-traffic-point-tooltip .is-direct { color: oklch(0.72 0.012 145); diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index 507c4c8..4ba4c5d 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -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 })); diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 88f647a..c610d9a 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -87,24 +87,26 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /client-device-traffic-breakdown[\s\S]*Gateway<\/b> 0n/); assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && Прокси<\/b>\('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 && 0n && Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/); - assert.match(chart, /