Refactor VPN proxy client implementation
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
+652
View File
@@ -0,0 +1,652 @@
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import {
copyText,
localProxyUrls,
} 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 {
HarborServer,
RouteRule,
StateSnapshot,
} from '../../shared/contracts/state.js';
const VERSION_PARTS = [
['major', 'Major'],
['minor', 'Minor'],
['hotfix', 'Hotfix'],
] as const;
interface UiError {
context?: 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 {
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (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;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
servers: HarborServer[];
pendingServerId: string;
setPendingServerId: (id: string) => void;
onFetchSubscription: () => Promise<unknown>;
onRefreshSubscription: () => Promise<unknown>;
onForgetSubscription: () => Promise<unknown>;
onApply: (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';
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', 'Применяем сервер…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
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 HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
isGateway: 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()}${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,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingServerId,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
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 hasSubscription = state?.subscription?.status === 'ready';
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId);
const desiredServer = servers.find(({ id }) => id === selectedServerId);
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const [now, setNow] = useState(Date.now());
const [showIntro, setShowIntro] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<{ kind: CopyKind; failed: boolean } | null>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | 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 connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const switchingServer = Boolean(
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
);
const subscriptionFeature = useSubscriptionFeature({
subscription: state?.subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
error,
serverCount: servers.length,
isGateway,
gatewayDirect,
validateSubscription: actions.validateSubscription,
onImport: onFetchSubscription,
onRefresh: onRefreshSubscription,
onForget: onForgetSubscription,
onDismissError,
});
const subscriptionContentReady = subscriptionFeature.contentReady;
const routingFeature = useRoutingFeature({
route: state?.route,
connected,
operations,
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 = isGateway || (hasSubscription && subscriptionContentReady);
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();
if (!isGateway) {
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
}
}
}, [hasSubscription, isGateway]);
useEffect(() => {
if (!diagnosticsAvailable) diagnosticsFeature.close();
}, [diagnosticsAvailable]);
useEffect(() => () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
}, []);
function selectServer(serverId: string) {
setPendingServerId(serverId);
if (connected && serverId) onApply(serverId);
}
async function copyProxy(kind: CopyKind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
try {
await copyText(value);
setCopyFeedback({ kind, failed: false });
} catch {
setCopyFeedback({ kind, failed: true });
}
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
}
function openRouting() {
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
routingFeature.open();
}
return (
<div
className={`client-shell${!isGateway && !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">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</div>
<HarborBrand
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <SubscriptionToggle
feature={subscriptionFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
subscriptionFeature.toggle();
}}
/>}
<InstructionsToggle
feature={instructionsFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
instructionsFeature.toggle();
}}
/>
{isGateway && <DevicesToggle
feature={devicesFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
diagnosticsFeature.close();
devicesFeature.toggle();
}}
/>}
<DiagnosticsToggle
feature={diagnosticsFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.toggle();
}}
/>
<RoutingToggle
feature={routingFeature}
gatewayDirect={gatewayDirect}
isGateway={isGateway}
hasSubscription={hasSubscription}
onOpen={openRouting}
/>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
<ConnectionPanel
visible={showPower}
isGateway={isGateway}
connected={connected}
gatewayDirect={gatewayDirect}
selectedServerId={selectedServerId}
configured={Boolean(state?.clientRuntime?.configured)}
startedAt={state?.connection?.startedAt}
gatewayAddress={gatewayAddress}
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
gatewayRouteAddress={state?.route?.gatewayAddress}
proxyPort={state?.clientRuntime?.proxyPort}
now={now}
blocked={connectionBlocked}
copyFeedback={copyFeedback}
onCopyProxy={copyProxy}
onApply={onApply}
onRestart={onRestart}
onStop={onStop}
routingSlot={<RoutingPendingStatus
feature={routingFeature}
blocked={connectionBlocked}
onRestart={onRestart}
/>}
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
<span className="client-gateway-summary-kicker">Сейчас</span>
<strong id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</strong>
<div className="client-gateway-route-slot">
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
</div>}
statusSlot={<>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
</>}
/>
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
<SubscriptionPanel
feature={subscriptionFeature}
statusSlot={<>
<InlineError error={subscriptionFeature.error || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</>}
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker
pingServers={actions.pingServers}
servers={servers}
selectedServerId={selectedServerId}
disabled={serverApplyBlocked}
prompt={!showPower}
leaving={subscriptionFeature.serversLeaving}
revealVersion={subscriptionFeature.serverRevealVersion}
onSelect={selectServer}
/>}
/>
</main>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
feature={instructionsFeature}
isGateway={isGateway}
/>}
{isGateway && <DevicesPanel feature={devicesFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
isGateway={isGateway}
/>}
{hasSubscription && subscriptionContentReady && <RoutingPanel
feature={routingFeature}
statusSlot={<>
<InlineError error={error} context="routing" />
<InlineProgress operations={operations} context="routing" />
</>}
/>}
<RoutingDiscardDialog feature={routingFeature} />
<SubscriptionDeleteDialog feature={subscriptionFeature} />
</div>
);
}