854 lines
31 KiB
TypeScript
854 lines
31 KiB
TypeScript
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 {
|
||
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', 'instructions', 'devices', 'diagnostics', 'routing'] as const;
|
||
type DrawerKey = typeof DRAWER_ORDER[number];
|
||
|
||
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>;
|
||
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
|
||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => 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>;
|
||
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', 'Применяем локальные правила…'],
|
||
};
|
||
|
||
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',
|
||
'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,
|
||
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<CopyFeedbackMap>({});
|
||
const [copyAnnouncement, setCopyAnnouncement] = useState<CopyAnnouncement>({ text: '', cycle: 0 });
|
||
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,
|
||
setDevicePolicy: actions.setDevicePolicy,
|
||
});
|
||
const diagnosticsFeature = useDiagnosticsFeature();
|
||
const instructionsFeature = useInstructionsFeature({
|
||
isGateway,
|
||
host: gatewayAddress,
|
||
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
|
||
controlHost,
|
||
});
|
||
const diagnosticsAvailable = hasSubscription;
|
||
const drawerControls = {
|
||
subscription: {
|
||
isOpen: subscriptionFeature.open,
|
||
panelRef: subscriptionFeature.panelRef,
|
||
show: subscriptionFeature.toggle,
|
||
close: subscriptionFeature.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,
|
||
},
|
||
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,
|
||
},
|
||
};
|
||
const drawerOrder = isGateway
|
||
? DRAWER_ORDER
|
||
: DRAWER_ORDER.filter((drawer) => drawer !== 'devices');
|
||
|
||
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 switchDrawer(target: DrawerKey) {
|
||
const activeSwitch = drawerSwitchRef.current;
|
||
const current = activeSwitch?.target
|
||
|| drawerOrder.find((drawer) => drawerControls[drawer].isOpen)
|
||
|| null;
|
||
activeSwitch?.finish();
|
||
|
||
if (current === target) {
|
||
drawerControls[target].close();
|
||
return;
|
||
}
|
||
if (current === 'routing' && routingFeature.dirty) {
|
||
routingFeature.requestClose();
|
||
return;
|
||
}
|
||
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;
|
||
}
|
||
|
||
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();
|
||
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());
|
||
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}`
|
||
: '';
|
||
|
||
return (
|
||
<div
|
||
className={`client-shell${!hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
|
||
>
|
||
<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}
|
||
onToggle={() => switchDrawer('subscription')}
|
||
/>
|
||
<InstructionsToggle
|
||
feature={instructionsFeature}
|
||
onToggle={() => switchDrawer('instructions')}
|
||
/>
|
||
{isGateway && <DevicesToggle
|
||
feature={devicesFeature}
|
||
onToggle={() => switchDrawer('devices')}
|
||
/>}
|
||
<DiagnosticsToggle
|
||
feature={diagnosticsFeature}
|
||
onToggle={() => switchDrawer('diagnostics')}
|
||
/>
|
||
<RoutingToggle
|
||
feature={routingFeature}
|
||
gatewayDirect={gatewayDirect}
|
||
isGateway={isGateway}
|
||
hasSubscription={hasSubscription}
|
||
onOpen={() => switchDrawer('routing')}
|
||
/>
|
||
</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} />}
|
||
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} />}
|
||
|
||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||
feature={diagnosticsFeature}
|
||
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
|
||
isGateway={isGateway}
|
||
/>}
|
||
|
||
{hasSubscription && <RoutingPanel
|
||
feature={routingFeature}
|
||
statusSlot={<>
|
||
<InlineError error={error} context="routing" />
|
||
<InlineProgress operations={visibleOperations} context="routing" />
|
||
</>}
|
||
/>}
|
||
<RoutingDiscardDialog feature={routingFeature} />
|
||
<SubscriptionDeleteDialog feature={subscriptionFeature} />
|
||
</div>
|
||
);
|
||
}
|