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.failed ? 'ОШИБКА' : 'ГОТОВО'}}
+ {copyFeedback?.kind === kind && {copyFeedback.failed ? 'Ошибка' : 'Скопировано'}}
))}
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 (