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 &&