From 12375006d38f835debaf9ff01971e360f865db90 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 20:48:43 +0300 Subject: [PATCH] Add Gateway connectivity diagnostics and traffic chart improvements --- src/server/config.js | 1 + src/server/dataplane.js | 7 + src/server/dataplaneClient.js | 1 + src/server/index.js | 21 + .../connectivityDiagnosticsService.js | 244 +++++++++ src/server/singbox.js | 10 + src/shared/versions.js | 6 +- src/web/api.js | 3 + src/web/components/ClientOverviewPage.jsx | 58 +++ .../ConnectivityDiagnosticsPanel.jsx | 159 ++++++ src/web/components/DevicesPanel.jsx | 127 +++-- src/web/styles.css | 480 +++++++++++++----- src/web/utils/format.js | 32 +- test/server/connectivity-diagnostics.test.js | 116 +++++ test/server/dataplane-client.test.js | 2 + test/server/singbox-gateway-mode.test.js | 14 + test/web/device-inventory-contract.test.js | 55 +- test/web/responsive-layout-contract.test.js | 3 + 18 files changed, 1133 insertions(+), 206 deletions(-) create mode 100644 src/server/services/connectivityDiagnosticsService.js create mode 100644 src/web/components/ConnectivityDiagnosticsPanel.jsx create mode 100644 test/server/connectivity-diagnostics.test.js diff --git a/src/server/config.js b/src/server/config.js index 106a764..08f9335 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -14,6 +14,7 @@ export const settings = { appMode: process.env.APP_MODE === "client" ? "client" : "gateway", port: parsePort(process.env.PORT, 3456), proxyPort, + diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080), tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), tproxyMark: process.env.TPROXY_MARK || "1", tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", diff --git a/src/server/dataplane.js b/src/server/dataplane.js index 2d6a29b..8e23f2c 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -7,6 +7,7 @@ import { buildVersionInfo } from './version.js'; import { readNeighborSnapshot } from './adapters/neighbors.js'; import { createDeviceTrafficService } from './services/deviceTrafficService.js'; import { createDevicePolicyService } from './services/devicePolicyService.js'; +import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; const socketPath = settings.dataplaneSocket; const runtime = createSingboxRuntime({ @@ -27,6 +28,9 @@ const devicePolicy = createDevicePolicyService({ tproxyPort: settings.tproxyPort, tproxyMark: settings.tproxyMark, }); +const connectivityDiagnostics = createConnectivityDiagnosticsService({ + proxyPort: settings.diagnosticsProxyPort, +}); let ready = false; let trafficTimer = null; const MAX_POLICY_BODY_BYTES = 256 * 1024; @@ -86,6 +90,9 @@ const server = http.createServer(async (req, res) => { const body = await readJson(req); return sendJson(res, 200, await devicePolicy.apply(body.devices)); } + if (req.method === 'POST' && req.url === '/diagnostics/connectivity') { + return sendJson(res, 200, await connectivityDiagnostics.run({ vpnAvailable: runtime.running })); + } if (req.method === 'POST' && req.url === '/apply') { return sendJson(res, 200, await runtime.apply()); } diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js index 21ce2d4..effe185 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.js @@ -56,6 +56,7 @@ export function createDataplaneClient(socketPath, send = request) { observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }), + runConnectivityDiagnostics: () => send(socketPath, '/diagnostics/connectivity', 'POST'), apply: () => update('/apply', 'POST'), restart: () => update('/restart', 'POST'), stop: () => update('/stop', 'POST'), diff --git a/src/server/index.js b/src/server/index.js index 78c6ae7..44e527b 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -47,6 +47,7 @@ import { migrateDeviceInventoryState, } from './services/deviceInventoryService.js'; import { buildGatewayVersionInfo, buildVersionInfo } from './version.js'; +import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js'; const MAX_BODY_BYTES = 1_000_000; const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; @@ -134,6 +135,9 @@ const deviceInventory = settings.appMode === 'gateway' vendor: createVendorLookup(), }) : null; +const localConnectivityDiagnostics = settings.appMode === 'gateway' && !remoteDataplane + ? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort }) + : null; let subscriptionRefreshPromise = null; let subscriptionRefreshTimer = null; let gatewayDiscoveryPromise = null; @@ -694,6 +698,23 @@ async function handleApi(req, res) { return sendState(res, { results }); } + if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') { + if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND'); + const state = stateStore.read(); + const appliedServerId = state.appliedServerId || state.selectedServerId; + const selected = (state.servers || []).find((server) => server.id === appliedServerId); + const result = remoteDataplane + ? await singboxRuntime.runConnectivityDiagnostics() + : await localConnectivityDiagnostics.run({ vpnAvailable: (await singboxRuntime.refresh()).running }); + return sendJson(res, 200, { + ...result, + vpn: { + ...result.vpn, + server: selected ? { id: selected.id, label: selected.label } : null, + }, + }); + } + if (req.method === 'POST' && req.url === '/api/subscription/fetch') { const { url = '' } = await readBody(req); const normalizedUrl = String(url).trim(); diff --git a/src/server/services/connectivityDiagnosticsService.js b/src/server/services/connectivityDiagnosticsService.js new file mode 100644 index 0000000..18d140d --- /dev/null +++ b/src/server/services/connectivityDiagnosticsService.js @@ -0,0 +1,244 @@ +import { execFile } from 'node:child_process'; +import net from 'node:net'; + +export const CURL_META_MARKER = '\n__HARBOR_CURL_META__'; + +const IP_PROBES = [ + { + id: 'cloudflare', + url: 'https://www.cloudflare.com/cdn-cgi/trace', + address: (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim(), + }, + { id: 'ipify', url: 'https://api.ipify.org', address: (body) => body.trim() }, +]; +const IP_FALLBACK = { + id: 'aws', + url: 'https://checkip.amazonaws.com', + address: (body) => body.trim(), +}; +const IPV6_PROBE = { + id: 'ipify-v6', + url: 'https://api6.ipify.org', + address: (body) => body.trim(), +}; +const SITE_PROBES = [ + { id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' }, + { id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' }, +]; + +function runCurl(args) { + return new Promise((resolve) => { + execFile('curl', args, { encoding: 'utf8', maxBuffer: 64 * 1024 }, (error, stdout, stderr) => { + resolve({ + exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0, + error: error?.message || '', + stderr: stderr || '', + stdout: stdout || '', + }); + }); + }); +} + +function stageFor(exitCode) { + if (exitCode === 6) return 'dns'; + if (exitCode === 7) return 'tcp'; + if ([35, 51, 58, 60].includes(exitCode)) return 'tls'; + if (exitCode === 28) return 'timeout'; + return 'request'; +} + +function milliseconds(value) { + const seconds = Number(value); + return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null; +} + +async function request(probe, path, proxyPort, execute, { body = false, ipv4 = false } = {}) { + const args = [ + '--silent', + '--show-error', + '--location', + '--connect-timeout', + '3', + '--max-time', + '6', + '--user-agent', + 'Harbor-Diagnostics/1', + '--output', + body ? '-' : '/dev/null', + '--write-out', + `${CURL_META_MARKER}%{json}`, + ...(path === 'vpn' + ? ['--proxy', `http://127.0.0.1:${proxyPort}`] + : ['--noproxy', '*']), + ...(ipv4 ? ['--ipv4'] : []), + probe.url, + ]; + const result = await execute(args); + const marker = result.stdout.lastIndexOf(CURL_META_MARKER); + const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : ''; + let meta = {}; + try { + meta = JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'); + } catch { + // Curl diagnostics remain useful even when an old curl cannot emit JSON metadata. + } + const exitCode = Number.isInteger(meta.exitcode) ? meta.exitcode : result.exitCode; + const ok = exitCode === 0; + return { + ok, + body: responseBody, + exitCode, + httpStatus: Number(meta.http_code) || null, + latencyMs: milliseconds(meta.time_starttransfer), + totalMs: milliseconds(meta.time_total), + stage: ok ? 'complete' : stageFor(exitCode), + error: ok ? null : String(meta.errormsg || result.stderr || result.error || 'request failed').trim(), + }; +} + +async function ipProbe(probe, path, proxyPort, execute, version) { + const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: version === 4 }); + const address = result.ok ? probe.address(result.body) : null; + const valid = typeof address === 'string' && net.isIP(address) === version; + return { + source: probe.id, + address: valid ? address : null, + latencyMs: result.latencyMs, + error: valid ? null : result.error || 'invalid IP response', + }; +} + +async function publicIps(path, proxyPort, execute) { + const [primary, ipv6] = await Promise.all([ + Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute, 4))), + ipProbe(IPV6_PROBE, path, proxyPort, execute, 6), + ]); + const primaryAddresses = new Set(primary.map((probe) => probe.address).filter(Boolean)); + const probes = primary.every((probe) => probe.address) && primaryAddresses.size === 1 + ? primary + : [...primary, await ipProbe(IP_FALLBACK, path, proxyPort, execute, 4)]; + return { + ipv4: { + addresses: [...new Set(probes.map((probe) => probe.address).filter(Boolean))], + sources: probes, + }, + ipv6: ipv6.address, + ipv6Source: ipv6, + }; +} + +async function siteProbe(probe, path, proxyPort, execute) { + let result = await request(probe, path, proxyPort, execute); + let attempts = 1; + if (!result.ok) { + result = await request(probe, path, proxyPort, execute); + attempts = 2; + } + const status = !result.ok + ? 'unavailable' + : result.httpStatus >= 200 && result.httpStatus < 400 + ? 'available' + : 'responded'; + return { + id: probe.id, + label: probe.label, + status, + attempts, + httpStatus: result.httpStatus, + latencyMs: result.latencyMs, + totalMs: result.totalMs, + stage: result.stage, + error: result.error, + }; +} + +async function probePath(path, proxyPort, execute) { + const [ip, sites] = await Promise.all([ + publicIps(path, proxyPort, execute), + Promise.all(SITE_PROBES.map((probe) => siteProbe(probe, path, proxyPort, execute))), + ]); + return { + available: true, + internetAvailable: Boolean( + ip.ipv4.addresses.length || ip.ipv6 || sites.some((site) => site.status !== 'unavailable'), + ), + ...ip, + sites, + }; +} + +export function assessConnectivity(direct, vpn) { + const comparisons = SITE_PROBES.map(({ id, label }) => { + const directSite = direct.sites.find((site) => site.id === id); + const vpnSite = vpn.sites?.find((site) => site.id === id); + let assessment = 'inconclusive'; + if (!vpn.available) assessment = 'not-tested'; + else if (directSite?.status === 'available' && vpnSite?.status === 'available') { + assessment = 'available'; + } else if ( + directSite?.status === 'responded' + && [403, 451].includes(directSite.httpStatus) + && vpnSite?.status === 'available' + ) assessment = 'likely-direct-restriction'; + else if ( + directSite?.status === 'unavailable' + && vpnSite?.status === 'available' + && direct.internetAvailable + ) { + assessment = 'likely-direct-restriction'; + } else if (directSite?.status === 'available' && vpnSite?.status !== 'available') { + assessment = 'vpn-problem'; + } else if (directSite?.status === 'unavailable' && vpnSite?.status === 'unavailable') { + assessment = 'unavailable'; + } + return { id, label, assessment, direct: directSite, vpn: vpnSite || null }; + }); + const directAddresses = [...direct.ipv4.addresses, direct.ipv6].filter(Boolean); + const vpnAddresses = [...(vpn.ipv4?.addresses || []), vpn.ipv6].filter(Boolean); + const sameEgress = directAddresses.some((address) => vpnAddresses.includes(address)); + let summary = 'inconclusive'; + if (!vpn.available) summary = 'vpn-off'; + else if (!direct.internetAvailable && !vpn.internetAvailable) summary = 'offline'; + else if (!direct.internetAvailable && vpn.internetAvailable) summary = 'direct-offline'; + else if (direct.internetAvailable && !vpn.internetAvailable) summary = 'vpn-problem'; + else if (comparisons.some((item) => item.assessment === 'likely-direct-restriction')) { + summary = 'likely-direct-restriction'; + } else if (sameEgress) summary = 'same-ip'; + else if (comparisons.every((item) => item.assessment === 'available')) summary = 'available'; + return { summary, sameEgress, comparisons }; +} + +export function createConnectivityDiagnosticsService({ + proxyPort, + execute = runCurl, + now = () => new Date().toISOString(), +}) { + let inFlight = null; + async function runOnce({ vpnAvailable }) { + const directPromise = probePath('direct', proxyPort, execute); + const vpnPromise = vpnAvailable + ? probePath('vpn', proxyPort, execute) + : Promise.resolve({ + available: false, + reason: 'vpn-off', + internetAvailable: false, + ipv4: { addresses: [], sources: [] }, + ipv6: null, + ipv6Source: null, + sites: [], + }); + const [direct, vpn] = await Promise.all([directPromise, vpnPromise]); + return { + checkedAt: now(), + direct, + vpn, + assessment: assessConnectivity(direct, vpn), + }; + } + return { + run(options) { + if (!inFlight) inFlight = runOnce(options).finally(() => { inFlight = null; }); + return inFlight; + }, + }; +} diff --git a/src/server/singbox.js b/src/server/singbox.js index e5bf1b5..61252ba 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -7,6 +7,7 @@ import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js'; const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']); const MIXED_INBOUND = 'mixed-in'; const TPROXY_INBOUND = 'tproxy-in'; +const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in'; function findOutbound(subscriptionConfig, selectedTag) { const outbounds = Array.isArray(subscriptionConfig?.outbounds) @@ -51,6 +52,14 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { sniff: true, set_system_proxy: false, }, + ...(!clientMode ? [{ + type: 'mixed', + tag: DIAGNOSTICS_INBOUND, + listen: '127.0.0.1', + listen_port: settings.diagnosticsProxyPort, + sniff: true, + set_system_proxy: false, + }] : []), ]; const directRules = normalizeRouteRules(routeRules) .filter((rule) => rule.enabled) @@ -58,6 +67,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, { const rules = clientMode ? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }] : [ + { inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag }, ...directRules, { inbound: [TPROXY_INBOUND], outbound: outboundTag }, { inbound: [MIXED_INBOUND], outbound: outboundTag }, diff --git a/src/shared/versions.js b/src/shared/versions.js index f84f1b7..121f26d 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.14.3', - gatewayClient: '0.15.3', - gatewayBackend: '0.15.0', + macClient: '0.15.0', + gatewayClient: '0.16.0', + gatewayBackend: '0.16.0', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index 308e7cd..bb3e35c 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -91,6 +91,9 @@ export const api = { body: JSON.stringify({ mode, expectedRevision }), }), }, + diagnostics: { + connectivity: () => request('/api/diagnostics/connectivity', { method: 'POST' }), + }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), restart: () => request('/api/singbox/restart', { method: 'POST' }), diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 6c3ab9f..f6f7211 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -16,6 +16,7 @@ import { instructionBlocks } from '../instructions.js'; import { operationBlocked } from '../state/operations.js'; import { ConfirmationPopup } from './ConfirmationPopup.jsx'; import { DevicesPanel } from './DevicesPanel.jsx'; +import { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.jsx'; import { ServerPicker } from './ServerPicker.jsx'; import { ERROR_DEFINITIONS } from '../../shared/errors.js'; import { canAppendRouteRule } from '../../shared/routingRules.js'; @@ -642,6 +643,7 @@ export function ClientOverviewPage({ const [instructionsOpen, setInstructionsOpen] = useState(false); const [localRulesOpen, setLocalRulesOpen] = useState(false); const [devicesOpen, setDevicesOpen] = useState(false); + const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [localRulesDraft, setLocalRulesDraft] = useState([]); const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0); const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false); @@ -660,6 +662,9 @@ export function ClientOverviewPage({ const devicesPanelRef = useRef(null); const devicesToggleRef = useRef(null); const devicesCloseRef = useRef(null); + const diagnosticsPanelRef = useRef(null); + const diagnosticsToggleRef = useRef(null); + const diagnosticsCloseRef = useRef(null); const localRulesBaselineRef = useRef('[]'); const previousHasSubscriptionRef = useRef(hasSubscription); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; @@ -794,6 +799,8 @@ export function ClientOverviewPage({ setEditingSubscription(true); setInstructionsOpen(false); setLocalRulesOpen(false); + setDevicesOpen(false); + setDiagnosticsOpen(false); } }, [hasSubscription]); @@ -876,6 +883,28 @@ export function ClientOverviewPage({ }; }, [devicesOpen]); + useEffect(() => { + if (!diagnosticsOpen) return undefined; + const frame = requestAnimationFrame(() => diagnosticsCloseRef.current?.focus()); + const closeDiagnostics = (event) => { + if (event.type === 'keydown' && event.key !== 'Escape') return; + if (event.type !== 'keydown' && ( + diagnosticsPanelRef.current?.contains(event.target) || diagnosticsToggleRef.current?.contains(event.target) + )) return; + setDiagnosticsOpen(false); + }; + document.addEventListener('pointerdown', closeDiagnostics); + document.addEventListener('keydown', closeDiagnostics); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener('pointerdown', closeDiagnostics); + document.removeEventListener('keydown', closeDiagnostics); + requestAnimationFrame(() => { + if (diagnosticsPanelRef.current?.contains(document.activeElement)) diagnosticsToggleRef.current?.focus(); + }); + }; + }, [diagnosticsOpen]); + useEffect(() => { if (!localRulesOpen) return undefined; const frame = requestAnimationFrame(() => localRulesCloseRef.current?.focus()); @@ -1021,6 +1050,7 @@ export function ClientOverviewPage({ const rules = state?.route?.localRules || []; setInstructionsOpen(false); setDevicesOpen(false); + setDiagnosticsOpen(false); localRulesBaselineRef.current = localRulesSignature(rules); setLocalRulesDraft(rules.map(createLocalRuleDraft)); setLocalRulesRevision(state?.route?.localRulesRevision || 0); @@ -1107,6 +1137,7 @@ export function ClientOverviewPage({ onClick={() => { if (localRulesOpen && !requestCloseLocalRules()) return; setDevicesOpen(false); + setDiagnosticsOpen(false); setInstructionsOpen((open) => !open); }} > @@ -1126,6 +1157,7 @@ export function ClientOverviewPage({ onClick={() => { if (localRulesOpen && !requestCloseLocalRules()) return; setInstructionsOpen(false); + setDiagnosticsOpen(false); setDevicesOpen((open) => !open); }} > @@ -1136,6 +1168,25 @@ export function ClientOverviewPage({ Устройства } + {isGateway && } +
+ Gateway · Direct ↔ VPN +

