From c430557dc093b4c0136cee6e171e231c29ef5b4d Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 17:42:06 +0300 Subject: [PATCH] Refine device panel layout and IP copy feedback --- README.md | 4 +- src/shared/versions.js | 4 +- src/web/components/ClientOverviewPage.jsx | 2 +- src/web/components/DevicesPanel.jsx | 109 +++++---- src/web/styles.css | 252 ++++++++++++++------- test/web/device-inventory-contract.test.js | 28 ++- test/web/rule-editor-contract.test.js | 2 + 7 files changed, 261 insertions(+), 140 deletions(-) diff --git a/README.md b/README.md index f95dfb0..81d4352 100644 --- a/README.md +++ b/README.md @@ -76,9 +76,9 @@ http://АДРЕС-GATEWAY:3456 ### Устройства Gateway -После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и компактно показывает IP, последний контакт, производителя из локальной OUI-базы и сохранённый интернет-трафик. В строке отдельно отмечаются ненулевые источники `Gateway` и `Прокси`; одно устройство может использовать оба, а подробное получено/отдано доступно при наведении или фокусе. Технический MAC хранится для идентификации и правил, но в обычной строке скрыт. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway. +После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway. -У однозначно распознанного устройства маршрут можно переключить между `VPN` и `Напрямую` независимо от закрепления. `VPN` означает обработку через sing-box и правила Gateway: например, включённое локальное доменное правило всё равно может выбрать прямой выход внутри sing-box. `Напрямую` полностью обходит sing-box на уровне iptables. Traffic totals учитываются в обоих режимах. Если правило не удалось применить, Harbor сохраняет выбранный режим и отдельно показывает последний фактически применённый маршрут. +У однозначно распознанного устройства маршрут можно переключить последней иконкой между `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 секунд; история по часам пока не хранится. diff --git a/src/shared/versions.js b/src/shared/versions.js index 5dde818..ff938ba 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.13.0', - gatewayClient: '0.14.0', + macClient: '0.13.1', + gatewayClient: '0.14.1', gatewayBackend: '0.14.0', }); diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 8c9a110..6c3ab9f 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1270,7 +1270,7 @@ export function ClientOverviewPage({ onClick={() => copyProxy(kind)} > {label} - {copyFeedback?.kind === kind && } + {copyFeedback?.kind === kind && } ))} diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 53edae6..4c45134 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -1,5 +1,6 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; +import { copyText } from '../utils/clientControls.js'; import { byteString, formatByteString, @@ -33,8 +34,10 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { const [refreshing, setRefreshing] = useState(false); const [refreshCycle, setRefreshCycle] = useState(0); const [sortDirection, setSortDirection] = useState('desc'); + const [copyFeedback, setCopyFeedback] = useState(null); const deviceNodes = useRef(new Map()); const previousPositions = useRef(new Map()); + const copyTimer = useRef(null); const devices = useMemo( () => sortDevicesByTraffic(snapshot?.devices, sortDirection), [snapshot?.devices, sortDirection], @@ -69,6 +72,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { return () => clearTimeout(timer); }, [open, refreshCycle, refreshing, status]); + useEffect(() => () => clearTimeout(copyTimer.current), []); + useLayoutEffect(() => { if (!open) { previousPositions.current.clear(); @@ -157,6 +162,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { } } + async function copyDeviceIp(device) { + if (!device.ip) return; + clearTimeout(copyTimer.current); + try { + await copyText(device.ip); + setCopyFeedback({ id: device.id, failed: false }); + } catch { + setCopyFeedback({ id: device.id, failed: true }); + } + copyTimer.current = setTimeout(() => setCopyFeedback(null), 800); + } + return (