Refactor VPN proxy components and update related behavior
This commit is contained in:
+144
-49
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1099,7 +1099,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.client-gateway-summary-kicker,
|
||||
.client-gateway-traffic-total > small {
|
||||
color: var(--client-muted);
|
||||
font-size: 9px;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user