From b86812d02b75deaf50546553b95da90cd22df274 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Wed, 12 Aug 2026 23:55:34 +0300 Subject: [PATCH] Add device traffic reset with outbound baselines --- README.md | 4 +- .../http/routes/deviceInventoryRoute.ts | 10 ++ src/server/services/deviceInventoryService.ts | 125 +++++++++++++++++- src/shared/versions.ts | 6 +- src/web/App.tsx | 1 + src/web/api/harborClient.ts | 4 + src/web/components/ClientOverviewPage.tsx | 2 + src/web/features/devices/DevicesFeature.tsx | 35 ++++- src/web/features/devices/DevicesPanel.tsx | 33 ++++- src/web/styles/features/devices.css | 51 ++++++- src/web/styles/themes.css | 2 + test/server/device-inventory.test.js | 65 ++++++++- test/server/device-routes.test.js | 21 ++- test/web/api-errors.test.js | 3 + test/web/component-actions-contract.test.js | 3 +- test/web/device-inventory-contract.test.js | 4 + test/web/devices-feature-contract.test.js | 5 +- test/web/style-boundaries.test.js | 28 ++-- 18 files changed, 369 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 6e6e592..999707c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,9 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. +Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: заданное название, hostname или IP, последний контакт, выбранный график трафика и иконку применённого маршрута. По умолчанию график показывает приблизительный выход `VPN`/`Direct`; переключатель `Вход` возвращает накопленную разбивку `Gateway`/`Прокси`. Наведите курсор на имя или переведите на него фокус, чтобы открыть IP, MAC и доступный hostname; нажатие на значение копирует его. Hostname определяется через локальное обратное разрешение имён и может отсутствовать, если сеть его не публикует. Технические interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Список разделён на «Закреплённые», «Остальные» и «Фоновые»: последняя группа сохраняется между перезапусками, показывает только identity/presence и кнопку возврата без графика, traffic и route controls. Название, закрепление, фоновое положение и накопленные totals сохраняются в volume Gateway, пока устройство остаётся в inventory. + +Красная кнопка `Сбросить данные` после отдельного подтверждения обнуляет вход и выход всех устройств и начинает считать их заново. Общий график скорости на Home и уже сохранённая история Prometheus/Grafana не очищаются: входной counter выглядит для Prometheus как стандартный reset, а для выхода Harbor сохраняет только baseline отображения и не изменяет raw dataplane counters. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. diff --git a/src/server/http/routes/deviceInventoryRoute.ts b/src/server/http/routes/deviceInventoryRoute.ts index 38cdfdb..6d89d08 100644 --- a/src/server/http/routes/deviceInventoryRoute.ts +++ b/src/server/http/routes/deviceInventoryRoute.ts @@ -7,6 +7,7 @@ interface DeviceInventoryPort { snapshot(): unknown; refresh(): Promise; update(deviceId: string, patch: Record, expectedRevision: unknown): unknown; + resetTraffic(expectedRevision: unknown): Promise; setPolicy(deviceId: string, mode: unknown, expectedRevision: unknown): Promise; } @@ -39,6 +40,15 @@ export function createDeviceInventoryRoute(dependencies: DeviceInventoryRouteDep return true; } + if (pathname === '/api/devices/traffic') { + if (!dependencies.deviceInventory || req.method !== 'DELETE') { + throw new HarborError('ENDPOINT_NOT_FOUND'); + } + const body = await dependencies.readBody(req); + sendJson(res, 200, await dependencies.deviceInventory.resetTraffic(body.expectedRevision)); + return true; + } + const deviceMatch = pathname.match(DEVICE_PATH); if (deviceMatch) { if (!dependencies.deviceInventory || req.method !== 'PUT') { diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index 0822d58..a6525c0 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -89,6 +89,7 @@ interface InventoryTrafficState { baselinesByMac: Record; totalsByMac: Record; rebaselineMacs: string[]; + outboundBaselinesByDeviceId: Record; proxy: ProxyTrafficState; global: { gateway: GlobalTrafficSource; proxy: GlobalTrafficSource }; [key: string]: unknown; @@ -164,6 +165,15 @@ interface OutboundTrafficCursor { unknown: bigint; } +interface OutboundTrafficBaseline { + routeEpoch: string; + directEpoch: string; + vpnBytes: string; + directTrackedBytes: string; + directIpv4Bytes: string; + unknownBytes: string; +} + interface DeviceObservation { mac: string; ip: string; @@ -250,6 +260,7 @@ const DEFAULT_STATE: InventoryState = { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [], + outboundBaselinesByDeviceId: {}, proxy: DEFAULT_PROXY_TRAFFIC, global: { gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE, @@ -599,6 +610,32 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState { recoveredTraffic = true; } } + const knownDeviceIds = new Set(devices.map(({ id }) => id)); + const outboundBaselinesByDeviceId: Record = {}; + if (traffic.outboundBaselinesByDeviceId !== undefined + && record(traffic.outboundBaselinesByDeviceId) !== traffic.outboundBaselinesByDeviceId) { + recoveredTraffic = true; + } + for (const [id, baseline] of recordEntries(traffic.outboundBaselinesByDeviceId)) { + const vpnBytes = parseStoredCounter(baseline.vpnBytes); + const directTrackedBytes = parseStoredCounter(baseline.directTrackedBytes); + const directIpv4Bytes = parseStoredCounter(baseline.directIpv4Bytes); + const unknownBytes = parseStoredCounter(baseline.unknownBytes); + if (!DEVICE_ID_PATTERN.test(id) || !knownDeviceIds.has(id) + || typeof baseline.routeEpoch !== 'string' || typeof baseline.directEpoch !== 'string' + || vpnBytes == null || directTrackedBytes == null || directIpv4Bytes == null || unknownBytes == null) { + recoveredTraffic = true; + continue; + } + outboundBaselinesByDeviceId[id] = { + routeEpoch: baseline.routeEpoch, + directEpoch: baseline.directEpoch, + vpnBytes, + directTrackedBytes, + directIpv4Bytes, + unknownBytes, + }; + } const global = { gateway: normalizeGlobalTrafficSource(record(traffic.global).gateway, { epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null, @@ -630,6 +667,7 @@ export function migrateDeviceInventoryState(value: unknown): InventoryState { baselinesByMac, totalsByMac, rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)), + outboundBaselinesByDeviceId, proxy: proxyTraffic, global, }, @@ -837,15 +875,28 @@ export function createDeviceInventoryService({ const total = totalFor(device.id); const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total }; const previous = outboundTrafficCursorByDeviceId.get(device.id); + const baseline = state.traffic.outboundBaselinesByDeviceId[device.id]; + const routeBaseline = baseline?.routeEpoch === routeEpoch ? baseline : null; + const directBaseline = baseline?.directEpoch === directEpoch ? baseline : null; + const routeVpn = BigInt(routeBaseline?.vpnBytes || '0'); + const routeDirect = BigInt(routeBaseline?.directTrackedBytes || '0'); + const routeUnknown = BigInt(routeBaseline?.unknownBytes || '0'); + const directIpv4Baseline = BigInt(directBaseline?.directIpv4Bytes || '0'); + const visible = { + vpn: total.vpn >= routeVpn ? total.vpn - routeVpn : 0n, + directTracked: total.directTracked >= routeDirect ? total.directTracked - routeDirect : 0n, + directIpv4: total.directIpv4 >= directIpv4Baseline ? total.directIpv4 - directIpv4Baseline : 0n, + unknown: total.unknown >= routeUnknown ? total.unknown - routeUnknown : 0n, + }; outboundTrafficCursorByDeviceId.set(device.id, current); if (observedAt) outboundTrafficByDeviceId.set(device.id, { observedAt, singboxObservedAt: routeObservedAt || null, directIpv4ObservedAt: directObservedAt || null, - vpnBytes: total.vpn.toString(), - directTrackedBytes: total.directTracked.toString(), - directIpv4Bytes: total.directIpv4.toString(), - unknownBytes: total.unknown.toString(), + vpnBytes: visible.vpn.toString(), + directTrackedBytes: visible.directTracked.toString(), + directIpv4Bytes: visible.directIpv4.toString(), + unknownBytes: visible.unknown.toString(), }); if (!previous || previous.signature === signature || !observedAt) continue; const routeDelta = (value: bigint, before: bigint) => ( @@ -1542,6 +1593,70 @@ export function createDeviceInventoryService({ return snapshot(); } + async function resetTraffic(expectedRevision: unknown) { + if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) + || expectedRevision < 0) { + throw new HarborError('REQUEST_INVALID'); + } + if (refreshPromise) await refreshPromise; + const nextState = store.update((stored) => { + const state = migrateDeviceInventoryState(stored); + if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + const totalsByMac: Record = {}; + const proxyTotalsByMac: Record = {}; + const outboundBaselinesByDeviceId: Record = {}; + const rebaselineMacs = new Set(state.traffic.rebaselineMacs); + const proxyRebaselineMacs = new Set(state.traffic.proxy.rebaselineMacs); + for (const device of state.devices) { + const traffic = state.traffic.totalsByMac[device.mac]; + const proxy = state.traffic.proxy.totalsByMac[device.mac]; + totalsByMac[device.mac] = { + uploadBytes: '0', + downloadBytes: '0', + observedAt: traffic?.observedAt || state.traffic.lastObservedAt, + }; + proxyTotalsByMac[device.mac] = { + uploadBytes: '0', + downloadBytes: '0', + observedAt: proxy?.observedAt || state.traffic.proxy.lastObservedAt, + }; + if (!state.traffic.baselinesByMac[device.mac]) rebaselineMacs.add(device.mac); + if (!state.traffic.proxy.baselinesByMac[device.mac]) proxyRebaselineMacs.add(device.mac); + const outbound = outboundTrafficCursorByDeviceId.get(device.id); + if (outbound) outboundBaselinesByDeviceId[device.id] = { + routeEpoch: outbound.routeEpoch, + directEpoch: outbound.directEpoch, + vpnBytes: outbound.vpn.toString(), + directTrackedBytes: outbound.directTracked.toString(), + directIpv4Bytes: outbound.directIpv4.toString(), + unknownBytes: outbound.unknown.toString(), + }; + } + return { + ...state, + revision: state.revision + 1, + traffic: { + ...state.traffic, + totalsByMac, + rebaselineMacs: [...rebaselineMacs], + outboundBaselinesByDeviceId, + proxy: { + ...state.traffic.proxy, + totalsByMac: proxyTotalsByMac, + rebaselineMacs: [...proxyRebaselineMacs], + }, + }, + }; + }); + + trafficHistoryByMac.clear(); + trafficCursorByMac.clear(); + captureTrafficHistory(nextState); + captureOutboundTrafficHistory(nextState); + outboundTrafficHistoryByDeviceId.clear(); + return snapshot(); + } + function setPolicy(id: string, mode: unknown, expectedRevision: unknown) { if (typeof expectedRevision !== 'number' || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) { @@ -1585,5 +1700,5 @@ export function createDeviceInventoryService({ return serializePolicy(() => reconcileLocked(observed, true)); } - return { snapshot, metricsSnapshot, refresh, update, setPolicy, reconcilePolicies }; + return { snapshot, metricsSnapshot, refresh, update, resetTraffic, setPolicy, reconcilePolicies }; } diff --git a/src/shared/versions.ts b/src/shared/versions.ts index e518e69..32f8718 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.5', - gatewayClient: '0.26.4', - gatewayBackend: '0.26.4', + macClient: '0.25.6', + gatewayClient: '0.26.5', + gatewayBackend: '0.26.5', }); export interface ParsedVersion { diff --git a/src/web/App.tsx b/src/web/App.tsx index 528f19b..4e58d62 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -20,6 +20,7 @@ import { const componentActions = { listDevices: api.devices.list, refreshDevices: api.devices.refresh, + resetDeviceTraffic: api.devices.resetTraffic, updateDevice: api.devices.update, setDevicePolicy: api.devices.setPolicy, pingServers: api.servers.ping, diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index 6f67272..9b565bc 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -156,6 +156,10 @@ export const api = { devices: { list: () => request('/api/devices'), refresh: () => request('/api/devices/refresh', { method: 'POST' }), + resetTraffic: (expectedRevision: unknown) => request('/api/devices/traffic', { + method: 'DELETE', + body: JSON.stringify({ expectedRevision }), + }), update: (id: string, patch: Record, expectedRevision: unknown) => request( `/api/devices/${id}`, { diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index 092370e..bced752 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -83,6 +83,7 @@ interface VersionBadgeProps { interface ComponentActions { listDevices: () => Promise; refreshDevices: () => Promise; + resetDeviceTraffic: (expectedRevision: number) => Promise; updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise; pingServers: (profileId: string, ids: string[]) => Promise; @@ -505,6 +506,7 @@ export function ClientOverviewPage({ isGateway, listDevices: actions.listDevices, refreshDevices: actions.refreshDevices, + resetDeviceTraffic: actions.resetDeviceTraffic, updateDevice: actions.updateDevice, setDevicePolicy: actions.setDevicePolicy, }); diff --git a/src/web/features/devices/DevicesFeature.tsx b/src/web/features/devices/DevicesFeature.tsx index a0f6f20..038814c 100644 --- a/src/web/features/devices/DevicesFeature.tsx +++ b/src/web/features/devices/DevicesFeature.tsx @@ -19,6 +19,7 @@ interface DevicesFeatureOptions { isGateway: boolean; listDevices: () => Promise; refreshDevices: () => Promise; + resetDeviceTraffic: (expectedRevision: number) => Promise; updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise; } @@ -40,6 +41,7 @@ export function useDevicesFeature({ isGateway, listDevices, refreshDevices, + resetDeviceTraffic, updateDevice: requestDeviceUpdate, setDevicePolicy, }: DevicesFeatureOptions) { @@ -50,6 +52,8 @@ export function useDevicesFeature({ const [refreshing, setRefreshing] = useState(false); const [refreshCycle, setRefreshCycle] = useState(0); const [savingId, setSavingId] = useState(''); + const [resetOpen, setResetOpen] = useState(false); + const [resetting, setResetting] = useState(false); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(null); @@ -136,6 +140,29 @@ export function useDevicesFeature({ } } + async function confirmResetTraffic() { + if (!snapshot) return; + setResetting(true); + try { + let next: DeviceSnapshot; + try { + next = parseDeviceSnapshot(await resetDeviceTraffic(snapshot.revision)); + } catch (caught) { + if (requestError(caught).code !== 'STATE_CONFLICT') throw caught; + const latest = parseDeviceSnapshot(await listDevices()); + publish(latest); + next = parseDeviceSnapshot(await resetDeviceTraffic(latest.revision)); + } + publish(next); + setError(null); + setResetOpen(false); + } catch (caught) { + setError(caught); + } finally { + setResetting(false); + } + } + useEffect(() => { if (!isGateway) return undefined; load(); @@ -152,6 +179,7 @@ export function useDevicesFeature({ if (!isOpen) return undefined; const frame = requestAnimationFrame(() => closeRef.current?.focus()); const closeDevices = (event: PointerEvent | KeyboardEvent) => { + if (resetOpen) return; if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return; if (event.type !== 'keydown' && ( panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node) @@ -168,7 +196,7 @@ export function useDevicesFeature({ if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus(); }); }; - }, [isOpen]); + }, [isOpen, resetOpen]); return { isOpen, @@ -178,12 +206,17 @@ export function useDevicesFeature({ refreshing, refreshCycle, savingId, + resetOpen, + resetting, panelRef, toggleRef, closeRef, load, updateDevice, updatePolicy, + requestTrafficReset: () => setResetOpen(true), + cancelTrafficReset: () => setResetOpen(false), + confirmResetTraffic, close: () => setIsOpen(false), toggle: () => setIsOpen((open) => !open), }; diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index cce43b6..e917503 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -7,6 +7,7 @@ import { type CSSProperties, } from 'react'; import { Drawer } from '../../ui/Drawer.js'; +import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import { Tooltip } from '../../ui/Tooltip.js'; import { copyText } from '../../utils/clientControls.js'; import { @@ -70,9 +71,14 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { refreshing, refreshCycle, savingId, + resetOpen, + resetting, load: onLoad, updateDevice, updatePolicy, + requestTrafficReset, + cancelTrafficReset, + confirmResetTraffic, close: onClose, } = feature; const [editingId, setEditingId] = useState(''); @@ -278,7 +284,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { }); } - return ( + return <> setTrafficScale('linear')}>Лин +

Устройства

@@ -623,5 +640,17 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { })}
- ); + + ; } diff --git a/src/web/styles/features/devices.css b/src/web/styles/features/devices.css index 05e606b..4dee98a 100644 --- a/src/web/styles/features/devices.css +++ b/src/web/styles/features/devices.css @@ -19,8 +19,9 @@ } .client-devices-kicker { - width: max-content; + width: 100%; display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; } @@ -66,6 +67,54 @@ color: var(--client-accent); } +.client-devices-reset { + min-height: 28px; + display: flex; + align-items: center; + gap: 5px; + padding: 0 6px; + border: 0; + background: transparent; + color: oklch(0.68 0.15 28); + font: var(--type-label); + letter-spacing: var(--type-label-tracking); + text-transform: var(--type-label-transform); + cursor: pointer; + white-space: nowrap; + transition: color 220ms ease, filter 300ms ease; +} + +.client-devices-reset svg { + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-devices-reset:hover:not(:disabled), +.client-devices-reset:focus-visible { + filter: drop-shadow(0 0 5px currentColor); +} + +.client-devices-reset:hover:not(:disabled) svg, +.client-devices-reset:focus-visible svg { + transform: rotate(-360deg); +} + +.client-devices-reset:focus-visible { + outline: 2px solid currentColor; + outline-offset: 2px; +} + +.client-devices-reset:disabled { + cursor: default; + opacity: 0.45; +} + .client-devices-sort { min-height: 28px; display: flex; diff --git a/src/web/styles/themes.css b/src/web/styles/themes.css index e56bede..2bd3a1e 100644 --- a/src/web/styles/themes.css +++ b/src/web/styles/themes.css @@ -83,6 +83,8 @@ .client-device-edit svg, .client-device-edit-wrap, .client-devices-refresh, + .client-devices-reset, + .client-devices-reset svg, .client-devices-sort, .client-devices-sort-icon, .client-devices-refresh-ring circle, diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index 6e8aff2..fe0375a 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -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']; diff --git a/test/server/device-routes.test.js b/test/server/device-routes.test.js index 18235f4..755e6e5 100644 --- a/test/server/device-routes.test.js +++ b/test/server/device-routes.test.js @@ -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 () => ({}), diff --git a/test/web/api-errors.test.js b/test/web/api-errors.test.js index 033d422..c611251 100644 --- a/test/web/api-errors.test.js +++ b/test/web/api-errors.test.js @@ -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 }), }], diff --git a/test/web/component-actions-contract.test.js b/test/web/component-actions-contract.test.js index a98dd8f..4036b10 100644 --- a/test/web/component-actions-contract.test.js +++ b/test/web/component-actions-contract.test.js @@ -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, /= 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>/); 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/); diff --git a/test/web/devices-feature-contract.test.js b/test/web/devices-feature-contract.test.js index bd3c5fe..3cc8ac1 100644 --- a/test/web/devices-feature-contract.test.js +++ b/test/web/devices-feature-contract.test.js @@ -20,7 +20,7 @@ test('devices feature is the sole public owner and legacy component paths are go assert.equal((page.match(//); 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, / 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'); });