Files
harbor-net/src/web/components/ClientOverviewPage.tsx
T

1000 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import { flushSync } from 'react-dom';
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 {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
} from '../features/traffic/index.js';
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
import {
ActivityJournalPanel,
ActivityJournalToggle,
useActivityJournalFeature,
} from '../features/activity-journal/index.js';
import type { FailoverPolicy } from '../../shared/failover.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;
const DRAWER_SWITCH_MS = 620;
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
type DrawerKey = typeof DRAWER_ORDER[number];
const failoverReasonLabel = (reason: string | null) => ({
'primary-healthy': 'основной работает',
'health-unknown': 'ожидаем проверку',
'failure-window': 'подтверждаем сбой',
'reserve-not-healthy': 'резерв не подтверждён',
'both-unhealthy': 'оба канала недоступны',
'primary-not-recovered': 'основной восстанавливается',
'recovery-hold': 'проверяем стабильность',
'activity-unknown': 'активность неизвестна',
'active-traffic': 'ждём завершения работы',
'quiet-window': 'проверяем тишину',
'primary-failed': 'основной недоступен',
'primary-recovered': 'основной восстановился',
'pending-activation': 'изменения ожидают запуска',
'vpn-stopped': 'VPN выключен',
paused: 'автоматика на паузе',
disabled: 'резерв выключен',
'switch-failed': 'не удалось переключить',
'selector-unknown': 'текущий канал неизвестен',
'reconcile-failed': 'мониторинг временно недоступен',
'revalidation-required': 'условия проверяются заново',
'manual-check': 'оба канала проверены',
}[reason || ''] || 'наблюдение');
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<unknown>;
refreshDevices: () => Promise<unknown>;
resetDeviceTraffic: (expectedRevision: number) => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
createDeviceTag: (name: string, expectedRevision: number) => Promise<unknown>;
renameDeviceTag: (id: string, name: string, expectedRevision: number) => Promise<unknown>;
deleteDeviceTag: (id: string, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
}
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<unknown>;
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
onRefreshProfile: (profileId: string) => Promise<unknown>;
onForgetProfile: (profileId: string, mode: 'delete' | 'stop-and-delete') => Promise<unknown>;
onApply: (profileId: string, serverId: string) => Promise<unknown>;
onRestart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
onPauseFailover: (paused: boolean) => Promise<unknown>;
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
onCheckFailover: () => Promise<unknown>;
onDismissError: () => void;
}
type CopyKind = 'gateway' | 'socks5' | 'http';
type CopyFeedback = { failed: boolean; cycle: number };
type CopyFeedbackMap = Partial<Record<CopyKind, CopyFeedback>>;
type CopyAnnouncement = { text: string; cycle: number };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
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 (
<div className={`harbor-version${incompatible ? ' is-incompatible' : ''}`}>
<span className="harbor-version-code" aria-hidden="true">{code}</span>
<span className="harbor-version-number" aria-label={`${component} ${version || 'не определена'}`}>
{VERSION_PARTS.map(([key, label], index) => {
const tooltipId = `harbor-version-${componentKey}-${key}`;
return <React.Fragment key={key}>
{index > 0 && <span className="harbor-version-dot" aria-hidden="true">.</span>}
<span
className="harbor-version-part"
tabIndex={0}
aria-describedby={tooltipId}
>
{values[index]}
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
<strong>{component} · {label} {values[index]}</strong>
<span>{description(key)}</span>
{runtime && <small>{runtime}</small>}
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
</span>
</span>
</React.Fragment>;
})}
</span>
</div>
);
}
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 <aside className="harbor-versions" aria-label="Версия Harbor">
<VersionBadge
code="M"
component="Mac client"
componentKey="macClient"
version={typeof components.macClient === 'string' ? components.macClient : HARBOR_VERSIONS.macClient}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
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 <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
<VersionBadge
code="C"
component="Gateway client UI"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway control backend"
componentKey="gatewayBackend"
version={backendVersion}
incompatible={incompatible}
/>
<VersionBadge
code="D"
component="Gateway dataplane"
componentKey="gatewayDataplane"
version={dataplaneVersion}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
function InlineError({ error, context }: { error?: UiError | null; context: string }) {
if (!error || error.context !== context) return null;
return (
<div className={`client-inline-error is-${context}`} role="alert">
<span>{error.message}</span>
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
{error.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)}
</div>
);
}
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
profileActivate: ['connection', 'Переключаем подписку…'],
profileAdd: ['subscription', 'Добавляем подписку…'],
profileRefresh: ['subscription', 'Обновляем подписку…'],
profileDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
failover: ['failover', 'Применяем настройки резерва…'],
};
const canonicalOperationKeys: Record<string, OperationKey> = {
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',
'failover-save': 'failover',
'failover-pause': 'failover',
'failover-resume': 'failover',
'failover-switch': 'failover',
'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 (
<div className={`client-inline-error client-operation-progress is-${context}`} role="status">
<span>{active[1][1]}</span>
</div>
);
}
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 <div className="client-applied-identity" aria-label={identity}>
<span className="client-applied-value" aria-hidden="true">
{previous && <strong className="is-leaving">{previous}</strong>}
<strong key={current} className="is-active">{current}</strong>
</span>
<div className="client-applied-operation">
{operation && <span>{operation}</span>}
</div>
</div>;
}
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 = <div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span className="harbor-brand-name">
<strong>Harbor</strong>
{switchable ? <span className="harbor-mode-control">
<span className="harbor-mode-stack" aria-hidden="true">
<em className="harbor-mode-connect">Connect</em>
<em className="harbor-mode-gateway"><span>Gateway</span></em>
</span>
<svg
className="harbor-mode-swap"
viewBox="0 0 18 18"
aria-hidden="true"
style={{ '--harbor-arrow-turn': `${arrowTurns}turn` } as CSSProperties}
>
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
</svg>
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
<span>{gatewayDirect
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
</span>
</span> : <em>{product}</em>}
</span>
</div>;
return (
<div className={`harbor-brand is-${product.toLowerCase()}${connected ? ' is-connected' : ''}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
{switchable ? <button
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
type="button"
aria-label={label}
aria-describedby="harbor-mode-tooltip"
aria-pressed={gatewayDirect}
disabled={blocked}
onPointerEnter={startModeAnimation}
onPointerLeave={finishModeAnimation}
onFocus={startModeAnimation}
onBlur={finishModeAnimation}
onAnimationIteration={(event) => {
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
stopModeAnimationRef.current = false;
setModeAnimating(false);
}
}}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
</div>
);
}
export function ClientOverviewPage({
actions,
state,
versionInfo,
operations = {},
error,
onAddProfile,
onSelectProfileServer,
onRefreshProfile,
onForgetProfile,
onApply,
onRestart,
onStop,
onSetGatewayAuto,
onSaveRouteRules,
onUpdateDiagnosticsSettings,
onSaveFailover,
onPauseFailover,
onSwitchFailover,
onCheckFailover,
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 [copyFeedback, setCopyFeedback] = useState<CopyFeedbackMap>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
const [drawerSwitchTarget, setDrawerSwitchTarget] = useState<DrawerKey | null>(null);
const copyTimersRef = useRef<Partial<Record<CopyKind, ReturnType<typeof setTimeout>>>>({});
const copyAttemptsRef = useRef<Partial<Record<CopyKind, object>>>({});
const drawerSwitchRef = useRef<{
target: DrawerKey;
finish: () => void;
} | null>(null);
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 subscriptionError = error?.context === 'subscription'
&& profiles.some((profile) => profile.id === error.profileId
&& profile.subscription.errorCode === 'SUBSCRIPTION_EXPIRED')
? null
: error;
const subscriptionFeature = useSubscriptionFeature({
profiles,
selection: state.selection,
connected,
operations: visibleOperations,
error: subscriptionError,
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,
resetDeviceTraffic: actions.resetDeviceTraffic,
updateDevice: actions.updateDevice,
createDeviceTag: actions.createDeviceTag,
renameDeviceTag: actions.renameDeviceTag,
deleteDeviceTag: actions.deleteDeviceTag,
setDevicePolicy: actions.setDevicePolicy,
});
const diagnosticsFeature = useDiagnosticsFeature();
const instructionsFeature = useInstructionsFeature({
isGateway,
host: gatewayAddress,
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
controlHost,
});
const failoverFeature = useFailoverFeature();
const activityJournalFeature = useActivityJournalFeature();
const trafficFeature = useTrafficFeature({
enabled: true,
isGateway,
loadLiveTraffic: actions.loadLiveTraffic,
});
const diagnosticsAvailable = hasSubscription;
const drawerControls = {
subscription: {
isOpen: subscriptionFeature.open,
panelRef: subscriptionFeature.panelRef,
show: subscriptionFeature.toggle,
close: subscriptionFeature.close,
},
failover: {
isOpen: failoverFeature.isOpen,
panelRef: failoverFeature.panelRef,
show: failoverFeature.toggle,
close: failoverFeature.close,
},
instructions: {
isOpen: instructionsFeature.isOpen,
panelRef: instructionsFeature.panelRef,
show: instructionsFeature.toggle,
close: instructionsFeature.close,
},
devices: {
isOpen: devicesFeature.isOpen,
panelRef: devicesFeature.panelRef,
show: devicesFeature.toggle,
close: devicesFeature.close,
},
traffic: {
isOpen: trafficFeature.isOpen,
panelRef: trafficFeature.panelRef,
show: trafficFeature.toggle,
close: trafficFeature.close,
},
diagnostics: {
isOpen: diagnosticsFeature.isOpen,
panelRef: diagnosticsFeature.panelRef,
show: diagnosticsFeature.toggle,
close: diagnosticsFeature.close,
},
routing: {
isOpen: routingFeature.isOpen,
panelRef: routingFeature.panelRef,
show: routingFeature.open,
close: routingFeature.forceClose,
},
journal: {
isOpen: activityJournalFeature.isOpen,
panelRef: activityJournalFeature.panelRef,
show: activityJournalFeature.toggle,
close: activityJournalFeature.close,
},
};
const drawerOrder = isGateway
? DRAWER_ORDER
: DRAWER_ORDER.filter((drawer) => !['devices', 'failover', 'journal'].includes(drawer));
const activeRailDrawer = drawerSwitchTarget && drawerControls[drawerSwitchTarget].isOpen
? drawerSwitchTarget
: drawerOrder.find((drawer) => drawerControls[drawer].isOpen) || null;
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 (!hasSubscription) {
routingFeature.forceClose();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
failoverFeature.close();
activityJournalFeature.close();
trafficFeature.close();
}
}, [hasSubscription]);
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 switchDrawer(target: DrawerKey) {
const activeSwitch = drawerSwitchRef.current;
const current = activeSwitch?.target
|| drawerOrder.find((drawer) => drawerControls[drawer].isOpen)
|| null;
activeSwitch?.finish();
if (current === target) {
if (target === 'failover') failoverFeature.pendingTargetRef.current = null;
drawerControls[target].close();
return;
}
if (current === 'routing' && routingFeature.dirty) {
routingFeature.requestClose();
return;
}
if (current === 'failover') {
if (!failoverFeature.beforeCloseRef.current()) {
failoverFeature.pendingTargetRef.current = () => switchDrawer(target);
return;
}
failoverFeature.pendingTargetRef.current = null;
}
if (!current) {
drawerControls[target].show();
return;
}
const fromControl = drawerControls[current];
const toControl = drawerControls[target];
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reducedMotion) {
flushSync(() => {
fromControl.close();
toControl.show();
});
return;
}
setDrawerSwitchTarget(target);
flushSync(() => toControl.show());
const from = fromControl.panelRef.current;
const to = toControl.panelRef.current;
if (!from || !to || typeof from.animate !== 'function' || typeof to.animate !== 'function') {
fromControl.close();
setDrawerSwitchTarget(null);
return;
}
from.inert = true;
from.setAttribute('aria-hidden', 'true');
const direction = DRAWER_ORDER.indexOf(target) > DRAWER_ORDER.indexOf(current) ? -1 : 1;
const options: KeyframeAnimationOptions = {
duration: DRAWER_SWITCH_MS,
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
fill: 'both',
};
const outgoing = from.animate([
{ transform: 'translateY(0)', opacity: 1 },
{ transform: `translateY(${direction * 100}%)`, opacity: 1 },
], options);
const incoming = to.animate([
{ transform: `translateY(${-direction * 100}%)`, opacity: 1 },
{ transform: 'translateY(0)', opacity: 1 },
], options);
let finished = false;
const cancel = () => {
from.inert = false;
from.removeAttribute('aria-hidden');
outgoing.cancel();
incoming.cancel();
};
const finish = () => {
if (finished) return;
finished = true;
flushSync(() => {
fromControl.close();
setDrawerSwitchTarget(null);
});
cancel();
if (drawerSwitchRef.current?.target === target) drawerSwitchRef.current = null;
};
incoming.addEventListener('finish', finish, { once: true });
drawerSwitchRef.current = { target, finish };
}
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}`
: '';
const failoverIdentity = isGateway && state.failover.enabled
? state.failover.currentRole === 'other'
? state.failover.reason === 'vpn-stopped'
? 'Резерв включится при запуске VPN'
: 'Резерв применится после перезапуска VPN'
: `${state.failover.currentRole === 'reserve'
? 'Резервный канал'
: 'Основной канал'} · ${failoverReasonLabel(state.failover.reason)}`
: '';
return (
<div
className={`client-shell${!hasSubscription ? ' is-first-run' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
<span key={copyAnnouncement.cycle}>{copyAnnouncement.text}</span>
</div>
{hasSubscription && <nav
className="client-secondary-menu"
aria-label="Дополнительные меню"
onPointerDown={(event) => event.stopPropagation()}
>
<SubscriptionToggle
feature={subscriptionFeature}
open={activeRailDrawer === 'subscription'}
onToggle={() => switchDrawer('subscription')}
/>
{isGateway && <FailoverToggle
feature={failoverFeature}
open={activeRailDrawer === 'failover'}
onToggle={() => switchDrawer('failover')}
/>}
<InstructionsToggle
feature={instructionsFeature}
open={activeRailDrawer === 'instructions'}
onToggle={() => switchDrawer('instructions')}
/>
{isGateway && <DevicesToggle
feature={devicesFeature}
open={activeRailDrawer === 'devices'}
onToggle={() => switchDrawer('devices')}
/>}
<TrafficToggle
feature={trafficFeature}
open={activeRailDrawer === 'traffic'}
onToggle={() => switchDrawer('traffic')}
/>
<DiagnosticsToggle
feature={diagnosticsFeature}
open={activeRailDrawer === 'diagnostics'}
onToggle={() => switchDrawer('diagnostics')}
/>
<RoutingToggle
feature={routingFeature}
open={activeRailDrawer === 'routing'}
gatewayDirect={gatewayDirect}
isGateway={isGateway}
hasSubscription={hasSubscription}
onOpen={() => switchDrawer('routing')}
/>
{isGateway && <ActivityJournalToggle
feature={activityJournalFeature}
open={activeRailDrawer === 'journal'}
onToggle={() => switchDrawer('journal')}
/>}
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway && hasSubscription ? ' is-gateway-home' : ''}`}>
<ConnectionPanel
visible={showPower}
isGateway={isGateway}
connected={connected}
gatewayDirect={gatewayDirect}
selectedServerId={selectedServerId}
startedAt={state?.connection?.startedAt}
gatewayAddress={gatewayAddress}
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
gatewayRouteAddress={state?.route?.gatewayAddress}
proxyPort={state?.clientRuntime?.proxyPort}
now={now}
blocked={connectionBlocked}
brandSlot={<HarborBrand
isGateway={isGateway}
connected={connected || gatewayDirect}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>}
copyFeedback={copyFeedback}
onCopyProxy={copyProxy}
onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)}
onStop={onStop}
routingSlot={<RoutingPendingStatus
feature={routingFeature}
blocked={connectionBlocked}
onRestart={onRestart}
/>}
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity || failoverIdentity} />}
statusSlot={<>
<InlineError error={error} context="connection" />
<InlineProgress operations={visibleOperations} context="connection" />
</>}
/>
{isGateway && hasSubscription && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
<SubscriptionPanel
feature={subscriptionFeature}
statusSlot={<>
<InlineError error={subscriptionFeature.error} context="subscription" />
</>}
renderServerPicker={(profile, pickerState) => <ServerPicker
profileId={profile.id}
pingServers={actions.pingServers}
servers={profile.servers}
selectedServerId={pickerState.selectedServerId}
disabled={serverApplyBlocked || pickerState.disabled}
leaving={pickerState.leaving}
revealVersion={pickerState.revealVersion}
anchorServerId={pickerState.anchorServerId}
onSelect={(serverId) => selectServer(profile, serverId)}
/>}
/>
</main>
{hasSubscription && <InstructionsPanel
feature={instructionsFeature}
isGateway={isGateway}
/>}
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
settings={state.diagnostics}
updateSettings={onUpdateDiagnosticsSettings}
isGateway={isGateway}
/>}
{hasSubscription && <RoutingPanel
feature={routingFeature}
statusSlot={<>
<InlineError error={error} context="routing" />
<InlineProgress operations={visibleOperations} context="routing" />
</>}
/>}
{isGateway && hasSubscription && <FailoverPanel
feature={failoverFeature}
snapshot={state.failover}
profiles={profiles}
diagnostics={state.diagnostics}
blocked={operationBlocked(visibleOperations, 'failover')}
onSave={onSaveFailover}
onPause={onPauseFailover}
onSwitch={onSwitchFailover}
onCheck={onCheckFailover}
onUpdateDiagnostics={onUpdateDiagnosticsSettings}
/>}
{isGateway && hasSubscription && <ActivityJournalPanel
feature={activityJournalFeature}
loadPage={actions.loadActivityJournal}
/>}
<RoutingDiscardDialog feature={routingFeature} />
<SubscriptionDeleteDialog feature={subscriptionFeature} />
</div>
);
}