From 90433d7cd8231510ec1e83cc90d7bd59f1895803 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 9 Aug 2026 11:55:02 +0300 Subject: [PATCH] Update VPN proxy client behavior --- README.md | 4 +- src/server/services/deviceInventoryService.ts | 18 ++- src/shared/versions.ts | 6 +- src/web/components/ClientOverviewPage.tsx | 50 +++++-- .../features/connection/ConnectionPanel.tsx | 123 +++++++++--------- src/web/features/devices/DevicesFeature.tsx | 31 ++++- src/web/features/devices/DevicesPanel.tsx | 47 +++++-- src/web/features/devices/TrafficChart.tsx | 97 +++++++++----- src/web/features/devices/deviceSnapshot.ts | 10 +- .../instructions/InstructionsFeature.tsx | 44 +++++-- .../subscription/SubscriptionFeature.tsx | 83 ++++++------ src/web/styles/features/connection.css | 26 +++- src/web/styles/features/devices.css | 100 ++++++++++++-- src/web/styles/features/subscription.css | 119 +++++++++++++---- src/web/styles/layout.css | 47 ++++++- src/web/styles/themes.css | 12 ++ src/web/utils/clientControls.ts | 34 +++-- src/web/utils/format.ts | 7 + test/server/device-inventory.test.js | 8 ++ test/web/client-controls.test.js | 68 ++++++++++ test/web/connection-panel-contract.test.js | 7 +- test/web/device-inventory-contract.test.js | 28 ++-- .../web/instructions-feature-contract.test.js | 4 +- test/web/responsive-layout-contract.test.js | 7 +- test/web/rule-editor-contract.test.js | 2 +- test/web/style-boundaries.test.js | 30 ++--- .../web/subscription-feature-contract.test.js | 12 ++ 27 files changed, 770 insertions(+), 254 deletions(-) diff --git a/README.md b/README.md index eb637dc..70d533c 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,13 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway. +Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название, IP и последний контакт, общий трафик с раскрываемой по hover/focus разбивкой `Gateway`/`Прокси`, затем иконку применённого маршрута. Нажмите IP, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway. У однозначно распознанного устройства маршрут можно переключить последней иконкой между `VPN` и `Напрямую` независимо от закрепления; точное значение и следующее действие показаны в tooltip. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. Список приблизительный: private/randomized MAC определяется как менее надёжная identity, один MAC с несколькими IP помечается как неоднозначный, а устройство появляется только после сетевого контакта с Gateway. Интерфейс самого Gateway не выдаётся за Wi-Fi/Ethernet устройства. Внешние сервисы распознавания производителя не используются. `Прокси` учитывает подключения устройства к общему proxy-порту Harbor, а `Gateway` — остальной публичный трафик через Gateway; трафик, который вообще не дошёл до Harbor, увидеть нельзя. Локальные, приватные и multicast-пакеты в totals не входят. При аварийном restart dataplane возможна потеря последних примерно 30 секунд; история по часам пока не хранится. -Home показывает фактически применённый VPN-сервер и общий график тех же счётчиков. `Учтено Harbor` — накопленная сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткий график последних 15-секундных интервалов после перезапуска начинает заполняться заново. +Home показывает фактически применённый VPN-сервер, накопленное `Учтено Harbor` и большой нижний график средней скорости Download/Upload за фактический интервал между снимками. `Учтено Harbor` — сумма `Gateway` и явного `Прокси` для всех наблюдавшихся устройств; это не лимит VPN-провайдера и не весь физический трафик Linux-машины. Накопленный total сохраняется при очистке старых устройств, а короткая история скорости после перезапуска начинает заполняться заново. ## Установка Harbor Connect на macOS diff --git a/src/server/services/deviceInventoryService.ts b/src/server/services/deviceInventoryService.ts index 8be2f08..69076ed 100644 --- a/src/server/services/deviceInventoryService.ts +++ b/src/server/services/deviceInventoryService.ts @@ -127,12 +127,16 @@ interface TrafficSample { observedAt: string | null | undefined; gatewayBytes: string; proxyBytes: string; + uploadBytes: string; + downloadBytes: string; } interface TrafficCursor { signature: string; gateway: bigint; proxy: bigint; + upload: bigint; + download: bigint; } interface DeviceObservation { @@ -674,10 +678,12 @@ export function createDeviceInventoryService({ const proxy = state.traffic.proxy.totalsByMac[device.mac]; const signature = `${traffic?.observedAt || ''}|${proxy?.observedAt || ''}`; if (signature === '|') continue; + const upload = BigInt(traffic?.uploadBytes || '0') + BigInt(proxy?.uploadBytes || '0'); + const download = BigInt(traffic?.downloadBytes || '0') + BigInt(proxy?.downloadBytes || '0'); 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 }); + trafficCursorByMac.set(device.mac, { signature, gateway, proxy: proxyTotal, upload, download }); if (!previous || previous.signature === signature) continue; const observedAt = [traffic?.observedAt, proxy?.observedAt].filter(Boolean).sort().at(-1); const samples = trafficHistoryByMac.get(device.mac) || []; @@ -685,6 +691,8 @@ export function createDeviceInventoryService({ observedAt, gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0', proxyBytes: proxyTotal > previous.proxy ? (proxyTotal - previous.proxy).toString() : '0', + uploadBytes: upload > previous.upload ? (upload - previous.upload).toString() : '0', + downloadBytes: download > previous.download ? (download - previous.download).toString() : '0', }].slice(-TRAFFIC_HISTORY_LIMIT)); } for (const mac of trafficCursorByMac.keys()) { @@ -695,16 +703,20 @@ export function createDeviceInventoryService({ } const gatewaySource = state.traffic.global.gateway; const proxySource = state.traffic.global.proxy; + const upload = BigInt(gatewaySource.uploadBytes) + BigInt(proxySource.uploadBytes); + const download = BigInt(gatewaySource.downloadBytes) + BigInt(proxySource.downloadBytes); const gateway = BigInt(gatewaySource.uploadBytes) + BigInt(gatewaySource.downloadBytes); const proxy = BigInt(proxySource.uploadBytes) + BigInt(proxySource.downloadBytes); const signature = `${gatewaySource.lastObservedAt || ''}|${proxySource.lastObservedAt || ''}`; const previous = globalTrafficCursor; - globalTrafficCursor = { signature, gateway, proxy }; + globalTrafficCursor = { signature, gateway, proxy, upload, download }; if (previous && previous.signature !== signature) { globalTrafficHistory = [...globalTrafficHistory, { observedAt: [gatewaySource.lastObservedAt, proxySource.lastObservedAt].filter(Boolean).sort().at(-1), gatewayBytes: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0', proxyBytes: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0', + uploadBytes: upload > previous.upload ? (upload - previous.upload).toString() : '0', + downloadBytes: download > previous.download ? (download - previous.download).toString() : '0', }].slice(-TRAFFIC_HISTORY_LIMIT); } } @@ -800,6 +812,8 @@ export function createDeviceInventoryService({ traffic: { gatewayBytes: gatewayBytes.toString(), proxyBytes: proxyBytes.toString(), + uploadBytes: (BigInt(gatewayTraffic.uploadBytes) + BigInt(proxyTraffic.uploadBytes)).toString(), + downloadBytes: (BigInt(gatewayTraffic.downloadBytes) + BigInt(proxyTraffic.downloadBytes)).toString(), totalBytes: (gatewayBytes + proxyBytes).toString(), gatewayObservedAt: gatewayTraffic.lastObservedAt, proxyObservedAt: proxyTraffic.lastObservedAt, diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 09c2cb5..03524dd 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.20.36', - gatewayClient: '0.21.21', - gatewayBackend: '0.21.22', + macClient: '0.21.0', + gatewayClient: '0.22.0', + gatewayBackend: '0.22.0', }); export interface ParsedVersion { diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index 507c35f..2593904 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -119,6 +119,9 @@ interface ClientOverviewPageProps { } type CopyKind = 'gateway' | 'socks5' | 'http'; +type CopyFeedback = { failed: boolean; cycle: number }; +type CopyFeedbackMap = Partial>; +type CopyAnnouncement = { text: string; cycle: number }; function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) @@ -263,8 +266,9 @@ function InlineProgress({ operations, context }: { ); } -function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: { +function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: { isGateway: boolean; + connected: boolean; gatewayAvailable: boolean; gatewayDirect: boolean; blocked: boolean; @@ -333,7 +337,7 @@ function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSe ; return ( -
+
{switchable ? ; return <> - {visible &&
+ {visible &&
{isGateway ? } : powerButton} +
+ {connected ? ( + + ) : ( +

+ {canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'} +

+ )} +
{routingSlot}

@@ -181,58 +233,6 @@ export function ConnectionPanel({

{serverSlot} -
- {connected ? ( - - ) : ( -

- {canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'} -

- )} -
@@ -249,18 +249,19 @@ export function ConnectionPanel({ {isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
- {proxyKinds.map(([kind, label]) => ( - - ))} + {feedback && } + ; + })}
diff --git a/src/web/features/devices/DevicesFeature.tsx b/src/web/features/devices/DevicesFeature.tsx index 0641ef2..d9f930f 100644 --- a/src/web/features/devices/DevicesFeature.tsx +++ b/src/web/features/devices/DevicesFeature.tsx @@ -1,5 +1,9 @@ import { useEffect, useRef, useState } from 'react'; -import { formatByteString, formatLastSeen } from '../../utils/format.js'; +import { + formatByteString, + formatLastSeen, + trafficBytesPerSecond, +} from '../../utils/format.js'; import { TrafficChart } from './TrafficChart.js'; import { parseDeviceSnapshot, @@ -207,6 +211,18 @@ export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) { const globalTraffic = feature.snapshot?.traffic; + const history = globalTraffic?.history || []; + const latest = history.at(-1); + const previous = history.at(-2); + const hasDirectionalRate = latest && previous + && typeof latest.downloadBytes === 'string' + && typeof latest.uploadBytes === 'string'; + const downloadRate = hasDirectionalRate + ? trafficBytesPerSecond(latest.downloadBytes, previous.observedAt, latest.observedAt) + : null; + const uploadRate = hasDirectionalRate + ? trafficBytesPerSecond(latest.uploadBytes, previous.observedAt, latest.observedAt) + : null; const trafficSourceError = feature.snapshot?.source?.traffic?.error || feature.snapshot?.source?.traffic?.proxy?.error || (feature.status === 'error' ? feature.error : null); @@ -216,14 +232,21 @@ export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeatur return
- Учтено Harbor - {formatByteString(globalTraffic?.totalBytes || '0')} + + Учтено Harbor + {formatByteString(globalTraffic?.totalBytes || '0')} + + + ↓ {downloadRate === null ? '—' : `${formatByteString(downloadRate)}/с`} + ↑ {uploadRate === null ? '—' : `${formatByteString(uploadRate)}/с`} +
diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index 3ce6330..d41830b 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -75,7 +75,8 @@ 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 [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null); + const [copyFeedback, setCopyFeedback] = useState>({}); + const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null); const [pencilAnimationId, setPencilAnimationId] = useState(''); const [trafficDeltas, setTrafficDeltas] = useState>({}); const deviceNodes = useRef(new Map()); @@ -85,7 +86,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const movementAnimations = useRef(new Map()); const previousTraffic = useRef(new Map()); const aliasBaseline = useRef({ id: '', value: '' }); - const copyTimer = useRef | null>(null); + const copyTimers = useRef(new Map>()); + const copyAttempts = useRef(new Map()); const trafficDeltaTimer = useRef | null>(null); const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] }); const devices = useMemo( @@ -104,7 +106,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { ); useEffect(() => () => { - if (copyTimer.current) clearTimeout(copyTimer.current); + for (const timer of copyTimers.current.values()) clearTimeout(timer); + copyTimers.current.clear(); + copyAttempts.current.clear(); if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current); }, []); @@ -190,14 +194,36 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { async function copyDeviceIp(device: Device) { if (!device.ip) return; - if (copyTimer.current) clearTimeout(copyTimer.current); + const activeTimer = copyTimers.current.get(device.id); + if (activeTimer) clearTimeout(activeTimer); + const attempt = {}; + copyAttempts.current.set(device.id, attempt); + let feedback: { failed: boolean }; try { await copyText(device.ip); - setCopyFeedback({ id: device.id, failed: false }); + feedback = { failed: false }; } catch { - setCopyFeedback({ id: device.id, failed: true }); + feedback = { failed: true }; } - copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS); + if (copyAttempts.current.get(device.id) !== attempt) return; + const announcement = { + id: device.id, + message: feedback.failed ? `Не удалось скопировать IP ${device.ip}` : `IP ${device.ip} скопирован`, + }; + const pendingTimer = copyTimers.current.get(device.id); + if (pendingTimer) clearTimeout(pendingTimer); + setCopyFeedback((current) => ({ ...current, [device.id]: feedback })); + setCopyAnnouncement(announcement); + copyTimers.current.set(device.id, setTimeout(() => { + setCopyFeedback((current) => { + const next = { ...current }; + delete next[device.id]; + return next; + }); + setCopyAnnouncement((current) => current?.id === device.id ? null : current); + copyTimers.current.delete(device.id); + copyAttempts.current.delete(device.id); + }, COPY_FEEDBACK_MS)); } function startEditing(device: Device) { @@ -303,7 +329,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { )}
- {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''} + {copyAnnouncement?.message || ''}
{devices.map((device) => { @@ -320,7 +346,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString()); const hasProxyTraffic = proxyTotal > 0n; const trafficDelta = trafficDeltas[device.id] || {}; - const copied = copyFeedback?.id === device.id; + const feedback = copyFeedback[device.id]; + const copied = Boolean(feedback); const policyBusy = device.policyStatus === 'applying'; const policyFailed = device.policyStatus === 'failed'; const policyPending = device.policyStatus === 'pending'; @@ -392,7 +419,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { >{title}} {(hasName || (editing && Boolean(alias))) && device.ip && } {device.ip ?
} {block.note &&

{block.note}

} diff --git a/src/web/features/subscription/SubscriptionFeature.tsx b/src/web/features/subscription/SubscriptionFeature.tsx index 1a57037..09970d5 100644 --- a/src/web/features/subscription/SubscriptionFeature.tsx +++ b/src/web/features/subscription/SubscriptionFeature.tsx @@ -413,35 +413,40 @@ export function SubscriptionPanel({ feature, statusSlot, serverSlot }: Subscript inert={editing ? true : undefined} >
- Ваша подписка - - - Обновить подписку + +