diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index 65496ed..0822d58 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -149,6 +149,11 @@ interface OutboundTrafficSample { unknownBytes: string; } +interface OutboundTrafficTotal extends OutboundTrafficSample { + singboxObservedAt: string | null; + directIpv4ObservedAt: string | null; +} + interface OutboundTrafficCursor { signature: string; routeEpoch: string; @@ -709,6 +714,7 @@ export function createDeviceInventoryService({ const trafficCursorByMac = new Map(); const outboundTrafficHistoryByDeviceId = new Map(); const outboundTrafficCursorByDeviceId = new Map(); + const outboundTrafficByDeviceId = new Map(); let globalTrafficHistory: TrafficSample[] = []; let globalTrafficCursor: TrafficCursor | null = null; const hostnameAttempts = new Map(); @@ -832,6 +838,15 @@ export function createDeviceInventoryService({ const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total }; const previous = outboundTrafficCursorByDeviceId.get(device.id); 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(), + }); if (!previous || previous.signature === signature || !observedAt) continue; const routeDelta = (value: bigint, before: bigint) => ( routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n @@ -852,6 +867,7 @@ export function createDeviceInventoryService({ if (!knownIds.has(deviceId)) { outboundTrafficCursorByDeviceId.delete(deviceId); outboundTrafficHistoryByDeviceId.delete(deviceId); + outboundTrafficByDeviceId.delete(deviceId); } } } @@ -919,6 +935,7 @@ export function createDeviceInventoryService({ proxyDownloadBytes: proxyTraffic?.downloadBytes || '0', proxyTrafficObservedAt: proxyTraffic?.observedAt || null, trafficHistory: trafficHistoryByMac.get(device.mac) || [], + outboundTraffic: outboundTrafficByDeviceId.get(device.id) || null, outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [], desiredPolicy: policy.desired, appliedPolicy: policy.applied, diff --git a/src/shared/versions.ts b/src/shared/versions.ts index d6f5136..def4d5f 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.25.3', - gatewayClient: '0.26.2', - gatewayBackend: '0.26.3', + macClient: '0.25.4', + gatewayClient: '0.26.3', + gatewayBackend: '0.26.4', }); export interface ParsedVersion { diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index cb69cb4..3a1dc7a 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -337,7 +337,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {

Устройства

Устройства, которые Gateway видит в локальной таблице соседей.

-

Учитывается только трафик, который прошёл через Harbor.

+

Вход — накопленные Gateway/Proxy. Выход — оценка VPN/Direct с запуска текущего учёта.

@@ -403,6 +403,24 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const proxyTraffic = formatByteString(proxyTotal.toString()); const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString()); const hasProxyTraffic = proxyTotal > 0n; + const outboundAvailable = Boolean( + device.outboundTraffic?.singboxObservedAt || device.outboundTraffic?.directIpv4ObservedAt, + ); + const vpnTotal = byteString(device.outboundTraffic?.vpnBytes); + const directTrackedTotal = byteString(device.outboundTraffic?.directTrackedBytes); + const directIpv4Total = byteString(device.outboundTraffic?.directIpv4Bytes); + const directTotal = directTrackedTotal + directIpv4Total; + const unknownTotal = byteString(device.outboundTraffic?.unknownBytes); + const outboundTotal = vpnTotal + directTotal + unknownTotal; + const displayedTotal = trafficView === 'outbound' + ? outboundAvailable ? `≈ ${formatByteString(outboundTotal.toString())}` : '—' + : totalTraffic; + const trafficLabel = trafficView === 'outbound' ? 'Выход' : 'Вход'; + const trafficAriaLabel = trafficView === 'outbound' + ? outboundAvailable + ? `Выход с запуска текущего учёта: примерно ${formatByteString(outboundTotal.toString())}. VPN ${formatByteString(vpnTotal.toString())}, Direct примерно ${formatByteString(directTotal.toString())}${unknownTotal > 0n ? `, неизвестно ${formatByteString(unknownTotal.toString())}` : ''}` + : 'Выход: ожидаем первые данные' + : `Вход накоплен: ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`; const trafficDelta = trafficDeltas[device.id] || {}; const feedback = copyFeedback[device.id]; const policyBusy = device.policyStatus === 'applying'; @@ -538,14 +556,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { className="client-device-traffic" role="group" tabIndex={0} - aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`} + aria-label={trafficAriaLabel} > } diff --git a/src/web/features/devices/TrafficChart.tsx b/src/web/features/devices/TrafficChart.tsx index 2645958..138108e 100644 --- a/src/web/features/devices/TrafficChart.tsx +++ b/src/web/features/devices/TrafficChart.tsx @@ -27,7 +27,7 @@ function chartTime(value: string) { type ChartSample = TrafficSample | OutboundTrafficSample; type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown'; -type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY'; +type ChartYKey = 'gatewayY' | 'proxyY' | 'unknownY'; interface ChartPoint { sample: ChartSample; @@ -38,7 +38,6 @@ interface ChartPoint { unknown: bigint; gatewayY: number; proxyY: number; - directIpv4Y: number; unknownY: number; } @@ -68,13 +67,11 @@ 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, }; }); @@ -133,7 +130,7 @@ export function TrafficChart({ return { sample, gateway: byteString(outbound.vpnBytes), - proxy: byteString(outbound.directTrackedBytes), + proxy: byteString(outbound.directTrackedBytes) + byteString(outbound.directIpv4Bytes), directIpv4: byteString(outbound.directIpv4Bytes), unknown: byteString(outbound.unknownBytes), }; @@ -148,8 +145,8 @@ export function TrafficChart({ unknown: 0n, }; }); - const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({ - gateway, proxy, directIpv4, unknown, + const max = trafficSeriesMax(values.map(({ gateway, proxy, unknown }) => ({ + gateway, proxy, directIpv4: 0n, unknown, }))); const mid = trafficAxisMid(max, scale); const firstSlot = capacity - visibleSamples.length; @@ -163,7 +160,6 @@ export function TrafficChart({ 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)), }; }); @@ -177,7 +173,6 @@ export function TrafficChart({ ? [ { valueKey: 'gateway', yKey: 'gatewayY' }, { valueKey: 'proxy', yKey: 'proxyY' }, - { valueKey: 'directIpv4', yKey: 'directIpv4Y' }, { valueKey: 'unknown', yKey: 'unknownY' }, ] : [ @@ -230,11 +225,13 @@ export function TrafficChart({ {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)}} + Учтено ≈ {formatByteString(hovered.gateway + hovered.proxy + hovered.unknown)} + {hovered.gateway > 0n && VPN {formatByteString(hovered.gateway)}} + {hovered.proxy > 0n && Direct ≈ {formatByteString(hovered.proxy)}} + {hovered.proxy - hovered.directIpv4 > 0n && через sing-box {formatByteString(hovered.proxy - hovered.directIpv4)}} + {hovered.directIpv4 > 0n && мимо sing-box · IPv4 {formatByteString(hovered.directIpv4)}} {hovered.unknown > 0n && Неизвестно · sing-box {formatByteString(hovered.unknown)}} - Разные уровни учёта не складываются + Оценка за 15-секундный интервал : <> Всего {formatByteString(hovered.gateway + hovered.proxy)} {routeLabel} {formatByteString(hovered.gateway)} @@ -269,19 +266,19 @@ export function TrafficChart({ } - {visibleLines.map((line) => previous.length > 0 && + {visibleLines.map((line) => previous.length > 0 && {animatePaths && } )} - {visibleLines.map((line) => penultimate && newest && + {visibleLines.map((line) => penultimate && newest && {animatePaths && } )} - {visibleLines.map((line) => !penultimate && newest && + {visibleLines.map((line) => !penultimate && newest && {animatePaths && } )} {hovered && - {visibleLines.map((line) => hovered[line.valueKey] > 0n && )} + {visibleLines.map((line) => hovered[line.valueKey] > 0n && )} } @@ -289,8 +286,7 @@ export function TrafficChart({ {series === 'outbound' ? VPN - Direct · sing-box - Direct · IPv4 + Direct ≈ ? : {series === 'speed' ? '↓ / ↑' : 'Вход'}} diff --git a/src/web/features/devices/deviceSnapshot.ts b/src/web/features/devices/deviceSnapshot.ts index 4deea1c..54454b6 100644 --- a/src/web/features/devices/deviceSnapshot.ts +++ b/src/web/features/devices/deviceSnapshot.ts @@ -21,6 +21,11 @@ export interface OutboundTrafficSample extends Record { unknownBytes: ByteValue; } +export interface OutboundTrafficTotal extends OutboundTrafficSample { + singboxObservedAt: string | null; + directIpv4ObservedAt: string | null; +} + export interface Device extends Record { id: string; alias: string | null; @@ -41,6 +46,7 @@ export interface Device extends Record { appliedPolicy: DevicePolicy; confidence: DeviceConfidence; trafficHistory: TrafficSample[]; + outboundTraffic?: OutboundTrafficTotal | null; outboundTrafficHistory?: OutboundTrafficSample[]; } @@ -130,6 +136,12 @@ function validOutboundHistory(value: unknown): value is OutboundTrafficSample[] return Array.isArray(value) && value.every(validOutboundTrafficSample); } +function validOutboundTraffic(value: unknown): value is OutboundTrafficTotal | null { + return value === null || (validOutboundTrafficSample(value) + && nullableTimestamp(value.singboxObservedAt) + && nullableTimestamp(value.directIpv4ObservedAt)); +} + function validDevice(value: unknown): value is Device { return record(value) && typeof value.id === 'string' @@ -155,6 +167,7 @@ function validDevice(value: unknown): value is Device { && (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct') && (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous') && validHistory(value.trafficHistory) + && (value.outboundTraffic === undefined || validOutboundTraffic(value.outboundTraffic)) && (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory)); } diff --git a/src/web/styles/features/devices.css b/src/web/styles/features/devices.css index 7c27d72..05e606b 100644 --- a/src/web/styles/features/devices.css +++ b/src/web/styles/features/devices.css @@ -694,6 +694,10 @@ transition-delay: 45ms; } +.client-device-traffic-breakdown > span:nth-child(3) { + transition-delay: 90ms; +} + .client-device-traffic:hover .client-device-traffic-breakdown, .client-device-traffic:focus .client-device-traffic-breakdown { visibility: visible; @@ -764,6 +768,18 @@ color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text)); } +.client-device-traffic-breakdown .is-vpn { + color: var(--harbor-connect); +} + +.client-device-traffic-breakdown .is-direct-total { + color: var(--harbor-gateway); +} + +.client-device-traffic-breakdown .is-unknown { + color: var(--client-muted); +} + .client-device-traffic-breakdown b { color: var(--client-accent); text-shadow: 0 0 3px var(--client-bg), 0 0 8px var(--client-bg); @@ -773,6 +789,12 @@ color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text)); } +.client-device-traffic-breakdown .is-vpn b, +.client-device-traffic-breakdown .is-direct-total b, +.client-device-traffic-breakdown .is-unknown b { + color: inherit; +} + .client-device-traffic:focus-visible { border-radius: 3px; outline: 2px solid var(--client-accent); @@ -913,16 +935,11 @@ stroke: var(--harbor-connect); } -.client-device-traffic-lines .is-direct-tracked { +.client-device-traffic-lines .is-direct-total { 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; @@ -967,14 +984,10 @@ stroke: var(--harbor-connect); } -.client-device-traffic-cursor .is-point.is-direct-tracked { +.client-device-traffic-cursor .is-point.is-direct-total { 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)); } @@ -1000,14 +1013,10 @@ color: var(--harbor-connect); } -.client-device-traffic-legend .is-direct-tracked { +.client-device-traffic-legend .is-direct-total { 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)); } @@ -1072,12 +1081,13 @@ color: oklch(0.75 0.1 185); } -.client-device-traffic-point-tooltip .is-direct-tracked { +.client-device-traffic-point-tooltip .is-direct-total { 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-direct-detail { + padding-left: 8px; + color: oklch(0.68 0.012 145); } .client-device-traffic-point-tooltip .is-unknown { diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index 4ba4c5d..6e8aff2 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -548,13 +548,32 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat }), }); - assert.deepEqual((await service.refresh()).devices[0].outboundTrafficHistory, []); + let snapshot = await service.refresh(); + assert.deepEqual(snapshot.devices[0].outboundTraffic, { + observedAt, + singboxObservedAt: observedAt, + directIpv4ObservedAt: observedAt, + vpnBytes: '300', + directTrackedBytes: '30', + directIpv4Bytes: '100', + unknownBytes: '3', + }); + assert.deepEqual(snapshot.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(); + snapshot = await service.refresh(); + assert.deepEqual(snapshot.devices[0].outboundTraffic, { + observedAt, + singboxObservedAt: observedAt, + directIpv4ObservedAt: observedAt, + vpnBytes: '400', + directTrackedBytes: '80', + directIpv4Bytes: '160', + unknownBytes: '12', + }); assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, [{ observedAt, vpnBytes: '100', @@ -570,6 +589,15 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat unknown = ['0', '1']; directIpv4 = ['5', '6']; snapshot = await service.refresh(); + assert.deepEqual(snapshot.devices[0].outboundTraffic, { + observedAt, + singboxObservedAt: observedAt, + directIpv4ObservedAt: observedAt, + vpnBytes: '7', + directTrackedBytes: '3', + directIpv4Bytes: '11', + unknownBytes: '1', + }); assert.deepEqual(snapshot.devices[0].outboundTrafficHistory.at(-1), { observedAt, vpnBytes: '0', @@ -578,6 +606,7 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat unknownBytes: '0', }); assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTrafficHistory/); + assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTraffic/); }); test('global traffic stays monotonic when a device expires and returns in the same epoch', async (t) => { diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index c610d9a..bf1f81a 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -82,9 +82,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /device\.confidence === 'ambiguous'/); assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/); assert.match(panel, /Трафик временно не обновляется/); - assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/); - assert.match(panel, /client-device-traffic-total[\s\S]*Всего<\/b>Gateway<\/b>\{trafficLabel\}<\/b>VPN<\/b>[\s\S]*Direct ≈<\/b>[\s\S]*Неизв\.<\/b>/); + assert.match(panel, /Gateway<\/b> 0n/); assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && Прокси<\/b> 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/); - 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, /proxy: byteString\(outbound\.directTrackedBytes\) \+ byteString\(outbound\.directIpv4Bytes\)/); + assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, 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, /VPN · sing-box[\s\S]*Direct · sing-box[\s\S]*Direct · IPv4[\s\S]*Неизвестно · sing-box[\s\S]*Разные уровни учёта не складываются/); - assert.match(chart, /