From ef4847a3e1d565a7e486d44f9c4761ef10995a68 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 19:34:59 +0300 Subject: [PATCH] Add persistent device traffic history charts --- src/server/services/deviceInventoryService.js | 36 +++++++++- src/shared/versions.js | 6 +- src/web/components/DevicesPanel.jsx | 65 +++++++++++-------- src/web/styles.css | 50 +++++++++++--- src/web/utils/format.js | 7 +- test/server/device-inventory.test.js | 45 +++++++++++++ test/web/device-inventory-contract.test.js | 10 ++- 7 files changed, 174 insertions(+), 45 deletions(-) diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index 8e78539..daf4f0c 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -9,6 +9,7 @@ export const DEVICE_INVENTORY_SCHEMA_VERSION = 2; const ONLINE_MS = 2 * 60 * 1000; const RECENT_MS = 24 * 60 * 60 * 1000; const RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const TRAFFIC_HISTORY_LIMIT = 120; const COUNTER_PATTERN = /^\d+$/; const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; @@ -302,6 +303,36 @@ export function createDeviceInventoryService({ }) { let refreshPromise = null; let policyQueue = Promise.resolve(); + const trafficHistoryByMac = new Map(); + const trafficCursorByMac = new Map(); + + function captureTrafficHistory(state) { + const knownMacs = new Set(state.devices.map(({ mac }) => mac)); + for (const device of state.devices) { + const traffic = state.traffic.totalsByMac[device.mac]; + const proxy = state.traffic.proxy.totalsByMac[device.mac]; + const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`; + if (signature === '|') continue; + const gateway = BigInt(traffic?.uploadBytes || '0') + BigInt(traffic?.downloadBytes || '0'); + const proxyTotal = BigInt(proxy?.uploadBytes || '0') + BigInt(proxy?.downloadBytes || '0'); + const previous = trafficCursorByMac.get(device.mac); + trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal }); + if (!previous || previous.signature === signature) continue; + const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1); + const samples = trafficHistoryByMac.get(device.mac) || []; + trafficHistoryByMac.set(device.mac, [...samples, { + observedAt, + gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0', + proxyBytes: proxyTotal > previous.proxy ? (proxyTotal - previous.proxy).toString() : '0', + }].slice(-TRAFFIC_HISTORY_LIMIT)); + } + for (const mac of trafficCursorByMac.keys()) { + if (!knownMacs.has(mac)) { + trafficCursorByMac.delete(mac); + trafficHistoryByMac.delete(mac); + } + } + } function serializePolicy(action) { const result = policyQueue.then(action, action); @@ -363,6 +394,7 @@ export function createDeviceInventoryService({ proxyUploadBytes: proxyTraffic?.uploadBytes || '0', proxyDownloadBytes: proxyTraffic?.downloadBytes || '0', proxyTrafficObservedAt: proxyTraffic?.observedAt || null, + trafficHistory: trafficHistoryByMac.get(device.mac) || [], desiredPolicy: policy.desired, appliedPolicy: policy.applied, policyStatus: policy.status, @@ -376,6 +408,7 @@ export function createDeviceInventoryService({ )); return { revision: state.revision, + trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT, source: { kind: 'neighbor', lastObservedAt: state.lastObservedAt, @@ -534,7 +567,7 @@ export function createDeviceInventoryService({ identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`); } return serializePolicy(async () => { - store.update((stored) => { + const nextState = store.update((stored) => { const state = migrateDeviceInventoryState(stored); const byMac = new Map(state.devices.map((device) => [device.mac, device])); for (const observation of observations) { @@ -752,6 +785,7 @@ export function createDeviceInventoryService({ devices, }; }); + captureTrafficHistory(nextState); if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError)); return reconcileLocked(policyResult, false); }); diff --git a/src/shared/versions.js b/src/shared/versions.js index c3391f2..a305535 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.13.6', - gatewayClient: '0.14.6', - gatewayBackend: '0.14.0', + macClient: '0.14.0', + gatewayClient: '0.15.0', + gatewayBackend: '0.15.0', }); export function parseVersion(value) { diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 53bb530..53110fd 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -14,7 +14,6 @@ const AUTO_REFRESH_MS = 15_000; const DEVICE_MOVE_MS = 520; const COPY_FEEDBACK_MS = 5_000; const TRAFFIC_DELTA_MS = 2_200; -const TRAFFIC_HISTORY_LIMIT = 18; function Tooltip({ children }) { return {children}; @@ -36,24 +35,42 @@ function TrafficValue({ value, delta }) { ; } -function TrafficChart({ samples }) { +function chartTime(value) { + return new Date(value).toLocaleTimeString('ru-RU', { + hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, + }); +} + +function TrafficChart({ samples, scale, capacity }) { const max = samples.reduce((largest, sample) => { - const total = byteString(sample.gateway) + byteString(sample.proxy); + const total = byteString(sample.gatewayBytes) + byteString(sample.proxyBytes); return total > largest ? total : largest; }, 0n); - const latest = samples.at(-1)?.id || 'empty'; - return