From 416b2b294a409f863e79a38113677ebb363878e3 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Wed, 19 Aug 2026 13:38:19 +0300 Subject: [PATCH] Persist configurable connectivity diagnostics --- .../references/visual-language.md | 2 + .../connectivityDiagnosticsUseCase.ts | 40 +++++- .../routes/connectivityDiagnosticsRoute.ts | 21 ++- src/server/index.ts | 6 +- src/server/services/stateStore.ts | 2 +- src/shared/connectivityDiagnostics.ts | 67 +++++++++ src/shared/contracts/state.ts | 36 +++-- src/shared/versions.ts | 6 +- src/web/App.tsx | 5 + src/web/api/harborClient.ts | 12 +- src/web/components/ClientOverviewPage.tsx | 6 +- .../ConnectivityDiagnosticsPanel.tsx | 126 +++++++++------- src/web/state/operations.ts | 3 +- src/web/styles/features/diagnostics.css | 8 +- test/server/connectivity-diagnostics.test.js | 134 +++++++++++++----- test/server/state-contract.test.js | 4 +- test/server/state-store.test.js | 11 +- test/web/api-errors.test.js | 9 +- test/web/component-actions-contract.test.js | 3 +- test/web/diagnostics-feature-contract.test.js | 12 +- test/web/responsive-layout-contract.test.js | 5 +- test/web/style-boundaries.test.js | 20 +-- 22 files changed, 407 insertions(+), 131 deletions(-) diff --git a/.codex/skills/design-vpn-client-ui/references/visual-language.md b/.codex/skills/design-vpn-client-ui/references/visual-language.md index 5ad27e0..703a5cf 100644 --- a/.codex/skills/design-vpn-client-ui/references/visual-language.md +++ b/.codex/skills/design-vpn-client-ui/references/visual-language.md @@ -20,6 +20,7 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt - Reserve identical height for mutually exclusive content such as timer versus connection hint. - Give copy buttons fixed width. Overlay temporary feedback instead of replacing text in normal flow. - Align icons and labels in the same flex row. Do not position an icon by guessed absolute offsets. +- Give repeated row actions one fixed-width trailing slot aligned to the same edge. Reserve that slot when labels wrap or statuses change; never place the action at the end of intrinsic label text. - Preserve a generous invisible hit area around icon-only controls. - Center proxy address and protocol actions with the power column. - Treat one-pixel optical misalignment as a defect when controls sit beside uppercase labels. @@ -60,3 +61,4 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt - Show expiry as both date and remaining days, with correct Russian forms. - If there is no total, say `без лимита` and omit the progress bar. - Hide unavailable rows instead of showing empty placeholders or zeros that imply real measurements. +- Keep `not tested`, `running`, `success`, and `failed` as separate row states. A result from one row must not turn untouched sibling rows into failures. diff --git a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts index d2e1c48..6925680 100644 --- a/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts +++ b/src/server/features/diagnostics/connectivityDiagnosticsUseCase.ts @@ -1,4 +1,11 @@ -import type { HarborServer, StoredProfile } from '../../../shared/contracts/state.js'; +import { isDeepStrictEqual } from 'node:util'; + +import { + normalizeDiagnosticSettings, + type DiagnosticSettings, +} from '../../../shared/connectivityDiagnostics.js'; +import type { HarborServer, StoredProfile, StoredState } from '../../../shared/contracts/state.js'; +import { HarborError } from '../../../shared/errors.js'; interface DiagnosticState { desiredProfileId?: unknown; @@ -6,6 +13,8 @@ interface DiagnosticState { appliedServerId?: unknown; appliedServerSnapshot?: HarborServer | null; profiles?: StoredProfile[]; + revision?: number; + diagnostics?: DiagnosticSettings; } interface DiagnosticsResult extends Record { @@ -13,7 +22,10 @@ interface DiagnosticsResult extends Record { } interface ConnectivityDiagnosticsDependencies { - readState(): DiagnosticState; + state: { + read(): DiagnosticState; + update(mutator: (state: StoredState) => Record): StoredState; + }; runDiagnostics(services: unknown, target: unknown): Promise; } @@ -40,9 +52,11 @@ export function createConnectivityDiagnosticsUseCase( dependencies: ConnectivityDiagnosticsDependencies, ) { return { - async run(services: unknown, target: unknown) { - const selected = selectedServer(dependencies.readState()); + async run(target: unknown) { + const state = dependencies.state.read(); + const selected = selectedServer(state); const server = selected ? { id: selected.id, label: selected.label } : null; + const services = normalizeDiagnosticSettings(state.diagnostics).customServices; const result = diagnosticsResult(await dependencies.runDiagnostics(services, target)); return { ...result, @@ -52,6 +66,24 @@ export function createConnectivityDiagnosticsUseCase( }, }; }, + updateSettings(settings: unknown, expectedRevision: unknown) { + if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) { + throw new HarborError('REQUEST_INVALID'); + } + let diagnostics: DiagnosticSettings; + try { + const requested = settings && typeof settings === 'object' && !Array.isArray(settings) + ? settings as Record + : {}; + diagnostics = normalizeDiagnosticSettings({ ...requested, configured: true }, { strict: true }); + } catch (cause) { + throw new HarborError('REQUEST_INVALID', { cause }); + } + const current = dependencies.state.read(); + if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + if (isDeepStrictEqual(current.diagnostics, diagnostics)) return; + dependencies.state.update((state) => ({ ...state, diagnostics })); + }, }; } diff --git a/src/server/http/routes/connectivityDiagnosticsRoute.ts b/src/server/http/routes/connectivityDiagnosticsRoute.ts index 44a7ee4..42d38a6 100644 --- a/src/server/http/routes/connectivityDiagnosticsRoute.ts +++ b/src/server/http/routes/connectivityDiagnosticsRoute.ts @@ -4,8 +4,9 @@ import type { ConnectivityDiagnosticsUseCase } from '../../features/diagnostics/ import { sendJson } from '../response.js'; interface ConnectivityDiagnosticsRouteDependencies { - diagnostics: Pick; + diagnostics: Pick; readBody(req: IncomingMessage): Promise>; + sendState(res: ServerResponse): Promise; } export function createConnectivityDiagnosticsRoute( @@ -13,11 +14,19 @@ export function createConnectivityDiagnosticsRoute( ) { return { async handle(req: IncomingMessage, res: ServerResponse) { - if (req.method !== 'POST' || req.url !== '/api/diagnostics/connectivity') return false; - const { services = [], target = null } = await dependencies.readBody(req); - const result = await dependencies.diagnostics.run(services, target); - sendJson(res, 200, result); - return true; + if (req.url === '/api/diagnostics/connectivity' && req.method === 'POST') { + const { target = null } = await dependencies.readBody(req); + const result = await dependencies.diagnostics.run(target); + sendJson(res, 200, result); + return true; + } + if (req.url === '/api/diagnostics/settings' && req.method === 'PUT') { + const { settings, expectedRevision } = await dependencies.readBody(req); + dependencies.diagnostics.updateSettings(settings, expectedRevision); + await dependencies.sendState(res); + return true; + } + return false; }, }; } diff --git a/src/server/index.ts b/src/server/index.ts index 7a369ab..16d1ae8 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -381,7 +381,10 @@ const deviceInventoryRoute = createDeviceInventoryRoute({ }); const prometheusMetricsRoute = createPrometheusMetricsRoute({ deviceInventory }); const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({ - readState: () => normalizeStoredState(stateStore.read()), + state: { + read: () => normalizeStoredState(stateStore.read()), + update: updateStoredState, + }, runDiagnostics: async (services, target) => remoteDataplane ? requireRemoteRuntime().runConnectivityDiagnostics(services, target) : requireLocalConnectivityDiagnostics().run({ @@ -393,6 +396,7 @@ const connectivityDiagnostics = createConnectivityDiagnosticsUseCase({ const connectivityDiagnosticsRoute = createConnectivityDiagnosticsRoute({ diagnostics: connectivityDiagnostics, readBody, + sendState: (res) => stateRoute.send(res), }); const gatewayPresenceRoute = createGatewayPresenceRoute({ appMode: settings.appMode, diff --git a/src/server/services/stateStore.ts b/src/server/services/stateStore.ts index 794a8e1..7f64335 100644 --- a/src/server/services/stateStore.ts +++ b/src/server/services/stateStore.ts @@ -13,7 +13,7 @@ import { type NormalizedServer, } from '../../shared/serverIdentity.js'; -export const STATE_SCHEMA_VERSION = 6; +export const STATE_SCHEMA_VERSION = 7; export interface AtomicWriteOptions { beforeRename?: (temporaryPath: string, filePath: string) => void; diff --git a/src/shared/connectivityDiagnostics.ts b/src/shared/connectivityDiagnostics.ts index e425461..c2c2ec6 100644 --- a/src/shared/connectivityDiagnostics.ts +++ b/src/shared/connectivityDiagnostics.ts @@ -25,6 +25,73 @@ export const CONNECTIVITY_SITES = Object.freeze([ export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5; +export interface DiagnosticService { + id: string; + label: string; + url: string; +} + +export interface DiagnosticSettings { + configured: boolean; + customServices: DiagnosticService[]; + hiddenServiceIds: string[]; +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function diagnosticService(value: unknown): DiagnosticService | null { + const candidate = record(value); + const id = typeof candidate.id === 'string' ? candidate.id.trim() : ''; + const label = typeof candidate.label === 'string' ? candidate.label.trim() : ''; + if (!/^custom-[a-z0-9-]{1,80}$/i.test(id) || !label || label.length > 40) return null; + try { + const url = new URL(typeof candidate.url === 'string' ? candidate.url.trim() : ''); + if ( + url.protocol !== 'https:' + || url.username + || url.password + || (url.port && url.port !== '443') + ) return null; + return { id, label, url: url.href }; + } catch { + return null; + } +} + +export function normalizeDiagnosticSettings( + value: unknown, + { strict = false }: { strict?: boolean } = {}, +): DiagnosticSettings { + const candidate = record(value); + const requestedServices = Array.isArray(candidate.customServices) ? candidate.customServices : []; + const customServices = requestedServices + .map(diagnosticService) + .filter((service): service is DiagnosticService => Boolean(service)) + .filter((service, index, services) => services.findIndex(({ id }) => id === service.id) === index) + .slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES); + const builtInIds = new Set(CONNECTIVITY_SITES.map(({ id }) => id)); + const requestedHiddenIds = Array.isArray(candidate.hiddenServiceIds) ? candidate.hiddenServiceIds : []; + const hiddenServiceIds = requestedHiddenIds + .filter((id): id is string => typeof id === 'string' && builtInIds.has(id)) + .filter((id, index, ids) => ids.indexOf(id) === index); + if (strict && ( + typeof candidate.configured !== 'boolean' + || !Array.isArray(candidate.customServices) + || !Array.isArray(candidate.hiddenServiceIds) + || customServices.length !== requestedServices.length + || hiddenServiceIds.length !== requestedHiddenIds.length + )) throw new TypeError('Invalid diagnostic settings'); + return { + configured: candidate.configured === true, + customServices, + hiddenServiceIds, + }; +} + export interface ConnectivitySiteResult { id: string; label: string; diff --git a/src/shared/contracts/state.ts b/src/shared/contracts/state.ts index c56ec86..070c2ae 100644 --- a/src/shared/contracts/state.ts +++ b/src/shared/contracts/state.ts @@ -4,6 +4,10 @@ import { type RouteRuleOutbound, } from '../routingRules.js'; import { normalizeServers, resolveServerId } from '../serverIdentity.js'; +import { + normalizeDiagnosticSettings, + type DiagnosticSettings, +} from '../connectivityDiagnostics.js'; export type HarborMode = 'client' | 'gateway'; export type ConnectionState = 'running' | 'stopped'; @@ -78,6 +82,7 @@ export interface StateSnapshot { startedAt: string | null; lastError: string | null; }; + diagnostics: DiagnosticSettings; route: { rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION; mode: string; @@ -116,6 +121,7 @@ export interface PersistedState extends Record { routeRulesRevision: number; connectionDesired?: ConnectionState; gatewayAutoEnabled?: boolean; + diagnostics: DiagnosticSettings; } // Legacy fields are derived in memory for bounded callers during the v5 cutover. @@ -286,6 +292,7 @@ export function normalizeStoredState(value: unknown): StoredState { && Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0 ? state.routeRulesRevision : 0, + diagnostics: normalizeDiagnosticSettings(state.diagnostics), subscriptionUrl: selectedProfile?.subscriptionUrl || '', selectedServerId, selectedTag: selectedServer?.label || '', @@ -383,6 +390,7 @@ export function createStateSnapshot({ startedAt: dateOrNull(runtime?.startedAt), lastError: null, }, + diagnostics: stored.diagnostics, route: { rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION, mode: routeMode, @@ -418,18 +426,21 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { ? { ...rule, outbound: 'direct' } : rule ); - const candidate = rawCandidate?.route?.rulesContractVersion === undefined - && Array.isArray(rawCandidate?.route?.localRules) - && Array.isArray(rawCandidate?.route?.activeLocalRules) + const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined + ? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) } + : rawCandidate; + const candidate = candidateWithDiagnostics?.route?.rulesContractVersion === undefined + && Array.isArray(candidateWithDiagnostics?.route?.localRules) + && Array.isArray(candidateWithDiagnostics?.route?.activeLocalRules) ? { - ...rawCandidate, + ...candidateWithDiagnostics, route: { - ...rawCandidate.route, - localRules: rawCandidate.route.localRules.map(legacyRule), - activeLocalRules: rawCandidate.route.activeLocalRules.map(legacyRule), + ...candidateWithDiagnostics.route, + localRules: candidateWithDiagnostics.route.localRules.map(legacyRule), + activeLocalRules: candidateWithDiagnostics.route.activeLocalRules.map(legacyRule), }, } - : rawCandidate; + : candidateWithDiagnostics; const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value)); const nullableDate = (value: unknown) => value === null || validDate(value); const nullableString = (value: unknown) => value === null || typeof value === 'string'; @@ -467,6 +478,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { typeof profile.desiredServerId === 'string' && Array.isArray(profile.servers) && profile.servers.every(validServer) ); + const validDiagnostics = (diagnostics: DiagnosticSettings) => { + try { + normalizeDiagnosticSettings(diagnostics, { strict: true }); + return true; + } catch { + return false; + } + }; if ( !snapshot || @@ -496,6 +515,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot { !CONNECTION_STATES.has(candidate.connection.process) || !nullableDate(candidate.connection.startedAt) || !nullableString(candidate.connection.lastError) || + !validDiagnostics(candidate.diagnostics) || !candidate.route || ![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) || typeof candidate.route.mode !== 'string' || diff --git a/src/shared/versions.ts b/src/shared/versions.ts index bbfe9b7..8e80f7e 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.26.7', - gatewayClient: '0.27.7', - gatewayBackend: '0.27.0', + macClient: '0.27.0', + gatewayClient: '0.28.0', + gatewayBackend: '0.28.0', }); export interface ParsedVersion { diff --git a/src/web/App.tsx b/src/web/App.tsx index 5dee45a..7e28ab8 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -299,6 +299,11 @@ export function App() { () => api.routeRules.update(rules, expectedRevision), 'routing', )} + onUpdateDiagnosticsSettings={(settings: unknown) => run( + 'diagnosticsSettings', + () => api.diagnostics.updateSettings(settings, revisionRef.current), + 'diagnostics', + )} onDismissError={() => { setError(null); setDismissedCanonicalError(canonicalErrorId); diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index c39c6b5..3d451ea 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -177,11 +177,18 @@ export const api = { ), }, diagnostics: { - connectivity: (services: unknown[] = [], target: unknown = null) => request( + connectivity: (target: unknown = null) => request( '/api/diagnostics/connectivity', { method: 'POST', - body: JSON.stringify({ services, target }), + body: JSON.stringify({ target }), + }, + ), + updateSettings: (settings: unknown, expectedRevision: number) => request( + '/api/diagnostics/settings', + { + method: 'PUT', + body: JSON.stringify({ settings, expectedRevision }), }, ), }, @@ -229,6 +236,7 @@ export function parseHarborState(value: unknown): HarborClientState { subscription: snapshot.subscription, selection: snapshot.selection, connection: snapshot.connection, + diagnostics: snapshot.diagnostics, route: snapshot.route, operation: snapshot.operation, servers: snapshot.servers, diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index 836f8f6..fe2ffac 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -92,7 +92,7 @@ interface ComponentActions { updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise; pingServers: (profileId: string, ids: string[]) => Promise; - runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise; + runConnectivityDiagnostics: (target?: unknown) => Promise; } interface ClientViewState extends StateSnapshot { @@ -118,6 +118,7 @@ interface ClientOverviewPageProps { onStop: () => Promise; onSetGatewayAuto: (enabled: boolean) => Promise; onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise; + onUpdateDiagnosticsSettings: (settings: unknown) => Promise; onDismissError: () => void; } @@ -423,6 +424,7 @@ export function ClientOverviewPage({ onStop, onSetGatewayAuto, onSaveRouteRules, + onUpdateDiagnosticsSettings, onDismissError, }: ClientOverviewPageProps) { const isGateway = state?.mode === 'gateway'; @@ -843,6 +845,8 @@ export function ClientOverviewPage({ {diagnosticsAvailable && } diff --git a/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx b/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx index f752afd..02b474c 100644 --- a/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx +++ b/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx @@ -13,6 +13,8 @@ import { CONNECTIVITY_NETWORK_SOURCE, CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, + type DiagnosticService, + type DiagnosticSettings, } from '../../../shared/connectivityDiagnostics.js'; import { parseConnectivityResult, @@ -25,12 +27,6 @@ import type { DiagnosticsFeature } from './DiagnosticsFeature.js'; const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'; const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'; -interface DiagnosticService extends Record { - id: string; - label: string; - url: string; -} - interface IpSourceDefinition { id: string; label: string; @@ -38,10 +34,8 @@ interface IpSourceDefinition { } type StatusValue = [className: string, label: string]; -type RunConnectivityDiagnostics = ( - services: DiagnosticService[], - target: string, -) => Promise; +type RunConnectivityDiagnostics = (target: string) => Promise; +type UpdateSettings = (settings: Pick) => Promise; function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); @@ -88,6 +82,15 @@ function readHiddenServices(): string[] { } } +function clearLegacyServices() { + try { + localStorage.removeItem(CUSTOM_SERVICES_KEY); + localStorage.removeItem(HIDDEN_SERVICES_KEY); + } catch { + // Browser storage is migration-only; canonical settings already live on the backend. + } +} + function resultStatus( site: DiagnosticSiteResult | undefined, pending: boolean, @@ -159,6 +162,7 @@ function IpCell({ if (path?.available === false) return ; if (pending) return ; if (!path?.available) return ; + if (!value) return ; if (!value?.address) return ; return {value.address}; } @@ -176,6 +180,7 @@ function NetworkCell({ if (path?.available === false) status = ['is-muted', '—']; else if (pending) status = ['is-running', 'Тестируем']; else if (!path?.available) status = ['is-muted', '—']; + else if (path.network == null) status = ['is-muted', '—']; const identity = [path?.network?.asn, path?.network?.provider].filter(Boolean).join(' · '); const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', '); if (!status && !identity && !location) status = ['is-error', 'Нет ответа']; @@ -230,20 +235,23 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity export function ConnectivityDiagnosticsPanel({ feature, runConnectivityDiagnostics, + settings, + updateSettings, isGateway, }: { feature: DiagnosticsFeature; runConnectivityDiagnostics: RunConnectivityDiagnostics; + settings: DiagnosticSettings; + updateSettings: UpdateSettings; isGateway: boolean; }) { const [result, setResult] = useState(null); const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle'); const [activeTarget, setActiveTarget] = useState(null); const [error, setError] = useState(null); - const [customServices, setCustomServices] = useState(readCustomServices); - const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices); const [adding, setAdding] = useState(false); const [removingServiceId, setRemovingServiceId] = useState(''); + const [settingsSaving, setSettingsSaving] = useState(false); const [serviceName, setServiceName] = useState(''); const [serviceUrl, setServiceUrl] = useState(''); const [formError, setFormError] = useState(''); @@ -251,23 +259,25 @@ export function ConnectivityDiagnosticsPanel({ const runnerRef = useRef(null); const previousTargetRef = useRef(null); const retryTargetRef = useRef(undefined); + const migrationAttemptedRef = useRef(false); const requestError = requestDetails(error); + const { customServices, hiddenServiceIds } = settings; useEffect(() => { - try { - localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices)); - } catch { - // The service still works for this session when browser storage is unavailable. + if (settings.configured) { + clearLegacyServices(); + return; } - }, [customServices]); - - useEffect(() => { - try { - localStorage.setItem(HIDDEN_SERVICES_KEY, JSON.stringify(hiddenServiceIds)); - } catch { - // The service list still works for this session when browser storage is unavailable. - } - }, [hiddenServiceIds]); + if (migrationAttemptedRef.current) return; + migrationAttemptedRef.current = true; + void updateSettings({ + customServices: readCustomServices(), + hiddenServiceIds: readHiddenServices(), + }).then((saved) => { + if (saved === false) return; + clearLegacyServices(); + }); + }, [settings.configured, updateSettings]); useLayoutEffect(() => { const sheet = sheetRef.current; @@ -306,7 +316,7 @@ export function ConnectivityDiagnosticsPanel({ ]; for (const target of targets) { setActiveTarget(target); - const partial = parseConnectivityResult(await runConnectivityDiagnostics(customServices, target)); + const partial = parseConnectivityResult(await runConnectivityDiagnostics(target)); const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1; next = legacyFullResult ? partial : mergeResult(next, partial); setResult(next); @@ -321,17 +331,22 @@ export function ConnectivityDiagnosticsPanel({ } } - function addService(event: FormEvent) { + async function addService(event: FormEvent) { event.preventDefault(); try { if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return; const parsed = new URL(serviceUrl.trim()); if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.'); - setCustomServices((services) => [...services, { + setSettingsSaving(true); + const saved = await updateSettings({ + customServices: [...customServices, { id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`, label: serviceName.trim() || parsed.hostname, url: parsed.href, - }]); + }], + hiddenServiceIds, + }); + if (saved === false) throw new Error('Не удалось сохранить сервис.'); setServiceName(''); setServiceUrl(''); setFormError(''); @@ -342,37 +357,52 @@ export function ConnectivityDiagnosticsPanel({ ? Reflect.get(validationError, 'message') : undefined; setFormError(typeof message === 'string' ? message : 'Проверьте адрес.'); + } finally { + setSettingsSaving(false); } } function removeService(serviceId: string) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) { - finishRemoveService(serviceId); + void finishRemoveService(serviceId); return; } setRemovingServiceId(serviceId); } - function finishRemoveService(serviceId: string) { - const update = () => flushSync(() => { + async function finishRemoveService(serviceId: string) { + const update = async () => { if (serviceId === 'draft') { - setAdding(false); - setServiceName(''); - setServiceUrl(''); - setFormError(''); - } else if (serviceId.startsWith('custom-')) { - setCustomServices((services) => services.filter((service) => service.id !== serviceId)); - } else { - setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]); + flushSync(() => { + setAdding(false); + setServiceName(''); + setServiceUrl(''); + setFormError(''); + setRemovingServiceId(''); + }); + return; } - setRemovingServiceId(''); - setResult(null); - }); + setSettingsSaving(true); + const saved = await updateSettings({ + customServices: serviceId.startsWith('custom-') + ? customServices.filter((service) => service.id !== serviceId) + : customServices, + hiddenServiceIds: serviceId.startsWith('custom-') + ? hiddenServiceIds + : [...new Set([...hiddenServiceIds, serviceId])], + }); + flushSync(() => { + setRemovingServiceId(''); + setSettingsSaving(false); + if (saved === false) setFormError('Не удалось сохранить список сервисов.'); + else setResult(null); + }); + }; if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) { - update(); + await update(); return; } - document.startViewTransition(update); + await document.startViewTransition(update).finished; } const pending = status === 'running'; @@ -380,7 +410,7 @@ export function ConnectivityDiagnosticsPanel({ ...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)), ...customServices, ]; - const serviceEditorBlocked = pending || Boolean(removingServiceId); + const serviceEditorBlocked = pending || settingsSaving || Boolean(removingServiceId); const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : ''); const { isOpen: open, panelRef, closeRef, close: onClose } = feature; @@ -527,7 +557,7 @@ export function ConnectivityDiagnosticsPanel({