import React, { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, } from 'react'; import { copyText, localProxyUrls, subscriptionDomain, } from '../utils/clientControls.js'; import { operationBlocked, type OperationKey, type OperationRegistrySnapshot, } from '../state/operations.js'; import { ConnectionPanel } from '../features/connection/index.js'; import { SubscriptionDeleteDialog, SubscriptionPanel, SubscriptionToggle, useSubscriptionFeature, } from '../features/subscription/index.js'; import { ServerPicker } from '../features/servers/index.js'; import { RoutingDiscardDialog, RoutingPanel, RoutingPendingStatus, RoutingToggle, useRoutingFeature, } from '../features/routing/index.js'; import { DevicesPanel, DevicesToggle, GatewayTrafficSummary, useDevicesFeature, } from '../features/devices/index.js'; import { ConnectivityDiagnosticsPanel, DiagnosticsToggle, useDiagnosticsFeature, } from '../features/diagnostics/index.js'; import { InstructionsPanel, InstructionsToggle, useInstructionsFeature, } from '../features/instructions/index.js'; import { HARBOR_VERSIONS, parseVersion, versionCompatibility, } from '../../shared/versions.js'; import type { ProfileSnapshot, RouteRule, StateSnapshot, } from '../../shared/contracts/state.js'; const VERSION_PARTS = [ ['major', 'Major'], ['minor', 'Minor'], ['hotfix', 'Hotfix'], ] as const; interface UiError { context?: string; profileId?: string; message?: string; correlationId?: string; retry?: (() => unknown) | null; } interface VersionBadgeProps { code: string; component: string; componentKey: string; version: unknown; runtime?: string | null; incompatible?: boolean; } interface ComponentActions { listDevices: () => Promise; refreshDevices: () => Promise; 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; } interface ClientViewState extends StateSnapshot { clientRuntime: { proxyPort: number; configured: boolean; gatewayAvailable: boolean; }; } interface ClientOverviewPageProps { actions: ComponentActions; state: ClientViewState; versionInfo: unknown; operations?: OperationRegistrySnapshot; error: UiError | null; onAddProfile: (label: string, url: string) => Promise; onSelectProfileServer: (profileId: string, serverId: string) => Promise; onRefreshProfile: (profileId: string) => Promise; onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise; onApply: (profileId: string, serverId: string) => Promise; onRestart: () => Promise; onStop: () => Promise; onSetGatewayAuto: (enabled: boolean) => Promise; onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise; onDismissError: () => void; } type CopyKind = 'gateway' | 'socks5' | 'http'; type CopyFeedback = { failed: boolean; cycle: number }; type CopyFeedbackMap = Partial>; type CopyAnnouncement = { text: string; cycle: number }; function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false, }: VersionBadgeProps) { const parsed = parseVersion(version); const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–']; function description(key: 'major' | 'minor' | 'hotfix') { if (key === 'major') { return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.'; } if (key === 'minor') { return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.'; } return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.'; } return (
{VERSION_PARTS.map(([key, label], index) => { const tooltipId = `harbor-version-${componentKey}-${key}`; return {index > 0 && } {values[index]} {component} · {label} {values[index]} {description(key)} {runtime && {runtime}} {incompatible && Версии Gateway несовместимы.} ; })}
); } function VersionDisplay({ isGateway, versionInfo }: { isGateway: boolean; versionInfo: unknown }) { const info = record(versionInfo); const runtime = record(info.runtime); const components = record(info.components); const runtimeSingBox = typeof runtime.singBox === 'string' ? runtime.singBox : null; if (!isGateway) { return ; } const backendVersion = typeof components.gatewayBackend === 'string' ? components.gatewayBackend : ''; const dataplaneVersion = typeof runtime.dataplaneVersion === 'string' ? runtime.dataplaneVersion : ''; const compatibility = backendVersion && versionCompatibility({ ...HARBOR_VERSIONS, gatewayBackend: backendVersion, }); const incompatible = Boolean(compatibility && !compatibility.compatible); return ; } function InlineError({ error, context }: { error?: UiError | null; context: string }) { if (!error || error.context !== context) return null; return (
{error.message} {error.retry && } {error.correlationId && ( Код: {error.correlationId.slice(0, 8)} )}
); } const operationProgress: Partial> = { connection: ['connection', 'Меняем состояние подключения…'], serverApply: ['connection', 'Применяем сервер…'], profileActivate: ['connection', 'Переключаем подписку…'], profileAdd: ['subscription', 'Добавляем подписку…'], profileRefresh: ['subscription', 'Обновляем подписку…'], profileDelete: ['subscription', 'Удаляем подписку…'], routeRules: ['routing', 'Применяем локальные правила…'], }; const canonicalOperationKeys: Record = { start: 'connection', stop: 'connection', 'apply-server': 'serverApply', 'profile-add': 'profileAdd', 'profile-rename': 'profileRename', 'profile-select-server': 'profileSelect', 'profile-activate': 'profileActivate', 'profile-refresh': 'profileRefresh', 'profile-delete': 'profileDelete', 'gateway-auto': 'gatewayAuto', 'route-rules': 'routeRules', 'subscription-import': 'profileAdd', 'subscription-refresh': 'profileRefresh', 'subscription-forget': 'profileDelete', }; function InlineProgress({ operations, context }: { operations: OperationRegistrySnapshot; context: string; }) { const active = (Object.entries(operationProgress) as Array<[ OperationKey, readonly [string, string], ]>).find(([key, [operationContext]]) => ( operationContext === context && operations[key]?.status === 'running' )); if (!active) return null; return (
{active[1][1]}
); } function AppliedIdentity({ identity, operation }: { identity: string; operation: string }) { const [current, setCurrent] = useState(identity); const [previous, setPrevious] = useState(''); useEffect(() => { if (identity === current) return undefined; setPrevious(current); setCurrent(identity); const timer = setTimeout(() => setPrevious(''), 360); return () => clearTimeout(timer); }, [identity]); return
{operation && {operation}}
; } function HarborBrand({ isGateway, connected, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: { isGateway: boolean; connected: boolean; gatewayAvailable: boolean; gatewayDirect: boolean; blocked: boolean; onSetGatewayAuto: (enabled: boolean) => unknown; }) { const [modeAnimating, setModeAnimating] = useState(false); const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0); const stopModeAnimationRef = useRef(false); const previousGatewayDirectRef = useRef(gatewayDirect); const product = isGateway ? 'Gateway' : 'Connect'; const switchable = !isGateway && gatewayAvailable; const label = gatewayDirect ? 'Игнорировать Harbor Gateway и использовать локальный VPN' : 'Использовать обнаруженный Harbor Gateway'; useLayoutEffect(() => { if (previousGatewayDirectRef.current === gatewayDirect) return; previousGatewayDirectRef.current = gatewayDirect; setArrowTurns((turns) => turns + 0.5); }, [gatewayDirect]); function startModeAnimation() { stopModeAnimationRef.current = false; setModeAnimating(true); } function finishModeAnimation() { if (matchMedia('(prefers-reduced-motion: reduce)').matches) { setModeAnimating(false); return; } stopModeAnimationRef.current = true; } const content =
Harbor {switchable ? {gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'} {gatewayDirect ? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.' : 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'} : {product}}
; return (
{switchable ? :
{content}
}
); } export function ClientOverviewPage({ actions, state, versionInfo, operations = {}, error, onAddProfile, onSelectProfileServer, onRefreshProfile, onForgetProfile, onApply, onRestart, onStop, onSetGatewayAuto, onSaveRouteRules, onDismissError, }: ClientOverviewPageProps) { const isGateway = state?.mode === 'gateway'; const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct'; const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable); const connected = state?.connection?.process === 'running'; const profiles = state?.profiles || []; const hasSubscription = profiles.length > 0; const desiredProfile = profiles.find(({ id }) => id === state?.selection?.desiredProfileId); const appliedProfile = profiles.find(({ id }) => id === state?.selection?.appliedProfileId); const selectedServerId = desiredProfile?.desiredServerId || ''; const appliedServerId = state?.selection?.appliedServerId || ''; const appliedServer = appliedProfile?.servers.find(({ id }) => id === appliedServerId) || (state?.selection?.appliedServerSnapshot?.id === appliedServerId ? state.selection.appliedServerSnapshot : null); const desiredServer = desiredProfile?.servers.find(({ id }) => id === selectedServerId); const showPower = hasSubscription; const [now, setNow] = useState(Date.now()); const [showIntro, setShowIntro] = useState(true); const [copyFeedback, setCopyFeedback] = useState({}); const [copyAnnouncement, setCopyAnnouncement] = useState({ text: '', cycle: 0 }); const copyTimersRef = useRef>>>({}); const copyAttemptsRef = useRef>>({}); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const controlHost = window.location.host || `${gatewayAddress}:3456`; const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress); const canonicalOperationKey = state.operation.status === 'running' ? canonicalOperationKeys[state.operation.kind || ''] : undefined; const canonicalTarget = state.operation.profileId ? `${state.operation.profileId}${state.operation.serverId ? `:${state.operation.serverId}` : ''}` : ''; const visibleOperations = canonicalOperationKey && !operations[canonicalOperationKey] ? { ...operations, [canonicalOperationKey]: { status: 'running' as const, startedAt: state.operation.startedAt || state.generatedAt, target: canonicalTarget, }, } : operations; const connectionBlocked = operationBlocked(visibleOperations, 'connection'); const serverApplyBlocked = operationBlocked(visibleOperations, 'serverApply'); const gatewayAutoBlocked = operationBlocked(visibleOperations, 'gatewayAuto'); const localApplyTarget = operations.serverApply?.target.split(':') || []; const canonicalSwitch = state.operation.status === 'running' && ['profile-activate', 'apply-server'].includes(state.operation.kind || ''); const operationProfileId = canonicalSwitch ? state.operation.profileId || '' : operations.profileActivate?.target || localApplyTarget[0] || ''; const operationProfile = profiles.find(({ id }) => id === operationProfileId); const operationServerId = canonicalSwitch ? state.operation.serverId || operationProfile?.desiredServerId || '' : localApplyTarget[1] || operationProfile?.desiredServerId || ''; const operationServer = operationProfile?.servers.find(({ id }) => id === operationServerId); const localSwitch = operations.profileActivate?.status === 'running' || operations.serverApply?.status === 'running'; const switchingServer = connected && !gatewayDirect && (canonicalSwitch || localSwitch) && Boolean(operationProfile && operationServer) && (operationProfile?.id !== appliedProfile?.id || operationServer?.id !== appliedServer?.id); const subscriptionFeature = useSubscriptionFeature({ profiles, selection: state.selection, connected, operations: visibleOperations, error, isGateway, gatewayDirect, onAdd: onAddProfile, onRefresh: onRefreshProfile, onForget: onForgetProfile, onDismissError, }); const routingFeature = useRoutingFeature({ route: state?.route, connected, operations: visibleOperations, onSave: onSaveRouteRules, onDismissError, }); const devicesFeature = useDevicesFeature({ isGateway, listDevices: actions.listDevices, refreshDevices: actions.refreshDevices, updateDevice: actions.updateDevice, setDevicePolicy: actions.setDevicePolicy, }); const diagnosticsFeature = useDiagnosticsFeature(); const instructionsFeature = useInstructionsFeature({ isGateway, host: gatewayAddress, port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082), controlHost, }); const diagnosticsAvailable = hasSubscription; useEffect(() => { setNow(Date.now()); if (!isGateway && (!connected || !state?.connection?.startedAt)) return undefined; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [isGateway, connected, state?.connection?.startedAt]); useEffect(() => { if (!showIntro) return undefined; const timer = setTimeout(() => setShowIntro(false), 1200); return () => clearTimeout(timer); }, [showIntro]); useEffect(() => { if (!hasSubscription) { routingFeature.forceClose(); instructionsFeature.close(); devicesFeature.close(); diagnosticsFeature.close(); } }, [hasSubscription, isGateway]); useEffect(() => { if (!diagnosticsAvailable) diagnosticsFeature.close(); }, [diagnosticsAvailable]); useEffect(() => () => { for (const timer of Object.values(copyTimersRef.current)) clearTimeout(timer); copyAttemptsRef.current = {}; }, []); function selectServer(profile: ProfileSnapshot, serverId: string) { if (connected && !gatewayDirect) { onApply(profile.id, serverId); return; } onSelectProfileServer(profile.id, serverId); } async function copyProxy(kind: CopyKind) { const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind]; const activeTimer = copyTimersRef.current[kind]; if (activeTimer) clearTimeout(activeTimer); const attempt = {}; copyAttemptsRef.current[kind] = attempt; let failed = false; try { await copyText(value); } catch { failed = true; } if (copyAttemptsRef.current[kind] !== attempt) return; const pendingTimer = copyTimersRef.current[kind]; if (pendingTimer) clearTimeout(pendingTimer); setCopyFeedback((current) => ({ ...current, [kind]: { failed, cycle: (current[kind]?.cycle || 0) + 1 }, })); setCopyAnnouncement((current) => ({ text: failed ? 'Не удалось скопировать' : 'Скопировано', cycle: current.cycle + 1, })); copyTimersRef.current[kind] = setTimeout(() => { setCopyFeedback((current) => { const next = { ...current }; delete next[kind]; return next; }); delete copyTimersRef.current[kind]; delete copyAttemptsRef.current[kind]; }, 800); } function openRouting() { subscriptionFeature.close(); instructionsFeature.close(); devicesFeature.close(); diagnosticsFeature.close(); routingFeature.open(); } const mainIdentity = gatewayDirect ? 'Gateway · сервер не определён' : connected ? appliedProfile && appliedServer ? `${subscriptionDomain(appliedProfile.subscription.host)} · ${appliedServer.label}` : 'VPN · сервер не определён' : desiredProfile && desiredServer ? `Выбран: ${subscriptionDomain(desiredProfile.subscription.host)} · ${desiredServer.label}` : 'Сервер не выбран'; const switchIdentity = gatewayDirect ? 'Данные применённого сервера Gateway недоступны' : switchingServer && operationProfile && operationServer ? `Переключаем на ${subscriptionDomain(operationProfile.subscription.host)} · ${operationServer.label}` : ''; return (
{copyAnnouncement.text}
{hasSubscription && }
} copyFeedback={copyFeedback} onCopyProxy={copyProxy} onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)} onStop={onStop} routingSlot={} serverSlot={} statusSlot={<> } /> {isGateway && hasSubscription && } } renderServerPicker={(profile, pickerState) => selectServer(profile, serverId)} />} />
{hasSubscription && } {isGateway && hasSubscription && } {diagnosticsAvailable && } {hasSubscription && } />}
); }