Проверка маршрутов

+
+

Сравнивает внешний IP и доступность сайтов напрямую и через выбранный VPN, не меняя правила.

+
+
+ + + + {error &&
+ {error.message} + {error.retryable && } +
} + + {!result && status !== 'running' && !error && ( +

Результаты появятся здесь после ручного запуска.

+ )} + + {result &&
+ +
+ Внешний IP + {ipText(result.direct)} + + {result.vpn.available ? ipText(result.vpn) : 'VPN выключен'} + +
+
+ Интернет + + +
+ {result.assessment.comparisons.map((comparison) => ( +
+ {comparison.label} + + +
+ ))} +

{summary[1]}

+

Проверено в {checkedAt}

+
+ Технические детали +
+ + +
+
+
} + + + ); +} diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 3c1c7f8..3a08e25 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { api } from '../api.js'; import { copyText } from '../utils/clientControls.js'; import { @@ -6,9 +7,9 @@ import { formatByteString, formatLastSeen, positiveByteDelta, - sortDevicesByTraffic, + stabilizeDevicesByTraffic, trafficAxisMid, - trafficSampleMetrics, + trafficScaleRatio, } from '../utils/format.js'; const AUTO_REFRESH_MS = 15_000; @@ -42,52 +43,94 @@ function chartTime(value) { }); } -function TrafficChart({ samples, scale, capacity, routeLabel }) { +function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { + const [hovered, setHovered] = useState(null); const max = samples.reduce((largest, sample) => { - const total = byteString(sample.gatewayBytes) + byteString(sample.proxyBytes); - return total > largest ? total : largest; + const gateway = byteString(sample.gatewayBytes); + const proxy = byteString(sample.proxyBytes); + return gateway > largest ? gateway : proxy > largest ? proxy : largest; }, 0n); const mid = trafficAxisMid(max, scale); const latest = samples.at(-1)?.observedAt || 'empty'; + const firstSlot = capacity - samples.length; + const points = samples.map((sample, index) => { + const gateway = byteString(sample.gatewayBytes); + const proxy = byteString(sample.proxyBytes); + return { + sample, + x: (firstSlot + index) * 100 / Math.max(1, capacity - 1), + gateway, + proxy, + gatewayY: 100 - trafficScaleRatio(gateway, max, scale) * 100, + proxyY: 100 - trafficScaleRatio(proxy, max, scale) * 100, + }; + }); + const previous = points.slice(0, -1); + const penultimate = points.at(-2); + const newest = points.at(-1); + const hasProxy = points.some(({ proxy }) => proxy > 0n); + + function trackPointer(event) { + const bounds = event.currentTarget.getBoundingClientRect(); + const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1)); + const index = slot - firstSlot; + if (index < 0 || index >= points.length) { + setHovered(null); + return; + } + setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY }); + } + + const tooltip = hovered && typeof document !== 'undefined' && createPortal( + + + Всего {formatByteString(hovered.gateway + hovered.proxy)} + {routeLabel} {formatByteString(hovered.gateway)} + {hovered.proxy > 0n && Proxy {formatByteString(hovered.proxy)}} + , + document.body, + ); + return - - {samples.map((sample, index) => { - const gateway = byteString(sample.gatewayBytes); - const proxy = byteString(sample.proxyBytes); - const total = gateway + proxy; - const time = chartTime(sample.observedAt); - const slot = capacity - samples.length + index; - const edge = slot < 28 ? ' is-edge-left' : slot >= capacity - 28 ? ' is-edge-right' : ''; - const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max, scale); - const newest = index === samples.length - 1; - return - ; - })} - - {max > 0n && ; } @@ -110,8 +153,16 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { const previousTraffic = useRef(new Map()); const copyTimer = useRef(null); const trafficDeltaTimer = useRef(null); + const trafficOrder = useRef({ direction: sortDirection, ids: [] }); const devices = useMemo( - () => sortDevicesByTraffic(snapshot?.devices, sortDirection), + () => { + const previousIds = trafficOrder.current.direction === sortDirection + ? trafficOrder.current.ids + : []; + const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds); + trafficOrder.current = { direction: sortDirection, ids: result.ids }; + return result.devices; + }, [snapshot?.devices, sortDirection], ); @@ -384,6 +435,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
{devices.map((device) => { + const hasName = Boolean(device.alias || device.hostname); const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const editing = editingId === device.id; const saving = savingId === device.id; @@ -459,13 +511,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { <> {device.ip ?