From 3b515ee355657ae494520ec67e9723fa13d7d353 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Wed, 19 Aug 2026 20:24:04 +0300 Subject: [PATCH] Improve failover startup handling and status UI --- .../features/connection/connectionService.ts | 11 ++- src/server/services/singboxSelectorService.ts | 23 ++++- src/shared/versions.ts | 6 +- src/web/components/ClientOverviewPage.tsx | 10 ++- src/web/features/failover/FailoverFeature.tsx | 86 +++++++++++++++---- src/web/styles/features/failover.css | 51 ++++++++--- test/server/connection-service.test.js | 4 +- test/server/singbox-selector-service.test.js | 19 ++++ test/web/failover-feature-contract.test.js | 11 ++- test/web/style-boundaries.test.js | 32 +++---- 10 files changed, 190 insertions(+), 63 deletions(-) create mode 100644 test/server/singbox-selector-service.test.js diff --git a/src/server/features/connection/connectionService.ts b/src/server/features/connection/connectionService.ts index 7923c21..5ecdc4d 100644 --- a/src/server/features/connection/connectionService.ts +++ b/src/server/features/connection/connectionService.ts @@ -86,6 +86,13 @@ function withDesiredServer(state: StoredState, profile: StoredProfile, serverId: } export function createConnectionService(dependencies: ConnectionServiceDependencies) { + const prepareFailoverActivation = async (role: 'primary' | 'reserve') => { + try { + await dependencies.failover?.prepareActivation(role); + } catch (cause) { + throw new HarborError('PROCESS_START_FAILED', { cause }); + } + }; const activationTarget = ( state: StoredState, applied: AppliedFailoverPolicy, @@ -158,7 +165,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc dependencies.config.write(nextConfig); runtimeMutationStarted = true; await dependencies.runtime.start(); - if (failoverCandidate) await dependencies.failover!.prepareActivation('primary'); + if (failoverCandidate) await prepareFailoverActivation('primary'); stateCommitStarted = true; dependencies.state.update((state) => ({ ...withDesiredServer(state, profile, selectedServer.id), @@ -320,7 +327,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc const command = await dependencies.runtime.restartCommand(); runtimeMutationStarted = command.mutationStarted; if (!command.ok) throw command.error; - if (failoverCandidate) await dependencies.failover!.prepareActivation(activationRole); + if (failoverCandidate) await prepareFailoverActivation(activationRole); stateCommitStarted = true; dependencies.state.update((state) => ({ ...state, diff --git a/src/server/services/singboxSelectorService.ts b/src/server/services/singboxSelectorService.ts index 7a00b23..62757e4 100644 --- a/src/server/services/singboxSelectorService.ts +++ b/src/server/services/singboxSelectorService.ts @@ -6,6 +6,25 @@ import { } from '../singbox.js'; type Role = 'primary' | 'reserve'; +const SELECTOR_READY_ATTEMPTS = 20; +const SELECTOR_READY_DELAY_MS = 100; + +const transientStartupError = (error: unknown) => ( + error && typeof error === 'object' && 'code' in error + ? ['ECONNREFUSED', 'ECONNRESET'].includes(String(error.code)) + : false +); + +async function whenReady(operation: () => Promise): Promise { + for (let attempt = 1; ; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (!transientStartupError(error) || attempt === SELECTOR_READY_ATTEMPTS) throw error; + await new Promise((resolve) => setTimeout(resolve, SELECTOR_READY_DELAY_MS)); + } + } +} function request(port: number, method: string, body?: unknown): Promise { return new Promise((resolve, reject) => { @@ -51,13 +70,13 @@ export function createSingboxSelectorService({ send?: (method: string, body?: unknown) => Promise; }) { async function read() { - const value = await send('GET') as Record; + const value = await whenReady(() => send('GET')) as Record; const role = roleFor(value.now); if (!role) throw new Error('Sing-box selector вернул неизвестный outbound'); return { role }; } async function select(role: Role) { - await send('PUT', { name: tagFor(role) }); + await whenReady(() => send('PUT', { name: tagFor(role) })); const selected = await read(); if (selected.role !== role) throw new Error('Sing-box selector не подтвердил переключение'); return selected; diff --git a/src/shared/versions.ts b/src/shared/versions.ts index ef77b08..9f89206 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.29.0', - gatewayClient: '0.30.0', - gatewayBackend: '0.30.0', + macClient: '0.29.1', + gatewayClient: '0.30.1', + gatewayBackend: '0.30.1', }); export interface ParsedVersion { diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index d153400..b48e70d 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -796,11 +796,13 @@ export function ClientOverviewPage({ ? `Переключаем на ${subscriptionDomain(operationProfile.subscription.host)} · ${operationServer.label}` : ''; const failoverIdentity = isGateway && state.failover.enabled - ? `${state.failover.currentRole === 'reserve' + ? state.failover.currentRole === 'other' + ? state.failover.reason === 'vpn-stopped' + ? 'Резерв включится при запуске VPN' + : 'Резерв применится после перезапуска VPN' + : `${state.failover.currentRole === 'reserve' ? 'Резервный канал' - : state.failover.currentRole === 'primary' - ? 'Основной канал' - : 'Текущий канал вне резервной пары'} · ${failoverReasonLabel(state.failover.reason)}` + : 'Основной канал'} · ${failoverReasonLabel(state.failover.reason)}` : ''; return ( diff --git a/src/web/features/failover/FailoverFeature.tsx b/src/web/features/failover/FailoverFeature.tsx index 340451c..e030806 100644 --- a/src/web/features/failover/FailoverFeature.tsx +++ b/src/web/features/failover/FailoverFeature.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { flushSync } from 'react-dom'; import { CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, type DiagnosticSettings } from '../../../shared/connectivityDiagnostics.js'; import { normalizeFailoverPolicy, type FailoverPolicy, type FailoverSnapshot } from '../../../shared/failover.js'; @@ -72,6 +72,14 @@ const milliseconds = (value: string, fallback: number) => { return Number.isFinite(parsed) ? Math.round(parsed * 1000) : fallback; }; +function pulseNumber(event: FormEvent) { + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return; + event.currentTarget.animate([ + { color: 'var(--client-accent)', textShadow: '0 0 10px color-mix(in oklch, var(--client-accent) 54%, transparent)' }, + { color: 'var(--client-text)', textShadow: '0 0 0 transparent' }, + ], { duration: 320, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }); +} + const reasonLabel = (reason: string | null) => ({ 'primary-healthy': 'Основной канал работает', 'health-unknown': 'Ожидаем результаты проверки', @@ -270,6 +278,8 @@ export function FailoverPanel({ const [serviceError, setServiceError] = useState(''); const [openPicker, setOpenPicker] = useState(''); const [removingCheckId, setRemovingCheckId] = useState(''); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [advancedClosing, setAdvancedClosing] = useState(false); useEffect(() => { if (!dirty) setDraft(snapshot.policy); }, [snapshot.policy, dirty]); @@ -374,6 +384,14 @@ export function FailoverPanel({ if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) finish(); else document.startViewTransition(finish); }; + const toggleAdvanced = () => { + if (!advancedOpen) { + setAdvancedOpen(true); + return; + } + if (matchMedia('(prefers-reduced-motion: reduce)').matches) setAdvancedOpen(false); + else setAdvancedClosing(true); + }; return + : health === 'healthy' ? 'Работает' : health === 'unhealthy' ? 'Недоступен' : health === 'not-monitoring' ? 'Не проверяется' : 'Ещё не проверен'; + return
- {channel === 'primary' ? 'Основной' : 'Резервный'} + {channel === 'primary' ? 'Основной' : 'Резервный'} {healthLabel}
updateChecks((checks) => checks.map((item) => item.serviceId === check.serviceId ? { ...item, timeoutMs: milliseconds(event.target.value, item.timeoutMs) } : item))} @@ -473,12 +498,17 @@ export function FailoverPanel({ aria-label={`Убрать проверку: ${service?.label || 'сервис недоступен'}`} disabled={blocked || !draft.enabled || draft.checks.length === 1 || Boolean(removingCheckId)} onClick={() => removeCheck(check.serviceId)} - >× + > + + ; })} {!addingService &&
- - - + + +
Переключение во время работы{draft.trafficGuard.enabled ? 'Ждать тишины перед автоматическим переключением' : 'Переключать сразу после подтверждённого сбоя'} update({ trafficGuard: { ...draft.trafficGuard, enabled } })} />
- - + +
-
Защита от повторных сбоев - - - - -
+
+