diff --git a/README.md b/README.md
index 81d4352..43c0f6d 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,11 @@ Harbor помогает пользоваться одной VPN-подписко
| **Harbor Gateway** | На отдельной Linux-машине | Проводит через VPN весь интернет-трафик домашних устройств или работает как общий HTTP/SOCKS5-прокси |
| **Harbor Connect** | На macOS | Даёт приложениям на Mac локальный HTTP/SOCKS5-прокси |
-В обоих режимах управление одинаковое: откройте веб-интерфейс, вставьте ссылку VPN-подписки, выберите сервер и нажмите кнопку подключения.
+В Harbor Connect подписка и выбор сервера остаются на основном экране. Harbor Gateway открывается как панель маршрутизации даже без подписки: Home, «Устройства» и «Диагностика» доступны сразу, а VPN-подписка настраивается отдельной верхней кнопкой в правой панели.
## Что понадобится
-- ссылка на подписку от VPN-провайдера;
+- ссылка на подписку от VPN-провайдера, если Harbor должен направлять трафик через VPN;
- Docker с командой `docker compose`;
- для ручной установки Gateway — Git;
- для Gateway — Linux-машина в одной локальной сети с устройствами;
@@ -60,12 +60,13 @@ http://АДРЕС-GATEWAY:3456
Например, если Linux-машина имеет адрес `192.168.1.20`, интерфейс будет доступен по адресу `http://192.168.1.20:3456`.
-### 4. Добавьте подписку
+### 4. При необходимости добавьте подписку
-1. Вставьте ссылку VPN-подписки.
-2. Нажмите «Сохранить подписку».
-3. Выберите сервер.
-4. Включите VPN.
+1. Нажмите «Подписка» — верхнюю кнопку в правой панели Gateway.
+2. Вставьте ссылку VPN-подписки.
+3. Нажмите «Сохранить подписку».
+4. Выберите сервер.
+5. Включите VPN.
После подключения Harbor покажет два варианта использования:
@@ -76,12 +77,14 @@ http://АДРЕС-GATEWAY:3456
### Устройства Gateway
-После добавления подписки откройте «Устройства» в правой панели Gateway. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `Gateway`/`Прокси`, затем иконку применённого маршрута. IP скрыт под названием: наведите или сфокусируйте название, чтобы увидеть адрес, и нажмите, чтобы скопировать его с feedback «Скопировано». Технические MAC, interface и manufacturer продолжают храниться для идентификации, но не занимают место в строке. Устройство можно переименовать и закрепить; закреплённые строки остаются наверху независимо от направления сортировки по трафику. Название, закрепление и накопленные totals сохраняются в volume Gateway.
+Откройте «Устройства» в правой панели Gateway — подписка для просмотра списка не требуется. Harbor раз в 15 секунд читает локальную таблицу соседей и показывает каждое устройство одной компактной строкой: название и последний контакт, два вертикальных счётчика `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-секундных интервалов после перезапуска начинает заполняться заново.
+
## Установка Harbor Connect на macOS
### 1. Запустите Docker Desktop
diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js
index daf4f0c..87ae312 100644
--- a/src/server/services/deviceInventoryService.js
+++ b/src/server/services/deviceInventoryService.js
@@ -5,7 +5,7 @@ import { HarborError } from '../../shared/errors.js';
import { isDeviceInterface } from '../adapters/neighbors.js';
import { fingerprintDirectDevices } from './devicePolicyService.js';
-export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
+export const DEVICE_INVENTORY_SCHEMA_VERSION = 3;
const ONLINE_MS = 2 * 60 * 1000;
const RECENT_MS = 24 * 60 * 60 * 1000;
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
@@ -47,6 +47,15 @@ const DEFAULT_PROXY_TRAFFIC = {
rebaselineMacs: [],
};
+const DEFAULT_GLOBAL_TRAFFIC_SOURCE = {
+ epoch: null,
+ lastObservedAt: null,
+ uploadBytes: '0',
+ downloadBytes: '0',
+ baselinesByMac: {},
+ rebaselineMacs: [],
+};
+
const DEFAULT_STATE = {
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
revision: 0,
@@ -62,6 +71,10 @@ const DEFAULT_STATE = {
totalsByMac: {},
rebaselineMacs: [],
proxy: DEFAULT_PROXY_TRAFFIC,
+ global: {
+ gateway: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
+ proxy: DEFAULT_GLOBAL_TRAFFIC_SOURCE,
+ },
},
devices: [],
};
@@ -77,6 +90,57 @@ const parseStoredCounter = (value) => {
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
};
+const sumStoredTotals = (totalsByMac, key) => recordEntries(totalsByMac)
+ .reduce((total, [, value]) => total + BigInt(value?.[key] || '0'), 0n)
+ .toString();
+
+function normalizeGlobalTrafficSource(value, fallback, version) {
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
+ const fallbackMacs = new Set([
+ ...Object.keys(fallback.baselinesByMac),
+ ...Object.keys(fallback.totalsByMac),
+ ...fallback.rebaselineMacs,
+ ]);
+ if (version < 3 || source !== value) {
+ return {
+ epoch: fallback.epoch,
+ lastObservedAt: fallback.lastObservedAt,
+ uploadBytes: sumStoredTotals(fallback.totalsByMac, 'uploadBytes'),
+ downloadBytes: sumStoredTotals(fallback.totalsByMac, 'downloadBytes'),
+ baselinesByMac: structuredClone(fallback.baselinesByMac),
+ rebaselineMacs: [...fallback.rebaselineMacs],
+ };
+ }
+ const rebaselineMacs = new Set((Array.isArray(source.rebaselineMacs) ? source.rebaselineMacs : [])
+ .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
+ const baselinesByMac = {};
+ let recovered = !source.baselinesByMac || typeof source.baselinesByMac !== 'object'
+ || Array.isArray(source.baselinesByMac);
+ for (const [rawMac, baseline] of recordEntries(source.baselinesByMac)) {
+ const mac = normalizeMac(rawMac);
+ const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
+ const downloadBytes = parseStoredCounter(baseline?.downloadBytes);
+ if (!MAC_PATTERN.test(mac) || typeof baseline?.epoch !== 'string' || !baseline.epoch
+ || uploadBytes == null || downloadBytes == null) {
+ if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
+ recovered = true;
+ continue;
+ }
+ baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
+ }
+ if (recovered) for (const mac of fallbackMacs) rebaselineMacs.add(mac);
+ return {
+ epoch: typeof source.epoch === 'string' ? source.epoch : fallback.epoch,
+ lastObservedAt: typeof source.lastObservedAt === 'string' ? source.lastObservedAt : fallback.lastObservedAt,
+ uploadBytes: parseStoredCounter(source.uploadBytes)
+ ?? sumStoredTotals(fallback.totalsByMac, 'uploadBytes'),
+ downloadBytes: parseStoredCounter(source.downloadBytes)
+ ?? sumStoredTotals(fallback.totalsByMac, 'downloadBytes'),
+ baselinesByMac,
+ rebaselineMacs: [...rebaselineMacs],
+ };
+}
+
function normalizeProxyTraffic(value, devices) {
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
@@ -264,6 +328,22 @@ export function migrateDeviceInventoryState(value) {
recoveredTraffic = true;
}
}
+ const global = {
+ gateway: normalizeGlobalTrafficSource(traffic.global?.gateway, {
+ epoch: typeof traffic.epoch === 'string' ? traffic.epoch : null,
+ lastObservedAt: typeof traffic.lastObservedAt === 'string' ? traffic.lastObservedAt : null,
+ baselinesByMac,
+ totalsByMac,
+ rebaselineMacs: [...rebaselineMacs],
+ }, version),
+ proxy: normalizeGlobalTrafficSource(traffic.global?.proxy, {
+ epoch: Object.values(proxyTraffic.baselinesByMac)[0]?.epoch || null,
+ lastObservedAt: proxyTraffic.lastObservedAt,
+ baselinesByMac: proxyTraffic.baselinesByMac,
+ totalsByMac: proxyTraffic.totalsByMac,
+ rebaselineMacs: proxyTraffic.rebaselineMacs,
+ }, version),
+ };
return {
...DEFAULT_STATE,
...state,
@@ -280,6 +360,7 @@ export function migrateDeviceInventoryState(value) {
totalsByMac,
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
proxy: proxyTraffic,
+ global,
},
devices,
};
@@ -292,6 +373,43 @@ function deviceStatus(lastSeenAt, now) {
return 'offline';
}
+function accumulateGlobalTraffic(source, countersByMac, epoch, observedAt, label) {
+ const epochChanged = Boolean(source.epoch && source.epoch !== epoch);
+ const baselinesByMac = epochChanged ? {} : { ...source.baselinesByMac };
+ const rebaselineMacs = new Set(epochChanged ? [] : source.rebaselineMacs);
+ let uploadBytes = BigInt(source.uploadBytes);
+ let downloadBytes = BigInt(source.downloadBytes);
+ for (const [mac, processTotal] of countersByMac) {
+ const baseline = baselinesByMac[mac];
+ const recovering = rebaselineMacs.has(mac);
+ const sameEpoch = !recovering && baseline?.epoch === epoch;
+ const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
+ const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
+ if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
+ throw new Error(`Dataplane ${label} traffic counter уменьшился внутри одного epoch`);
+ }
+ if (!recovering) {
+ uploadBytes += processTotal.upload - baselineUpload;
+ downloadBytes += processTotal.download - baselineDownload;
+ }
+ baselinesByMac[mac] = {
+ epoch,
+ uploadBytes: processTotal.upload.toString(),
+ downloadBytes: processTotal.download.toString(),
+ };
+ rebaselineMacs.delete(mac);
+ }
+ // ponytail: per-MAC baselines live for one dataplane epoch; use a dataplane-wide counter if MAC churn becomes large.
+ return {
+ epoch,
+ lastObservedAt: observedAt || source.lastObservedAt,
+ uploadBytes: uploadBytes.toString(),
+ downloadBytes: downloadBytes.toString(),
+ baselinesByMac,
+ rebaselineMacs: [...rebaselineMacs],
+ };
+}
+
export function createDeviceInventoryService({
store,
observe,
@@ -305,6 +423,8 @@ export function createDeviceInventoryService({
let policyQueue = Promise.resolve();
const trafficHistoryByMac = new Map();
const trafficCursorByMac = new Map();
+ let globalTrafficHistory = [];
+ let globalTrafficCursor = null;
function captureTrafficHistory(state) {
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
@@ -332,6 +452,20 @@ export function createDeviceInventoryService({
trafficHistoryByMac.delete(mac);
}
}
+ const gatewaySource = state.traffic.global.gateway;
+ const proxySource = state.traffic.global.proxy;
+ 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 };
+ 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',
+ }].slice(-TRAFFIC_HISTORY_LIMIT);
+ }
}
function serializePolicy(action) {
@@ -406,9 +540,29 @@ export function createDeviceInventoryService({
|| rank[left.status] - rank[right.status]
|| String(right.lastSeenAt).localeCompare(String(left.lastSeenAt))
));
+ const gatewayTraffic = state.traffic.global.gateway;
+ const proxyTraffic = state.traffic.global.proxy;
+ const gatewayBytes = BigInt(gatewayTraffic.uploadBytes) + BigInt(gatewayTraffic.downloadBytes);
+ const proxyBytes = BigInt(proxyTraffic.uploadBytes) + BigInt(proxyTraffic.downloadBytes);
+ const contributingTimes = [
+ gatewayBytes > 0n ? gatewayTraffic.lastObservedAt : null,
+ proxyBytes > 0n ? proxyTraffic.lastObservedAt : null,
+ ].filter(Boolean);
+ const observedTimes = contributingTimes.length
+ ? contributingTimes
+ : [gatewayTraffic.lastObservedAt, proxyTraffic.lastObservedAt].filter(Boolean);
return {
revision: state.revision,
trafficHistoryCapacity: TRAFFIC_HISTORY_LIMIT,
+ traffic: {
+ gatewayBytes: gatewayBytes.toString(),
+ proxyBytes: proxyBytes.toString(),
+ totalBytes: (gatewayBytes + proxyBytes).toString(),
+ gatewayObservedAt: gatewayTraffic.lastObservedAt,
+ proxyObservedAt: proxyTraffic.lastObservedAt,
+ observedAt: observedTimes.sort()[0] || null,
+ history: globalTrafficHistory,
+ },
source: {
kind: 'neighbor',
lastObservedAt: state.lastObservedAt,
@@ -678,6 +832,13 @@ export function createDeviceInventoryService({
};
rebaselineMacs.delete(mac);
}
+ const globalGateway = accumulateGlobalTraffic(
+ traffic.global.gateway,
+ processByMac,
+ trafficResult.epoch,
+ trafficResult.observedAt || traffic.lastObservedAt,
+ 'Gateway',
+ );
for (const mac of Object.keys(totalsByMac)) {
if (!knownMacs.has(mac)) {
delete totalsByMac[mac];
@@ -714,6 +875,7 @@ export function createDeviceInventoryService({
totalsByMac: proxyTotals,
rebaselineMacs: [...proxyRebaseline],
};
+ let globalProxy = traffic.global.proxy;
if (proxySampleError) {
proxy = { ...proxy, lastError: proxySampleError };
} else if (proxyRows) {
@@ -746,6 +908,13 @@ export function createDeviceInventoryService({
};
nextProxyRebaseline.delete(mac);
}
+ const nextGlobalProxy = accumulateGlobalTraffic(
+ traffic.global.proxy,
+ proxyByMac,
+ trafficResult.epoch,
+ trafficResult.observedAt || proxy.lastObservedAt,
+ 'proxy',
+ );
proxy = {
...proxy,
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
@@ -755,6 +924,7 @@ export function createDeviceInventoryService({
totalsByMac: nextProxyTotals,
rebaselineMacs: [...nextProxyRebaseline],
};
+ globalProxy = nextGlobalProxy;
} catch (error) {
proxy = { ...proxy, lastError: error.message || String(error) };
}
@@ -770,6 +940,7 @@ export function createDeviceInventoryService({
totalsByMac,
rebaselineMacs: [...rebaselineMacs],
proxy,
+ global: { gateway: globalGateway, proxy: globalProxy },
};
} catch (error) {
traffic = { ...traffic, lastError: error.message || String(error) };
diff --git a/src/shared/versions.js b/src/shared/versions.js
index a978a8a..1e93a52 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.17.16',
- gatewayClient: '0.18.16',
- gatewayBackend: '0.18.1',
+ macClient: '0.18.0',
+ gatewayClient: '0.19.0',
+ gatewayBackend: '0.19.0',
});
export function parseVersion(value) {
diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx
index 93e002e..f088127 100644
--- a/src/web/components/ClientOverviewPage.jsx
+++ b/src/web/components/ClientOverviewPage.jsx
@@ -11,13 +11,14 @@ import {
subscriptionDaysLeft,
subscriptionUsage,
} from '../utils/clientControls.js';
-import { formatBytes } from '../utils/format.js';
+import { formatBytes, formatByteString, formatLastSeen } from '../utils/format.js';
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 { TrafficChart } from './TrafficChart.jsx';
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
import { canAppendRouteRule } from '../../shared/routingRules.js';
import {
@@ -27,10 +28,11 @@ import {
} from '../../shared/versions.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
+const DEVICE_AUTO_REFRESH_MS = 15_000;
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
-function CloudTooltip({ children }) {
- return {children};
+function CloudTooltip({ children, id }) {
+ return {children};
}
const VERSION_PARTS = [
@@ -616,8 +618,12 @@ export function ClientOverviewPage({
const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription);
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
- const showPower = hasSubscription && Boolean(selectedServerId);
+ const appliedServerId = state?.selection?.appliedServerId || '';
+ const appliedServer = servers.find(({ id }) => id === appliedServerId);
+ const desiredServer = servers.find(({ id }) => id === selectedServerId);
+ const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const canStart = Boolean(selectedServerId || state?.configExists);
+ const powerUnavailable = isGateway && !connected && !canStart;
const [now, setNow] = useState(Date.now());
const [durationMode, setDurationMode] = useState(() => {
try {
@@ -641,9 +647,15 @@ export function ClientOverviewPage({
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [instructionsOpen, setInstructionsOpen] = useState(false);
+ const [subscriptionOpen, setSubscriptionOpen] = useState(false);
const [localRulesOpen, setLocalRulesOpen] = useState(false);
const [devicesOpen, setDevicesOpen] = useState(false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
+ const [deviceSnapshot, setDeviceSnapshot] = useState(null);
+ const [deviceStatus, setDeviceStatus] = useState('idle');
+ const [deviceError, setDeviceError] = useState(null);
+ const [devicesRefreshing, setDevicesRefreshing] = useState(false);
+ const [deviceRefreshCycle, setDeviceRefreshCycle] = useState(0);
const [localRulesDraft, setLocalRulesDraft] = useState([]);
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
@@ -656,6 +668,9 @@ export function ClientOverviewPage({
const instructionsPanelRef = useRef(null);
const instructionsToggleRef = useRef(null);
const instructionsCloseRef = useRef(null);
+ const subscriptionPanelRef = useRef(null);
+ const subscriptionToggleRef = useRef(null);
+ const subscriptionCloseRef = useRef(null);
const localRulesPanelRef = useRef(null);
const localRulesToggleRef = useRef(null);
const localRulesCloseRef = useRef(null);
@@ -666,7 +681,9 @@ export function ClientOverviewPage({
const diagnosticsToggleRef = useRef(null);
const diagnosticsCloseRef = useRef(null);
const localRulesBaselineRef = useRef('[]');
+ const confirmingDeleteRef = useRef(confirmingDelete);
const previousHasSubscriptionRef = useRef(hasSubscription);
+ confirmingDeleteRef.current = confirmingDelete;
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
const usage = subscriptionUsage(state?.userInfo);
@@ -724,14 +741,54 @@ export function ClientOverviewPage({
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
+ const globalTraffic = deviceSnapshot?.traffic;
+ const trafficSourceError = deviceSnapshot?.source?.traffic?.error
+ || deviceSnapshot?.source?.traffic?.proxy?.error
+ || (deviceStatus === 'error' ? deviceError : null);
+ const trafficFreshness = globalTraffic?.observedAt
+ ? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
+ : 'Нет данных';
+ const switchingServer = Boolean(
+ selectedServerId && selectedServerId !== appliedServerId && desiredServer,
+ );
+
+ async function loadDevices(quiet = false, discover = false) {
+ if (!isGateway) return;
+ if (!quiet) setDeviceStatus(deviceSnapshot ? 'refreshing' : 'loading');
+ setDevicesRefreshing(true);
+ try {
+ const next = await (discover ? api.devices.refresh() : api.devices.list());
+ setDeviceSnapshot((current) => !current || next.revision > current.revision ? next : current);
+ setDeviceError(null);
+ setDeviceStatus('ready');
+ } catch (requestError) {
+ setDeviceError(requestError);
+ setDeviceStatus('error');
+ } finally {
+ setDevicesRefreshing(false);
+ setDeviceRefreshCycle((cycle) => cycle + 1);
+ }
+ }
useEffect(() => {
setNow(Date.now());
- if (!connected || !state?.singboxStartedAt) return undefined;
+ if (!isGateway && (!connected || !state?.singboxStartedAt)) return undefined;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
- }, [connected, state?.singboxStartedAt]);
+ }, [isGateway, connected, state?.singboxStartedAt]);
+
+ useEffect(() => {
+ if (!isGateway) return undefined;
+ loadDevices();
+ return undefined;
+ }, [isGateway]);
+
+ useEffect(() => {
+ if (!isGateway || devicesRefreshing || deviceStatus === 'loading') return undefined;
+ const timer = setTimeout(() => loadDevices(true), DEVICE_AUTO_REFRESH_MS);
+ return () => clearTimeout(timer);
+ }, [isGateway, deviceRefreshCycle, devicesRefreshing, deviceStatus]);
useEffect(() => {
if (editingSubscription) subscriptionInputRef.current?.focus();
@@ -797,12 +854,14 @@ export function ClientOverviewPage({
useEffect(() => {
if (!hasSubscription) {
setEditingSubscription(true);
- setInstructionsOpen(false);
setLocalRulesOpen(false);
- setDevicesOpen(false);
- setDiagnosticsOpen(false);
+ if (!isGateway) {
+ setInstructionsOpen(false);
+ setDevicesOpen(false);
+ setDiagnosticsOpen(false);
+ }
}
- }, [hasSubscription]);
+ }, [hasSubscription, isGateway]);
useEffect(() => {
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
@@ -845,6 +904,29 @@ export function ClientOverviewPage({
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
+ useEffect(() => {
+ if (!subscriptionOpen) return undefined;
+ const frame = requestAnimationFrame(() => subscriptionCloseRef.current?.focus());
+ const closeSubscription = (event) => {
+ if (confirmingDeleteRef.current) return;
+ if (event.type === 'keydown' && event.key !== 'Escape') return;
+ if (event.type !== 'keydown' && (
+ subscriptionPanelRef.current?.contains(event.target) || subscriptionToggleRef.current?.contains(event.target)
+ )) return;
+ setSubscriptionOpen(false);
+ };
+ document.addEventListener('pointerdown', closeSubscription);
+ document.addEventListener('keydown', closeSubscription);
+ return () => {
+ cancelAnimationFrame(frame);
+ document.removeEventListener('pointerdown', closeSubscription);
+ document.removeEventListener('keydown', closeSubscription);
+ requestAnimationFrame(() => {
+ if (subscriptionPanelRef.current?.contains(document.activeElement)) subscriptionToggleRef.current?.focus();
+ });
+ };
+ }, [subscriptionOpen]);
+
useEffect(() => {
if (!instructionsOpen) return undefined;
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
@@ -1048,6 +1130,7 @@ export function ClientOverviewPage({
function openLocalRules() {
const rules = state?.route?.localRules || [];
+ setSubscriptionOpen(false);
setInstructionsOpen(false);
setDevicesOpen(false);
setDiagnosticsOpen(false);
@@ -1111,9 +1194,26 @@ export function ClientOverviewPage({
document.startViewTransition(update);
}
+ const powerButton = ;
+
return (
@@ -1126,7 +1226,27 @@ export function ClientOverviewPage({
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
- {hasSubscription && subscriptionContentReady &&