Refactor VPN proxy components and update related behavior
This commit is contained in:
@@ -51,7 +51,7 @@ import {
|
||||
versionCompatibility,
|
||||
} from '../../shared/versions.js';
|
||||
import type {
|
||||
HarborServer,
|
||||
ProfileSnapshot,
|
||||
RouteRule,
|
||||
StateSnapshot,
|
||||
} from '../../shared/contracts/state.js';
|
||||
@@ -64,6 +64,7 @@ const VERSION_PARTS = [
|
||||
|
||||
interface UiError {
|
||||
context?: string;
|
||||
profileId?: string;
|
||||
message?: string;
|
||||
correlationId?: string;
|
||||
retry?: (() => unknown) | null;
|
||||
@@ -79,12 +80,11 @@ interface VersionBadgeProps {
|
||||
}
|
||||
|
||||
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>;
|
||||
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
|
||||
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -102,15 +102,13 @@ interface ClientOverviewPageProps {
|
||||
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>;
|
||||
onAddProfile: (label: string, url: string) => Promise<unknown>;
|
||||
onRenameProfile: (profileId: string, label: string) => Promise<unknown>;
|
||||
onSelectProfileServer: (profileId: string, serverId: string) => Promise<unknown>;
|
||||
onActivateProfile: (profileId: 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>;
|
||||
@@ -243,11 +241,30 @@ function InlineError({ error, context }: { error?: UiError | null; context: stri
|
||||
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
|
||||
connection: ['connection', 'Меняем состояние подключения…'],
|
||||
serverApply: ['connection', 'Применяем сервер…'],
|
||||
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||||
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||||
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;
|
||||
@@ -266,6 +283,29 @@ function InlineProgress({ operations, context }: {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -369,14 +409,12 @@ export function ClientOverviewPage({
|
||||
versionInfo,
|
||||
operations = {},
|
||||
error,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingServerId,
|
||||
setPendingServerId,
|
||||
onFetchSubscription,
|
||||
onRefreshSubscription,
|
||||
onForgetSubscription,
|
||||
onAddProfile,
|
||||
onRenameProfile,
|
||||
onSelectProfileServer,
|
||||
onActivateProfile,
|
||||
onRefreshProfile,
|
||||
onForgetProfile,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
@@ -388,12 +426,18 @@ export function ClientOverviewPage({
|
||||
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 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 = servers.find(({ id }) => id === appliedServerId);
|
||||
const desiredServer = servers.find(({ id }) => id === selectedServerId);
|
||||
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
|
||||
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>({});
|
||||
@@ -403,32 +447,62 @@ export function ClientOverviewPage({
|
||||
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 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({
|
||||
subscription: state?.subscription,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
operations,
|
||||
profiles,
|
||||
selection: state.selection,
|
||||
connected,
|
||||
operations: visibleOperations,
|
||||
error,
|
||||
serverCount: servers.length,
|
||||
isGateway,
|
||||
gatewayDirect,
|
||||
validateSubscription: actions.validateSubscription,
|
||||
onImport: onFetchSubscription,
|
||||
onRefresh: onRefreshSubscription,
|
||||
onForget: onForgetSubscription,
|
||||
onAdd: onAddProfile,
|
||||
onRename: onRenameProfile,
|
||||
onRefresh: onRefreshProfile,
|
||||
onForget: onForgetProfile,
|
||||
onActivate: onActivateProfile,
|
||||
onDismissError,
|
||||
});
|
||||
const subscriptionContentReady = subscriptionFeature.contentReady;
|
||||
const routingFeature = useRoutingFeature({
|
||||
route: state?.route,
|
||||
connected,
|
||||
operations,
|
||||
operations: visibleOperations,
|
||||
onSave: onSaveRouteRules,
|
||||
onDismissError,
|
||||
});
|
||||
@@ -446,7 +520,7 @@ export function ClientOverviewPage({
|
||||
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
|
||||
controlHost,
|
||||
});
|
||||
const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady);
|
||||
const diagnosticsAvailable = hasSubscription;
|
||||
|
||||
useEffect(() => {
|
||||
setNow(Date.now());
|
||||
@@ -465,11 +539,9 @@ export function ClientOverviewPage({
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) {
|
||||
routingFeature.forceClose();
|
||||
if (!isGateway) {
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
}
|
||||
instructionsFeature.close();
|
||||
devicesFeature.close();
|
||||
diagnosticsFeature.close();
|
||||
}
|
||||
}, [hasSubscription, isGateway]);
|
||||
|
||||
@@ -482,9 +554,12 @@ export function ClientOverviewPage({
|
||||
copyAttemptsRef.current = {};
|
||||
}, []);
|
||||
|
||||
function selectServer(serverId: string) {
|
||||
setPendingServerId(serverId);
|
||||
if (connected && serverId) onApply(serverId);
|
||||
function selectServer(profile: ProfileSnapshot, serverId: string) {
|
||||
if (connected && !gatewayDirect && state.selection.appliedProfileId === profile.id) {
|
||||
onApply(profile.id, serverId);
|
||||
return;
|
||||
}
|
||||
onSelectProfileServer(profile.id, serverId);
|
||||
}
|
||||
|
||||
async function copyProxy(kind: CopyKind) {
|
||||
@@ -529,16 +604,31 @@ export function ClientOverviewPage({
|
||||
routingFeature.open();
|
||||
}
|
||||
|
||||
const mainIdentity = gatewayDirect
|
||||
? 'Gateway · сервер не определён'
|
||||
: connected
|
||||
? appliedProfile && appliedServer
|
||||
? `${appliedProfile.label} · ${appliedServer.label}`
|
||||
: 'VPN · сервер не определён'
|
||||
: desiredProfile && desiredServer
|
||||
? `Выбран: ${desiredProfile.label} · ${desiredServer.label}`
|
||||
: 'Сервер не выбран';
|
||||
const switchIdentity = gatewayDirect
|
||||
? 'Данные применённого сервера Gateway недоступны'
|
||||
: switchingServer && operationProfile && operationServer
|
||||
? `Переключаем на ${operationProfile.label} · ${operationServer.label}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
|
||||
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>
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||
{isGateway && <SubscriptionToggle
|
||||
{hasSubscription && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||||
<SubscriptionToggle
|
||||
feature={subscriptionFeature}
|
||||
onToggle={() => {
|
||||
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
|
||||
@@ -547,7 +637,7 @@ export function ClientOverviewPage({
|
||||
diagnosticsFeature.close();
|
||||
subscriptionFeature.toggle();
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
<InstructionsToggle
|
||||
feature={instructionsFeature}
|
||||
onToggle={() => {
|
||||
@@ -586,14 +676,13 @@ export function ClientOverviewPage({
|
||||
onOpen={openRouting}
|
||||
/>
|
||||
</nav>}
|
||||
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
|
||||
<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}
|
||||
configured={Boolean(state?.clientRuntime?.configured)}
|
||||
startedAt={state?.connection?.startedAt}
|
||||
gatewayAddress={gatewayAddress}
|
||||
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
|
||||
@@ -603,7 +692,7 @@ export function ClientOverviewPage({
|
||||
blocked={connectionBlocked}
|
||||
brandSlot={<HarborBrand
|
||||
isGateway={isGateway}
|
||||
connected={connected}
|
||||
connected={connected || gatewayDirect}
|
||||
gatewayAvailable={gatewayAvailable}
|
||||
gatewayDirect={gatewayDirect}
|
||||
blocked={gatewayAutoBlocked}
|
||||
@@ -611,56 +700,47 @@ export function ClientOverviewPage({
|
||||
/>}
|
||||
copyFeedback={copyFeedback}
|
||||
onCopyProxy={copyProxy}
|
||||
onApply={onApply}
|
||||
onRestart={onRestart}
|
||||
onApply={(serverId) => desiredProfile && onApply(desiredProfile.id, serverId)}
|
||||
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>}
|
||||
serverSlot={<AppliedIdentity identity={mainIdentity} operation={switchIdentity} />}
|
||||
statusSlot={<>
|
||||
<InlineError error={error} context="connection" />
|
||||
<InlineProgress operations={operations} context="connection" />
|
||||
<InlineProgress operations={visibleOperations} context="connection" />
|
||||
</>}
|
||||
/>
|
||||
|
||||
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
|
||||
{isGateway && hasSubscription && <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
|
||||
renderServerPicker={(profile, pickerState) => <ServerPicker
|
||||
profileId={profile.id}
|
||||
pingServers={actions.pingServers}
|
||||
servers={servers}
|
||||
selectedServerId={selectedServerId}
|
||||
disabled={serverApplyBlocked}
|
||||
prompt={!showPower}
|
||||
leaving={subscriptionFeature.serversLeaving}
|
||||
revealVersion={subscriptionFeature.serverRevealVersion}
|
||||
onSelect={selectServer}
|
||||
servers={profile.servers}
|
||||
selectedServerId={profile.desiredServerId}
|
||||
disabled={serverApplyBlocked || pickerState.disabled}
|
||||
prompt={!profile.desiredServerId}
|
||||
leaving={pickerState.leaving}
|
||||
revealVersion={pickerState.revealVersion}
|
||||
onSelect={(serverId) => selectServer(profile, serverId)}
|
||||
/>}
|
||||
/>
|
||||
</main>
|
||||
|
||||
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
|
||||
{hasSubscription && <InstructionsPanel
|
||||
feature={instructionsFeature}
|
||||
isGateway={isGateway}
|
||||
/>}
|
||||
|
||||
{isGateway && <DevicesPanel feature={devicesFeature} />}
|
||||
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
|
||||
|
||||
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
|
||||
feature={diagnosticsFeature}
|
||||
@@ -668,11 +748,11 @@ export function ClientOverviewPage({
|
||||
isGateway={isGateway}
|
||||
/>}
|
||||
|
||||
{hasSubscription && subscriptionContentReady && <RoutingPanel
|
||||
{hasSubscription && <RoutingPanel
|
||||
feature={routingFeature}
|
||||
statusSlot={<>
|
||||
<InlineError error={error} context="routing" />
|
||||
<InlineProgress operations={operations} context="routing" />
|
||||
<InlineProgress operations={visibleOperations} context="routing" />
|
||||
</>}
|
||||
/>}
|
||||
<RoutingDiscardDialog feature={routingFeature} />
|
||||
|
||||
Reference in New Issue
Block a user