Refactor VPN proxy components and update related behavior
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 01:27:46 +03:00
parent c89e56942a
commit aa9c959368
58 changed files with 4234 additions and 2755 deletions
+144 -49
View File
@@ -18,7 +18,6 @@ import {
} from './state/operations.js';
const componentActions = {
validateSubscription: api.subscription.validate,
listDevices: api.devices.list,
refreshDevices: api.devices.refresh,
updateDevice: api.devices.update,
@@ -29,23 +28,43 @@ const componentActions = {
interface UiError {
context: string;
profileId: string;
message: string;
code: string;
correlationId: string;
retry: (() => unknown) | null;
}
const operationErrorContext: Record<string, string> = {
start: 'connection',
stop: 'connection',
'apply-server': 'connection',
'profile-activate': 'connection',
'gateway-auto': 'connection',
'profile-add': 'subscription',
'profile-rename': 'subscription',
'profile-select-server': 'subscription',
'profile-refresh': 'subscription',
'profile-delete': 'subscription',
'subscription-import': 'subscription',
'subscription-refresh': 'subscription',
'subscription-forget': 'subscription',
'route-rules': 'routing',
};
export function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
const [{ snapshot: state, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState<OperationRegistrySnapshot>({});
const [error, setError] = useState<UiError | null>(null);
const [dismissedCanonicalError, setDismissedCanonicalError] = useState('');
const [versionInfo, setVersionInfo] = useState<unknown>(null);
const pollGeneration = useRef(0);
const revisionRef = useRef(0);
const hasAcceptedSnapshotRef = useRef(false);
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry((next) => {
@@ -53,16 +72,16 @@ export function App() {
});
}
function setPendingServerId(serverId: string) {
dispatch({ type: 'select-server', serverId });
}
async function loadState({ retry = false }: { retry?: boolean } = {}) {
if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current;
try {
const snapshot = await harborClient.getState();
if (generation === pollGeneration.current) {
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
}
} catch (requestError) {
@@ -98,30 +117,43 @@ export function App() {
if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2';
}, [state?.mode]);
function run(key: OperationKey, action: () => Promise<unknown>, context: string) {
const canonicalErrorId = state?.operation?.status === 'failed' && state.operation.error
? [state.operation.kind, state.operation.startedAt, state.operation.profileId, state.operation.error].join(':')
: '';
useEffect(() => setDismissedCanonicalError(''), [canonicalErrorId]);
function run(
key: OperationKey,
action: () => Promise<unknown>,
context: string,
target = '',
profileId = '',
) {
setError(null);
return operationRegistry.current!.run(key, async () => {
try {
return await applyMutation(action);
} catch (err) {
await loadState();
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
setError({
context,
profileId,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.'
: safeError.message,
code: safeError.code,
correlationId: safeError.correlationId,
retry: safeError.retryable && safeError.code !== 'STATE_CONFLICT'
? () => run(key, action, context)
? () => run(key, action, context, target, profileId)
: null,
});
return false;
}
});
}, target);
}
async function applyMutation(action: () => Promise<unknown>) {
@@ -133,6 +165,10 @@ export function App() {
const result = response as Record<string, unknown>;
if (!result.state) throw new Error('Harbor API не вернул state snapshot');
const snapshot = parseHarborState(result.state);
if (!hasAcceptedSnapshotRef.current || snapshot.revision > revisionRef.current) {
hasAcceptedSnapshotRef.current = true;
revisionRef.current = snapshot.revision;
}
dispatch({
type: 'sync-succeeded',
snapshot,
@@ -141,36 +177,59 @@ export function App() {
return result;
}
async function fetchSubscription() {
return run('subscriptionImport', async () => {
const data = await api.subscription.fetch(subscriptionUrl);
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
async function refreshSubscription() {
return run('subscriptionRefresh', api.subscription.refresh, 'subscription');
}
async function forgetSubscription() {
return run('subscriptionDelete', async () => {
const data = await api.subscription.forget();
setSubscriptionUrl('');
dispatch({ type: 'clear-pending-server' });
return data;
}, 'subscription');
}
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
const previewServer = {
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
};
const displayState = previewReady ? {
...state,
mode: 'client' as const,
profiles: [{
id: 'preview-personal',
label: 'Личный',
subscription: {
status: 'ready' as const,
host: 'harbor.example/…',
fetchedAt: new Date().toISOString(),
userInfo: {},
lastRefreshAttemptAt: new Date().toISOString(),
errorCode: null,
},
desiredServerId: previewServer.id,
servers: [previewServer],
}],
subscription: { ...state.subscription, status: 'ready' as const, host: 'harbor.example' },
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
selection: {
...state.selection,
desiredProfileId: 'preview-personal',
desiredServerId: 'preview-amsterdam',
appliedProfileId: 'preview-personal',
appliedServerId: 'preview-amsterdam',
appliedServerSnapshot: previewServer,
},
servers: [previewServer],
clientRuntime: { ...state.clientRuntime, proxyPort: 8082 },
} : state;
const canonicalErrorContext = operationErrorContext[state.operation.kind || ''];
const visibleError = error || (
canonicalErrorId
&& canonicalErrorId !== dismissedCanonicalError
&& canonicalErrorContext
? {
context: canonicalErrorContext,
profileId: state.operation.profileId || '',
message: state.operation.error || 'Операция не выполнена.',
code: 'UNKNOWN',
correlationId: '',
retry: null,
}
: null
);
return (
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
@@ -182,22 +241,55 @@ export function App() {
state={displayState}
versionInfo={versionInfo}
operations={operations}
error={error}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
servers={previewReady ? [{
id: 'preview-amsterdam',
label: 'Amsterdam',
host: '127.0.0.1',
port: 443,
protocol: 'vless',
}] : state.servers || []}
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
setPendingServerId={setPendingServerId}
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')}
error={visibleError}
onAddProfile={(label: string, url: string) => run(
'profileAdd',
() => api.profiles.add(label, url, revisionRef.current),
'subscription',
label,
)}
onRenameProfile={(profileId: string, label: string) => run(
'profileRename',
() => api.profiles.rename(profileId, label, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onSelectProfileServer={(profileId: string, serverId: string) => run(
'profileSelect',
() => api.profiles.selectServer(profileId, serverId, revisionRef.current),
'subscription',
`${profileId}:${serverId}`,
profileId,
)}
onActivateProfile={(profileId: string) => run(
'profileActivate',
() => api.profiles.activate(profileId, revisionRef.current),
'connection',
profileId,
profileId,
)}
onRefreshProfile={(profileId: string) => run(
'profileRefresh',
() => api.profiles.refresh(profileId, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onForgetProfile={(profileId: string, mode: 'delete' | 'stop-and-delete') => run(
'profileDelete',
() => api.profiles.forget(profileId, mode, revisionRef.current),
'subscription',
profileId,
profileId,
)}
onApply={(profileId: string, serverId: string) => run(
'serverApply',
() => api.apply(profileId, serverId, revisionRef.current),
'connection',
`${profileId}:${serverId}`,
profileId,
)}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
@@ -206,7 +298,10 @@ export function App() {
() => api.routeRules.update(rules, expectedRevision),
'routing',
)}
onDismissError={() => setError(null)}
onDismissError={() => {
setError(null);
setDismissedCanonicalError(canonicalErrorId);
}}
/>
</main>
</div>
+49 -5
View File
@@ -96,10 +96,50 @@ export const api = {
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (serverId: string) => request('/api/apply', {
profiles: {
add: (label: string, url: string, expectedRevision: number) => request('/api/profiles', {
method: 'POST',
body: JSON.stringify({ label, url, expectedRevision }),
}),
rename: (profileId: string, label: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}`,
{
method: 'PATCH',
body: JSON.stringify({ label, expectedRevision }),
},
),
selectServer: (profileId: string, serverId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/server`,
{
method: 'PUT',
body: JSON.stringify({ serverId, expectedRevision }),
},
),
activate: (profileId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/activate`,
{
method: 'POST',
body: JSON.stringify({ expectedRevision }),
},
),
refresh: (profileId: string, expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}/refresh`,
{
method: 'POST',
body: JSON.stringify({ expectedRevision }),
},
),
forget: (profileId: string, mode: 'delete' | 'stop-and-delete', expectedRevision: number) => request(
`/api/profiles/${encodeURIComponent(profileId)}`,
{
method: 'DELETE',
body: JSON.stringify({ mode, expectedRevision }),
},
),
},
apply: (profileId: string, serverId: string, expectedRevision: number) => request('/api/apply', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
body: JSON.stringify({ profileId, serverId, expectedRevision }),
}),
gatewayAuto: {
setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
@@ -145,10 +185,13 @@ export const api = {
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds: string[]) => request('/api/servers/ping-all', {
ping: (profileId: string, serverIds: string[]) => request(
`/api/profiles/${encodeURIComponent(profileId)}/servers/ping`,
{
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
),
},
};
@@ -177,6 +220,7 @@ export function parseHarborState(value: unknown): HarborClientState {
revision: snapshot.revision,
generatedAt: snapshot.generatedAt,
mode: snapshot.mode,
profiles: snapshot.profiles,
subscription: snapshot.subscription,
selection: snapshot.selection,
connection: snapshot.connection,
+166 -86
View File
@@ -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} />
+21 -21
View File
@@ -26,7 +26,6 @@ interface ConnectionPanelProps {
connected: boolean;
gatewayDirect: boolean;
selectedServerId: string;
configured: boolean;
startedAt?: string | null;
gatewayAddress: string;
gatewayUiOrigin?: string | null;
@@ -41,7 +40,6 @@ interface ConnectionPanelProps {
statusSlot?: ReactNode;
onCopyProxy: (kind: CopyKind) => unknown;
onApply: (serverId: string) => unknown;
onRestart: () => unknown;
onStop: () => unknown;
}
@@ -67,7 +65,6 @@ export function ConnectionPanel({
connected,
gatewayDirect,
selectedServerId,
configured,
startedAt,
gatewayAddress,
gatewayUiOrigin,
@@ -82,7 +79,6 @@ export function ConnectionPanel({
statusSlot,
onCopyProxy,
onApply,
onRestart,
onStop,
}: ConnectionPanelProps) {
const [durationMode, setDurationMode] = useState(() => {
@@ -93,8 +89,9 @@ export function ConnectionPanel({
}
});
const [confirmingStop, setConfirmingStop] = useState(false);
const canStart = Boolean(selectedServerId || configured);
const powerUnavailable = isGateway && !connected && !canStart;
const remoteOwned = !isGateway && gatewayDirect;
const canStart = Boolean(selectedServerId);
const powerUnavailable = remoteOwned || (!connected && !canStart);
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
const duration = connectionDurationParts(startedAt, now);
const clockUnits: Array<[string, DurationUnit]> = [
@@ -104,9 +101,9 @@ export function ConnectionPanel({
];
const wordClockDuration = clockUnits
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const connectionTitle = remoteOwned
? 'Gateway подключён'
: connected ? 'VPN включён' : 'Подключение выключено';
const proxyKinds: Array<[CopyKind, string]> = isGateway
? [
['gateway', 'GATEWAY'],
@@ -119,13 +116,12 @@ export function ConnectionPanel({
];
function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: configured });
const action = connectionAction({ connected, selectedServerId });
if (action?.type === 'stop') {
setConfirmingStop(true);
return;
}
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
async function stopConnection() {
@@ -149,12 +145,14 @@ export function ConnectionPanel({
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
aria-checked={connected || remoteOwned}
aria-label={remoteOwned
? 'Подключением управляет Harbor Gateway'
: isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={blocked || (!connected && !canStart)}
disabled={blocked || powerUnavailable}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -165,7 +163,7 @@ export function ConnectionPanel({
return <>
{visible ? <section className={`client-power-section${isGateway ? ' is-gateway' : ''}`} aria-labelledby="connection-title">
<div
className={`client-power-control${isGateway ? ' client-tooltip-anchor' : ''}`}
className={`client-power-control${powerUnavailable ? ' client-tooltip-anchor' : ''}`}
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
@@ -173,11 +171,11 @@ export function ConnectionPanel({
{brandSlot}
{powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
Сначала добавьте подписку и выберите сервер
{remoteOwned ? 'Подключением управляет Harbor Gateway' : 'Сначала добавьте подписку и выберите сервер'}
</span>}
</div>
<div className="client-state-detail">
{connected ? (
{connected && !remoteOwned ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
@@ -224,16 +222,18 @@ export function ConnectionPanel({
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
{remoteOwned
? 'Управляется Harbor Gateway'
: canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
<span className={!connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !remoteOwned ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={remoteOwned ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
</div>
+21 -13
View File
@@ -53,9 +53,9 @@ function write(key: string, value: string | string[]) {
}
}
function readAuto() {
function readAuto(key: string) {
try {
return localStorage.getItem(AUTO_KEY) === 'true';
return localStorage.getItem(key) === 'true';
} catch {
return false;
}
@@ -153,7 +153,8 @@ function ServerRow({
}
interface ServerPickerProps {
pingServers: (ids: string[]) => Promise<unknown>;
profileId: string;
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
servers: PickerServer[];
selectedServerId: string;
disabled: boolean;
@@ -164,6 +165,7 @@ interface ServerPickerProps {
}
export function ServerPicker({
profileId,
pingServers,
servers,
selectedServerId,
@@ -173,13 +175,16 @@ export function ServerPicker({
revealVersion,
onSelect,
}: ServerPickerProps) {
const favoritesKey = `${FAVORITES_KEY}:${profileId}`;
const recentKey = `${RECENT_KEY}:${profileId}`;
const autoKey = `${AUTO_KEY}:${profileId}`;
const [query, setQuery] = useState('');
const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all');
const [page, setPage] = useState(0);
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
const [autoActive, setAutoActive] = useState(readAuto);
const [favorites, setFavorites] = useState(() => readList(favoritesKey));
const [recent, setRecent] = useState(() => readList(recentKey));
const [autoActive, setAutoActive] = useState(() => readAuto(autoKey));
const [collapsed, setCollapsed] = useState<string[]>([]);
const [pings, setPings] = useState<PingState>({});
const [checking, setChecking] = useState(false);
@@ -208,18 +213,18 @@ export function ServerPicker({
function toggleFavorite(id: string) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next);
write(favoritesKey, next);
return next;
});
}
function select(id: string, automatic = false) {
setAutoActive(automatic);
write(AUTO_KEY, String(automatic));
write(autoKey, String(automatic));
if (!automatic) {
setRecent((current) => {
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
write(RECENT_KEY, next);
write(recentKey, next);
return next;
});
}
@@ -236,7 +241,7 @@ export function ServerPicker({
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
}));
try {
const results = parseServerPingResults(await pingServers(ids)) as PingResult[];
const results = parseServerPingResults(await pingServers(profileId, ids)) as PingResult[];
setPings((current) => ({
...current,
...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])),
@@ -251,7 +256,10 @@ export function ServerPicker({
}])),
}));
} finally {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
const elapsed = performance.now() - startedAt;
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const completeAt = reduced ? elapsed : Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, Math.max(0, completeAt - elapsed)));
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
@@ -324,7 +332,7 @@ export function ServerPicker({
inert={advanced ? true : undefined}
>
<div className="client-server-mode-panel-inner">
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${revealVersion}`}>
<div className="client-server-grid">
{simpleServers.map((server, index) => <ServerRow
key={server.id}
@@ -401,7 +409,7 @@ export function ServerPicker({
/>}
</div>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${revealVersion}`}>
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
{grouped ? groupServers(visible).map(([group, items]) => {
const isCollapsed = collapsed.includes(group);
File diff suppressed because it is too large Load Diff
-24
View File
@@ -4,7 +4,6 @@ export type SyncErrorKind = 'incompatible-api' | 'control-unreachable' | 'fatal'
export interface HarborReducerState {
snapshot: HarborClientState | null;
pendingServerId: string;
transport: {
bootStatus: 'loading' | 'ready' | SyncErrorKind;
lastSuccessfulSyncAt: string | null;
@@ -15,15 +14,12 @@ export interface HarborReducerState {
}
export type HarborAction =
| { type: 'select-server'; serverId: string }
| { type: 'clear-pending-server' }
| { type: 'retry-sync' }
| { type: 'sync-failed'; error: unknown }
| { type: 'sync-succeeded'; snapshot: HarborClientState; receivedAt: string };
export const initialHarborState: HarborReducerState = {
snapshot: null,
pendingServerId: '',
transport: {
bootStatus: 'loading',
lastSuccessfulSyncAt: null,
@@ -43,24 +39,7 @@ export function classifySyncError(error: unknown): SyncErrorKind {
return 'fatal';
}
function reconcilePendingServer(pendingServerId: string, snapshot: HarborClientState) {
if (!pendingServerId || snapshot.selection.desiredServerId === pendingServerId) return '';
return snapshot.servers.some((server) => server.id === pendingServerId)
? pendingServerId
: '';
}
export function harborReducer(current: HarborReducerState, action: HarborAction): HarborReducerState {
if (action.type === 'select-server') {
return action.serverId === current.pendingServerId
? current
: { ...current, pendingServerId: action.serverId };
}
if (action.type === 'clear-pending-server') {
return current.pendingServerId ? { ...current, pendingServerId: '' } : current;
}
if (action.type === 'retry-sync') {
return current.snapshot ? current : {
...current,
@@ -95,9 +74,6 @@ export function harborReducer(current: HarborReducerState, action: HarborAction)
return {
snapshot: newer ? snapshot : current.snapshot,
pendingServerId: newer
? reconcilePendingServer(current.pendingServerId, snapshot)
: current.pendingServerId,
transport: {
bootStatus: 'ready',
lastSuccessfulSyncAt: action.receivedAt,
+35 -21
View File
@@ -1,23 +1,36 @@
export type OperationKey = 'connection' | 'serverApply' | 'subscriptionImport'
| 'subscriptionRefresh' | 'subscriptionDelete' | 'gatewayAuto' | 'routeRules';
export interface OperationState { status: 'running'; startedAt: string }
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
| 'gatewayAuto' | 'routeRules';
export interface OperationState {
status: 'running';
startedAt: string;
target: string;
}
export type OperationRegistrySnapshot = Partial<Record<OperationKey, OperationState>>;
export const OPERATION_CONFLICTS: Readonly<Record<OperationKey, readonly OperationKey[]>> = Object.freeze({
connection: ['serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
serverApply: ['connection', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionImport: ['connection', 'serverApply', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionRefresh: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionDelete', 'gatewayAuto', 'routeRules'],
subscriptionDelete: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'gatewayAuto', 'routeRules'],
gatewayAuto: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'routeRules'],
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
});
const OPERATION_KEYS: readonly OperationKey[] = [
'connection',
'serverApply',
'profileAdd',
'profileRename',
'profileSelect',
'profileActivate',
'profileRefresh',
'profileDelete',
'gatewayAuto',
'routeRules',
];
// The backend has one canonical revision and one mutation queue, so the UI mirrors that lock.
export const OPERATION_CONFLICTS = Object.freeze(Object.fromEntries(
OPERATION_KEYS.map((key) => [key, OPERATION_KEYS.filter((candidate) => candidate !== key)]),
) as unknown as Record<OperationKey, readonly OperationKey[]>);
export function operationBlocked(operations: OperationRegistrySnapshot, key: OperationKey) {
if (operations[key]?.status === 'running') return true;
return (OPERATION_CONFLICTS[key] || []).some(
(conflict) => operations[conflict]?.status === 'running',
);
return OPERATION_CONFLICTS[key].some((conflict) => operations[conflict]?.status === 'running');
}
export function createOperationRegistry(
@@ -25,14 +38,15 @@ export function createOperationRegistry(
now = () => new Date().toISOString(),
) {
let operations: OperationRegistrySnapshot = {};
const inFlight = new Map<OperationKey, Promise<unknown>>();
const inFlight = new Map<string, Promise<unknown>>();
function run<T>(key: OperationKey, action: () => T | Promise<T>): Promise<T | false> {
const existing = inFlight.get(key);
function run<T>(key: OperationKey, action: () => T | Promise<T>, target = ''): Promise<T | false> {
const identity = `${key}:${target}`;
const existing = inFlight.get(identity);
if (existing) return existing as Promise<T>;
if (operationBlocked(operations, key)) return Promise.resolve(false);
operations = { ...operations, [key]: { status: 'running', startedAt: now() } };
operations = { ...operations, [key]: { status: 'running', startedAt: now(), target } };
onChange(operations);
const promise: Promise<T> = Promise.resolve()
@@ -40,10 +54,10 @@ export function createOperationRegistry(
.finally(() => {
const { [key]: completed, ...remaining } = operations;
operations = remaining;
inFlight.delete(key);
inFlight.delete(identity);
onChange(operations);
});
inFlight.set(key, promise as Promise<unknown>);
inFlight.set(identity, promise as Promise<unknown>);
return promise;
}
+59
View File
@@ -28,6 +28,65 @@
min-height: 76px;
}
.client-applied-identity {
min-height: 38px;
display: grid;
justify-items: center;
align-content: start;
gap: 3px;
padding-top: 5px;
}
.client-applied-value {
max-width: min(320px, calc(100vw - 42px));
display: grid;
}
.client-applied-value strong {
grid-area: 1 / 1;
max-width: min(320px, calc(100vw - 42px));
overflow: hidden;
color: var(--client-text);
font-size: 12px;
font-weight: 650;
letter-spacing: -0.025em;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 260ms ease, filter 360ms cubic-bezier(0.16, 1, 0.3, 1), transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-applied-value strong.is-active {
animation: client-applied-identity-in 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-applied-value strong.is-leaving {
opacity: 0;
filter: blur(5px);
transform: translateY(-2px);
}
.client-applied-operation {
width: 100%;
min-height: 15px;
overflow: hidden;
color: var(--client-accent);
font-size: 8px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes client-applied-identity-in {
from { opacity: 0; filter: blur(5px); transform: translateY(2px); }
}
@media (prefers-reduced-motion: reduce) {
.client-applied-value strong {
transition: none;
animation: none;
}
}
.client-power-control {
position: relative;
width: 96px;
-1
View File
@@ -1099,7 +1099,6 @@
text-align: center;
}
.client-gateway-summary-kicker,
.client-gateway-traffic-total > small {
color: var(--client-muted);
font-size: 9px;
+439 -452
View File
@@ -1,454 +1,3 @@
.client-icon-tooltip {
width: 32px;
height: 32px;
display: grid;
place-items: center;
}
.client-subscription-drawer .client-subscription,
.client-subscription-drawer .client-usage,
.client-subscription-drawer .client-servers {
width: min(100%, 300px);
margin-inline: auto;
}
.client-subscription {
position: relative;
min-width: 0;
min-height: 88px;
display: grid;
align-items: center;
justify-items: center;
}
.client-subscription-refresh,
.client-subscription-delete {
width: 32px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--client-muted);
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 300ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-refresh svg,
.client-subscription-delete svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.client-subscription-delete .client-trash-lid {
transform-origin: 12px 7px;
transition: transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-delete:hover:not(:disabled) .client-trash-lid,
.client-subscription-delete:focus-visible .client-trash-lid {
transform: translateY(-2px) rotate(-10deg);
}
.client-subscription-refresh:hover:not(:disabled) {
color: var(--client-accent);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 55%, transparent));
transform: rotate(90deg);
}
.client-subscription-refresh:disabled {
color: var(--client-accent);
cursor: wait;
animation: client-spin 900ms linear infinite;
}
.client-subscription-refresh:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-subscription-delete:hover:not(:disabled) {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 6px oklch(0.68 0.15 28 / 0.45));
}
.client-subscription-delete:disabled {
opacity: 0.4;
cursor: wait;
}
.client-subscription-delete:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-subscription.is-editing .client-subscription-refresh,
.client-subscription.is-editing .client-subscription-delete {
opacity: 0;
pointer-events: none;
}
.client-subscription.is-editing .client-icon-tooltip {
opacity: 0;
pointer-events: none;
}
.client-subscription-edit,
.client-subscription-summary {
grid-area: 1 / 1;
width: 100%;
border: 0;
background: transparent;
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-edit {
width: min(100%, 380px);
min-height: 44px;
display: block;
position: relative;
opacity: 0;
filter: blur(16px);
transform: translateY(6px) scale(0.96);
pointer-events: none;
}
.client-subscription-edit::before,
.client-subscription-edit::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
pointer-events: none;
}
.client-subscription-edit::before {
background: var(--client-border);
}
.client-subscription-edit::after {
background: var(--client-accent);
opacity: 0.72;
filter: blur(0.5px);
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
transition: background 500ms ease, box-shadow 500ms ease;
}
.client-subscription-edit.is-invalid::after {
background: oklch(0.68 0.15 28);
box-shadow: 0 0 8px oklch(0.68 0.15 28 / 0.72), 0 0 18px oklch(0.68 0.15 28 / 0.24);
}
.client-subscription.is-timing-out .client-subscription-edit::after {
animation: client-subscription-timeout 5s linear forwards;
}
@keyframes client-subscription-timeout {
0% {
opacity: 0.9;
box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft);
}
100% {
opacity: 0.08;
box-shadow: 0 0 0 transparent;
}
}
.client-subscription.is-editing .client-subscription-edit {
opacity: 1;
filter: blur(0);
transform: translateY(0);
pointer-events: auto;
}
.client-subscription-edit input {
width: 100%;
min-width: 0;
height: 44px;
padding: 0 42px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
caret-color: transparent;
font-size: 12px;
text-align: center;
}
.client-subscription-edit input::placeholder {
color: var(--client-muted);
}
.client-subscription-edit input.has-value {
color: transparent;
caret-color: transparent;
}
.client-subscription-edit input:-webkit-autofill,
.client-subscription-edit input:-webkit-autofill:hover,
.client-subscription-edit input:-webkit-autofill:focus {
box-shadow: 0 0 0 1000px var(--client-bg) inset;
-webkit-text-fill-color: transparent;
}
.client-subscription-domain {
position: absolute;
top: 50%;
left: 42px;
right: 42px;
overflow: hidden;
color: var(--client-text);
font-size: 12px;
text-align: center;
text-overflow: ellipsis;
transform: translateY(-50%);
white-space: nowrap;
pointer-events: none;
}
.client-subscription-submit {
position: absolute;
top: 4px;
right: 0;
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 19px;
font-weight: 700;
cursor: pointer;
transition: color 250ms ease, filter 500ms ease, opacity 250ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-submit:hover:not(:disabled),
.client-subscription-submit:focus-visible {
filter: drop-shadow(0 0 5px var(--client-accent)) drop-shadow(0 0 14px var(--client-accent));
transform: scale(1.14);
}
.client-subscription-submit:disabled {
cursor: default;
}
.client-subscription-edit.is-invalid .client-subscription-submit {
color: oklch(0.68 0.15 28);
opacity: 0.9;
}
.client-subscription-heading {
width: 100%;
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 64px;
align-items: center;
}
.client-subscription-status {
grid-column: 2;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-subscription-status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.client-subscription-actions {
grid-column: 3;
display: flex;
justify-content: flex-end;
}
.client-subscription-domain-button {
width: 100%;
min-width: 0;
max-width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.client-subscription-domain-button .client-subscription-label {
display: block;
margin-bottom: 3px;
}
.client-subscription-summary {
min-width: 0;
max-width: 100%;
min-height: 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
padding: 0 2px;
color: var(--client-muted);
text-align: center;
cursor: default;
overflow: visible;
}
.client-subscription.is-editing .client-subscription-summary {
opacity: 0;
filter: blur(18px);
transform: translateY(-4px) scale(1.06);
pointer-events: none;
}
.client-subscription-label {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 30px;
font-weight: 650;
letter-spacing: -0.05em;
opacity: 0.96;
text-shadow: 0 0 20px color-mix(in oklch, var(--client-accent) 24%, transparent);
transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1), transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
text-overflow: ellipsis;
white-space: nowrap;
}
.client-subscription-domain-button:hover strong {
opacity: 1;
transform: translateY(-2px);
text-shadow: 0 0 28px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-usage {
width: min(100%, 280px);
min-height: 76px;
display: grid;
align-content: start;
gap: 9px;
margin: -6px auto 0;
color: var(--client-muted);
text-align: center;
}
.client-usage-summary {
display: grid;
gap: 3px;
}
.client-usage-summary > span,
.client-usage-details > span {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-usage-summary > strong {
color: var(--client-text);
font-size: 16px;
font-weight: 600;
transition: color 700ms ease, filter 700ms ease, text-shadow 700ms ease;
}
.client-usage.is-updated {
animation: client-usage-glow 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage.is-updated .client-usage-summary > strong {
color: var(--client-accent);
filter: brightness(1.25);
text-shadow: 0 0 8px var(--client-accent), 0 0 22px color-mix(in oklch, var(--client-accent) 70%, transparent);
}
.client-usage.is-updated .client-usage-bar i {
animation: client-bar-flare 1100ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes client-usage-glow {
30% { filter: drop-shadow(0 0 18px color-mix(in oklch, var(--client-accent) 78%, transparent)); }
}
@keyframes client-bar-flare {
30% { box-shadow: 0 0 6px var(--client-accent), 0 0 20px var(--client-accent); filter: brightness(1.45); }
}
.client-usage-summary > strong small {
color: var(--client-muted);
font-size: 10px;
font-weight: 500;
}
.client-usage-bar {
height: 2px;
overflow: hidden;
background: var(--client-border);
}
.client-usage-bar i {
display: block;
height: 100%;
background: var(--client-accent);
box-shadow: 0 0 8px var(--client-accent);
transition: width 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-usage-details {
min-height: 24px;
display: grid;
grid-template-columns: auto auto;
justify-content: center;
align-items: baseline;
column-gap: 6px;
font-size: 9px;
white-space: nowrap;
}
.client-usage-details > span {
grid-column: 1 / -1;
font-size: 8px;
opacity: 0.72;
}
.client-usage-details > strong {
color: var(--client-text);
font-size: 10px;
font-weight: 600;
}
.client-usage-details > small {
color: var(--client-muted);
font-size: 9px;
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
@@ -483,8 +32,446 @@
}
}
.client-subscription-sheet {
width: 100%;
display: block;
padding: 32px 30px 76px;
}
.client-profiles-header {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
padding-right: 76px;
}
.client-profiles-header h2 {
flex: 1;
margin: 0;
font-size: 16px;
letter-spacing: 0.02em;
}
.client-profiles-header > button:not(.client-drawer-close) {
width: 40px;
height: 40px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 300 24px/1 inherit;
cursor: pointer;
}
.client-profiles-header > button:hover,
.client-profiles-header > button:focus-visible {
color: var(--client-accent);
}
.client-profiles-operation,
.client-profiles-current {
min-height: 42px;
display: flex;
align-items: center;
margin-top: 14px;
padding: 8px 12px;
border: 1px solid var(--client-border);
color: var(--client-muted);
font-size: 9px;
}
.client-profiles-operation {
gap: 8px;
}
.client-profiles-operation.is-active::before {
content: '';
width: 7px;
height: 7px;
border: 1px solid currentColor;
border-radius: 50%;
}
.client-profiles-current {
gap: 8px;
margin-bottom: 18px;
}
.client-profiles-current strong {
min-width: 0;
overflow: hidden;
color: var(--client-text);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-list {
border-top: 1px solid var(--client-border);
}
.client-profile-group {
position: relative;
border-bottom: 1px solid var(--client-border);
}
.client-profile-header {
min-height: 72px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto 36px 36px;
align-items: center;
gap: 4px;
}
.client-profile-disclosure {
min-width: 0;
min-height: 56px;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
padding: 6px 0;
border: 0;
background: transparent;
color: var(--client-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.client-profile-chevron {
color: var(--client-muted);
font-size: 20px;
transform: rotate(0);
transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-profile-disclosure[aria-expanded='true'] .client-profile-chevron {
transform: rotate(90deg);
}
.client-profile-title {
min-width: 0;
display: grid;
gap: 4px;
}
.client-profile-title > span {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.client-profile-title strong,
.client-profile-title small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-title strong {
font-size: 12px;
letter-spacing: -0.02em;
}
.client-profile-title small {
color: var(--client-muted);
font-size: 9px;
}
.client-profile-title em {
padding: 3px 6px;
border: 1px solid color-mix(in oklch, var(--client-accent) 30%, transparent);
border-radius: 3px;
background: color-mix(in oklch, var(--client-accent) 8%, transparent);
color: var(--client-accent);
font-size: 7px;
font-style: normal;
letter-spacing: 0.08em;
}
.client-profile-usage {
max-width: 126px;
overflow: hidden;
color: var(--client-muted);
font-size: 8px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-profile-refresh,
.client-profile-menu-toggle {
width: 36px;
height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
font: 400 18px/1 inherit;
cursor: pointer;
}
.client-profile-refresh:hover:not(:disabled),
.client-profile-refresh:focus-visible,
.client-profile-menu-toggle:hover,
.client-profile-menu-toggle:focus-visible {
color: var(--client-accent);
}
.client-profile-refresh.is-refreshing {
color: var(--client-accent);
animation: client-spin 900ms linear infinite;
}
.client-profile-refresh:disabled,
.client-profile-menu-toggle:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-menu-wrap {
position: relative;
}
.client-profile-menu {
position: absolute;
top: 34px;
right: 0;
z-index: 4;
width: 150px;
display: grid;
padding: 6px;
border: 1px solid var(--client-border);
background: var(--client-bg);
box-shadow: 0 16px 38px color-mix(in oklch, var(--client-bg) 80%, transparent);
}
.client-profile-menu button {
min-height: 34px;
padding: 0 8px;
border: 0;
background: transparent;
color: var(--client-text);
font: 500 9px/1.2 inherit;
text-align: left;
cursor: pointer;
}
.client-profile-menu button:hover,
.client-profile-menu button:focus-visible {
background: color-mix(in oklch, var(--client-accent) 7%, transparent);
}
.client-profile-menu button.is-danger {
color: oklch(0.68 0.15 28);
}
.client-profile-body {
max-height: 3200px;
padding: 0 0 20px 18px;
overflow: hidden;
opacity: 1;
visibility: visible;
transition: max-height 560ms cubic-bezier(0.16, 1, 0.3, 1), opacity 260ms ease, padding 420ms ease, visibility 0s;
}
.client-profile-body[aria-hidden='true'] {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
visibility: hidden;
transition: max-height 420ms cubic-bezier(0.16, 1, 0.3, 1), opacity 180ms ease, padding 320ms ease, visibility 0s 420ms;
}
.client-profile-body .client-servers {
width: 100%;
margin: 0;
}
.client-profile-activate,
.client-profile-add-trigger {
min-height: 36px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: 600 9px/1 inherit;
letter-spacing: 0.03em;
text-transform: uppercase;
cursor: pointer;
}
.client-profile-activate {
display: block;
margin-left: auto;
}
.client-profile-activate:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-add-trigger {
width: 100%;
margin-top: 24px;
text-align: left;
}
.client-profile-server-hint,
.client-profile-local-status,
.client-profile-form-status {
min-height: 18px;
margin: 0;
color: var(--client-muted);
font-size: 8px;
line-height: 1.45;
}
.client-profile-local-status {
padding-left: 18px;
color: oklch(0.68 0.14 72);
}
.client-profile-add,
.client-profile-rename {
display: grid;
gap: 10px;
padding: 16px 0 20px;
border-bottom: 1px solid var(--client-border);
}
.client-profile-add label {
display: grid;
gap: 6px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-profile-add input,
.client-profile-rename input {
min-width: 0;
height: 40px;
padding: 0 10px;
border: 1px solid var(--client-border);
border-radius: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 10px/1 inherit;
}
.client-profile-add input:focus,
.client-profile-rename input:focus {
border: 1px solid var(--client-accent);
}
.client-profile-add input[aria-invalid='true'],
.client-profile-rename input[aria-invalid='true'] {
border: 1px solid oklch(0.68 0.15 28);
}
.client-profile-form-actions,
.client-profile-rename {
grid-template-columns: 1fr auto auto;
align-items: center;
}
.client-profile-form-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.client-profile-form-actions button,
.client-profile-rename button {
min-height: 34px;
padding: 0 10px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 600 9px/1 inherit;
cursor: pointer;
}
.client-profile-form-actions button.is-primary,
.client-profile-rename button[type='submit'] {
color: var(--client-accent);
}
.client-profile-form-actions button:disabled,
.client-profile-rename button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.client-profile-rename {
grid-template-columns: minmax(0, 1fr) auto auto;
padding-left: 18px;
}
.client-profile-rename > span {
grid-column: 1 / -1;
min-height: 12px;
color: oklch(0.68 0.15 28);
font-size: 8px;
}
.client-subscription-first-run {
gap: 18px;
}
.client-first-run-copy {
display: grid;
gap: 6px;
text-align: center;
}
.client-first-run-copy span {
color: var(--client-muted);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.client-first-run-copy strong {
font-size: 16px;
}
@media (max-width: 520px) {
.client-subscription-sheet {
padding: 24px 18px 64px;
}
.client-profile-header {
grid-template-columns: minmax(0, 1fr) 36px 36px;
}
.client-profile-usage {
display: none;
}
.client-profile-body {
padding-left: 8px;
}
.client-profile-disclosure,
.client-profile-refresh,
.client-profile-menu-toggle,
.client-profiles-header > button:not(.client-drawer-close) {
min-height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
.client-usage-summary > strong {
.client-profile-chevron,
.client-profile-body,
.client-profile-refresh {
transition: none;
animation: none;
}
}
-42
View File
@@ -558,40 +558,6 @@
row-gap: 36px;
}
.client-gateway-route-summary {
min-height: 54px;
display: grid;
justify-items: center;
gap: 2px;
margin-top: 6px;
}
.client-gateway-route-summary strong {
max-width: min(300px, 100%);
min-height: 20px;
overflow: hidden;
color: var(--client-text);
font-size: 15px;
letter-spacing: -0.035em;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-gateway-route-slot {
width: 100%;
min-height: 16px;
overflow: hidden;
color: var(--client-accent);
font-size: 9px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-panel.has-subscription .client-form {
height: var(--client-work-height);
}
.client-panel.is-gateway-home .client-state-copy {
min-height: 84px;
}
@@ -602,10 +568,6 @@
--client-power-top: calc((var(--client-work-height) - 96px) / 2);
transform: translateY(-9vh);
}
.client-panel.has-subscription .client-form-content {
padding-top: var(--client-power-top);
}
}
@media (max-width: 920px) {
@@ -715,10 +677,6 @@
bottom: 0;
}
.client-form-content {
gap: 24px;
}
.harbor-brand {
transform: translateX(-50%) scale(1.35);
}
+1 -18
View File
@@ -490,15 +490,6 @@
transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-form-content {
display: grid;
align-content: start;
gap: 24px;
opacity: 1;
filter: blur(0);
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-subscription-drawer {
width: min(480px, 100vw);
}
@@ -645,13 +636,7 @@
cursor: wait;
}
.client-form.is-waiting {
opacity: 0;
filter: blur(10px);
pointer-events: none;
}
.client-shell:has(.harbor-brand.is-gateway-active) .client-form:not(.is-waiting) {
.client-shell:has(.harbor-brand.is-gateway-active) .client-form {
opacity: 0.34;
filter: grayscale(1) saturate(0);
}
@@ -660,8 +645,6 @@
to { transform: rotate(360deg); }
}
.client-subscription-edit button:focus-visible,
.client-subscription-domain-button:focus-visible,
.client-server:focus-visible,
.client-power:focus-visible {
outline: 2px solid var(--client-accent);
+1 -13
View File
@@ -21,16 +21,11 @@
.client-power::before,
.client-power::after,
.client-power svg,
.client-usage-bar i,
.client-subscription-refresh,
.client-subscription-delete,
.client-power-section,
.client-gateway-traffic-chart,
.client-gateway-traffic-freshness,
.client-subscription-drawer,
.client-form,
.client-usage,
.client-usage > strong,
.client-server,
.client-server-auto,
.client-server-health > span,
@@ -93,11 +88,6 @@
.client-devices-refresh-ring circle,
.client-devices-refresh-icon,
.client-text-morph-value,
.client-subscription-edit,
.client-subscription-edit::after,
.client-subscription-submit,
.client-subscription-summary,
.client-subscription-summary strong,
.client-proxy-label > span {
transition: none;
animation: none;
@@ -180,12 +170,10 @@
animation: none;
}
.client-form-content,
.client-confirmation-popup,
.client-confirmation-dialog,
.client-confirmation-dialog > *,
.client-confirmation-actions button,
.client-subscription-delete .client-trash-lid {
.client-confirmation-actions button {
transition: none;
animation: none;
}
+1 -4
View File
@@ -1,17 +1,14 @@
interface ConnectionActionInput {
connected: boolean;
selectedServerId: string;
configExists: boolean;
}
export function connectionAction({ connected, selectedServerId, configExists }: ConnectionActionInput):
export function connectionAction({ connected, selectedServerId }: ConnectionActionInput):
| { type: 'stop' }
| { type: 'apply'; serverId: string }
| { type: 'restart' }
| null {
if (connected) return { type: 'stop' };
if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
if (configExists) return { type: 'restart' };
return null;
}