import { useEffect, useLayoutEffect, useRef, useState, type FormEvent, } from 'react'; import { flushSync } from 'react-dom'; import { Drawer } from '../../ui/Drawer.js'; import { Tooltip } from '../../ui/Tooltip.js'; import { CONNECTIVITY_IP_SOURCES, CONNECTIVITY_NETWORK_SOURCE, CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, } from '../../../shared/connectivityDiagnostics.js'; import { parseConnectivityResult, type ConnectivityResult, type DiagnosticPath, type DiagnosticSiteResult, } from './connectivityResult.js'; 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; family: number; } type StatusValue = [className: string, label: string]; type RunConnectivityDiagnostics = ( services: DiagnosticService[], target: string, ) => Promise; function record(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function validCustomService(value: unknown): value is DiagnosticService { return record(value) && typeof Reflect.get(value, 'id') === 'string' && String(Reflect.get(value, 'id')).startsWith('custom-') && typeof Reflect.get(value, 'label') === 'string' && typeof Reflect.get(value, 'url') === 'string'; } function requestDetails(value: unknown) { if (!record(value)) return { message: undefined, retryable: false }; const message = Reflect.get(value, 'message'); return { message: typeof message === 'string' ? message : undefined, retryable: Boolean(Reflect.get(value, 'retryable')), }; } function readCustomServices(): DiagnosticService[] { try { const value: unknown = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]'); return Array.isArray(value) ? value.filter(validCustomService).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : []; } catch { return []; } } function readHiddenServices(): string[] { try { const value: unknown = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]'); return Array.isArray(value) ? value.filter((id): id is string => ( typeof id === 'string' && CONNECTIVITY_SITES.some((service) => service.id === id) )) : []; } catch { return []; } } function resultStatus( site: DiagnosticSiteResult | undefined, pending: boolean, available = true, ): StatusValue { if (!available) return ['is-muted', '—']; if (pending) return ['is-running', 'Тестируем']; if (!site) return ['is-muted', '—']; if (site.status === 'unavailable') return ['is-error', 'Нет доступа']; if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`]; return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`]; } function Status({ value, route }: { value: StatusValue; route: string }) { const [className, label] = value; return {label} {className === 'is-running' && } ; } function RowRefresh({ label, running, disabled, onRun, }: { label: string; running: boolean; disabled: boolean; onRun: () => void; }) { return Проверить только эту строку ; } function ipResult(path: DiagnosticPath | undefined, source: IpSourceDefinition) { if (!path?.available) return null; return source.family === 6 ? path.ipv6Source : path.ipv4?.sources?.find((item) => item.source === source.id); } function IpCell({ path, source, pending, route, }: { path: DiagnosticPath | undefined; source: IpSourceDefinition; pending: boolean; route: string; }) { const value = ipResult(path, source); if (path?.available === false) return ; if (pending) return ; if (!path?.available) return ; if (!value?.address) return ; return {value.address}; } function NetworkCell({ path, pending, route, }: { path: DiagnosticPath | undefined; pending: boolean; route: string; }) { let status: StatusValue | null = null; if (path?.available === false) status = ['is-muted', '—']; else if (pending) status = ['is-running', 'Тестируем']; else if (!path?.available) 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', 'Нет ответа']; if (status) return <>
; return {identity || location}
{identity && location ? location : <> }
; } function mergeItems(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) { const merged = [...previous]; for (const item of incoming) { const index = merged.findIndex((value) => key(value) === key(item)); if (index >= 0) merged[index] = item; else merged.push(item); } return merged; } function mergePath(previous: DiagnosticPath | undefined, incoming: DiagnosticPath): DiagnosticPath { const sources = mergeItems(previous?.ipv4.sources, incoming.ipv4.sources, ({ source }) => source); const sites = mergeItems(previous?.sites, incoming.sites, ({ id }) => id); const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null; const ipv6 = ipv6Source?.address || null; const addresses = [...new Set(sources .map(({ address }) => address) .filter((address): address is string => Boolean(address)))]; return { ...previous, ...incoming, internetAvailable: Boolean( addresses.length || ipv6 || sites.some(({ status }) => status !== 'unavailable'), ), ipv4: { addresses, sources }, ipv6, ipv6Source, network: incoming.available === false ? null : incoming.network ?? previous?.network ?? null, sites, }; } function mergeResult(previous: ConnectivityResult | null, incoming: ConnectivityResult): ConnectivityResult { const direct = mergePath(previous?.direct, incoming.direct); const vpn = { ...mergePath(previous?.vpn, incoming.vpn), server: incoming.vpn.server }; return { ...incoming, direct, vpn }; } export function ConnectivityDiagnosticsPanel({ feature, runConnectivityDiagnostics, isGateway, }: { feature: DiagnosticsFeature; runConnectivityDiagnostics: RunConnectivityDiagnostics; 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 [serviceName, setServiceName] = useState(''); const [serviceUrl, setServiceUrl] = useState(''); const [formError, setFormError] = useState(''); const sheetRef = useRef(null); const runnerRef = useRef(null); const previousTargetRef = useRef(null); const retryTargetRef = useRef(undefined); const requestError = requestDetails(error); useEffect(() => { try { localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices)); } catch { // The service still works for this session when browser storage is unavailable. } }, [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]); useLayoutEffect(() => { const sheet = sheetRef.current; const runner = runnerRef.current; if (!sheet || !runner) return; if (!activeTarget) { runner.classList.remove('is-visible', 'is-moving'); previousTargetRef.current = null; return; } const row = [...sheet.querySelectorAll('[data-diagnostic-target]')] .find((item) => item.dataset.diagnosticTarget === activeTarget); if (!row) return; const rowRect = row.getBoundingClientRect(); const sheetRect = sheet.getBoundingClientRect(); runner.style.setProperty('--diagnostics-runner-x', `${rowRect.left - sheetRect.left}px`); runner.style.setProperty('--diagnostics-runner-y', `${rowRect.top - sheetRect.top}px`); runner.style.width = `${rowRect.width}px`; runner.style.height = `${rowRect.height}px`; runner.classList.toggle('is-moving', previousTargetRef.current !== null); runner.classList.add('is-visible'); previousTargetRef.current = activeTarget; }, [activeTarget]); async function run(onlyTarget?: string) { retryTargetRef.current = onlyTarget; setStatus('running'); setError(null); try { let next = result; const targets = onlyTarget ? [onlyTarget] : [ CONNECTIVITY_NETWORK_SOURCE.id, ...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`), ...sites.map(({ id }) => `site:${id}`), ]; for (const target of targets) { setActiveTarget(target); const partial = parseConnectivityResult(await runConnectivityDiagnostics(customServices, target)); const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1; next = legacyFullResult ? partial : mergeResult(next, partial); setResult(next); if (legacyFullResult) break; } setStatus('ready'); } catch (requestError) { setError(requestError); setStatus('error'); } finally { setActiveTarget(null); } } 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, { id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`, label: serviceName.trim() || parsed.hostname, url: parsed.href, }]); setServiceName(''); setServiceUrl(''); setFormError(''); setAdding(false); setResult(null); } catch (validationError) { const message = validationError && typeof validationError === 'object' && !Array.isArray(validationError) ? Reflect.get(validationError, 'message') : undefined; setFormError(typeof message === 'string' ? message : 'Проверьте адрес.'); } } function removeService(serviceId: string) { if (matchMedia('(prefers-reduced-motion: reduce)').matches) { finishRemoveService(serviceId); return; } setRemovingServiceId(serviceId); } function finishRemoveService(serviceId: string) { const update = () => flushSync(() => { 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])]); } setRemovingServiceId(''); setResult(null); }); if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) { update(); return; } document.startViewTransition(update); } const pending = status === 'running'; const sites = [ ...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)), ...customServices, ]; const serviceEditorBlocked = pending || Boolean(removingServiceId); const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : ''); const { isOpen: open, panelRef, closeRef, close: onClose } = feature; return ( ); }