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

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
+73 -47
View File
@@ -1,46 +1,67 @@
import React, { useEffect, useReducer, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import './styles.css';
import { api, HarborApiError } from './api.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.jsx';
import { BootStatePage, StaleBanner } from './components/SyncStatus.jsx';
import {
compatibleSnapshot,
api,
harborClient,
HarborApiError,
parseHarborState,
} from './api/harborClient.js';
import { ClientOverviewPage } from './components/ClientOverviewPage.js';
import { BootStatePage, StaleBanner } from './components/SyncStatus.js';
import {
harborReducer,
initialHarborState,
} from './state/harborReducer.js';
import { createOperationRegistry } from './state/operations.js';
import {
createOperationRegistry,
type OperationKey,
type OperationRegistrySnapshot,
} from './state/operations.js';
function App() {
const componentActions = {
validateSubscription: api.subscription.validate,
listDevices: api.devices.list,
refreshDevices: api.devices.refresh,
updateDevice: api.devices.update,
setDevicePolicy: api.devices.setPolicy,
pingServers: api.servers.ping,
runConnectivityDiagnostics: api.diagnostics.connectivity,
};
interface UiError {
context: string;
message: string;
code: string;
correlationId: string;
retry: (() => unknown) | null;
}
export function App() {
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
harborReducer,
initialHarborState,
);
const [subscriptionUrl, setSubscriptionUrl] = useState('');
const [operations, setOperations] = useState({});
const [error, setError] = useState(null);
const [versionInfo, setVersionInfo] = useState(null);
const [operations, setOperations] = useState<OperationRegistrySnapshot>({});
const [error, setError] = useState<UiError | null>(null);
const [versionInfo, setVersionInfo] = useState<unknown>(null);
const pollGeneration = useRef(0);
const operationRegistry = useRef(null);
const operationRegistry = useRef<ReturnType<typeof createOperationRegistry> | null>(null);
if (!operationRegistry.current) {
operationRegistry.current = createOperationRegistry(setOperations);
operationRegistry.current = createOperationRegistry((next) => {
setOperations(next);
});
}
function setPendingServerId(serverId) {
function setPendingServerId(serverId: string) {
dispatch({ type: 'select-server', serverId });
}
async function loadState({ retry = false } = {}) {
async function loadState({ retry = false }: { retry?: boolean } = {}) {
if (retry) dispatch({ type: 'retry-sync' });
const generation = pollGeneration.current;
try {
const snapshot = await api.state();
if (!compatibleSnapshot(snapshot)) {
const incompatible = new Error('Ожидался Harbor state apiVersion 1');
incompatible.code = 'INCOMPATIBLE_API';
throw incompatible;
}
const snapshot = await harborClient.getState();
if (generation === pollGeneration.current) {
dispatch({ type: 'sync-succeeded', snapshot, receivedAt: new Date().toISOString() });
}
@@ -61,8 +82,9 @@ function App() {
let cancelled = false;
api.version().then((info) => {
if (!cancelled) setVersionInfo(info);
}).catch((requestError) => {
console.warn(`[version] Не удалось получить runtime-версию: ${requestError.message}`);
}).catch((requestError: unknown) => {
const message = requestError instanceof Error ? requestError.message : String(requestError);
console.warn(`[version] Не удалось получить runtime-версию: ${message}`);
if (!cancelled) setVersionInfo(null);
});
return () => { cancelled = true; };
@@ -72,20 +94,20 @@ function App() {
if (!state?.mode) return;
const isGateway = state.mode === 'gateway';
document.title = isGateway ? 'Harbor Gateway' : 'Harbor Connect';
document.getElementById('harbor-favicon').href = isGateway
? '/harbor-gateway.svg?v=2'
: '/harbor-connect.svg?v=2';
const favicon = document.getElementById('harbor-favicon') as HTMLLinkElement | null;
if (favicon) favicon.href = isGateway ? '/harbor-gateway.svg?v=2' : '/harbor-connect.svg?v=2';
}, [state?.mode]);
function run(key, action, context) {
function run(key: OperationKey, action: () => Promise<unknown>, context: string) {
setError(null);
return operationRegistry.current.run(key, async () => {
return operationRegistry.current!.run(key, async () => {
try {
return await applyMutation(action);
} catch (err) {
const candidate = err && typeof err === 'object' ? err as Record<string, unknown> : {};
const safeError = err instanceof HarborApiError
? err
: new HarborApiError({ code: err?.code }, err?.status);
: new HarborApiError({ code: candidate.code }, Number(candidate.status));
setError({
context,
message: context === 'routing' && safeError.code === 'STATE_CONFLICT'
@@ -102,14 +124,18 @@ function App() {
});
}
async function applyMutation(action) {
async function applyMutation(action: () => Promise<unknown>) {
pollGeneration.current += 1;
const result = await action();
if (!result?.state) throw new Error('Harbor API не вернул state snapshot');
if (!compatibleSnapshot(result.state)) throw new Error('Harbor API не вернул state snapshot v1');
const response = await action();
if (!response || typeof response !== 'object' || Array.isArray(response)) {
throw new Error('Harbor API не вернул state snapshot');
}
const result = response as Record<string, unknown>;
if (!result.state) throw new Error('Harbor API не вернул state snapshot');
const snapshot = parseHarborState(result.state);
dispatch({
type: 'sync-succeeded',
snapshot: result.state,
snapshot,
receivedAt: new Date().toISOString(),
});
return result;
@@ -138,20 +164,22 @@ function App() {
if (!state) return <BootStatePage transport={transport} onRetry={() => loadState({ retry: true })} />;
const displayState = previewReady ? {
...state,
mode: 'client' as const,
subscription: { ...state.subscription, status: 'ready' as const, host: 'harbor.example' },
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
clientRuntime: { ...state.clientRuntime, proxyPort: 8082 },
} : state;
return (
<div className={`app client-app${state.mode === 'gateway' ? ' is-gateway-app' : ''}`}>
<StaleBanner transport={transport} onRetry={() => loadState({ retry: true })} />
<div className="app-body client-mode">
<main className="app-main">
<ClientOverviewPage
state={previewReady ? {
...state,
mode: 'client',
hasSubscription: true,
subscriptionHost: 'harbor.example',
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
proxyPort: 8082,
} : state}
actions={componentActions}
state={displayState}
versionInfo={versionInfo}
operations={operations}
error={error}
@@ -169,11 +197,11 @@ function App() {
onFetchSubscription={fetchSubscription}
onRefreshSubscription={refreshSubscription}
onForgetSubscription={forgetSubscription}
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
onApply={(serverId: string) => run('serverApply', () => api.apply(serverId), 'connection')}
onRestart={() => run('connection', api.singbox.restart, 'connection')}
onStop={() => run('connection', api.singbox.stop, 'connection')}
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
onSaveRouteRules={(rules, expectedRevision) => run(
onSetGatewayAuto={(enabled: boolean) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
onSaveRouteRules={(rules: unknown[], expectedRevision: number) => run(
'routeRules',
() => api.routeRules.update(rules, expectedRevision),
'routing',
@@ -185,5 +213,3 @@ function App() {
</div>
);
}
createRoot(document.getElementById('root')).render(<App />);
-110
View File
@@ -1,110 +0,0 @@
import { ERROR_DEFINITIONS, errorDefinition } from '../shared/errors.js';
export class HarborApiError extends Error {
constructor(payload = {}, status = 0) {
const code = ERROR_DEFINITIONS[payload.code] ? payload.code : 'UNKNOWN';
const definition = errorDefinition(code);
super(definition.message);
this.name = 'HarborApiError';
this.code = code;
this.status = status >= 400 ? status : definition.status;
this.retryable = definition.retryable;
this.details = payload.details;
this.correlationId = payload.correlationId
|| globalThis.crypto?.randomUUID?.()
|| new Date().toISOString();
}
}
export async function request(url, options = {}, fetchImpl = fetch) {
let response;
try {
response = await fetchImpl(url, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
} catch (error) {
if (error?.name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data = {};
try {
data = await response.json();
} catch {
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
}
if (!response.ok || data?.success === false) {
const payload = data?.error && typeof data.error === 'object'
? data.error
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
throw new HarborApiError(payload, response.status);
}
return data;
}
export const api = {
state: () => request('/api/state'),
version: () => request('/api/version'),
subscription: {
validate: (url, { signal } = {}) => request('/api/subscription/validate', {
method: 'POST',
body: JSON.stringify({ url }),
signal,
}),
fetch: (url) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
}),
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (serverId) => request('/api/apply', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
}),
gatewayAuto: {
setEnabled: (enabled) => request('/api/gateway-auto', {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
},
routeRules: {
update: (rules, expectedRulesRevision) => request('/api/route-rules', {
method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }),
}),
},
devices: {
list: () => request('/api/devices'),
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
update: (id, patch, expectedRevision) => request(`/api/devices/${id}`, {
method: 'PUT',
body: JSON.stringify({ ...patch, expectedRevision }),
}),
setPolicy: (id, mode, expectedRevision) => request(`/api/devices/${id}/policy`, {
method: 'PUT',
body: JSON.stringify({ mode, expectedRevision }),
}),
},
diagnostics: {
connectivity: (services = [], target = null) => request('/api/diagnostics/connectivity', {
method: 'POST',
body: JSON.stringify({ services, target }),
}),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds) => request('/api/servers/ping-all', {
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
};
+203
View File
@@ -0,0 +1,203 @@
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
type RequestOptions = Omit<RequestInit, 'headers'> & {
headers?: Record<string, string>;
};
interface JsonResponse {
ok: boolean;
status: number;
json(): Promise<unknown>;
}
type FetchImplementation = (url: string, options: RequestOptions) => Promise<JsonResponse>;
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export class HarborApiError extends Error {
code: string;
status: number;
retryable: boolean;
details: unknown;
correlationId: string;
constructor(payload: unknown = {}, status = 0) {
const candidate = record(payload);
const requestedCode = typeof candidate.code === 'string' ? candidate.code : '';
const code = Object.hasOwn(ERROR_DEFINITIONS, requestedCode) ? requestedCode : 'UNKNOWN';
const definition = errorDefinition(code);
super(definition.message);
this.name = 'HarborApiError';
this.code = code;
this.status = status >= 400 ? status : definition.status;
this.retryable = definition.retryable;
this.details = candidate.details;
this.correlationId = typeof candidate.correlationId === 'string' && candidate.correlationId
? candidate.correlationId
: globalThis.crypto?.randomUUID?.() || new Date().toISOString();
}
}
export async function request(
url: string,
options: RequestOptions = {},
fetchImpl: FetchImplementation = fetch,
): Promise<unknown> {
let response: JsonResponse;
try {
response = await fetchImpl(url, {
...options,
headers: {
'content-type': 'application/json',
...(options.headers || {}),
},
});
} catch (error) {
if (record(error).name === 'AbortError') throw error;
throw new HarborApiError({ code: 'CONTROL_UNREACHABLE' });
}
let data: unknown = {};
try {
data = await response.json();
} catch {
if (response.ok) throw new HarborApiError({ code: 'UNKNOWN' }, response.status);
}
const payload = record(data);
if (!response.ok || payload.success === false) {
const errorPayload = payload.error && typeof payload.error === 'object'
? payload.error
: { code: response.status >= 500 ? 'CONTROL_UNREACHABLE' : 'UNKNOWN' };
throw new HarborApiError(errorPayload, response.status);
}
return data;
}
export const api = {
version: () => request('/api/version'),
subscription: {
validate: (url: string, { signal }: { signal?: AbortSignal } = {}) => request(
'/api/subscription/validate',
{
method: 'POST',
body: JSON.stringify({ url }),
signal,
},
),
fetch: (url: string) => request('/api/subscription/fetch', {
method: 'POST',
body: JSON.stringify({ url }),
}),
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
forget: () => request('/api/subscription', { method: 'DELETE' }),
},
apply: (serverId: string) => request('/api/apply', {
method: 'POST',
// selectedTag keeps this client compatible with pre-ID Harbor backends.
body: JSON.stringify({ serverId, selectedTag: serverId }),
}),
gatewayAuto: {
setEnabled: (enabled: boolean) => request('/api/gateway-auto', {
method: 'POST',
body: JSON.stringify({ enabled }),
}),
},
routeRules: {
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', {
method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }),
}),
},
devices: {
list: () => request('/api/devices'),
refresh: () => request('/api/devices/refresh', { method: 'POST' }),
update: (id: string, patch: Record<string, unknown>, expectedRevision: unknown) => request(
`/api/devices/${id}`,
{
method: 'PUT',
body: JSON.stringify({ ...patch, expectedRevision }),
},
),
setPolicy: (id: string, mode: unknown, expectedRevision: unknown) => request(
`/api/devices/${id}/policy`,
{
method: 'PUT',
body: JSON.stringify({ mode, expectedRevision }),
},
),
},
diagnostics: {
connectivity: (services: unknown[] = [], target: unknown = null) => request(
'/api/diagnostics/connectivity',
{
method: 'POST',
body: JSON.stringify({ services, target }),
},
),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
},
servers: {
ping: (serverIds: string[]) => request('/api/servers/ping-all', {
method: 'POST',
body: JSON.stringify({ serverIds }),
}),
},
};
export interface HarborClientState extends StateSnapshot {
clientRuntime: {
proxyPort: number;
configured: boolean;
gatewayAvailable: boolean;
};
}
export function parseHarborState(value: unknown): HarborClientState {
let snapshot: StateSnapshot;
try {
snapshot = assertStateSnapshot(value);
} catch (cause) {
throw Object.assign(new Error('Ожидался Harbor state apiVersion 1', { cause }), {
code: 'INCOMPATIBLE_API',
});
}
const payload = record(value);
const gatewayAuto = record(payload.gatewayAuto);
const parsedProxyPort = Number(payload.proxyPort);
const canonical: StateSnapshot = {
apiVersion: snapshot.apiVersion,
revision: snapshot.revision,
generatedAt: snapshot.generatedAt,
mode: snapshot.mode,
subscription: snapshot.subscription,
selection: snapshot.selection,
connection: snapshot.connection,
route: snapshot.route,
operation: snapshot.operation,
servers: snapshot.servers,
};
return {
...canonical,
clientRuntime: {
proxyPort: Number.isInteger(parsedProxyPort) && parsedProxyPort > 0
? parsedProxyPort
: snapshot.mode === 'gateway' ? 8080 : 8082,
configured: payload.configExists === true,
gatewayAvailable: gatewayAuto.available === true,
},
};
}
export const harborClient = {
async getState(): Promise<HarborClientState> {
return parseHarborState(await request('/api/state'));
},
};
File diff suppressed because it is too large Load Diff
+652
View File
@@ -0,0 +1,652 @@
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
} from 'react';
import {
copyText,
localProxyUrls,
} from '../utils/clientControls.js';
import {
operationBlocked,
type OperationKey,
type OperationRegistrySnapshot,
} from '../state/operations.js';
import { ConnectionPanel } from '../features/connection/index.js';
import {
SubscriptionDeleteDialog,
SubscriptionPanel,
SubscriptionToggle,
useSubscriptionFeature,
} from '../features/subscription/index.js';
import { ServerPicker } from '../features/servers/index.js';
import {
RoutingDiscardDialog,
RoutingPanel,
RoutingPendingStatus,
RoutingToggle,
useRoutingFeature,
} from '../features/routing/index.js';
import {
DevicesPanel,
DevicesToggle,
GatewayTrafficSummary,
useDevicesFeature,
} from '../features/devices/index.js';
import {
ConnectivityDiagnosticsPanel,
DiagnosticsToggle,
useDiagnosticsFeature,
} from '../features/diagnostics/index.js';
import {
InstructionsPanel,
InstructionsToggle,
useInstructionsFeature,
} from '../features/instructions/index.js';
import {
HARBOR_VERSIONS,
parseVersion,
versionCompatibility,
} from '../../shared/versions.js';
import type {
HarborServer,
RouteRule,
StateSnapshot,
} from '../../shared/contracts/state.js';
const VERSION_PARTS = [
['major', 'Major'],
['minor', 'Minor'],
['hotfix', 'Hotfix'],
] as const;
interface UiError {
context?: string;
message?: string;
correlationId?: string;
retry?: (() => unknown) | null;
}
interface VersionBadgeProps {
code: string;
component: string;
componentKey: string;
version: unknown;
runtime?: string | null;
incompatible?: boolean;
}
interface ComponentActions {
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: 'vpn' | 'direct', expectedRevision: number) => Promise<unknown>;
pingServers: (ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (services?: unknown[], target?: unknown) => Promise<unknown>;
}
interface ClientViewState extends StateSnapshot {
clientRuntime: {
proxyPort: number;
configured: boolean;
gatewayAvailable: boolean;
};
}
interface ClientOverviewPageProps {
actions: ComponentActions;
state: ClientViewState;
versionInfo: unknown;
operations?: OperationRegistrySnapshot;
error: UiError | null;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
servers: HarborServer[];
pendingServerId: string;
setPendingServerId: (id: string) => void;
onFetchSubscription: () => Promise<unknown>;
onRefreshSubscription: () => Promise<unknown>;
onForgetSubscription: () => Promise<unknown>;
onApply: (serverId: string) => Promise<unknown>;
onRestart: () => Promise<unknown>;
onStop: () => Promise<unknown>;
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onDismissError: () => void;
}
type CopyKind = 'gateway' | 'socks5' | 'http';
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function VersionBadge({
code,
component,
componentKey,
version,
runtime,
incompatible = false,
}: VersionBadgeProps) {
const parsed = parseVersion(version);
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['', '', ''];
function description(key: 'major' | 'minor' | 'hotfix') {
if (key === 'major') {
return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.';
}
if (key === 'minor') {
return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.';
}
return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.';
}
return (
<div className={`harbor-version${incompatible ? ' is-incompatible' : ''}`}>
<span className="harbor-version-code" aria-hidden="true">{code}</span>
<span className="harbor-version-number" aria-label={`${component} ${version || 'не определена'}`}>
{VERSION_PARTS.map(([key, label], index) => {
const tooltipId = `harbor-version-${componentKey}-${key}`;
return <React.Fragment key={key}>
{index > 0 && <span className="harbor-version-dot" aria-hidden="true">.</span>}
<span
className="harbor-version-part"
tabIndex={0}
aria-describedby={tooltipId}
>
{values[index]}
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
<strong>{component} · {label} {values[index]}</strong>
<span>{description(key)}</span>
{runtime && <small>{runtime}</small>}
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
</span>
</span>
</React.Fragment>;
})}
</span>
</div>
);
}
function VersionDisplay({ isGateway, versionInfo }: { isGateway: boolean; versionInfo: unknown }) {
const info = record(versionInfo);
const runtime = record(info.runtime);
const components = record(info.components);
const runtimeSingBox = typeof runtime.singBox === 'string' ? runtime.singBox : null;
if (!isGateway) {
return <aside className="harbor-versions" aria-label="Версия Harbor">
<VersionBadge
code="M"
component="Mac client"
componentKey="macClient"
version={typeof components.macClient === 'string' ? components.macClient : HARBOR_VERSIONS.macClient}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
const backendVersion = typeof components.gatewayBackend === 'string' ? components.gatewayBackend : '';
const dataplaneVersion = typeof runtime.dataplaneVersion === 'string' ? runtime.dataplaneVersion : '';
const compatibility = backendVersion && versionCompatibility({
...HARBOR_VERSIONS,
gatewayBackend: backendVersion,
});
const incompatible = Boolean(compatibility && !compatibility.compatible);
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
<VersionBadge
code="C"
component="Gateway client UI"
componentKey="gatewayClient"
version={HARBOR_VERSIONS.gatewayClient}
incompatible={incompatible}
/>
<VersionBadge
code="B"
component="Gateway control backend"
componentKey="gatewayBackend"
version={backendVersion}
incompatible={incompatible}
/>
<VersionBadge
code="D"
component="Gateway dataplane"
componentKey="gatewayDataplane"
version={dataplaneVersion}
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
/>
</aside>;
}
function InlineError({ error, context }: { error?: UiError | null; context: string }) {
if (!error || error.context !== context) return null;
return (
<div className={`client-inline-error is-${context}`} role="alert">
<span>{error.message}</span>
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
{error.correlationId && (
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
)}
</div>
);
}
const operationProgress: Partial<Record<keyof OperationRegistrySnapshot, readonly [string, string]>> = {
connection: ['connection', 'Меняем состояние подключения…'],
serverApply: ['connection', 'Применяем сервер…'],
subscriptionImport: ['subscription', 'Загружаем подписку…'],
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
routeRules: ['routing', 'Применяем локальные правила…'],
};
function InlineProgress({ operations, context }: {
operations: OperationRegistrySnapshot;
context: string;
}) {
const active = (Object.entries(operationProgress) as Array<[
OperationKey,
readonly [string, string],
]>).find(([key, [operationContext]]) => (
operationContext === context && operations[key]?.status === 'running'
));
if (!active) return null;
return (
<div className={`client-inline-error client-operation-progress is-${context}`} role="status">
<span>{active[1][1]}</span>
</div>
);
}
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }: {
isGateway: boolean;
gatewayAvailable: boolean;
gatewayDirect: boolean;
blocked: boolean;
onSetGatewayAuto: (enabled: boolean) => unknown;
}) {
const [modeAnimating, setModeAnimating] = useState(false);
const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0);
const stopModeAnimationRef = useRef(false);
const previousGatewayDirectRef = useRef(gatewayDirect);
const product = isGateway ? 'Gateway' : 'Connect';
const switchable = !isGateway && gatewayAvailable;
const label = gatewayDirect
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
: 'Использовать обнаруженный Harbor Gateway';
useLayoutEffect(() => {
if (previousGatewayDirectRef.current === gatewayDirect) return;
previousGatewayDirectRef.current = gatewayDirect;
setArrowTurns((turns) => turns + 0.5);
}, [gatewayDirect]);
function startModeAnimation() {
stopModeAnimationRef.current = false;
setModeAnimating(true);
}
function finishModeAnimation() {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setModeAnimating(false);
return;
}
stopModeAnimationRef.current = true;
}
const content = <div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span className="harbor-brand-name">
<strong>Harbor</strong>
{switchable ? <span className="harbor-mode-control">
<span className="harbor-mode-stack" aria-hidden="true">
<em className="harbor-mode-connect">Connect</em>
<em className="harbor-mode-gateway"><span>Gateway</span></em>
</span>
<svg
className="harbor-mode-swap"
viewBox="0 0 18 18"
aria-hidden="true"
style={{ '--harbor-arrow-turn': `${arrowTurns}turn` } as CSSProperties}
>
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
</svg>
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
<span>{gatewayDirect
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
</span>
</span> : <em>{product}</em>}
</span>
</div>;
return (
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
{switchable ? <button
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
type="button"
aria-label={label}
aria-describedby="harbor-mode-tooltip"
aria-pressed={gatewayDirect}
disabled={blocked}
onPointerEnter={startModeAnimation}
onPointerLeave={finishModeAnimation}
onFocus={startModeAnimation}
onBlur={finishModeAnimation}
onAnimationIteration={(event) => {
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
stopModeAnimationRef.current = false;
setModeAnimating(false);
}
}}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
</div>
);
}
export function ClientOverviewPage({
actions,
state,
versionInfo,
operations = {},
error,
subscriptionUrl,
setSubscriptionUrl,
servers,
pendingServerId,
setPendingServerId,
onFetchSubscription,
onRefreshSubscription,
onForgetSubscription,
onApply,
onRestart,
onStop,
onSetGatewayAuto,
onSaveRouteRules,
onDismissError,
}: ClientOverviewPageProps) {
const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.route?.mode === 'gateway-direct';
const gatewayAvailable = !isGateway && Boolean(state?.clientRuntime?.gatewayAvailable);
const connected = state?.connection?.process === 'running';
const hasSubscription = state?.subscription?.status === 'ready';
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
const appliedServerId = state?.selection?.appliedServerId || '';
const appliedServer = servers.find(({ id }) => id === appliedServerId);
const desiredServer = servers.find(({ id }) => id === selectedServerId);
const showPower = isGateway || (hasSubscription && Boolean(selectedServerId));
const [now, setNow] = useState(Date.now());
const [showIntro, setShowIntro] = useState(true);
const [copyFeedback, setCopyFeedback] = useState<{ kind: CopyKind; failed: boolean } | null>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
const controlHost = window.location.host || `${gatewayAddress}:3456`;
const proxyUrls = localProxyUrls(state?.clientRuntime?.proxyPort, gatewayAddress);
const connectionBlocked = operationBlocked(operations, 'connection');
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
const switchingServer = Boolean(
selectedServerId && selectedServerId !== appliedServerId && desiredServer,
);
const subscriptionFeature = useSubscriptionFeature({
subscription: state?.subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
error,
serverCount: servers.length,
isGateway,
gatewayDirect,
validateSubscription: actions.validateSubscription,
onImport: onFetchSubscription,
onRefresh: onRefreshSubscription,
onForget: onForgetSubscription,
onDismissError,
});
const subscriptionContentReady = subscriptionFeature.contentReady;
const routingFeature = useRoutingFeature({
route: state?.route,
connected,
operations,
onSave: onSaveRouteRules,
onDismissError,
});
const devicesFeature = useDevicesFeature({
isGateway,
listDevices: actions.listDevices,
refreshDevices: actions.refreshDevices,
updateDevice: actions.updateDevice,
setDevicePolicy: actions.setDevicePolicy,
});
const diagnosticsFeature = useDiagnosticsFeature();
const instructionsFeature = useInstructionsFeature({
isGateway,
host: gatewayAddress,
port: state?.clientRuntime?.proxyPort || (isGateway ? 8080 : 8082),
controlHost,
});
const diagnosticsAvailable = isGateway || (hasSubscription && subscriptionContentReady);
useEffect(() => {
setNow(Date.now());
if (!isGateway && (!connected || !state?.connection?.startedAt)) return undefined;
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [isGateway, connected, state?.connection?.startedAt]);
useEffect(() => {
if (!showIntro) return undefined;
const timer = setTimeout(() => setShowIntro(false), 1200);
return () => clearTimeout(timer);
}, [showIntro]);
useEffect(() => {
if (!hasSubscription) {
routingFeature.forceClose();
if (!isGateway) {
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
}
}
}, [hasSubscription, isGateway]);
useEffect(() => {
if (!diagnosticsAvailable) diagnosticsFeature.close();
}, [diagnosticsAvailable]);
useEffect(() => () => {
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
}, []);
function selectServer(serverId: string) {
setPendingServerId(serverId);
if (connected && serverId) onApply(serverId);
}
async function copyProxy(kind: CopyKind) {
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
try {
await copyText(value);
setCopyFeedback({ kind, failed: false });
} catch {
setCopyFeedback({ kind, failed: true });
}
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
}
function openRouting() {
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
routingFeature.open();
}
return (
<div
className={`client-shell${!isGateway && !hasSubscription ? ' is-first-run' : ''}${showIntro ? ' is-intro' : ''}`}
>
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</div>
<HarborBrand
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
{isGateway && <SubscriptionToggle
feature={subscriptionFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
subscriptionFeature.toggle();
}}
/>}
<InstructionsToggle
feature={instructionsFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
devicesFeature.close();
diagnosticsFeature.close();
instructionsFeature.toggle();
}}
/>
{isGateway && <DevicesToggle
feature={devicesFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
diagnosticsFeature.close();
devicesFeature.toggle();
}}
/>}
<DiagnosticsToggle
feature={diagnosticsFeature}
onToggle={() => {
if (routingFeature.isOpen && !routingFeature.requestClose()) return;
subscriptionFeature.close();
instructionsFeature.close();
devicesFeature.close();
diagnosticsFeature.toggle();
}}
/>
<RoutingToggle
feature={routingFeature}
gatewayDirect={gatewayDirect}
isGateway={isGateway}
hasSubscription={hasSubscription}
onOpen={openRouting}
/>
</nav>}
<main className={`client-panel${showPower ? '' : ' is-setup'}${!isGateway && hasSubscription ? ' has-subscription' : ''}${isGateway ? ' is-gateway-home' : ''}`}>
<ConnectionPanel
visible={showPower}
isGateway={isGateway}
connected={connected}
gatewayDirect={gatewayDirect}
selectedServerId={selectedServerId}
configured={Boolean(state?.clientRuntime?.configured)}
startedAt={state?.connection?.startedAt}
gatewayAddress={gatewayAddress}
gatewayUiOrigin={state?.route?.gatewayUiOrigin}
gatewayRouteAddress={state?.route?.gatewayAddress}
proxyPort={state?.clientRuntime?.proxyPort}
now={now}
blocked={connectionBlocked}
copyFeedback={copyFeedback}
onCopyProxy={copyProxy}
onApply={onApply}
onRestart={onRestart}
onStop={onStop}
routingSlot={<RoutingPendingStatus
feature={routingFeature}
blocked={connectionBlocked}
onRestart={onRestart}
/>}
serverSlot={isGateway && <div className="client-gateway-route-summary" aria-labelledby="gateway-summary-title">
<span className="client-gateway-summary-kicker">Сейчас</span>
<strong id="gateway-summary-title">
{appliedServer?.label || 'VPN-сервер не используется'}
</strong>
<div className="client-gateway-route-slot">
{switchingServer && desiredServer && <span>Переключаем на {desiredServer.label}</span>}
</div>
</div>}
statusSlot={<>
<InlineError error={error} context="connection" />
<InlineProgress operations={operations} context="connection" />
</>}
/>
{isGateway && <GatewayTrafficSummary feature={devicesFeature} now={now} />}
<SubscriptionPanel
feature={subscriptionFeature}
statusSlot={<>
<InlineError error={subscriptionFeature.error || error} context="subscription" />
<InlineProgress operations={operations} context="subscription" />
</>}
serverSlot={hasSubscription && subscriptionContentReady && <ServerPicker
pingServers={actions.pingServers}
servers={servers}
selectedServerId={selectedServerId}
disabled={serverApplyBlocked}
prompt={!showPower}
leaving={subscriptionFeature.serversLeaving}
revealVersion={subscriptionFeature.serverRevealVersion}
onSelect={selectServer}
/>}
/>
</main>
{(isGateway || (hasSubscription && subscriptionContentReady)) && <InstructionsPanel
feature={instructionsFeature}
isGateway={isGateway}
/>}
{isGateway && <DevicesPanel feature={devicesFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
isGateway={isGateway}
/>}
{hasSubscription && subscriptionContentReady && <RoutingPanel
feature={routingFeature}
statusSlot={<>
<InlineError error={error} context="routing" />
<InlineProgress operations={operations} context="routing" />
</>}
/>}
<RoutingDiscardDialog feature={routingFeature} />
<SubscriptionDeleteDialog feature={subscriptionFeature} />
</div>
);
}
@@ -1,4 +1,8 @@
import React from 'react';
import type { HarborReducerState } from '../state/harborReducer.js';
type TransportState = HarborReducerState['transport'];
interface SyncStatusProps { transport: TransportState; onRetry: () => void }
const bootCopy = {
'control-unreachable': {
@@ -15,10 +19,10 @@ const bootCopy = {
},
};
export function BootStatePage({ transport, onRetry }) {
export function BootStatePage({ transport, onRetry }: SyncStatusProps) {
if (transport.bootStatus === 'loading') return <div className="app-loading">Harbor</div>;
const copy = bootCopy[transport.bootStatus] || bootCopy.fatal;
const copy = transport.bootStatus === 'ready' ? bootCopy.fatal : bootCopy[transport.bootStatus];
return (
<main className="app-boot">
<span>Harbor</span>
@@ -34,7 +38,7 @@ export function BootStatePage({ transport, onRetry }) {
);
}
export function StaleBanner({ transport, onRetry }) {
export function StaleBanner({ transport, onRetry }: SyncStatusProps) {
if (!transport.stale) return null;
const lastSync = transport.lastSuccessfulSyncAt
? new Date(transport.lastSuccessfulSyncAt).toLocaleTimeString('ru-RU')
@@ -0,0 +1,283 @@
import { useState, type ReactNode } from 'react';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import {
connectionAction,
connectionDurationParts,
localProxyUrls,
} from '../../utils/clientControls.js';
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
type CopyKind = 'gateway' | 'socks5' | 'http';
interface CopyFeedback {
kind: CopyKind;
failed: boolean;
}
interface DurationUnit {
value: number;
label: string;
}
interface ConnectionPanelProps {
visible: boolean;
isGateway: boolean;
connected: boolean;
gatewayDirect: boolean;
selectedServerId: string;
configured: boolean;
startedAt?: string | null;
gatewayAddress: string;
gatewayUiOrigin?: string | null;
gatewayRouteAddress?: string | null;
proxyPort?: number;
now: number;
blocked: boolean;
copyFeedback?: CopyFeedback | null;
routingSlot?: ReactNode;
serverSlot?: ReactNode;
statusSlot?: ReactNode;
onCopyProxy: (kind: CopyKind) => unknown;
onApply: (serverId: string) => unknown;
onRestart: () => unknown;
onStop: () => unknown;
}
function DurationPart({ name, children }: { name: string; children: ReactNode }) {
return (
<span
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
>
{children}
</span>
);
}
function AnimatedSeconds({ value, padded = true }: { value: number; padded?: boolean }) {
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
<span className="client-duration-second-digit" key={`${index}-${digit}`}>{digit}</span>
));
}
export function ConnectionPanel({
visible,
isGateway,
connected,
gatewayDirect,
selectedServerId,
configured,
startedAt,
gatewayAddress,
gatewayUiOrigin,
gatewayRouteAddress,
proxyPort,
now,
blocked,
copyFeedback,
routingSlot,
serverSlot,
statusSlot,
onCopyProxy,
onApply,
onRestart,
onStop,
}: ConnectionPanelProps) {
const [durationMode, setDurationMode] = useState(() => {
try {
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
} catch {
return 'digital';
}
});
const [confirmingStop, setConfirmingStop] = useState(false);
const canStart = Boolean(selectedServerId || configured);
const powerUnavailable = isGateway && !connected && !canStart;
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
const duration = connectionDurationParts(startedAt, now);
const clockUnits: Array<[string, DurationUnit]> = [
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
];
const wordClockDuration = clockUnits
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const proxyKinds: Array<[CopyKind, string]> = isGateway
? [
['gateway', 'GATEWAY'],
['socks5', 'SOCKS5'],
['http', 'HTTP'],
]
: [
['socks5', 'SOCKS5'],
['http', 'HTTP'],
];
function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: configured });
if (action?.type === 'stop') {
setConfirmingStop(true);
return;
}
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
async function stopConnection() {
if (!await onStop()) return;
setConfirmingStop(false);
}
function toggleDurationMode() {
setDurationMode((mode) => {
const nextMode = mode === 'digital' ? 'words' : 'digital';
try {
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
} catch {
// The visual preference still works for this session.
}
return nextMode;
});
}
const powerButton = <button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={blocked || (!connected && !canStart)}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
</svg>
</button>;
return <>
{visible && <section className="client-power-section" aria-labelledby="connection-title">
{isGateway ? <span
className="client-power-control client-tooltip-anchor"
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
>
{powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
Сначала добавьте подписку и выберите сервер
</span>}
</span> : powerButton}
{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>
</h2>
{serverSlot}
<div className="client-state-detail">
{connected ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
key="duration"
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
onClick={toggleDurationMode}
>
<span className="client-duration-stack">
<time
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'digital'}
>
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
</time>
<time
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{duration.days.value > 0 && (
<span className="client-duration-word-row is-calendar">
<span className="client-duration-unit" data-unit="days">
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
<DurationPart name="days-label">{duration.days.label}</DurationPart>
</span>
</span>
)}
<span className="client-duration-word-row is-clock">
{wordClockDuration.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span>
))}
</span>
</time>
</span>
<span className="client-tooltip" role="tooltip">
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
</span>
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
</div>
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
<div className="client-access-point">
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
<span className={gatewayDirect ? 'is-active' : ''}>
Через <a href={gatewayUiOrigin || `http://${gatewayRouteAddress}:3456`}>Harbor Gateway</a> · {gatewayRouteAddress}
</span>
</span>
)}
<strong className="client-proxy-address">
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{proxyKinds.map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
onClick={() => onCopyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
</button>
))}
</div>
</div>
</section>
{statusSlot}
</section>}
<ConfirmationDialog
open={confirmingStop}
id="stop-connection"
kicker="Защита от случайного отключения"
title="Отключить VPN?"
description="Harbor остановит текущее VPN-подключение. Локальный прокси перестанет передавать трафик до повторного включения."
cancelLabel="Оставить включённым"
confirmLabel="Отключить VPN"
busy={blocked}
onCancel={() => setConfirmingStop(false)}
onConfirm={stopConnection}
/>
</>;
}
+1
View File
@@ -0,0 +1 @@
export { ConnectionPanel } from './ConnectionPanel.js';
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useRef, useState } from 'react';
import { formatByteString, formatLastSeen } from '../../utils/format.js';
import { TrafficChart } from './TrafficChart.js';
import {
parseDeviceSnapshot,
type Device,
type DevicePolicy,
type DeviceSnapshot,
} from './deviceSnapshot.js';
const DEVICE_AUTO_REFRESH_MS = 15_000;
interface DevicesFeatureOptions {
isGateway: boolean;
listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
}
interface RequestError {
code?: string;
}
function record(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function requestError(value: unknown): RequestError {
if (!record(value)) return {};
return { code: typeof value.code === 'string' ? value.code : undefined };
}
export function useDevicesFeature({
isGateway,
listDevices,
refreshDevices,
updateDevice: requestDeviceUpdate,
setDevicePolicy,
}: DevicesFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [snapshot, setSnapshot] = useState<DeviceSnapshot | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'refreshing' | 'ready' | 'error'>('idle');
const [error, setError] = useState<unknown>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshCycle, setRefreshCycle] = useState(0);
const [savingId, setSavingId] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
function publish(value: unknown) {
const next = parseDeviceSnapshot(value);
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
return next;
}
async function load(quiet = false, discover = false) {
if (!isGateway) return;
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
setRefreshing(true);
try {
publish(await (discover ? refreshDevices() : listDevices()));
setError(null);
setStatus('ready');
} catch (requestError) {
setError(requestError);
setStatus('error');
} finally {
setRefreshing(false);
setRefreshCycle((cycle) => cycle + 1);
}
}
async function updateDevice(device: Device, patch: Record<string, unknown>) {
if (!snapshot) return false;
setSavingId(device.id);
try {
let next: DeviceSnapshot;
try {
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, snapshot.revision));
} catch (caught) {
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
const latest = parseDeviceSnapshot(await listDevices());
publish(latest);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
throw caught;
}
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, latest.revision));
}
publish(next);
setError(null);
return true;
} catch (caught) {
setError(caught);
return false;
} finally {
setSavingId('');
}
}
async function updatePolicy(device: Device, mode: DevicePolicy) {
if (!snapshot) return;
setSavingId(device.id);
try {
let next: DeviceSnapshot;
try {
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, snapshot.revision));
} catch (caught) {
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
const latest = parseDeviceSnapshot(await listDevices());
publish(latest);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw caught;
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, latest.revision));
}
publish(next);
setError(null);
} catch (caught) {
if (requestError(caught).code === 'DEVICE_POLICY_APPLY_FAILED') {
try {
publish(parseDeviceSnapshot(await listDevices()));
} catch {
// Keep the policy error as the actionable result.
}
}
setError(caught);
} finally {
setSavingId('');
}
}
useEffect(() => {
if (!isGateway) return undefined;
load();
return undefined;
}, [isGateway]);
useEffect(() => {
if (!isGateway || refreshing || status === 'loading') return undefined;
const timer = setTimeout(() => load(true), DEVICE_AUTO_REFRESH_MS);
return () => clearTimeout(timer);
}, [isGateway, refreshCycle, refreshing, status]);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeDevices);
document.addEventListener('keydown', closeDevices);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeDevices);
document.removeEventListener('keydown', closeDevices);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
return {
isOpen,
snapshot,
status,
error,
refreshing,
refreshCycle,
savingId,
panelRef,
toggleRef,
closeRef,
load,
updateDevice,
updatePolicy,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
};
}
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.isOpen}
aria-controls="client-devices"
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
</svg>
<span>Устройства</span>
</button>;
}
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
const globalTraffic = feature.snapshot?.traffic;
const trafficSourceError = feature.snapshot?.source?.traffic?.error
|| feature.snapshot?.source?.traffic?.proxy?.error
|| (feature.status === 'error' ? feature.error : null);
const trafficFreshness = globalTraffic?.observedAt
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
: 'Нет данных';
return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
<div className="client-gateway-traffic-heading">
<span>Учтено Harbor</span>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
</div>
<div className="client-gateway-traffic-chart">
<TrafficChart
samples={globalTraffic?.history || []}
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway"
/>
</div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
{trafficSourceError
? `Трафик не обновляется · последние данные ${trafficFreshness}`
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
</div>
</section>;
}
@@ -1,24 +1,45 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { copyText } from '../utils/clientControls.js';
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from 'react';
import { copyText } from '../../utils/clientControls.js';
import {
byteString,
formatByteString,
formatLastSeen,
positiveByteDelta,
stabilizeDevicesByTraffic,
} from '../utils/format.js';
import { TrafficChart } from './TrafficChart.jsx';
} from '../../utils/format.js';
import { TrafficChart } from './TrafficChart.js';
import { type Device } from './deviceSnapshot.js';
import type { DevicesFeature } from './DevicesFeature.js';
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 800;
const TRAFFIC_DELTA_MS = 2_200;
function Tooltip({ children }) {
interface TrafficDelta {
gateway?: string;
proxy?: string;
total?: string;
}
function requestMessage(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const message: unknown = Reflect.get(value, 'message');
return typeof message === 'string' ? message : undefined;
}
function Tooltip({ children }: { children: ReactNode }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
function TextMorph({ from, to }) {
function TextMorph({ from, to }: { from: string; to: string }) {
const anchor = from.length >= to.length ? from : to;
return <span className="client-text-morph" aria-hidden="true">
<span className="client-text-morph-anchor">{anchor}</span>
@@ -27,41 +48,55 @@ function TextMorph({ from, to }) {
</span>;
}
function TrafficValue({ value, delta }) {
function TrafficValue({ value, delta }: { value: string; delta?: string }) {
return <strong className={`client-device-traffic-value${delta ? ' has-delta' : ''}`}>
<span className="is-total">{value}</span>
<span className="is-delta">{delta ? `+${delta}` : ''}</span>
</strong>;
}
export function DevicesPanel({
open, panelRef, closeRef, onClose, snapshot, status, error, refreshing, refreshCycle,
onLoad, onSnapshot, onError,
}) {
export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const {
isOpen: open,
panelRef,
closeRef,
snapshot,
status,
error,
refreshing,
refreshCycle,
savingId,
load: onLoad,
updateDevice,
updatePolicy,
close: onClose,
} = feature;
const [editingId, setEditingId] = useState('');
const [alias, setAlias] = useState('');
const [savingId, setSavingId] = useState('');
const [sortDirection, setSortDirection] = useState('desc');
const [trafficScale, setTrafficScale] = useState('linear');
const [copyFeedback, setCopyFeedback] = useState(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState({});
const deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
const previousOrder = useRef([]);
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
const deviceNodes = useRef(new Map<string, HTMLElement>());
const previousPositions = useRef(new Map<string, DOMRect>());
const previousOrder = useRef<string[]>([]);
const previousScrollTop = useRef(0);
const movementAnimations = useRef(new Map());
const previousTraffic = useRef(new Map());
const movementAnimations = useRef(new Map<string, Animation>());
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
const aliasBaseline = useRef({ id: '', value: '' });
const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null);
const trafficOrder = useRef({ direction: sortDirection, ids: [] });
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
const devices = useMemo(
() => {
const previousIds = trafficOrder.current.direction === sortDirection
? trafficOrder.current.ids
: [];
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds);
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds) as {
ids: string[];
devices: Device[];
};
trafficOrder.current = { direction: sortDirection, ids: result.ids };
return result.devices;
},
@@ -69,20 +104,20 @@ export function DevicesPanel({
);
useEffect(() => () => {
clearTimeout(copyTimer.current);
clearTimeout(trafficDeltaTimer.current);
if (copyTimer.current) clearTimeout(copyTimer.current);
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
}, []);
useEffect(() => {
if (!open) {
previousTraffic.current.clear();
clearTimeout(trafficDeltaTimer.current);
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
setTrafficDeltas({});
return;
}
const next = new Map();
const deltas = {};
const next = new Map<string, { gateway: bigint; proxy: bigint }>();
const deltas: Record<string, TrafficDelta> = {};
for (const device of snapshot?.devices || []) {
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
@@ -98,7 +133,7 @@ export function DevicesPanel({
previousTraffic.current = next;
if (!Object.keys(deltas).length) return;
setTrafficDeltas(deltas);
clearTimeout(trafficDeltaTimer.current);
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
}, [snapshot?.devices, open]);
@@ -111,7 +146,7 @@ export function DevicesPanel({
movementAnimations.current.clear();
return;
}
const positions = new Map();
const positions = new Map<string, DOMRect>();
for (const [id, node] of deviceNodes.current) {
movementAnimations.current.get(id)?.cancel();
positions.set(id, node.getBoundingClientRect());
@@ -143,34 +178,7 @@ export function DevicesPanel({
previousScrollTop.current = currentScrollTop;
}, [devices, open, panelRef]);
async function updateDevice(device, patch) {
setSavingId(device.id);
try {
let next;
try {
next = await api.devices.update(device.id, patch, snapshot.revision);
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
throw requestError;
}
next = await api.devices.update(device.id, patch, latest.revision);
}
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
onError(null);
return true;
} catch (requestError) {
onError(requestError);
return false;
} finally {
setSavingId('');
}
}
async function saveAlias(device) {
async function saveAlias(device: Device) {
const nextAlias = alias.trim();
if (aliasBaseline.current.id === device.id && nextAlias === aliasBaseline.current.value.trim()) {
setEditingId((current) => current === device.id ? '' : current);
@@ -180,40 +188,9 @@ export function DevicesPanel({
setEditingId((current) => current === device.id ? '' : current);
}
async function updatePolicy(device, mode) {
setSavingId(device.id);
try {
let next;
try {
next = await api.devices.setPolicy(device.id, mode, snapshot.revision);
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
next = await api.devices.setPolicy(device.id, mode, latest.revision);
}
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
onError(null);
} catch (requestError) {
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
try {
const latest = await api.devices.list();
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
} catch {
// Keep the policy error as the actionable result.
}
}
onError(requestError);
} finally {
setSavingId('');
}
}
async function copyDeviceIp(device) {
async function copyDeviceIp(device: Device) {
if (!device.ip) return;
clearTimeout(copyTimer.current);
if (copyTimer.current) clearTimeout(copyTimer.current);
try {
await copyText(device.ip);
setCopyFeedback({ id: device.id, failed: false });
@@ -223,7 +200,7 @@ export function DevicesPanel({
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
}
function startEditing(device) {
function startEditing(device: Device) {
const value = device.alias || device.hostname || '';
aliasBaseline.current = { id: device.id, value };
setEditingId(device.id);
@@ -294,29 +271,29 @@ export function DevicesPanel({
</div>
</header>
{snapshot?.source?.error && (
{Boolean(snapshot?.source?.error) && (
<p className="client-devices-source" role="status">
Список временно не обновляется. Показаны последние сохранённые данные.
</p>
)}
{snapshot?.source?.traffic?.error && (
{Boolean(snapshot?.source?.traffic?.error) && (
<p className="client-devices-source" role="status">
Трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{snapshot?.source?.traffic?.proxy?.error && (
{Boolean(snapshot?.source?.traffic?.proxy?.error) && (
<p className="client-devices-source" role="status">
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{snapshot?.source?.policy?.error && (
{Boolean(snapshot?.source?.policy?.error) && (
<p className="client-devices-source" role="status">
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
</p>
)}
{error && (
{Boolean(error) && (
<div className="client-devices-error" role="alert">
<span>{error.message}</span>
<span>{requestMessage(error)}</span>
<button type="button" onClick={() => onLoad()}>Повторить</button>
</div>
)}
@@ -395,8 +372,8 @@ export function DevicesPanel({
<input
className="client-device-alias-input"
value={alias}
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` }}
maxLength="64"
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` } as CSSProperties}
maxLength={64}
autoFocus
aria-label="Название устройства"
aria-busy={saving}
@@ -440,9 +417,9 @@ export function DevicesPanel({
</button>
<Tooltip>Изменить название</Tooltip>
</span>}
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex={0}>
<time
dateTime={device.lastSeenAt}
dateTime={device.lastSeenAt || undefined}
aria-label={`${online ? 'В сети' : 'Не в сети'}. Последний контакт: ${seen.tooltip}`}
>
{online ? 'В сети' : <TextMorph from="Не в сети" to={seen.relative} />}
@@ -453,7 +430,7 @@ export function DevicesPanel({
<span
className="client-device-traffic"
role="group"
tabIndex="0"
tabIndex={0}
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
>
<span className="client-device-traffic-total" aria-hidden="true">
@@ -487,7 +464,7 @@ export function DevicesPanel({
<TrafficChart
samples={device.trafficHistory || []}
scale={trafficScale}
capacity={snapshot.trafficHistoryCapacity || device.trafficHistory?.length || 1}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
pinned={device.pinned}
/>
@@ -1,22 +1,37 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import React, { useLayoutEffect, useRef, useState, type CSSProperties, type PointerEvent } from 'react';
import { createPortal } from 'react-dom';
import {
byteString,
formatByteString,
trafficAxisMid,
trafficScaleRatio,
} from '../utils/format.js';
} from '../../utils/format.js';
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
const TRAFFIC_CHART_HEADROOM = 10;
const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
function chartTime(value) {
function chartTime(value: string) {
return new Date(value).toLocaleTimeString('ru-RU', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
}
function smoothTrafficPath(points, valueKey) {
interface ChartPoint {
sample: TrafficSample;
x: number;
gateway: bigint;
proxy: bigint;
gatewayY: number;
proxyY: number;
}
interface HoveredPoint extends ChartPoint {
clientX: number;
clientY: number;
}
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') {
if (!points.length) return '';
return points.slice(1).reduce((path, point, index) => {
const previous = points[index];
@@ -25,7 +40,7 @@ function smoothTrafficPath(points, valueKey) {
}, `M ${points[0].x},${points[0][valueKey]}`);
}
function trafficSeriesMax(samples) {
function trafficSeriesMax(samples: TrafficSample[]) {
return samples.reduce((largest, sample) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
@@ -35,10 +50,22 @@ function trafficSeriesMax(samples) {
}, 0n);
}
export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel, pinned = true }) {
const [hovered, setHovered] = useState(null);
const previousPoints = useRef([]);
const previousScale = useRef(scale);
export function TrafficChart({
samples,
scale = 'linear',
capacity,
routeLabel,
pinned = true,
}: {
samples: TrafficSample[];
scale?: TrafficScale;
capacity: number;
routeLabel: string;
pinned?: boolean;
}) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]);
const previousScale = useRef<TrafficScale>(scale);
const max = trafficSeriesMax(samples);
const mid = trafficAxisMid(max, scale);
const firstSlot = capacity - samples.length;
@@ -68,7 +95,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
previousScale.current = scale;
}, [points, scale]);
function trackPointer(event) {
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
const index = slot - firstSlot;
@@ -103,7 +130,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
style={{
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
}}
} as CSSProperties}
>
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
<span className="is-max">{formatByteString(max)}</span>
@@ -117,7 +144,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}>
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>}
@@ -142,7 +169,7 @@ export function TrafficChart({ samples, scale = 'linear', capacity, routeLabel,
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
<span>15 с</span>
<time dateTime={samples.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
<time dateTime={samples[samples.length - 1].observedAt}>{chartTime(samples[samples.length - 1].observedAt)}</time>
</span>}
{tooltip}
</span>;
+172
View File
@@ -0,0 +1,172 @@
export type ByteValue = string;
export type TrafficScale = 'linear' | 'log';
export type DevicePolicy = 'vpn' | 'direct';
type DeviceStatus = 'online' | 'recent' | 'offline';
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
export interface TrafficSample extends Record<string, unknown> {
observedAt: string;
gatewayBytes: ByteValue;
proxyBytes: ByteValue;
}
export interface Device extends Record<string, unknown> {
id: string;
alias: string | null;
hostname: string | null;
ip: string | null;
lastSeenAt: string | null;
status: DeviceStatus;
pinned: boolean;
downloadBytes: ByteValue;
uploadBytes: ByteValue;
proxyDownloadBytes: ByteValue;
proxyUploadBytes: ByteValue;
policyStatus: DevicePolicyStatus;
policyError: string | null;
desiredPolicy: DevicePolicy;
appliedPolicy: DevicePolicy;
confidence: DeviceConfidence;
trafficHistory: TrafficSample[];
}
interface SnapshotSource extends Record<string, unknown> {
kind: 'neighbor';
error: unknown;
lastObservedAt: string | null;
traffic: {
error: unknown;
lastObservedAt: string | null;
proxy: {
error: unknown;
lastObservedAt: string | null;
[key: string]: unknown;
};
[key: string]: unknown;
};
policy: {
error: unknown;
lastAppliedAt: string | null;
[key: string]: unknown;
};
}
export interface DeviceSnapshot extends Record<string, unknown> {
revision: number;
devices: Device[];
trafficHistoryCapacity: number;
traffic: {
gatewayBytes: ByteValue;
proxyBytes: ByteValue;
totalBytes: ByteValue;
gatewayObservedAt: string | null;
proxyObservedAt: string | null;
observedAt: string | null;
history: TrafficSample[];
[key: string]: unknown;
};
source: SnapshotSource;
}
function record(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function nullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function timestamp(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && Number.isFinite(Date.parse(value));
}
function nullableTimestamp(value: unknown): value is string | null {
return value === null || timestamp(value);
}
function bytes(value: unknown): value is ByteValue {
return typeof value === 'string' && /^\d+$/.test(value);
}
function validTrafficSample(value: unknown): value is TrafficSample {
return record(value)
&& timestamp(value.observedAt)
&& bytes(value.gatewayBytes)
&& bytes(value.proxyBytes);
}
function validHistory(value: unknown): value is TrafficSample[] {
return Array.isArray(value) && value.every(validTrafficSample);
}
function validDevice(value: unknown): value is Device {
return record(value)
&& typeof value.id === 'string'
&& /^dev_[a-f0-9]{16}$/.test(value.id)
&& nullableString(value.alias)
&& nullableString(value.hostname)
&& nullableString(value.ip)
&& nullableTimestamp(value.lastSeenAt)
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
&& typeof value.pinned === 'boolean'
&& bytes(value.downloadBytes)
&& bytes(value.uploadBytes)
&& bytes(value.proxyDownloadBytes)
&& bytes(value.proxyUploadBytes)
&& (value.policyStatus === 'applied' || value.policyStatus === 'applying'
|| value.policyStatus === 'pending' || value.policyStatus === 'failed')
&& nullableString(value.policyError)
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
&& validHistory(value.trafficHistory);
}
function validSource(value: unknown): value is SnapshotSource {
return record(value)
&& value.kind === 'neighbor'
&& Object.hasOwn(value, 'error')
&& nullableTimestamp(value.lastObservedAt)
&& record(value.traffic)
&& Object.hasOwn(value.traffic, 'error')
&& nullableTimestamp(value.traffic.lastObservedAt)
&& record(value.traffic.proxy)
&& Object.hasOwn(value.traffic.proxy, 'error')
&& nullableTimestamp(value.traffic.proxy.lastObservedAt)
&& record(value.policy)
&& Object.hasOwn(value.policy, 'error')
&& nullableTimestamp(value.policy.lastAppliedAt);
}
function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
return record(value)
&& bytes(value.gatewayBytes)
&& bytes(value.proxyBytes)
&& bytes(value.totalBytes)
&& nullableTimestamp(value.gatewayObservedAt)
&& nullableTimestamp(value.proxyObservedAt)
&& nullableTimestamp(value.observedAt)
&& validHistory(value.history);
}
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
if (!record(value)
|| !Number.isSafeInteger(value.revision)
|| typeof value.revision !== 'number'
|| value.revision < 0
|| !Array.isArray(value.devices)
|| !value.devices.every(validDevice)
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|| typeof value.trafficHistoryCapacity !== 'number'
|| value.trafficHistoryCapacity <= 0
|| !validTraffic(value.traffic)
|| !validSource(value.source)) {
throw new TypeError('Harbor device inventory returned an invalid snapshot');
}
}
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
assertDeviceSnapshot(value);
return value;
}
+6
View File
@@ -0,0 +1,6 @@
export { DevicesPanel } from './DevicesPanel.js';
export {
DevicesToggle,
GatewayTrafficSummary,
useDevicesFeature,
} from './DevicesFeature.js';
@@ -1,44 +1,95 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type FormEvent,
} from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js';
} from '../../../shared/connectivityDiagnostics.js';
import {
parseConnectivityResult,
type ConnectivityResult,
type DiagnosticPath,
type DiagnosticSiteResult,
} from './connectivityResult.js';
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
function readCustomServices() {
interface DiagnosticService extends Record<string, unknown> {
id: string;
label: string;
url: string;
}
interface IpSourceDefinition {
id: string;
label: string;
family: number;
}
type StatusValue = [className: string, label: string];
type RunConnectivityDiagnostics = (
services: DiagnosticService[],
target: string,
) => Promise<unknown>;
function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function validCustomService(value: unknown): value is DiagnosticService {
return record(value)
&& typeof Reflect.get(value, 'id') === 'string'
&& String(Reflect.get(value, 'id')).startsWith('custom-')
&& typeof Reflect.get(value, 'label') === 'string'
&& typeof Reflect.get(value, 'url') === 'string';
}
function requestDetails(value: unknown) {
if (!record(value)) return { message: undefined, retryable: false };
const message = Reflect.get(value, 'message');
return {
message: typeof message === 'string' ? message : undefined,
retryable: Boolean(Reflect.get(value, 'retryable')),
};
}
function readCustomServices(): DiagnosticService[] {
try {
const value = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
const value: unknown = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
return Array.isArray(value)
? value.filter((service) => (
service
&& typeof service.id === 'string'
&& service.id.startsWith('custom-')
&& typeof service.label === 'string'
&& typeof service.url === 'string'
)).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
? value.filter(validCustomService).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
: [];
} catch {
return [];
}
}
function readHiddenServices() {
function readHiddenServices(): string[] {
try {
const value = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]');
const value: unknown = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]');
return Array.isArray(value)
? value.filter((id) => CONNECTIVITY_SITES.some((service) => service.id === id))
? value.filter((id): id is string => (
typeof id === 'string' && CONNECTIVITY_SITES.some((service) => service.id === id)
))
: [];
} catch {
return [];
}
}
function resultStatus(site, pending, available = true) {
function resultStatus(
site: DiagnosticSiteResult | undefined,
pending: boolean,
available = true,
): StatusValue {
if (!available) return ['is-muted', '—'];
if (pending) return ['is-running', 'Тестируем'];
if (!site) return ['is-muted', '—'];
@@ -47,7 +98,7 @@ function resultStatus(site, pending, available = true) {
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
}
function Status({ value, route }) {
function Status({ value, route }: { value: StatusValue; route: string }) {
const [className, label] = value;
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>
{label}
@@ -55,14 +106,24 @@ function Status({ value, route }) {
</span>;
}
function ipResult(path, source) {
function ipResult(path: DiagnosticPath | undefined, source: IpSourceDefinition) {
if (!path?.available) return null;
return source.family === 6
? path.ipv6Source
: path.ipv4?.sources?.find((item) => item.source === source.id);
}
function IpCell({ path, source, pending, route }) {
function IpCell({
path,
source,
pending,
route,
}: {
path: DiagnosticPath | undefined;
source: IpSourceDefinition;
pending: boolean;
route: string;
}) {
const value = ipResult(path, source);
if (path?.available === false) return <Status value={['is-muted', '—']} route={route} />;
if (pending) return <Status value={['is-running', 'Тестируем']} route={route} />;
@@ -71,22 +132,24 @@ function IpCell({ path, source, pending, route }) {
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
}
function mergeItems(previous = [], incoming = [], key) {
function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) {
const merged = [...previous];
for (const item of incoming) {
const index = merged.findIndex((value) => value[key] === item[key]);
const index = merged.findIndex((value) => key(value) === key(item));
if (index >= 0) merged[index] = item;
else merged.push(item);
}
return merged;
}
function mergePath(previous, incoming) {
const sources = mergeItems(previous?.ipv4?.sources, incoming.ipv4?.sources, 'source');
const sites = mergeItems(previous?.sites, incoming.sites, 'id');
function mergePath(previous: DiagnosticPath | undefined, incoming: DiagnosticPath): DiagnosticPath {
const sources = mergeItems(previous?.ipv4.sources, incoming.ipv4.sources, ({ source }) => source);
const sites = mergeItems(previous?.sites, incoming.sites, ({ id }) => id);
const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null;
const ipv6 = ipv6Source?.address || null;
const addresses = [...new Set(sources.map(({ address }) => address).filter(Boolean))];
const addresses = [...new Set(sources
.map(({ address }) => address)
.filter((address): address is string => Boolean(address)))];
return {
...previous,
...incoming,
@@ -100,17 +163,25 @@ function mergePath(previous, incoming) {
};
}
function mergeResult(previous, incoming) {
function mergeResult(previous: ConnectivityResult | null, incoming: ConnectivityResult): ConnectivityResult {
const direct = mergePath(previous?.direct, incoming.direct);
const vpn = mergePath(previous?.vpn, incoming.vpn);
const vpn = { ...mergePath(previous?.vpn, incoming.vpn), server: incoming.vpn.server };
return { ...incoming, direct, vpn };
}
export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) {
const [result, setResult] = useState(null);
const [status, setStatus] = useState('idle');
const [activeTarget, setActiveTarget] = useState(null);
const [error, setError] = useState(null);
export function ConnectivityDiagnosticsPanel({
feature,
runConnectivityDiagnostics,
isGateway,
}: {
feature: DiagnosticsFeature;
runConnectivityDiagnostics: RunConnectivityDiagnostics;
isGateway: boolean;
}) {
const [result, setResult] = useState<ConnectivityResult | null>(null);
const [status, setStatus] = useState<'idle' | 'running' | 'ready' | 'error'>('idle');
const [activeTarget, setActiveTarget] = useState<string | null>(null);
const [error, setError] = useState<unknown>(null);
const [customServices, setCustomServices] = useState(readCustomServices);
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
const [adding, setAdding] = useState(false);
@@ -118,9 +189,10 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
const [serviceName, setServiceName] = useState('');
const [serviceUrl, setServiceUrl] = useState('');
const [formError, setFormError] = useState('');
const sheetRef = useRef(null);
const runnerRef = useRef(null);
const previousTargetRef = useRef(null);
const sheetRef = useRef<HTMLDivElement>(null);
const runnerRef = useRef<HTMLSpanElement>(null);
const previousTargetRef = useRef<string | null>(null);
const requestError = requestDetails(error);
useEffect(() => {
try {
@@ -148,7 +220,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
return;
}
const row = [...sheet.querySelectorAll('[data-diagnostic-target]')]
const row = [...sheet.querySelectorAll<HTMLElement>('[data-diagnostic-target]')]
.find((item) => item.dataset.diagnosticTarget === activeTarget);
if (!row) return;
const rowRect = row.getBoundingClientRect();
@@ -173,7 +245,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
];
for (const target of targets) {
setActiveTarget(target);
const partial = await api.diagnostics.connectivity(customServices, target);
const partial = parseConnectivityResult(await runConnectivityDiagnostics(customServices, target));
const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
next = legacyFullResult ? partial : mergeResult(next, partial);
setResult(next);
@@ -188,7 +260,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
}
}
function addService(event) {
function addService(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
try {
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
@@ -205,11 +277,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
setAdding(false);
setResult(null);
} catch (validationError) {
setFormError(validationError.message || 'Проверьте адрес.');
const message = validationError && typeof validationError === 'object' && !Array.isArray(validationError)
? Reflect.get(validationError, 'message')
: undefined;
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
}
}
function removeService(serviceId) {
function removeService(serviceId: string) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
finishRemoveService(serviceId);
return;
@@ -217,7 +292,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
setRemovingServiceId(serviceId);
}
function finishRemoveService(serviceId) {
function finishRemoveService(serviceId: string) {
const update = () => flushSync(() => {
if (serviceId === 'draft') {
setAdding(false);
@@ -246,6 +321,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
];
const serviceEditorBlocked = pending || Boolean(removingServiceId);
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
return (
<aside
@@ -287,10 +363,10 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
</div>
</header>
{error && <div className="client-diagnostics-feedback">
{Boolean(error) && <div className="client-diagnostics-feedback">
<div className="client-diagnostics-error" role="alert">
<span>{error.message}</span>
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
<span>{requestError.message}</span>
{requestError.retryable && <button type="button" onClick={run}>Повторить</button>}
</div>
</div>}
@@ -366,7 +442,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
>
<input
type="text"
maxLength="40"
maxLength={40}
placeholder="Название"
aria-label="Название сервиса"
value={serviceName}
@@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from 'react';
export function useDiagnosticsFeature() {
const [isOpen, setIsOpen] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeDiagnostics = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeDiagnostics);
document.addEventListener('keydown', closeDiagnostics);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeDiagnostics);
document.removeEventListener('keydown', closeDiagnostics);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
return {
isOpen,
panelRef,
toggleRef,
closeRef,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
};
}
export type DiagnosticsFeature = ReturnType<typeof useDiagnosticsFeature>;
export function DiagnosticsToggle({
feature,
onToggle,
}: {
feature: DiagnosticsFeature;
onToggle: () => void;
}) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle client-diagnostics-toggle${feature.isOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.isOpen}
aria-controls="client-diagnostics"
aria-label={feature.isOpen ? 'Закрыть диагностику' : 'Проверить маршруты'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-rail-diagnostics-base" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
<path className="client-rail-diagnostics-pulse" pathLength="1" d="M3 12h4l2.2-5 4.2 10 2.1-5H21" />
</svg>
<span>Диагностика</span>
</button>;
}
@@ -0,0 +1,96 @@
export type DiagnosticSiteStatus = 'available' | 'responded' | 'unavailable';
export interface DiagnosticIpResult extends Record<string, unknown> {
source: string;
address: string | null;
}
export interface DiagnosticSiteResult extends Record<string, unknown> {
id: string;
status: DiagnosticSiteStatus;
httpStatus: number | null;
latencyMs: number | null;
}
export interface DiagnosticServer extends Record<string, unknown> {
id: string;
label: string;
}
export interface DiagnosticPath extends Record<string, unknown> {
available: boolean;
internetAvailable: boolean;
ipv4: {
addresses: string[];
sources: DiagnosticIpResult[];
};
ipv6: string | null;
ipv6Source: DiagnosticIpResult | null;
sites: DiagnosticSiteResult[];
server?: DiagnosticServer | null;
}
export interface ConnectivityResult extends Record<string, unknown> {
direct: DiagnosticPath;
vpn: DiagnosticPath & { server: DiagnosticServer | null };
}
function record(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function nullableNonnegativeNumber(value: unknown): value is number | null {
return value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
}
function validIpResult(value: unknown): value is DiagnosticIpResult {
return record(value)
&& typeof value.source === 'string'
&& value.source.length > 0
&& (value.address === null || (typeof value.address === 'string' && value.address.length > 0));
}
function validSiteResult(value: unknown): value is DiagnosticSiteResult {
return record(value)
&& typeof value.id === 'string'
&& value.id.length > 0
&& (value.status === 'available' || value.status === 'responded' || value.status === 'unavailable')
&& nullableNonnegativeNumber(value.httpStatus)
&& nullableNonnegativeNumber(value.latencyMs);
}
function validServer(value: unknown): value is DiagnosticServer | null {
return value === null || (record(value)
&& typeof value.id === 'string'
&& typeof value.label === 'string');
}
function validPath(value: unknown): value is DiagnosticPath {
return record(value)
&& typeof value.available === 'boolean'
&& typeof value.internetAvailable === 'boolean'
&& record(value.ipv4)
&& Array.isArray(value.ipv4.addresses)
&& value.ipv4.addresses.every((address) => typeof address === 'string' && address.length > 0)
&& Array.isArray(value.ipv4.sources)
&& value.ipv4.sources.every(validIpResult)
&& (value.ipv6 === null || (typeof value.ipv6 === 'string' && value.ipv6.length > 0))
&& (value.ipv6Source === null || validIpResult(value.ipv6Source))
&& Array.isArray(value.sites)
&& value.sites.every(validSiteResult);
}
function assertConnectivityResult(value: unknown): asserts value is ConnectivityResult {
if (!record(value)
|| !validPath(value.direct)
|| !validPath(value.vpn)
|| !Object.hasOwn(value.vpn, 'server')
|| !validServer(value.vpn.server)) {
throw new TypeError('Harbor connectivity diagnostics returned an invalid result');
}
}
export function parseConnectivityResult(value: unknown): ConnectivityResult {
assertConnectivityResult(value);
return value;
}
+6
View File
@@ -0,0 +1,6 @@
export { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.js';
export {
DiagnosticsToggle,
useDiagnosticsFeature,
type DiagnosticsFeature,
} from './DiagnosticsFeature.js';
@@ -0,0 +1,275 @@
import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { copyText } from '../../utils/clientControls.js';
import { instructionBlocks } from './instructionBlocks.js';
interface InstructionLinkStep {
before?: string;
link: [string, string];
after?: string;
}
interface InstructionCopyAction {
id: string;
label: string;
text: string;
}
interface InstructionBlockData {
id: string;
label: string;
title: string;
summary: string;
paragraphs?: string[];
steps?: Array<string | InstructionLinkStep>;
code?: string;
multilineCode?: boolean;
copies?: InstructionCopyAction[];
note?: string;
}
interface InstructionsFeatureOptions {
isGateway: boolean;
host: string;
port: number;
controlHost: string;
}
function InstructionStep({ step }: { step: string | InstructionLinkStep }) {
if (typeof step === 'string') return step;
return (
<>
{step.before}
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
{step.after}
</>
);
}
function InstructionBlock({
block,
open,
onToggle,
}: {
block: InstructionBlockData;
open: boolean;
onToggle: () => void;
}) {
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
async function copyInstruction(action: InstructionCopyAction) {
if (copyTimer.current) clearTimeout(copyTimer.current);
try {
await copyText(action.text);
setCopyFeedback({ id: action.id, failed: false });
} catch {
setCopyFeedback({ id: action.id, failed: true });
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
}
return (
<section
className={`client-instruction-block${open ? ' is-open' : ''}`}
style={{ viewTransitionName: `instruction-${block.id}` }}
>
<button
className="client-instruction-summary"
type="button"
aria-expanded={open}
onClick={onToggle}
>
<span>{block.label}</span>
<strong>{block.title}</strong>
<small>{block.summary}</small>
<i aria-hidden="true" />
</button>
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
<div className="client-instruction-body">
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
{block.steps && (
<ol>
{block.steps.map((step) => (
<li key={typeof step === 'string' ? step : step.link[1]}>
<InstructionStep step={step} />
</li>
))}
</ol>
)}
{block.code && (block.multilineCode
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
: <code>{block.code}</code>)}
{block.copies && <div className="client-instruction-copies">
{block.copies.map((action) => {
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
return <div className="client-instruction-copy" key={action.id}>
<span>{action.label}</span>
<button
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
onClick={() => copyInstruction(action)}
>
<span className="client-copy-label">Скопировать</span>
{feedback && <span className="client-copy-feedback" aria-hidden="true">
{feedback.failed ? 'Ошибка' : 'Скопировано'}
</span>}
</button>
</div>;
})}
<span className="client-live-region" role="status" aria-live="polite">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</span>
</div>}
{block.note && <p className="client-instruction-note">{block.note}</p>}
</div>
</div>
</section>
);
}
export function useInstructionsFeature({
isGateway,
host,
port,
controlHost,
}: InstructionsFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [intro, ...guides] = instructionBlocks({ isGateway, host, port, controlHost }) as InstructionBlockData[];
const openInstruction = guides.find((block) => block.id === openInstructionId);
const orderedGuides = openInstruction
? [openInstruction, ...guides.filter((block) => block.id !== openInstructionId)]
: guides;
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', closeOnEscape);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeOutside = (event: PointerEvent) => {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeOutside);
return () => document.removeEventListener('pointerdown', closeOutside);
}, [isOpen]);
function toggleInstruction(id: string) {
const update = () => flushSync(() => {
setOpenInstructionId((current) => current === id ? '' : id);
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
return {
isOpen,
openInstructionId,
intro,
guides: orderedGuides,
panelRef,
toggleRef,
closeRef,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
toggleInstruction,
};
}
export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
export function InstructionsToggle({
feature,
onToggle,
}: {
feature: InstructionsFeature;
onToggle: () => void;
}) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.isOpen}
aria-controls="client-instructions"
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
<path d="M12 11v5M12 8h.01" />
</svg>
<span>Как использовать</span>
</button>;
}
export function InstructionsPanel({
feature,
isGateway,
}: {
feature: InstructionsFeature;
isGateway: boolean;
}) {
return <aside
ref={feature.panelRef}
id="client-instructions"
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
aria-labelledby="instructions-title"
aria-hidden={!feature.isOpen}
inert={!feature.isOpen ? true : undefined}
>
<div className="client-drawer-sheet client-instructions-sheet">
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть инструкции"
onClick={feature.close}
>×</button>
<header className="client-instructions-header">
<span>Подключение</span>
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
<div className="client-instructions-intro">
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</header>
<div className="client-instruction-list">
{feature.guides.map((block) => (
<InstructionBlock
block={block}
key={block.id}
open={block.id === feature.openInstructionId}
onToggle={() => feature.toggleInstruction(block.id)}
/>
))}
</div>
</div>
</aside>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
InstructionsPanel,
InstructionsToggle,
useInstructionsFeature,
type InstructionsFeature,
} from './InstructionsFeature.js';
@@ -1,6 +1,11 @@
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
export function instructionBlocks({ isGateway, host, port, controlHost }) {
export function instructionBlocks({ isGateway, host, port, controlHost }: {
isGateway: boolean;
host: string;
port: number;
controlHost: string;
}) {
const httpProxy = `http://${host}:${port}`;
const socksProxy = `socks5://${host}:${port}`;
@@ -1,8 +1,8 @@
import dashboard from '../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
import dashboard from '../../../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
export function prometheusScrapeConfig(controlHost) {
export function prometheusScrapeConfig(controlHost: string) {
return `scrape_configs:
- job_name: harbor_gateway
scrape_interval: 30s
+584
View File
@@ -0,0 +1,584 @@
import {
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
type RefObject,
} from 'react';
import { flushSync } from 'react-dom';
import { canAppendRouteRule } from '../../../shared/routingRules.js';
import type { RouteRule } from '../../../shared/contracts/state.js';
import { operationBlocked } from '../../state/operations.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'],
['domain_suffix', 'Суффикс'],
['domain_keyword', 'Содержит'],
];
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
domain: 'example.com или полный URL',
domain_suffix: 'example.org',
domain_keyword: 'cdn',
};
interface DraftRule extends RouteRule {
_key: string;
removing?: boolean;
}
interface RoutingState {
localRules?: RouteRule[];
activeLocalRules?: RouteRule[];
localRulesRevision?: number;
localRulesPendingRestart?: boolean;
}
interface RoutingFeatureOptions {
route?: RoutingState | null;
connected: boolean;
operations: Record<string, { status?: string } | undefined>;
onSave: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onDismissError: () => void;
}
interface RoutingSaveState {
localRulesRevision: number;
localRulesPendingRestart: boolean;
}
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled })),
);
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
);
function localRuleStatus(
rule: DraftRule,
savedRules: RouteRule[],
activeRules: RouteRule[],
runtimeActive: boolean,
) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
function routingSaveState(result: unknown): RoutingSaveState | null {
if (!result) return null;
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const state = (result as Record<string, unknown>).state;
if (!state || typeof state !== 'object' || Array.isArray(state)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const route = (state as Record<string, unknown>).route;
if (!route || typeof route !== 'object' || Array.isArray(route)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const { localRulesRevision, localRulesPendingRestart } = route as Record<string, unknown>;
if (
!Number.isSafeInteger(localRulesRevision)
|| (localRulesRevision as number) < 0
|| typeof localRulesPendingRestart !== 'boolean'
) {
throw new TypeError('Harbor route mutation returned invalid state');
}
return { localRulesRevision: localRulesRevision as number, localRulesPendingRestart };
}
export function useRoutingFeature({
route,
connected,
operations,
onSave,
onDismissError,
}: RoutingFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]');
const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || [];
const dirty = localRulesSignature(rules) !== baselineRef.current;
const pendingRestart = connected && route?.localRulesPendingRestart === true;
const pendingCount = pendingRestart
? savedRules.filter((rule) => (
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
return () => {
cancelAnimationFrame(frame);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeRouting = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
} else {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
}
requestClose();
};
document.addEventListener('pointerdown', closeRouting);
document.addEventListener('keydown', closeRouting);
return () => {
document.removeEventListener('pointerdown', closeRouting);
document.removeEventListener('keydown', closeRouting);
};
}, [isOpen, dirty]);
useEffect(() => {
if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isOpen, dirty]);
function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
setRules(savedRules.map(createLocalRuleDraft));
setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false);
onDismissError();
setIsOpen(true);
}
function forceClose() {
setIsOpen(false);
}
function requestClose() {
if (dirty) {
setConfirmingClose(true);
return false;
}
setIsOpen(false);
return true;
}
function discard() {
setConfirmingClose(false);
setIsOpen(false);
}
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
setRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
)));
}
function add() {
setRules((current) => [
...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
]);
}
function remove(ruleKey: string) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
return;
}
setRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
function finishRemove(ruleKey: string) {
const update = () => flushSync(() => {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const values = localRuleValues(rules);
const result = routingSaveState(await onSave(values, revision));
if (!result) return;
baselineRef.current = JSON.stringify(values);
setRevision(result.localRulesRevision);
setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
}
return {
isOpen,
rules,
savedRules,
activeRules,
connected,
dirty,
pendingRestart,
pendingCount,
blocked,
confirmingClose,
panelRef,
toggleRef,
closeRef,
open,
forceClose,
requestClose,
setConfirmingClose,
discard,
change,
add,
remove,
finishRemove,
save,
};
}
type RoutingFeature = ReturnType<typeof useRoutingFeature>;
interface RuleTypePickerProps {
value: RouteRule['type'];
ruleKey: string;
index: number;
disabled?: boolean;
onChange: (value: RouteRule['type']) => void;
}
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypePickerProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listId = `${ruleKey}-types`;
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
useEffect(() => {
if (!open) return undefined;
optionRefs.current[selectedIndex]?.focus();
const close = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
};
}, [open, selectedIndex]);
function choose(type: RouteRule['type']) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event: React.KeyboardEvent<HTMLButtonElement>, offset: number) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
event.preventDefault();
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
return;
}
const current = optionRefs.current.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? ROUTE_RULE_OPTIONS.length - 1
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
optionRefs.current[next]?.focus();
}
return (
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
<button
ref={triggerRef}
className="client-rule-type-trigger"
type="button"
aria-label={`Тип правила ${index + 1}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
return;
}
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
setOpen(true);
}}
>
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
<button
ref={(node) => { optionRefs.current[optionIndex] = node; }}
type="button"
role="option"
aria-selected={type === value}
tabIndex={open ? 0 : -1}
key={type}
onClick={() => choose(type)}
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
>
{label}
</button>
))}
</div>
</div>
);
}
export function RoutingToggle({
feature,
gatewayDirect,
isGateway,
hasSubscription,
onOpen,
}: {
feature: RoutingFeature;
gatewayDirect: boolean;
isGateway: boolean;
hasSubscription: boolean;
onOpen: () => void;
}) {
const disabled = gatewayDirect || (isGateway && !hasSubscription);
return (
<button
ref={feature.toggleRef}
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`}
type="button"
disabled={disabled}
aria-expanded={feature.isOpen}
aria-controls="client-local-rules"
aria-label={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
</svg>
<span>{disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>
);
}
export function RoutingPendingStatus({
feature,
blocked,
onRestart,
}: {
feature: RoutingFeature;
blocked: boolean;
onRestart: () => unknown;
}) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && (
<>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
</div>;
}
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const draftRules = feature.rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={feature.panelRef as RefObject<HTMLElement>}
id="client-local-rules"
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`}
aria-labelledby="local-rules-title"
aria-hidden={!feature.isOpen}
inert={!feature.isOpen ? true : undefined}
>
<div className="client-drawer-sheet client-local-rules-sheet">
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть локальные правила"
onClick={feature.requestClose}
>×</button>
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<button
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
{feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(
rule,
feature.savedRules,
feature.activeRules,
feature.connected,
);
return (
<div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
onChange={(type) => feature.change(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
required
aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.value)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button
className="client-local-rule-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
onClick={() => feature.remove(rule._key)}
>
×
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
</div>
);
})}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
</section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
{statusSlot}
<div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button>
</div>
</form>
</div>
</aside>
);
}
export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) {
return <ConfirmationDialog
open={feature.confirmingClose}
id="discard-local-rules"
title="Есть несохранённые настройки"
description="Закрыть редактор и потерять изменения?"
cancelLabel="Остаться"
confirmLabel="Закрыть без сохранения"
onCancel={() => feature.setConfirmingClose(false)}
onConfirm={feature.discard}
/>;
}
+7
View File
@@ -0,0 +1,7 @@
export {
RoutingDiscardDialog,
RoutingPanel,
RoutingPendingStatus,
RoutingToggle,
useRoutingFeature,
} from './RoutingFeature.js';
@@ -1,18 +1,42 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '../api.js';
import {
useEffect,
useMemo,
useState,
type CSSProperties,
} from 'react';
import {
autoServer,
filterServers,
groupServers,
parseServerPingResults,
SERVER_RESULT_WINDOW,
} from '../utils/serverPicker.js';
} from './serverPickerModel.js';
import type { HarborServer } from '../../../shared/contracts/state.js';
type PickerServer = HarborServer & {
country?: string;
city?: string;
provider?: string;
};
const FAVORITES_KEY = 'harbor-server-favorites';
const RECENT_KEY = 'harbor-server-recent';
const AUTO_KEY = 'harbor-server-auto';
const SIMPLE_SERVER_LIMIT = 5;
function readList(key) {
interface PingResult {
id?: string;
latency?: number | null;
ok?: boolean;
error?: unknown;
checkedAt?: string;
checking?: boolean;
[key: string]: unknown;
}
type PingState = Record<string, PingResult | undefined>;
function readList(key: string) {
try {
const value = JSON.parse(localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value.map(String) : [];
@@ -21,7 +45,7 @@ function readList(key) {
}
}
function write(key, value) {
function write(key: string, value: string | string[]) {
try {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
} catch {
@@ -37,20 +61,20 @@ function readAuto() {
}
}
function serverHealthText(ping) {
function serverHealthText(ping?: PingResult) {
if (ping?.error) return 'Проверка недоступна';
if (ping?.ok) return `${ping.latency} мс`;
return ping ? 'Недоступен' : null;
}
function ServerHealth({ ping, fallback }) {
function ServerHealth({ ping, fallback }: { ping?: PingResult; fallback?: string }) {
const health = fallback || serverHealthText(ping);
if (!health && !ping?.checking) return null;
return <small
className={`client-server-health${ping?.checking ? ' is-checking' : ''}`}
title={ping?.checkedAt || undefined}
aria-label={ping?.checking ? 'Проверяем пинг' : health}
aria-label={ping?.checking ? 'Проверяем пинг' : health || undefined}
>
<span aria-hidden="true">{health}</span>
<svg className="client-server-health-checking" viewBox="0 0 24 24" aria-hidden="true">
@@ -59,7 +83,15 @@ function ServerHealth({ ping, fallback }) {
</small>;
}
function ServerCheckButton({ checking, disabled, onClick }) {
function ServerCheckButton({
checking,
disabled,
onClick,
}: {
checking: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return <button
className={`client-server-check client-tooltip-anchor${checking ? ' is-checking' : ''}`}
type="button"
@@ -74,7 +106,25 @@ function ServerCheckButton({ checking, disabled, onClick }) {
</button>;
}
function ServerRow({ server, selected, favorite, ping, disabled, index, onSelect, onFavorite }) {
function ServerRow({
server,
selected,
favorite,
ping,
disabled,
index,
onSelect,
onFavorite,
}: {
server: PickerServer;
selected: boolean;
favorite?: boolean;
ping?: PingResult;
disabled: boolean;
index: number;
onSelect: (id: string) => unknown;
onFavorite?: (id: string) => void;
}) {
const health = ping?.checking ? 'Проверяем пинг' : serverHealthText(ping);
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
@@ -84,7 +134,7 @@ function ServerRow({ server, selected, favorite, ping, disabled, index, onSelect
disabled={disabled}
aria-pressed={selected}
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
style={{ '--server-index': Math.min(index, 7) }}
style={{ '--server-index': Math.min(index, 7) } as CSSProperties}
onClick={() => onSelect(server.id)}
>
<strong>{server.label}</strong>
@@ -102,7 +152,19 @@ function ServerRow({ server, selected, favorite, ping, disabled, index, onSelect
</div>;
}
interface ServerPickerProps {
pingServers: (ids: string[]) => Promise<unknown>;
servers: PickerServer[];
selectedServerId: string;
disabled: boolean;
prompt: boolean;
leaving: boolean;
revealVersion: number;
onSelect: (id: string) => unknown;
}
export function ServerPicker({
pingServers,
servers,
selectedServerId,
disabled,
@@ -110,16 +172,16 @@ export function ServerPicker({
leaving,
revealVersion,
onSelect,
}) {
}: ServerPickerProps) {
const [query, setQuery] = useState('');
const [advanced, setAdvanced] = useState(false);
const [view, setView] = useState('all');
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 [collapsed, setCollapsed] = useState([]);
const [pings, setPings] = useState({});
const [collapsed, setCollapsed] = useState<string[]>([]);
const [pings, setPings] = useState<PingState>({});
const [checking, setChecking] = useState(false);
const serverKey = servers.map(({ id }) => id).join('|');
@@ -128,8 +190,8 @@ export function ServerPicker({
}, [query, view, serverKey]);
const selected = servers.find(({ id }) => id === selectedServerId);
const filtered = useMemo(() => {
const found = filterServers(servers, query);
const filtered = useMemo<PickerServer[]>(() => {
const found = filterServers(servers, query) as PickerServer[];
if (view === 'favorites') return found.filter(({ id }) => favorites.includes(id));
if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
return found;
@@ -143,7 +205,7 @@ export function ServerPicker({
setPage((current) => Math.min(current, pageCount - 1));
}, [pageCount]);
function toggleFavorite(id) {
function toggleFavorite(id: string) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
write(FAVORITES_KEY, next);
@@ -151,7 +213,7 @@ export function ServerPicker({
});
}
function select(id, automatic = false) {
function select(id: string, automatic = false) {
setAutoActive(automatic);
write(AUTO_KEY, String(automatic));
if (!automatic) {
@@ -174,15 +236,19 @@ export function ServerPicker({
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
}));
try {
const data = await api.servers.ping(ids);
const results = parseServerPingResults(await pingServers(ids)) as PingResult[];
setPings((current) => ({
...current,
...Object.fromEntries((data.results || []).map((result) => [result.id, { ...result, checking: true }])),
...Object.fromEntries(results.map((result) => [result.id, { ...result, checking: true }])),
}));
} catch {
setPings((current) => ({
...current,
...Object.fromEntries(ids.map((id) => [id, { error: true, checking: true, checkedAt: new Date().toISOString() }])),
...Object.fromEntries(ids.map((id) => [id, {
error: true,
checking: true,
checkedAt: new Date().toISOString(),
}])),
}));
} finally {
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
@@ -215,7 +281,7 @@ export function ServerPicker({
</section>;
}
const renderRows = (items, offset = 0) => items.map((server, index) => (
const renderRows = (items: PickerServer[], offset = 0) => items.map((server, index) => (
<ServerRow
key={server.id}
server={server}
@@ -292,11 +358,11 @@ export function ServerPicker({
onChange={(event) => setQuery(event.target.value)}
/>
<div className="client-server-filters" aria-label="Фильтр серверов">
{[
{([
['all', 'Все'],
['favorites', '★'],
['recent', 'Недавние'],
].map(([id, label]) => <button
] as const).map(([id, label]) => <button
type="button"
className={view === id ? 'is-active' : ''}
aria-pressed={view === id}
@@ -312,7 +378,10 @@ export function ServerPicker({
type="button"
aria-pressed={autoActive}
disabled={disabled || !servers.length}
onClick={() => select(autoServer(servers)?.id, true)}
onClick={() => {
const automatic = autoServer(servers);
if (automatic) select(automatic.id, true);
}}
>
<strong>Auto</strong>
<ServerHealth
+1
View File
@@ -0,0 +1 @@
export { ServerPicker } from './ServerPicker.js';
@@ -0,0 +1,73 @@
import type { HarborServer } from '../../../shared/contracts/state.js';
export const SERVER_RESULT_WINDOW = 60;
type PickerServer = HarborServer & {
country?: string;
city?: string;
provider?: string;
};
export interface ParsedPingResult extends Record<string, unknown> {
id: string;
ok?: boolean;
latency?: number | null;
checkedAt?: string;
}
const searchable = (server: PickerServer) => [
server.label,
server.host,
server.country,
server.city,
server.provider,
server.protocol,
].filter(Boolean).join(' ').toLocaleLowerCase('ru');
export function filterServers(servers: PickerServer[], query: unknown) {
const needle = String(query || '').trim().toLocaleLowerCase('ru');
return needle ? servers.filter((server) => searchable(server).includes(needle)) : servers;
}
export function serverGroup(server: PickerServer) {
return server.country || server.provider || 'Другие';
}
export function groupServers(servers: PickerServer[]) {
return [...servers.reduce((groups, server) => {
const name = serverGroup(server);
groups.set(name, [...(groups.get(name) || []), server]);
return groups;
}, new Map<string, PickerServer[]>())];
}
export function autoServer(servers: PickerServer[]) {
return [...servers].sort((left, right) => left.id.localeCompare(right.id))[0] || null;
}
export function parseServerPingResults(value: unknown): ParsedPingResult[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Expected server ping response object');
}
const response = value as Record<string, unknown>;
if (!Object.hasOwn(response, 'results') || response.results === undefined) return [];
if (!Array.isArray(response.results)) throw new TypeError('Expected server ping results array');
for (const result of response.results) {
if (!result || typeof result !== 'object' || Array.isArray(result) || typeof result.id !== 'string' || !result.id) {
throw new TypeError('Expected server ping result ID');
}
if (Object.hasOwn(result, 'ok') && typeof result.ok !== 'boolean') {
throw new TypeError('Expected server ping result status');
}
if (Object.hasOwn(result, 'latency') && result.latency !== null && (
typeof result.latency !== 'number' || !Number.isFinite(result.latency) || result.latency < 0
)) {
throw new TypeError('Expected server ping result latency');
}
if (Object.hasOwn(result, 'checkedAt') && typeof result.checkedAt !== 'string') {
throw new TypeError('Expected server ping result timestamp');
}
}
return response.results as ParsedPingResult[];
}
@@ -0,0 +1,557 @@
import {
useEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react';
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
import { operationBlocked } from '../../state/operations.js';
import {
isSubscriptionUrlValid,
subscriptionDaysLeft,
subscriptionDomain,
subscriptionUsage,
} from '../../utils/clientControls.js';
import { formatBytes } from '../../utils/format.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { normalizeRequestError, type RequestError } from './requestError.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
interface SubscriptionState {
status?: string;
host?: string | null;
userInfo?: Record<string, unknown> | null;
}
interface SubscriptionFeatureOptions {
subscription?: SubscriptionState | null;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
operations: Record<string, { status?: string } | undefined>;
error?: RequestError | null;
serverCount: number;
isGateway: boolean;
gatewayDirect: boolean;
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
onImport: () => Promise<unknown>;
onRefresh: () => Promise<unknown>;
onForget: () => Promise<unknown>;
onDismissError: () => void;
}
interface SubscriptionValidation {
url: string;
status: 'idle' | 'checking' | 'valid' | 'invalid';
error: RequestError | null;
}
function CloudTooltip({ children }: { children: ReactNode }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
export function useSubscriptionFeature({
subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
error,
serverCount,
isGateway,
gatewayDirect,
validateSubscription,
onImport,
onRefresh,
onForget,
onDismissError,
}: SubscriptionFeatureOptions) {
const hasSubscription = subscription?.status === 'ready';
const [editing, setEditing] = useState(!hasSubscription);
const [contentReady, setContentReady] = useState(hasSubscription);
const [validation, setValidation] = useState<SubscriptionValidation>({
url: '',
status: 'idle',
error: null,
});
const [validationAttempt, setValidationAttempt] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [open, setOpen] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const subscriptionRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const confirmingDeleteRef = useRef(confirmingDelete);
const previousHasSubscriptionRef = useRef(hasSubscription);
confirmingDeleteRef.current = confirmingDelete;
const usage = subscriptionUsage(subscription?.userInfo || undefined);
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
subscription?.userInfo
&& ['upload', 'download', 'total', 'expire'].some((key) => key in subscription.userInfo!),
);
const normalizedUrl = subscriptionUrl.trim();
const currentValidation = validation.url === normalizedUrl ? validation : null;
const localError = normalizedUrl && !isSubscriptionUrlValid(normalizedUrl)
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
: null;
const subscriptionError = currentValidation?.error
|| localError
|| (error?.context === 'subscription' ? error : null);
const validationStatus = !normalizedUrl
? 'idle'
: subscriptionError || !isSubscriptionUrlValid(normalizedUrl)
? 'invalid'
: currentValidation?.status || 'checking';
const waiting = hasSubscription && !contentReady;
const importBlocked = operationBlocked(operations, 'subscriptionImport');
const refreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
const deleteBlocked = operationBlocked(operations, 'subscriptionDelete');
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
useEffect(() => {
if (!normalizedUrl || !isSubscriptionUrlValid(normalizedUrl)) return undefined;
const controller = new AbortController();
setValidation({ url: normalizedUrl, status: 'checking', error: null });
const timer = setTimeout(async () => {
try {
await validateSubscription(normalizedUrl, { signal: controller.signal });
setValidation({ url: normalizedUrl, status: 'valid', error: null });
} catch (caught) {
const requestError = normalizeRequestError(caught);
if (requestError.name === 'AbortError') return;
setValidation({
url: normalizedUrl,
status: 'invalid',
error: {
context: 'subscription',
message: requestError.message,
correlationId: requestError.correlationId,
retry: requestError.retryable
? () => setValidationAttempt((attempt) => attempt + 1)
: null,
},
});
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalizedUrl, validationAttempt, validateSubscription]);
useEffect(() => {
const previouslyHadSubscription = previousHasSubscriptionRef.current;
previousHasSubscriptionRef.current = hasSubscription;
if (!hasSubscription) {
setContentReady(false);
return undefined;
}
if (previouslyHadSubscription) {
setContentReady(true);
return undefined;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setContentReady(true);
return undefined;
}
const timer = setTimeout(() => setContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
return () => clearTimeout(timer);
}, [hasSubscription]);
useEffect(() => {
if (!hasSubscription) setEditing(true);
}, [hasSubscription]);
useEffect(() => {
if (!editing || !hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditing(false), 5000);
return () => clearTimeout(timer);
}, [editing, hasSubscription, subscriptionUrl]);
useEffect(() => {
if (!editing || !hasSubscription) return undefined;
const closeOnOutsideClick = (event: PointerEvent) => {
if (subscriptionRef.current?.contains(event.target as Node | null)) return;
setSubscriptionUrl('');
setEditing(false);
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [editing, hasSubscription, setSubscriptionUrl]);
useEffect(() => {
if (!hasSubscription) return undefined;
onRefresh();
return undefined;
}, [hasSubscription]);
useEffect(() => {
const from = displayedUsed;
const to = usage.used;
if (from === to) return undefined;
const startedAt = performance.now();
let frame: number;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / 900);
const eased = 1 - Math.pow(1 - progress, 4);
setDisplayedUsed(from + (to - from) * eased);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [usage.used]);
useEffect(() => {
if (!open) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeSubscription = (event: PointerEvent | KeyboardEvent) => {
if (confirmingDeleteRef.current) return;
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
const target = event.target as Node | null;
if (event.type !== 'keydown' && (
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
)) return;
setOpen(false);
};
document.addEventListener('pointerdown', closeSubscription);
document.addEventListener('keydown', closeSubscription);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeSubscription);
document.removeEventListener('keydown', closeSubscription);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [open]);
async function submit() {
if (validationStatus !== 'valid') return;
if (!await onImport()) return;
setSubscriptionUrl('');
setEditing(false);
}
async function refresh() {
const startedAt = performance.now();
setRefreshing(true);
try {
if (!await onRefresh()) return;
setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
setServersLeaving(true);
await new Promise((resolve) => setTimeout(
resolve,
420 + Math.min(7, Math.max(0, serverCount - 1)) * 90,
));
setServerRevealVersion((version) => version + 1);
setServersLeaving(false);
} finally {
const elapsed = performance.now() - startedAt;
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
setRefreshing(false);
}
}
async function forget() {
if (!await onForget()) return;
setConfirmingDelete(false);
}
function changeUrl(value: string) {
if (error?.context === 'subscription') onDismissError();
setValidation({ url: '', status: 'idle', error: null });
setSubscriptionUrl(value);
}
function cancelEditing() {
if (!hasSubscription) return;
setSubscriptionUrl('');
setEditing(false);
}
return {
subscription,
subscriptionUrl,
hasSubscription,
hasUsage,
isGateway,
gatewayDirect,
editing,
contentReady,
waiting,
open,
confirmingDelete,
refreshing,
usageUpdated,
usage,
displayedUsed,
validationStatus,
normalizedUrl,
error: subscriptionError,
importBlocked,
refreshBlocked,
deleteBlocked,
serversLeaving,
serverRevealVersion,
inputRef,
subscriptionRef,
panelRef,
toggleRef,
closeRef,
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
edit: () => setEditing(true),
changeUrl,
cancelEditing,
submit,
refresh,
requestDelete: () => setConfirmingDelete(true),
cancelDelete: () => setConfirmingDelete(false),
forget,
};
}
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
interface SubscriptionToggleProps {
feature: SubscriptionFeatureController;
onToggle: () => void;
}
export function SubscriptionToggle({ feature, onToggle }: SubscriptionToggleProps) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle client-subscription-toggle${feature.open ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.open}
aria-controls="client-subscription-drawer"
aria-label={feature.open ? 'Закрыть подписку' : 'Управление подпиской'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
</svg>
<span>Подписка</span>
</button>;
}
interface SubscriptionPanelProps {
feature: SubscriptionFeatureController;
statusSlot?: ReactNode;
serverSlot?: ReactNode;
}
export function SubscriptionPanel({ feature, statusSlot, serverSlot }: SubscriptionPanelProps) {
const {
subscription,
subscriptionUrl,
hasSubscription,
hasUsage,
isGateway,
gatewayDirect,
editing,
contentReady,
waiting,
open,
refreshing,
usageUpdated,
usage,
displayedUsed,
validationStatus,
normalizedUrl,
error,
importBlocked,
refreshBlocked,
deleteBlocked,
inputRef,
subscriptionRef,
panelRef,
closeRef,
} = feature;
return <div
ref={isGateway ? panelRef : undefined}
id={isGateway ? 'client-subscription-drawer' : undefined}
className={isGateway
? `client-drawer client-subscription-drawer${open ? ' is-open' : ''}`
: `client-form${waiting ? ' is-waiting' : ''}`}
aria-label={isGateway ? 'Управление подпиской' : undefined}
aria-hidden={isGateway ? !open : waiting}
aria-disabled={gatewayDirect}
inert={(isGateway && !open) || waiting || gatewayDirect ? true : undefined}
>
{isGateway && <button
ref={closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть подписку"
onClick={feature.close}
>×</button>}
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
<div
ref={subscriptionRef}
className={`client-subscription ${editing ? 'is-editing' : ''}${editing && hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
>
<div
className="client-subscription-summary"
aria-hidden={editing}
inert={editing ? true : undefined}
>
<div className="client-subscription-heading">
<span className="client-subscription-label">Ваша подписка</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-refresh"
type="button"
aria-label="Обновить подписку"
disabled={refreshing || refreshBlocked}
onClick={feature.refresh}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</button>
<CloudTooltip>Обновить подписку</CloudTooltip>
</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
disabled={deleteBlocked}
onClick={feature.requestDelete}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
<CloudTooltip>Удалить подписку</CloudTooltip>
</span>
</div>
<button
className="client-subscription-domain-button"
type="button"
tabIndex={editing ? -1 : 0}
onClick={feature.edit}
>
<strong>{subscriptionDomain(subscription?.host)}</strong>
</button>
</div>
<form
className={`client-subscription-edit is-${validationStatus}`}
autoComplete="off"
aria-hidden={!editing}
inert={!editing ? true : undefined}
onSubmit={(event) => {
event.preventDefault();
feature.submit();
}}
>
<input
ref={inputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="off"
tabIndex={editing ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl}
onChange={(event) => feature.changeUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') feature.cancelEditing();
}}
/>
{subscriptionUrl && (
<span className="client-subscription-domain">
{subscriptionDomain(subscriptionUrl)}
</span>
)}
{normalizedUrl && (
<button
className="client-subscription-submit"
type="submit"
aria-live="polite"
aria-label={validationStatus === 'valid'
? 'Сохранить подписку'
: validationStatus === 'checking'
? 'Проверяем подписку'
: error?.message || 'Ссылка подписки не распознана'}
disabled={importBlocked || validationStatus !== 'valid'}
>
{validationStatus === 'valid'
? '✓'
: validationStatus === 'checking' ? '…' : '×'}
</button>
)}
</form>
{statusSlot}
</div>
{hasSubscription && contentReady && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span>
<strong>
{formatBytes(displayedUsed)}
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
</strong>
{usage.percent !== null && (
<div
className="client-usage-bar"
role="progressbar"
aria-label="Использованный трафик"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(usage.percent)}
>
<i style={{ width: `${usage.percent}%` }} />
</div>
)}
<div className="client-usage-details">
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
<span>
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
</span>
)}
</div>
</section>
)}
{serverSlot}
</div>
</div>;
}
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
return <ConfirmationDialog
open={feature.confirmingDelete}
id="delete-subscription"
kicker="Необратимое действие"
title="Удалить подписку?"
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
cancelLabel="Отмена"
confirmLabel="Удалить"
busy={feature.deleteBlocked}
onCancel={feature.cancelDelete}
onConfirm={feature.forget}
/>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
SubscriptionDeleteDialog,
SubscriptionPanel,
SubscriptionToggle,
useSubscriptionFeature,
} from './SubscriptionFeature.js';
@@ -0,0 +1,32 @@
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
export interface RequestError {
name?: string;
context?: string;
message?: string;
correlationId?: string;
retryable?: boolean;
retry?: (() => unknown) | null;
}
export function normalizeRequestError(value: unknown): RequestError & { message: string } {
const candidate = value && (typeof value === 'object' || typeof value === 'function')
? value
: null;
const property = (key: string): unknown => candidate ? Reflect.get(candidate, key) : undefined;
const name = property('name');
const context = property('context');
const message = property('message');
const correlationId = property('correlationId');
const retry = property('retry');
return {
name: typeof name === 'string' ? name : undefined,
context: typeof context === 'string' ? context : undefined,
message: typeof message === 'string' && message
? message
: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message,
correlationId: typeof correlationId === 'string' ? correlationId : undefined,
retryable: property('retryable') === true,
retry: typeof retry === 'function' ? retry as () => unknown : null,
};
}
+8
View File
@@ -0,0 +1,8 @@
import { createRoot } from 'react-dom/client';
import { App } from './App.js';
import './styles/index.css';
const root = document.getElementById('root');
if (!root) throw new Error('Harbor root element not found');
createRoot(root).render(<App />);
@@ -1,4 +1,27 @@
export const initialHarborState = {
import type { HarborClientState } from '../api/harborClient.js';
export type SyncErrorKind = 'incompatible-api' | 'control-unreachable' | 'fatal';
export interface HarborReducerState {
snapshot: HarborClientState | null;
pendingServerId: string;
transport: {
bootStatus: 'loading' | 'ready' | SyncErrorKind;
lastSuccessfulSyncAt: string | null;
consecutiveFailures: number;
stale: boolean;
error: { kind: SyncErrorKind; message: string } | null;
};
}
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: {
@@ -12,28 +35,22 @@ export const initialHarborState = {
export const STALE_FAILURE_THRESHOLD = 3;
export function compatibleSnapshot(snapshot) {
return snapshot?.apiVersion === 1 &&
Number.isSafeInteger(snapshot.revision) &&
typeof snapshot.selection?.desiredServerId === 'string' &&
Array.isArray(snapshot.servers);
}
export function classifySyncError(error) {
const status = Number(error?.status) || 0;
if (error?.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api';
if (error?.code === 'CONTROL_UNREACHABLE' || error?.name === 'TypeError' || status >= 500) return 'control-unreachable';
export function classifySyncError(error: unknown): SyncErrorKind {
const candidate = error && typeof error === 'object' ? error as Record<string, unknown> : {};
const status = Number(candidate.status) || 0;
if (candidate.code === 'INCOMPATIBLE_API' || status === 404) return 'incompatible-api';
if (candidate.code === 'CONTROL_UNREACHABLE' || candidate.name === 'TypeError' || status >= 500) return 'control-unreachable';
return 'fatal';
}
function reconcilePendingServer(pendingServerId, snapshot) {
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, action) {
export function harborReducer(current: HarborReducerState, action: HarborAction): HarborReducerState {
if (action.type === 'select-server') {
return action.serverId === current.pendingServerId
? current
@@ -65,7 +82,7 @@ export function harborReducer(current, action) {
),
error: {
kind: bootStatus,
message: action.error?.message || 'Неизвестная ошибка',
message: action.error instanceof Error ? action.error.message : 'Неизвестная ошибка',
},
},
};
@@ -1,4 +1,9 @@
export const OPERATION_CONFLICTS = Object.freeze({
export type OperationKey = 'connection' | 'serverApply' | 'subscriptionImport'
| 'subscriptionRefresh' | 'subscriptionDelete' | 'gatewayAuto' | 'routeRules';
export interface OperationState { status: 'running'; startedAt: 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'],
@@ -8,25 +13,29 @@ export const OPERATION_CONFLICTS = Object.freeze({
routeRules: ['connection', 'serverApply', 'subscriptionImport', 'subscriptionRefresh', 'subscriptionDelete', 'gatewayAuto'],
});
export function operationBlocked(operations, key) {
export function operationBlocked(operations: OperationRegistrySnapshot, key: OperationKey) {
if (operations[key]?.status === 'running') return true;
return (OPERATION_CONFLICTS[key] || []).some(
(conflict) => operations[conflict]?.status === 'running',
);
}
export function createOperationRegistry(onChange = () => {}, now = () => new Date().toISOString()) {
let operations = {};
const inFlight = new Map();
export function createOperationRegistry(
onChange: (operations: OperationRegistrySnapshot) => void = () => {},
now = () => new Date().toISOString(),
) {
let operations: OperationRegistrySnapshot = {};
const inFlight = new Map<OperationKey, Promise<unknown>>();
function run(key, action) {
if (inFlight.has(key)) return inFlight.get(key);
function run<T>(key: OperationKey, action: () => T | Promise<T>): Promise<T | false> {
const existing = inFlight.get(key);
if (existing) return existing as Promise<T>;
if (operationBlocked(operations, key)) return Promise.resolve(false);
operations = { ...operations, [key]: { status: 'running', startedAt: now() } };
onChange(operations);
const promise = Promise.resolve()
const promise: Promise<T> = Promise.resolve()
.then(action)
.finally(() => {
const { [key]: completed, ...remaining } = operations;
@@ -34,7 +43,7 @@ export function createOperationRegistry(onChange = () => {}, now = () => new Dat
inFlight.delete(key);
onChange(operations);
});
inFlight.set(key, promise);
inFlight.set(key, promise as Promise<unknown>);
return promise;
}
-5412
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
}
button,
input {
font: inherit;
color: inherit;
}
h2,
p {
margin: 0;
}
.app {
min-height: 100vh;
display: grid;
}
.app-body {
min-height: 0;
}
.app-main {
min-width: 0;
}
.app-loading {
min-height: 100vh;
display: grid;
place-items: center;
font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145));
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
}
.app-boot {
min-height: 100vh;
display: grid;
place-content: center;
justify-items: start;
gap: 14px;
padding: 32px;
background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145));
color: light-dark(oklch(0.24 0.014 145), oklch(0.93 0.008 145));
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}
.app-boot > span,
.app-boot summary {
color: light-dark(oklch(0.53 0.014 145), oklch(0.68 0.012 145));
font-size: 11px;
}
.app-boot h1 {
max-width: 22ch;
margin: 0;
font-size: clamp(22px, 5vw, 34px);
letter-spacing: -0.05em;
}
.app-boot p {
max-width: 58ch;
color: light-dark(oklch(0.53 0.014 145), oklch(0.68 0.012 145));
font-size: 12px;
line-height: 1.7;
}
.app-boot button,
.client-stale-banner button {
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: light-dark(oklch(0.91 0.02 185), oklch(0.28 0.03 185));
cursor: pointer;
}
.app-boot button:focus-visible,
.client-stale-banner button:focus-visible {
outline: 2px solid oklch(0.65 0.11 185);
outline-offset: 3px;
}
.app-boot details {
max-width: min(640px, calc(100vw - 64px));
margin-top: 8px;
}
.app-boot summary {
cursor: pointer;
}
.app-boot code,
.app-boot pre {
display: block;
overflow-x: auto;
margin: 12px 0 0;
font-size: 11px;
line-height: 1.6;
white-space: pre-wrap;
}
.client-stale-banner {
position: fixed;
top: 16px;
left: 50%;
z-index: 60;
display: grid;
grid-template-columns: auto auto auto;
align-items: center;
gap: 12px;
padding: 9px 10px 9px 14px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-panel) 90%, transparent);
box-shadow: 0 10px 32px oklch(0.08 0.015 145 / 0.14);
backdrop-filter: blur(12px);
color: var(--client-muted);
font: 600 10px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
transform: translateX(-50%);
}
.client-stale-banner strong {
color: var(--client-text);
}
.client-stale-banner button {
color: var(--client-text);
font-size: 10px;
}
+412
View File
@@ -0,0 +1,412 @@
.client-power-section p {
color: var(--client-muted);
font-size: 11px;
}
.client-power-section {
position: relative;
grid-column: 2;
display: grid;
justify-items: center;
gap: 18px;
text-align: center;
animation: client-power-arrive 850ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-power-section:has(.client-tooltip-anchor:hover),
.client-power-section:has(.client-tooltip-anchor:focus-visible),
.client-power-section:has(.client-tooltip-anchor > :focus-visible) {
z-index: 90;
}
@keyframes client-power-arrive {
0% { opacity: 0; filter: blur(8px); transform: scale(0.94); }
100% { opacity: 1; filter: blur(0); transform: scale(1); }
}
.client-state-copy {
min-height: 76px;
}
.client-power-control {
width: 96px;
height: 96px;
display: grid;
place-items: center;
border-radius: 50%;
}
.client-power-control:focus-visible {
outline: 1px solid color-mix(in oklch, var(--client-accent) 64%, transparent);
outline-offset: 5px;
}
.client-power {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--client-muted);
cursor: pointer;
transition: color 800ms cubic-bezier(0.16, 1, 0.3, 1), opacity 600ms ease;
}
.client-power::before,
.client-power::after {
content: '';
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.client-power::before {
width: 54px;
height: 54px;
border-radius: 50%;
background: radial-gradient(circle, color-mix(in oklch, currentColor 38%, transparent), transparent 70%);
opacity: 0;
filter: blur(2px);
transform: translate3d(0, 4px, 0) scale(0.62);
transition: opacity 1050ms cubic-bezier(0.16, 1, 0.3, 1), transform 1250ms cubic-bezier(0.16, 1, 0.3, 1), filter 1050ms ease;
}
.client-power::after {
width: 24px;
height: 24px;
background: radial-gradient(circle, color-mix(in oklch, currentColor 48%, transparent), transparent 72%);
opacity: 0;
filter: blur(5px);
transform: translate3d(-8px, 7px, 0) scale(0.7);
transition: opacity 850ms cubic-bezier(0.16, 1, 0.3, 1), transform 950ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-power[aria-checked='true']::before {
opacity: 0.76;
filter: blur(3px);
transform: translate3d(-3px, -2px, 0) scale(1.48);
}
.client-power[aria-checked='true']::after {
animation: client-power-light-flicker 4.7s 650ms ease-in-out infinite;
}
.client-power:hover:not(:disabled) {
color: var(--client-muted);
}
.client-power[aria-checked='true'] {
color: var(--client-accent);
}
.client-power:active:not(:disabled) {
opacity: 0.82;
}
.client-power:active:not(:disabled)::before {
animation: none;
opacity: 0.86;
transform: translate3d(3px, -2px, 0) scale(1.56);
}
.client-power:active:not(:disabled)::after {
animation: none;
opacity: 0.7;
transform: translate3d(-7px, 8px, 0) scale(1.18);
}
.client-power:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.client-power svg {
position: relative;
width: 40px;
height: 40px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
filter: drop-shadow(0 0 0 transparent);
transition: filter 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-power:hover:not(:disabled) svg {
filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-muted) 32%, transparent));
}
.client-power[aria-checked='true']:hover:not(:disabled) {
color: var(--client-accent);
}
.client-power[aria-checked='true']:hover:not(:disabled) svg {
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 48%, transparent));
}
.client-power[aria-checked='true'] svg {
filter: drop-shadow(0 0 4px color-mix(in oklch, var(--client-accent) 42%, transparent));
}
@keyframes client-power-light-flicker {
0%, 100% { opacity: 0.18; transform: translate3d(-8px, 7px, 0) scale(0.82); }
19% { opacity: 0.5; transform: translate3d(7px, 5px, 0) scale(1.08); }
46% { opacity: 0.26; transform: translate3d(8px, -7px, 0) scale(0.78); }
71% { opacity: 0.58; transform: translate3d(-6px, -8px, 0) scale(1.12); }
}
.client-power-section h2 {
font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: -0.04em;
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-connection-title {
display: grid;
min-height: 24px;
place-items: center;
}
.client-connection-title > span {
grid-area: 1 / 1;
opacity: 0;
filter: blur(5px);
transform: scale(0.97);
transition: opacity 480ms ease, filter 620ms cubic-bezier(0.16, 1, 0.3, 1), transform 620ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-connection-title > .is-active {
opacity: 1;
filter: blur(0);
transform: scale(1);
}
.client-state-detail {
min-height: 44px;
display: grid;
place-items: center;
margin-top: 8px;
}
.client-state-detail > * {
grid-area: 1 / 1;
margin: 0;
animation: client-state-reveal 850ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-duration {
grid-area: 1 / 1;
display: block;
color: var(--client-text);
font-size: 14px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.04em;
opacity: 0;
filter: blur(5px);
transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-duration.is-active {
opacity: 1;
filter: blur(0);
}
.client-duration-stack {
display: grid;
place-items: center;
}
.client-duration-words {
display: grid;
place-items: center;
gap: 3px;
}
.client-duration-word-row {
min-height: 19px;
display: flex;
align-items: baseline;
justify-content: center;
white-space: nowrap;
}
.client-duration-unit {
display: inline-flex;
align-items: baseline;
gap: 0.4em;
}
.client-duration-unit .client-duration-part.is-value {
display: inline-block;
min-width: 2ch;
text-align: right;
}
.client-duration-unit .client-duration-part.is-label {
display: inline-block;
text-align: left;
}
.client-duration-unit + .client-duration-unit {
margin-left: 0.9em;
}
.client-duration-seconds-value {
display: inline-block;
}
.client-duration-second-digit {
display: inline-block;
animation: client-second-tick 520ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes client-second-tick {
from {
opacity: 0.82;
filter: blur(1px);
text-shadow: 0 0 8px color-mix(in oklch, var(--client-accent) 52%, transparent);
}
to {
opacity: 1;
filter: blur(0);
text-shadow: 0 0 0 transparent;
}
}
.client-duration-toggle {
width: min(290px, 100%);
min-height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.client-duration-toggle:hover .client-duration,
.client-duration-toggle:focus-visible .client-duration {
text-shadow: 0 0 10px color-mix(in oklch, var(--client-text) 22%, transparent);
}
.client-duration-toggle:focus-visible {
outline: none;
}
.client-power-section p {
min-height: 0;
line-height: 20px;
}
@keyframes client-state-reveal {
0% { opacity: 0; filter: blur(5px); }
100% { opacity: 1; filter: blur(0); }
}
@media (min-width: 921px) {
.client-panel.has-subscription .client-power-section {
align-self: center;
align-content: start;
box-sizing: border-box;
height: var(--client-work-height);
padding-top: var(--client-power-top);
}
}
.client-proxies {
display: grid;
justify-items: center;
gap: 5px;
width: 240px;
margin-top: 6px;
}
.client-proxies.is-gateway {
width: 270px;
}
.client-proxies.is-gateway .client-copy-button {
width: 82px;
}
.client-proxy-label {
display: grid;
place-items: center;
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-proxy-label > span {
grid-area: 1 / 1;
opacity: 0;
filter: blur(3px);
pointer-events: none;
transform: translateX(-12px);
transition: color 700ms ease, opacity 520ms ease, filter 620ms ease, transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-proxy-label > span:last-child {
color: var(--client-accent);
transform: translateX(12px);
}
.client-proxy-label > span.is-active {
opacity: 1;
filter: blur(0);
pointer-events: auto;
transform: translateX(0);
}
.client-proxy-label a {
color: var(--client-accent);
text-decoration: none;
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-proxy-label a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.client-proxy-label a:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-proxy-address {
color: var(--client-text);
font-size: 12px;
font-weight: 500;
text-shadow: 0 0 12px color-mix(in oklch, var(--client-accent) 12%, transparent);
}
.client-access-point {
display: grid;
justify-items: center;
gap: 5px;
animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes client-access-reveal {
0% { opacity: 0; filter: blur(4px); }
100% { opacity: 1; filter: blur(0); }
}
.client-proxy-actions {
display: flex;
gap: 8px;
margin-top: 7px;
}
.client-inline-error.is-connection {
top: calc(100% + 12px);
}
File diff suppressed because it is too large Load Diff
+429
View File
@@ -0,0 +1,429 @@
.client-instructions.client-diagnostics {
width: min(580px, 100vw);
}
.client-diagnostics-header {
margin-bottom: 12px;
}
.client-diagnostics-title-row {
display: flex;
align-items: center;
gap: 12px;
}
.client-diagnostics-title-row h2 {
flex: 0 1 auto;
}
.client-diagnostics-refresh-wrap {
width: 36px;
height: 36px;
display: grid;
place-items: center;
}
.client-diagnostics-refresh {
width: 36px;
height: 36px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
cursor: pointer;
transition: color 220ms ease, filter 320ms ease;
}
.client-diagnostics-refresh svg {
width: 17px;
height: 17px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-diagnostics-refresh:hover:not(:disabled),
.client-diagnostics-refresh:focus-visible,
.client-diagnostics-refresh.is-running {
color: var(--client-accent);
filter: drop-shadow(0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent));
}
.client-diagnostics-refresh:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 1px;
}
.client-diagnostics-refresh:hover:not(:disabled) svg,
.client-diagnostics-refresh:focus-visible:not(.is-running) svg {
transform: rotate(90deg);
}
.client-diagnostics-refresh.is-running svg {
animation: client-spin 900ms linear infinite;
}
.client-diagnostics-refresh:disabled {
cursor: wait;
}
.client-diagnostics-refresh-wrap > .client-tooltip {
right: 0;
left: auto;
text-transform: none;
transform: translate(0, 2px);
}
.client-diagnostics-refresh-wrap:hover > .client-tooltip,
.client-diagnostics-refresh-wrap:has(> :focus-visible) > .client-tooltip {
transform: translate(0, 0);
}
.client-diagnostics-feedback {
display: flex;
align-items: center;
gap: 10px;
margin: 0 8px 10px;
}
.client-diagnostics-error {
display: flex;
align-items: center;
gap: 12px;
color: oklch(0.68 0.15 28);
font-size: 9px;
line-height: 1.5;
}
.client-diagnostics-error button {
padding: 0;
border: 0;
background: transparent;
color: inherit;
font-weight: 700;
cursor: pointer;
}
.client-diagnostics-section {
display: grid;
gap: 5px;
margin: 0 8px 24px;
}
.client-diagnostics-section-title {
min-height: 30px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-diagnostics-section-title button {
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
}
.client-diagnostics-section-title button:disabled {
color: var(--client-muted);
cursor: default;
opacity: 0.5;
}
.client-diagnostics-section-title button:focus-visible,
.client-diagnostics-error button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-diagnostics-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
transition: opacity 220ms ease, filter 320ms ease;
}
.client-diagnostics-table th,
.client-diagnostics-table td {
min-width: 0;
padding: 9px 8px;
overflow-wrap: anywhere;
text-align: left;
vertical-align: middle;
}
.client-diagnostics-table th:first-child {
width: 35%;
}
.client-diagnostics-table thead th {
padding-top: 2px;
padding-bottom: 7px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.client-diagnostics-table tbody tr {
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
}
.client-diagnostics-active-marker {
position: absolute;
top: 0;
left: 0;
z-index: 0;
background-color: color-mix(in oklch, var(--client-accent) 8%, transparent);
box-shadow: inset 2px 0 var(--client-accent);
opacity: 0;
pointer-events: none;
transform: translate3d(var(--diagnostics-runner-x, 0), var(--diagnostics-runner-y, 0), 0);
transition: opacity 220ms ease;
will-change: transform;
}
.client-diagnostics-active-marker.is-visible {
opacity: 1;
}
.client-diagnostics-active-marker.is-moving {
transition: transform 680ms cubic-bezier(0.16, 1, 0.3, 1), opacity 220ms ease;
}
.client-diagnostics-header,
.client-diagnostics-feedback,
.client-diagnostics-section {
position: relative;
z-index: 1;
}
.client-diagnostics-table tbody tr.is-running th {
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
}
.client-diagnostics-table tbody th {
color: var(--client-text);
font-size: 9px;
font-weight: 600;
transition: color 420ms ease, text-shadow 520ms ease;
}
.client-diagnostics-service-table {
display: grid;
}
.client-diagnostics-service-header,
.client-diagnostics-service-row {
min-width: 0;
display: grid;
grid-template-columns: 35% minmax(0, 1fr) minmax(0, 1fr) 28px;
align-items: center;
}
.client-diagnostics-service-header {
padding: 2px 0 7px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.client-diagnostics-service-header > span,
.client-diagnostics-service-row > span:not(.client-delete-strike),
.client-diagnostics-service-row > input {
min-width: 0;
padding-inline: 8px;
}
.client-diagnostics-service-row {
position: relative;
isolation: isolate;
min-height: 37px;
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
animation: client-local-rule-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-diagnostics-service-row.is-removing {
pointer-events: none;
animation: client-local-rule-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.client-diagnostics-service-row.is-running .client-diagnostics-service-name {
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
}
.client-diagnostics-service-name {
overflow-wrap: anywhere;
color: var(--client-text);
font-size: 9px;
font-weight: 600;
transition: color 420ms ease, text-shadow 520ms ease;
}
.client-diagnostics-table code,
.client-diagnostics-status {
display: inline-block;
max-width: 100%;
overflow-wrap: anywhere;
color: var(--client-text);
font: 600 9px/1.4 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}
.client-diagnostics-status.is-good {
color: var(--client-accent);
}
.client-diagnostics-status.is-warning {
color: oklch(0.68 0.14 72);
}
.client-diagnostics-status.is-error {
color: oklch(0.68 0.15 28);
}
.client-diagnostics-status.is-muted {
color: var(--client-muted);
}
.client-diagnostics-status.is-running {
color: var(--client-accent);
white-space: nowrap;
}
.client-diagnostics-dots {
position: relative;
display: inline-block;
margin-left: 0.15em;
color: color-mix(in oklch, var(--client-accent) 24%, transparent);
}
.client-diagnostics-dots::after {
position: absolute;
inset: 0;
color: var(--client-accent);
clip-path: inset(0 100% 0 0);
content: '...';
animation: client-diagnostics-dots-fill 1200ms steps(3, end) infinite;
}
@keyframes client-diagnostics-dots-fill {
0%, 12% { clip-path: inset(0 100% 0 0); }
82%, 94% { clip-path: inset(0); }
100% { clip-path: inset(0 100% 0 0); }
}
.client-diagnostics-service-draft input {
min-width: 0;
height: 34px;
padding: 0 2px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
box-shadow: 0 1px 0 transparent;
transition: box-shadow 300ms ease, text-shadow 300ms ease;
}
.client-diagnostics-service-draft input::placeholder {
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
}
.client-diagnostics-service-draft input:focus {
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.client-diagnostics-service-url {
min-width: 0;
grid-column: 2 / 4;
display: flex;
align-items: center;
gap: 8px;
}
.client-diagnostics-service-url input {
flex: 1 1 auto;
}
.client-diagnostics-service-url button {
padding: 5px 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: 700 8px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
transition: filter 260ms ease, text-shadow 300ms ease;
}
.client-diagnostics-service-url button:hover,
.client-diagnostics-service-url button:focus-visible {
outline: 0;
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 42%, transparent));
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-diagnostics-services-empty {
padding: 14px 8px 4px;
color: var(--client-muted);
font-size: 9px;
}
.client-diagnostics-add-slot {
padding-inline: 8px;
}
@media (max-width: 560px) {
.client-diagnostics-table th,
.client-diagnostics-table td {
padding-inline: 5px;
}
.client-diagnostics-table th:first-child {
width: 32%;
}
.client-diagnostics-service-header,
.client-diagnostics-service-row {
grid-template-columns: 32% minmax(0, 1fr) minmax(0, 1fr) 44px;
}
.client-diagnostics-service-draft {
grid-template-columns: minmax(0, 1fr) 44px;
padding-block: 5px;
}
.client-diagnostics-service-draft > input {
grid-column: 1;
}
.client-diagnostics-service-url {
grid-column: 1;
grid-row: 2;
}
.client-diagnostics-service-draft > .client-local-rule-delete {
grid-column: 2;
grid-row: 1 / 3;
width: 44px;
height: 44px;
}
}
+239
View File
@@ -0,0 +1,239 @@
.client-instructions {
width: min(470px, 100vw);
}
.client-instruction-list {
display: grid;
gap: 15px;
}
.client-instruction-block {
border: 0;
border-radius: 22px;
background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
box-shadow: 0 14px 36px oklch(0.1 0.015 145 / 0.065);
transition: background 300ms ease, box-shadow 500ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-instruction-block:nth-child(even) {
margin-left: 12px;
}
.client-instruction-block:nth-child(3n) {
margin-right: 9px;
}
.client-instruction-block:hover,
.client-instruction-block.is-open {
background: color-mix(in oklch, var(--client-panel) 68%, var(--client-bg));
box-shadow: 0 20px 48px oklch(0.1 0.015 145 / 0.095);
transform: translateY(-2px);
}
.client-instruction-block.is-open {
animation: client-instruction-promote 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes client-instruction-promote {
from { opacity: 0.72; transform: translateY(10px); }
to { opacity: 1; transform: translateY(-2px); }
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-group(*) {
animation-duration: 420ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
::view-transition-group(instruction-proxybridge),
::view-transition-group(instruction-switchyomega),
::view-transition-group(instruction-vscode),
::view-transition-group(instruction-router) {
animation-duration: 560ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
.client-instruction-summary {
position: relative;
width: 100%;
display: grid;
gap: 7px;
padding: 21px 46px 21px 22px;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.client-instruction-summary:focus-visible {
outline: none;
}
.client-instruction-block:has(.client-instruction-summary:focus-visible) {
background: color-mix(in oklch, var(--client-panel) 72%, var(--client-bg));
box-shadow: 0 20px 48px oklch(0.1 0.015 145 / 0.11);
}
.client-instruction-summary > i {
position: absolute;
top: 27px;
right: 20px;
width: 12px;
height: 12px;
}
.client-instruction-summary > i::before,
.client-instruction-summary > i::after {
content: '';
position: absolute;
top: 5px;
left: 0;
width: 12px;
height: 1px;
background: var(--client-muted);
transform-origin: center;
transition: transform 520ms cubic-bezier(0.16, 1, 0.3, 1), opacity 360ms ease;
}
.client-instruction-summary > i::after {
transform: rotate(90deg);
}
.client-instruction-block.is-open .client-instruction-summary > i::after {
opacity: 0;
transform: rotate(180deg);
}
.client-instruction-block.is-open .client-instruction-summary > i::before {
transform: rotate(180deg);
}
.client-instruction-summary strong {
font-size: 14px;
}
.client-instruction-summary small {
max-width: 54ch;
color: var(--client-muted);
font-size: 10px;
line-height: 1.55;
}
.client-instruction-reveal {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
filter: blur(4px);
transition: grid-template-rows 600ms cubic-bezier(0.16, 1, 0.3, 1), opacity 420ms ease, filter 500ms ease;
}
.client-instruction-block.is-open .client-instruction-reveal {
grid-template-rows: 1fr;
opacity: 1;
filter: blur(0);
}
.client-instruction-body {
min-height: 0;
overflow: hidden;
display: grid;
gap: 14px;
padding: 0 22px 24px;
color: var(--client-muted);
font-size: 11px;
line-height: 1.7;
}
.client-instruction-body p,
.client-instruction-body ol {
margin: 0;
}
.client-instruction-body ol {
display: grid;
gap: 9px;
padding-left: 22px;
}
.client-instruction-body li::marker {
color: var(--client-accent);
font-variant-numeric: tabular-nums;
}
.client-instruction-body code {
display: block;
overflow-x: auto;
padding: 12px 14px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-control) 84%, transparent);
color: var(--client-text);
font: 500 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
white-space: nowrap;
}
.client-instruction-code {
max-width: 100%;
margin: 0;
overflow-x: auto;
padding: 12px 14px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-control) 84%, transparent);
}
.client-instruction-code code {
overflow: visible;
padding: 0;
border-radius: 0;
background: transparent;
line-height: 1.55;
white-space: pre;
}
.client-instruction-copies {
position: relative;
display: grid;
gap: 7px;
}
.client-instruction-copy {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.client-instruction-copy > span {
min-width: 0;
overflow: hidden;
color: var(--client-text);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-instruction-body .client-instruction-note {
padding: 11px 13px;
border-radius: 12px;
background: color-mix(in oklch, var(--client-control) 48%, transparent);
color: var(--client-text);
}
.client-instruction-body a {
width: fit-content;
color: var(--client-accent);
font-weight: 700;
text-decoration: none;
}
.client-instruction-body a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
+401
View File
@@ -0,0 +1,401 @@
.client-local-rules {
width: min(480px, 100vw);
}
.client-local-rules-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 9px;
margin: 0 8px 34px;
}
.client-local-rules-header > span,
.client-local-rules-group > span {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-local-rules-header h2 {
grid-column: 1 / -1;
font-size: 22px;
letter-spacing: -0.045em;
}
.client-local-rules-header p {
grid-column: 1 / -1;
max-width: 46ch;
color: var(--client-muted);
font-size: 11px;
line-height: 1.7;
}
.client-local-rules-save {
align-self: center;
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: color 200ms ease, filter 300ms ease, opacity 220ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-save:hover:not(:disabled) {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 42%, transparent));
transform: translateY(-1px);
}
.client-local-rules-save:disabled {
opacity: 0.28;
cursor: default;
}
.client-local-rules-header .client-local-rules-runtime {
color: oklch(0.68 0.14 72);
animation: client-local-rules-notice 480ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-local-rules-form,
.client-local-rules-group,
.client-local-rules-list {
display: grid;
}
.client-local-rules-form {
gap: 28px;
}
.client-local-rules-group {
gap: 11px;
}
.client-local-rules-list {
gap: 2px;
}
.client-local-rule {
position: relative;
isolation: isolate;
min-width: 0;
display: grid;
grid-template-columns: 24px 116px minmax(0, 1fr) 112px 28px;
align-items: center;
gap: 10px;
padding: 7px 0;
animation: client-local-rule-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 260ms ease, filter 360ms ease;
}
.client-local-rule:has(.client-rule-type.is-open) {
z-index: 5;
}
.client-local-rule-status {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.07em;
text-align: right;
text-transform: uppercase;
transition: color 280ms ease, opacity 280ms ease, filter 360ms ease;
}
.client-local-rule.is-active .client-local-rule-status {
color: var(--client-accent);
}
.client-local-rule.is-pending .client-local-rule-status,
.client-local-rule.is-unsaved .client-local-rule-status {
color: var(--client-warning, oklch(0.72 0.12 72));
}
.client-local-rule.is-disabled {
opacity: 0.42;
filter: saturate(0);
}
.client-local-rule.is-removing {
pointer-events: none;
animation: client-local-rule-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.client-local-rule-enabled {
width: 24px;
height: 32px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
cursor: pointer;
}
.client-local-rule-enabled svg {
width: 18px;
height: 18px;
overflow: visible;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
transition: color 260ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled circle {
stroke-width: 1.4;
transition: fill 320ms ease, stroke 260ms ease;
}
.client-rule-check {
stroke-width: 1.8;
stroke-dasharray: 12;
stroke-dashoffset: 12;
transition: opacity 160ms ease, stroke-dashoffset 360ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-enabled[aria-checked='true'] {
color: var(--client-accent);
}
.client-local-rule-enabled[aria-checked='true'] svg {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 48%, transparent));
transform: scale(1.08);
}
.client-local-rule-enabled[aria-checked='true'] circle {
fill: color-mix(in oklch, var(--client-accent) 12%, transparent);
}
.client-local-rule-enabled[aria-checked='true'] .client-rule-check {
stroke-dashoffset: 0;
}
.client-rule-type {
position: relative;
min-width: 0;
z-index: 1;
}
.client-rule-type.is-open {
z-index: 4;
}
.client-rule-type-trigger {
width: 100%;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-text);
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: left;
cursor: pointer;
transition: color 220ms ease, text-shadow 300ms ease;
}
.client-rule-type-trigger svg {
width: 9px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
transition: transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rule-type.is-open .client-rule-type-trigger {
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 44%, transparent);
}
.client-rule-type.is-open .client-rule-type-trigger svg {
transform: rotate(180deg);
}
.client-rule-type-list {
position: absolute;
top: calc(100% - 1px);
left: -8px;
z-index: 3;
width: max-content;
min-width: calc(100% + 16px);
display: grid;
gap: 1px;
padding: 7px 8px;
background: color-mix(in oklch, var(--client-bg) 91%, transparent);
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
opacity: 0;
visibility: hidden;
filter: blur(8px);
transform: translateY(-8px) scale(0.97);
transform-origin: top left;
pointer-events: none;
transition: opacity 200ms ease, filter 360ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 420ms;
}
.client-rule-type.is-open .client-rule-type-list {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0) scale(1);
pointer-events: auto;
transition-delay: 0s;
}
.client-rule-type-list button {
padding: 7px 6px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 600 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: left;
white-space: nowrap;
cursor: pointer;
opacity: 0;
filter: blur(5px);
transform: translateY(-5px);
transition: color 180ms ease, opacity 260ms ease, filter 340ms ease, transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-rule-type.is-open .client-rule-type-list button {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
.client-rule-type.is-open .client-rule-type-list button:nth-child(2) {
transition-delay: 35ms;
}
.client-rule-type.is-open .client-rule-type-list button:nth-child(3) {
transition-delay: 70ms;
}
.client-rule-type-list button:hover,
.client-rule-type-list button:focus-visible,
.client-rule-type-list button[aria-selected='true'] {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 40%, transparent);
}
.client-local-rule input {
min-width: 0;
height: 34px;
padding: 0 2px;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 10px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
box-shadow: 0 1px 0 transparent;
transition: color 220ms ease, box-shadow 300ms ease, text-shadow 300ms ease;
}
.client-local-rule input::placeholder {
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
}
.client-local-rule input:focus {
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.client-local-rules-empty {
padding: 14px 4px 4px;
color: var(--client-muted);
font-size: 10px;
line-height: 1.6;
}
.client-local-rules-note {
max-width: 48ch;
color: var(--client-muted);
font-size: 9px;
line-height: 1.65;
}
.client-inline-error.is-routing {
position: static;
width: 100%;
transform: none;
}
.client-local-rules-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.client-local-rules-actions button {
padding: 8px 0 8px 14px;
border: 0;
background: transparent;
color: var(--client-text);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: color 200ms ease, filter 300ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-actions button:hover:not(:disabled) {
color: var(--client-accent);
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 40%, transparent));
transform: translateY(-1px);
}
.client-local-rules-actions button:disabled {
opacity: 0.45;
cursor: wait;
}
@keyframes client-local-rules-notice {
from { opacity: 0; filter: blur(6px); transform: translateY(-6px); }
to { opacity: 1; filter: blur(0); transform: translateY(0); }
}
.client-route-rules-pending {
min-height: 17px;
display: flex;
align-items: center;
gap: 10px;
margin: -10px 0 -8px;
color: var(--client-warning, oklch(0.72 0.12 72));
font-size: 9px;
font-weight: 700;
letter-spacing: 0.06em;
opacity: 0;
filter: blur(5px);
transform: translateY(-3px);
transition: opacity 240ms ease, filter 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-route-rules-pending.is-visible {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
.client-route-rules-pending button {
padding: 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: inherit;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
}
.client-route-rules-pending button:disabled {
cursor: default;
opacity: 0.35;
}
+556
View File
@@ -0,0 +1,556 @@
.client-servers {
display: block;
}
.client-server-prompt {
display: block;
margin-bottom: 12px;
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-align: center;
text-transform: uppercase;
animation: client-state-reveal 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-server-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0;
width: min(100%, 220px);
margin-inline: auto;
}
.client-server-row {
width: 220px;
display: grid;
grid-template-columns: minmax(0, 1fr) 120px minmax(0, 1fr);
align-items: center;
margin-inline: auto;
}
.client-server {
min-width: 0;
min-height: 44px;
display: grid;
place-items: end center;
padding: 0 4px 4px;
border: 0;
border-bottom: 1px solid var(--client-border);
border-radius: 0;
background: transparent;
color: var(--client-text);
text-align: center;
cursor: pointer;
animation: client-server-enter 760ms calc(var(--server-index) * 110ms) cubic-bezier(0.16, 1, 0.3, 1) both;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
@keyframes client-server-leave {
0% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
100% {
opacity: 0;
filter: blur(3px);
transform: translateY(8px);
}
}
@keyframes client-server-enter {
0% {
opacity: 0;
filter: blur(3px);
transform: translateY(-8px);
}
100% {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
}
.client-server:hover:not(:disabled) {
transform: translateY(-2px);
background: transparent;
opacity: 1;
}
.client-server:active:not(:disabled) {
transform: translateY(0) scale(0.97);
}
.client-server.is-selected {
border-bottom: 2px solid var(--client-accent);
background: transparent;
}
.client-server strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
overflow-wrap: anywhere;
font-size: 11px;
line-height: 1.4;
text-align: center;
}
.client-server small {
color: var(--client-muted);
font-size: 11px;
text-align: center;
}
.client-server-health {
display: grid;
place-items: center;
color: var(--client-muted);
font-size: 9px;
font-variant-numeric: tabular-nums;
line-height: 1.2;
white-space: nowrap;
}
.client-server-meta .client-server-health {
width: 42px;
min-height: 44px;
place-items: end start;
padding-bottom: 7px;
}
.client-server-health > span,
.client-server-health > svg {
grid-area: 1 / 1;
transition: opacity 420ms ease, filter 520ms cubic-bezier(0.16, 1, 0.3, 1), color 520ms ease;
}
.client-server-health-checking {
width: 12px;
height: 12px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
color: var(--client-accent);
opacity: 0;
filter: blur(4px);
}
.client-server-health.is-checking > span:first-child {
opacity: 0;
filter: blur(2px);
}
.client-server-health.is-checking .client-server-health-checking {
opacity: 1;
filter: blur(0);
animation: client-spin 900ms linear infinite;
}
.client-servers.is-scalable {
width: min(100%, 300px);
margin-inline: auto;
}
.client-server-tools {
display: grid;
gap: 6px;
margin-bottom: 6px;
}
.client-server-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
grid-template-rows: 32px 28px;
align-items: center;
margin-bottom: 0;
}
.client-server-toolbar-title {
grid-column: 2;
grid-row: 1;
color: var(--client-muted);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
white-space: nowrap;
}
.client-server-toolbar.is-single {
grid-template-rows: 32px;
}
.client-server-mode-toggle {
grid-column: 2;
grid-row: 2;
min-height: 28px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
margin: 0;
padding: 5px 4px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
transition: color 220ms ease, text-shadow 320ms ease;
}
.client-server-check {
grid-column: 3;
grid-row: 1;
justify-self: start;
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: color 300ms ease, opacity 300ms ease, text-shadow 500ms ease;
}
.client-server-check svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-server-check.is-checking {
color: var(--client-accent);
opacity: 1;
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 46%, transparent);
}
.client-server-check.is-checking svg {
animation: client-spin 900ms linear infinite;
}
.client-server-check:hover:not(:disabled),
.client-server-check:focus-visible {
color: var(--client-accent);
}
.client-server-check:hover:not(:disabled) svg,
.client-server-check:focus-visible:not(.is-checking) svg {
transform: rotate(90deg);
}
.client-server-check:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 1px;
}
.client-server-check:disabled {
cursor: default;
}
.client-server-check:disabled:not(.is-checking) {
opacity: 0.55;
}
.client-server-mode-toggle:hover,
.client-server-mode-toggle.is-open {
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 38%, transparent);
}
.client-server-mode-toggle svg {
width: 10px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-server-mode-toggle.is-open svg {
transform: rotate(180deg);
}
.client-server-mode-panels {
display: grid;
}
.client-server-mode-panel {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
filter: blur(7px);
transform: translateY(-10px);
transition:
grid-template-rows 620ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 260ms ease,
filter 420ms ease,
transform 520ms cubic-bezier(0.16, 1, 0.3, 1),
visibility 0s 620ms;
}
.client-server-mode-panel.is-open {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0);
transition-delay: 0s;
}
.client-server-mode-panel-inner {
min-height: 0;
overflow: hidden;
}
.client-server-mode-panel.is-advanced .client-server-tools,
.client-server-mode-panel.is-advanced .client-server-pinned,
.client-server-mode-panel.is-advanced .client-server-scroll {
opacity: 0;
filter: blur(5px);
transform: translateY(-8px);
transition: opacity 300ms ease, filter 440ms ease, transform 520ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-server-mode-panel.is-advanced.is-open .client-server-tools,
.client-server-mode-panel.is-advanced.is-open .client-server-pinned,
.client-server-mode-panel.is-advanced.is-open .client-server-scroll {
opacity: 1;
filter: blur(0);
transform: translateY(0);
}
.client-server-mode-panel.is-advanced.is-open .client-server-tools {
transition-delay: 90ms;
}
.client-server-mode-panel.is-advanced.is-open .client-server-pinned {
transition-delay: 150ms;
}
.client-server-mode-panel.is-advanced.is-open .client-server-scroll {
transition-delay: 210ms;
}
.client-server-overflow-note {
margin: 10px 0 0;
color: var(--client-muted);
font-size: 9px;
text-align: center;
}
.client-server-tools input {
width: 100%;
height: 36px;
padding: 0 4px;
border: 0;
border-bottom: 1px solid var(--client-border);
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 11px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}
.client-server-tools input:focus {
border-bottom-color: var(--client-accent);
}
.client-server-filters {
display: flex;
justify-content: center;
gap: 5px;
flex-wrap: wrap;
}
.client-server-filters button,
.client-server-group-toggle,
.client-server-more {
min-height: 30px;
padding: 5px 7px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
}
.client-server-filters button.is-active,
.client-server-filters button:hover:not(:disabled),
.client-server-group-toggle:hover,
.client-server-more:hover {
color: var(--client-accent);
}
.client-server-filters button:disabled {
opacity: 0.4;
cursor: default;
}
.client-server-pinned {
display: grid;
gap: 3px;
margin-bottom: 6px;
}
.client-server-auto {
width: 220px;
min-height: 44px;
display: grid;
place-items: center;
gap: 1px;
margin-inline: auto;
padding: 3px 6px 7px;
border: 0;
border-bottom: 1px solid var(--client-border);
background: transparent;
color: var(--client-text);
font: inherit;
cursor: pointer;
}
.client-server-auto.is-selected {
border-bottom: 2px solid var(--client-accent);
}
.client-server-auto strong {
font-size: 11px;
}
.client-server-auto small {
color: var(--client-muted);
font-size: 9px;
}
.client-server-scroll {
max-height: 330px;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 5px;
scrollbar-width: none;
}
.client-server-scroll::-webkit-scrollbar {
display: none;
}
.client-server-mode-panel.is-simple .client-server-scroll {
max-height: none;
overflow: visible;
padding-right: 0;
}
.client-server-scroll.is-leaving {
pointer-events: none;
}
.client-server-scroll.is-leaving .client-server {
animation: client-server-leave 420ms calc(var(--server-index) * 45ms) cubic-bezier(0.4, 0, 1, 1) forwards;
}
.client-server-group + .client-server-group {
margin-top: 8px;
}
.client-server-group-toggle {
width: 100%;
display: flex;
justify-content: space-between;
text-align: left;
text-transform: uppercase;
}
.client-server-group-toggle small {
font-size: 9px;
}
.client-servers.is-scalable .client-server-grid {
gap: 0;
}
.client-server-meta {
position: relative;
grid-column: 3;
justify-self: start;
width: 42px;
min-height: 44px;
display: grid;
place-items: center;
margin-left: 4px;
}
.client-server-row .client-server {
grid-column: 2;
width: 120px;
}
.client-server-favorite {
position: absolute;
top: 50%;
left: calc(100% + 2px);
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-border);
font-size: 10px;
cursor: pointer;
opacity: 0.45;
transform: translateY(-50%);
}
.client-server-favorite.is-active {
color: var(--client-accent);
opacity: 1;
}
.client-server-empty {
padding: 24px 4px;
color: var(--client-muted);
font-size: 10px;
text-align: center;
}
.client-server-more {
margin-top: 8px;
}
.client-server-pages {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--client-muted);
font-size: 9px;
}
.client-server-more:disabled {
opacity: 0.35;
cursor: default;
}
.client-server-filters button:focus-visible,
.client-server-mode-toggle:focus-visible,
.client-server-group-toggle:focus-visible,
.client-server-more:focus-visible,
.client-server-auto:focus-visible,
.client-server-favorite:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 1px;
}
.client-server:disabled {
cursor: wait;
}
+421
View File
@@ -0,0 +1,421 @@
.client-icon-tooltip {
width: 16px;
height: 16px;
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: 16px;
height: 16px;
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 {
display: flex;
align-items: center;
gap: 8px;
}
.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-summary {
min-width: 0;
max-width: 100%;
min-height: 88px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 2px;
color: var(--client-muted);
text-align: center;
cursor: pointer;
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 {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.client-subscription-domain-button strong {
display: block;
overflow: hidden;
color: var(--client-text);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.055em;
opacity: 0.88;
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%, 250px);
display: grid;
gap: 7px;
margin: -10px auto 0;
color: var(--client-muted);
text-align: center;
}
.client-usage > span {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-usage > strong {
color: var(--client-text);
font-size: 14px;
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 > 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 > 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 {
display: flex;
justify-content: center;
gap: 12px;
font-size: 9px;
white-space: nowrap;
}
.client-inline-error.is-subscription {
top: calc(100% + 2px);
}
.client-inline-error.is-subscription > span,
.client-inline-error.is-subscription > button {
animation: client-subscription-error-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-inline-error.is-subscription small {
flex-basis: 100%;
font-size: 8px;
font-weight: 500;
letter-spacing: 0.04em;
opacity: 0.45;
animation: client-subscription-code-in 560ms 90ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes client-subscription-error-in {
from {
opacity: 0;
filter: blur(7px);
transform: translateY(-3px);
}
}
@keyframes client-subscription-code-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(-2px);
}
}
+12
View File
@@ -0,0 +1,12 @@
@import './tokens.css';
@import './base.css';
@import './features/devices.css';
@import './features/routing.css';
@import './features/instructions.css';
@import './features/connection.css';
@import './features/subscription.css';
@import './features/servers.css';
@import './primitives.css';
@import './features/diagnostics.css';
@import './layout.css';
@import './themes.css';
+792
View File
@@ -0,0 +1,792 @@
.app.client-app {
grid-template-rows: 1fr;
color-scheme: light;
background: var(--client-bg);
}
.app-body.client-mode {
min-height: 100vh;
background: var(--client-bg);
}
.client-mode .app-main {
min-height: 100vh;
display: grid;
place-items: center;
padding: 48px 24px 32px;
overflow-y: auto;
}
.client-shell {
position: relative;
width: min(100%, 1100px);
display: grid;
gap: 12px;
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
color: var(--client-text);
}
.harbor-versions {
position: fixed;
right: max(14px, env(safe-area-inset-right));
bottom: max(12px, env(safe-area-inset-bottom));
z-index: 40;
display: grid;
justify-items: end;
gap: 4px;
color: var(--client-muted);
font: 650 12px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
font-variant-numeric: tabular-nums;
}
.harbor-version {
display: flex;
align-items: center;
gap: 6px;
opacity: 0.56;
transition: color 240ms ease, opacity 240ms ease;
}
.harbor-version:focus-within,
.harbor-version:hover {
color: var(--client-text);
opacity: 1;
}
.harbor-version.is-incompatible {
color: oklch(0.68 0.15 28);
opacity: 0.9;
}
.harbor-version-code {
width: 1.3ch;
color: var(--client-accent);
font-size: 10px;
text-align: center;
}
.harbor-version-number {
display: flex;
align-items: center;
}
.harbor-version-part {
position: relative;
min-width: 1ch;
padding: 5px 2px;
border-radius: 5px;
cursor: help;
text-align: center;
}
.harbor-version-part:hover,
.harbor-version-part:focus-visible {
outline: none;
color: var(--client-accent);
text-shadow: 0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent);
}
.harbor-version-part:focus-visible {
box-shadow: 0 0 0 1px var(--client-accent);
}
.harbor-version-tooltip {
position: absolute;
right: 0;
bottom: calc(100% + 7px);
width: min(300px, calc(100vw - 28px));
display: grid;
gap: 7px;
padding: 12px 13px;
border-radius: 12px;
background: oklch(0.14 0.012 145);
box-shadow: 0 9px 30px oklch(0.08 0.015 145 / 0.16);
color: oklch(0.88 0.012 145);
font-size: 11px;
font-weight: 500;
line-height: 1.5;
text-align: left;
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translateY(3px);
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
}
.harbor-version-tooltip strong {
color: oklch(0.96 0.008 145);
font-size: 11px;
}
.harbor-version-tooltip small {
color: var(--client-accent);
font-size: 10px;
}
.harbor-version-tooltip .is-warning {
color: oklch(0.68 0.15 28);
}
.harbor-version-part:hover .harbor-version-tooltip,
.harbor-version-part:focus-visible .harbor-version-tooltip {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.harbor-brand {
position: fixed;
top: 50%;
left: 50%;
z-index: 2;
color: var(--harbor-word);
font-size: 13px;
letter-spacing: -0.035em;
transform: translate(-50%, calc(-50% - 25vh)) scale(3);
transform-origin: center;
transition: transform 1800ms cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
pointer-events: none;
}
.harbor-brand.is-switchable {
pointer-events: auto;
}
.harbor-brand-control {
position: relative;
appearance: none;
border: 0;
border-radius: 7px;
padding: 3px 5px;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
cursor: pointer;
}
.harbor-brand-control:focus-visible {
outline: 1px solid color-mix(in srgb, var(--harbor-gateway) 55%, transparent);
outline-offset: 4px;
}
.harbor-brand-control:disabled {
cursor: wait;
}
.client-shell.is-first-run .harbor-brand {
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
}
.harbor-brand-content {
position: relative;
display: flex;
align-items: center;
gap: 9px;
}
.client-shell.is-intro .harbor-brand-content {
animation: harbor-startup-brand 760ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-shell.is-intro .client-panel,
.client-shell.is-intro .client-secondary-menu,
.client-shell.is-intro .harbor-versions {
animation: harbor-startup-content 720ms 120ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes harbor-startup-brand {
from { opacity: 0.35; filter: blur(5px); }
}
@keyframes harbor-startup-content {
from { opacity: 0; filter: blur(6px); }
}
.harbor-brand svg {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.harbor-brand .harbor-anchor-left {
stroke: var(--harbor-connect);
}
.harbor-brand .harbor-anchor-right {
stroke: var(--harbor-gateway);
}
.harbor-brand.is-gateway .harbor-anchor-left {
stroke: var(--harbor-gateway);
}
.harbor-brand strong {
font-weight: 800;
}
.harbor-brand-name {
display: flex;
align-items: center;
gap: 0.45em;
}
.harbor-brand em {
color: var(--harbor-connect);
font-style: normal;
font-weight: 600;
}
.harbor-brand.is-gateway em {
color: var(--harbor-gateway);
}
.harbor-mode-control,
.harbor-mode-stack {
position: relative;
display: inline-block;
width: 4.5em;
height: 1.25em;
}
.harbor-mode-stack {
width: 100%;
height: 100%;
}
.harbor-mode-stack em {
position: absolute;
inset: 0 auto auto 0;
transition: color 420ms ease, opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-brand .harbor-mode-connect {
z-index: 2;
color: var(--harbor-connect);
animation: harbor-mode-ambient-front 10s ease-in-out infinite;
}
.harbor-brand .harbor-mode-gateway {
z-index: 1;
color: var(--harbor-gateway);
opacity: 0.2;
filter: blur(0.4px);
transform: translate(0.42em, 0.34em) scale(0.96);
animation: harbor-mode-ambient-back 10s ease-in-out infinite;
}
.harbor-mode-gateway > span {
display: block;
animation: harbor-mode-discovered 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.harbor-brand.is-gateway-active .harbor-mode-connect {
z-index: 1;
opacity: 0.18;
filter: blur(0.4px);
transform: translate(0.42em, 0.34em) scale(0.96);
}
.harbor-brand.is-gateway-active .harbor-mode-gateway {
z-index: 2;
opacity: 1;
filter: none;
transform: none;
}
@keyframes harbor-mode-discovered {
from {
opacity: 0;
filter: blur(3px);
transform: translateX(4.8em);
}
}
.harbor-brand.is-gateway-active .harbor-mode-connect {
animation-name: harbor-mode-ambient-back;
}
.harbor-brand.is-gateway-active .harbor-mode-gateway {
animation-name: harbor-mode-ambient-front;
}
.harbor-brand-control.is-mode-animating .harbor-mode-connect {
animation: harbor-mode-float-front 1500ms ease-in-out infinite;
}
.harbor-brand-control.is-mode-animating .harbor-mode-gateway {
animation: harbor-mode-float-back 1500ms ease-in-out infinite;
}
.harbor-brand.is-gateway-active .harbor-brand-control.is-mode-animating .harbor-mode-connect {
animation-name: harbor-mode-float-back;
}
.harbor-brand.is-gateway-active .harbor-brand-control.is-mode-animating .harbor-mode-gateway {
animation-name: harbor-mode-float-front;
}
@keyframes harbor-mode-float-front {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(0.12em); }
}
@keyframes harbor-mode-float-back {
0%, 100% { transform: translate(0.42em, 0.34em) scale(0.96); }
50% { transform: translate(0.42em, 0.22em) scale(0.96); }
}
@keyframes harbor-mode-ambient-front {
0%, 88%, 96%, 100% { transform: translateY(0); }
92% { transform: translateY(0.08em); }
}
@keyframes harbor-mode-ambient-back {
0%, 88%, 96%, 100% { transform: translate(0.42em, 0.34em) scale(0.96); }
92% { transform: translate(0.42em, 0.26em) scale(0.96); }
}
.harbor-brand .harbor-mode-swap {
position: absolute;
top: 50%;
left: calc(100% + 4px);
width: 14px;
height: 14px;
opacity: 0.82;
stroke-width: 1.35;
transform: translateY(-50%);
transition: color 320ms ease, opacity 320ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
animation: harbor-mode-arrows-discovered 260ms 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes harbor-mode-arrows-discovered {
from {
opacity: 0;
filter: blur(2px);
}
}
.harbor-mode-swap g {
transform-box: view-box;
transform-origin: 9px 9px;
transform: rotate(var(--harbor-arrow-turn, 0turn));
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap path {
transform-box: fill-box;
transform-origin: center;
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap .is-connect {
opacity: 1;
stroke: var(--harbor-connect);
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap .is-connect path {
animation: harbor-arrow-ambient-right 10s ease-in-out infinite;
}
.harbor-mode-swap .is-gateway {
opacity: 0.28;
filter: blur(0.45px);
stroke: var(--harbor-gateway);
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap .is-gateway path {
animation: harbor-arrow-ambient-left 10s ease-in-out infinite;
}
.harbor-brand.is-gateway-active .harbor-mode-swap .is-connect {
opacity: 0.28;
filter: blur(0.45px);
}
.harbor-brand.is-gateway-active .harbor-mode-swap .is-gateway {
opacity: 1;
filter: none;
}
.harbor-mode-tooltip {
position: absolute;
bottom: calc(100% + 9px);
left: 0;
width: calc(100% + 22px);
display: grid;
gap: 2px;
padding: 5px 6px;
border-radius: 5px;
background: oklch(0.14 0.012 145);
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
color: oklch(0.88 0.012 145);
font-size: 4px;
line-height: 1.5;
letter-spacing: 0;
text-align: left;
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translateY(3px);
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
}
.harbor-mode-tooltip strong {
color: oklch(0.96 0.008 145);
font-size: 4.2px;
}
.harbor-brand-control:hover .harbor-mode-tooltip,
.harbor-brand-control:focus-visible .harbor-mode-tooltip {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.harbor-brand-control:hover .harbor-mode-swap,
.harbor-brand-control:focus-visible .harbor-mode-swap {
opacity: 1;
}
.harbor-brand-control.is-mode-animating .harbor-mode-swap .is-connect path {
animation: harbor-arrow-float-right 1500ms ease-in-out infinite;
}
.harbor-brand-control.is-mode-animating .harbor-mode-swap .is-gateway path {
animation: harbor-arrow-float-left 1500ms ease-in-out infinite;
}
@keyframes harbor-arrow-float-right {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(1.25px); }
}
@keyframes harbor-arrow-float-left {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(-1.25px); }
}
@keyframes harbor-arrow-ambient-right {
0%, 88%, 96%, 100% { transform: translateX(0); }
92% { transform: translateX(0.75px); }
}
@keyframes harbor-arrow-ambient-left {
0%, 88%, 96%, 100% { transform: translateX(0); }
92% { transform: translateX(-0.75px); }
}
.client-secondary-menu {
position: fixed;
top: 50%;
right: max(14px, env(safe-area-inset-right));
z-index: 60;
display: grid;
gap: 6px;
transform: translateY(-50%);
}
.client-panel {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 280px) minmax(0, 1fr);
place-items: center;
width: 100%;
min-height: 520px;
padding: 24px 0;
}
.client-panel.is-setup {
grid-template-columns: minmax(0, 1fr);
}
.client-panel.is-setup .client-form {
grid-column: 1;
justify-self: center;
}
.client-panel.is-gateway-home {
grid-template-rows: minmax(0, 1fr) auto;
row-gap: 28px;
}
.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: 136px;
}
@media (min-width: 921px) {
.client-panel.has-subscription {
--client-work-height: min(640px, calc(100vh - 120px));
--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) {
.client-mode .app-main {
place-items: start center;
}
.client-shell {
width: 100%;
}
.client-panel,
.client-panel.is-setup {
grid-template-columns: minmax(0, 1fr);
gap: 40px;
min-height: 0;
padding: 110px 6px 72px;
}
.client-power-section,
.client-form,
.client-panel.is-setup .client-form {
grid-column: 1;
justify-self: center;
width: min(100%, 380px);
}
.client-power-section {
order: 1;
}
.client-gateway-summary {
grid-row: auto;
order: 2;
}
.client-form {
height: auto;
max-height: none;
overflow-y: visible;
overscroll-behavior: auto;
}
.harbor-brand,
.client-shell.is-first-run .harbor-brand {
position: absolute;
top: 28px;
transform: translateX(-50%) scale(1.55);
}
.client-inline-error.is-connection,
.client-inline-error.is-subscription {
position: static;
width: min(100%, 360px);
transform: none;
}
}
@media (max-width: 560px) {
.client-local-rule {
grid-template-columns: 24px 104px minmax(0, 1fr) 28px;
}
.client-local-rule-status {
grid-column: 3;
text-align: left;
}
.client-mode .app-main {
padding: 20px 14px;
}
.client-stale-banner {
width: calc(100% - 28px);
grid-template-columns: 1fr auto;
}
.client-stale-banner span {
grid-column: 1 / -1;
grid-row: 2;
}
.client-panel {
position: static;
grid-template-columns: 1fr;
gap: 26px;
min-height: 0;
padding: 96px 6px 72px;
}
.client-power-section {
padding-bottom: 40px;
}
.client-inline-error.is-connection {
top: auto;
bottom: 0;
}
.client-form-content {
gap: 24px;
}
.harbor-brand {
transform: translateX(-50%) scale(1.35);
}
.client-secondary-menu {
right: 8px;
}
.client-duration-toggle > .client-tooltip {
right: calc(50% + 36px);
max-width: 110px;
}
.client-copy-button {
min-height: 44px;
font-size: 10px;
}
.client-server-filters button,
.client-server-mode-toggle,
.client-server-check,
.client-server-group-toggle,
.client-server-more,
.client-server-favorite {
min-height: 44px;
}
.client-server-check {
width: 44px;
height: 44px;
}
.client-server-toolbar {
grid-template-rows: 44px 44px;
}
.client-server-toolbar.is-single {
grid-template-rows: 44px;
}
.client-local-rule {
grid-template-columns: 44px minmax(0, 1fr) 44px;
}
.client-local-rule-enabled,
.client-local-rule-delete {
width: 44px;
height: 44px;
}
.client-local-rules-header > span,
.client-local-rules-group > span,
.client-instructions-header span,
.client-instruction-summary > span {
font-size: 10px;
}
.client-instructions-sheet {
padding: 40px 58px 60px 18px;
}
.client-subscription-sheet {
padding: 70px 58px 60px 18px;
}
.client-local-rules-sheet {
padding: 40px 58px 60px 18px;
}
.client-local-rules-header h2 {
font-size: 18px;
}
.client-local-rule {
gap: 8px;
}
.client-local-rule-enabled {
grid-column: 1;
grid-row: 1;
}
.client-rule-type {
grid-column: 2;
grid-row: 1;
}
.client-local-rule-delete {
grid-column: 3;
grid-row: 1;
}
.client-local-rule input {
grid-column: 2 / -1;
grid-row: 2;
}
.client-instructions-header h2 {
font-size: 18px;
}
.client-instruction-block:nth-child(n) {
margin-inline: 0;
}
.client-form {
position: relative;
width: 100%;
transform: none;
}
.client-panel.is-setup .client-form {
transform: none;
}
}
+788
View File
@@ -0,0 +1,788 @@
.client-live-region {
position: fixed;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.client-instructions-toggle,
.client-local-rules-toggle {
position: relative;
width: 42px;
height: 48px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
cursor: pointer;
transition: color 240ms ease, transform 440ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-instructions-toggle svg,
.client-local-rules-toggle svg {
width: 23px;
height: 23px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
overflow: visible;
transition: color 240ms ease, filter 360ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-instructions-toggle span,
.client-local-rules-toggle span {
position: absolute;
right: 48px;
width: max-content;
color: var(--client-text);
font: 700 10px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0.04em;
opacity: 0;
filter: blur(5px);
pointer-events: none;
transform: translateX(8px);
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-instructions-toggle:hover,
.client-instructions-toggle:focus-visible,
.client-local-rules-toggle:hover,
.client-local-rules-toggle:focus-visible {
outline: 0;
color: var(--client-accent);
transform: translateX(-3px);
}
.client-instructions-toggle:hover span,
.client-instructions-toggle:focus-visible span,
.client-local-rules-toggle:hover span,
.client-local-rules-toggle:focus-visible span {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
.client-instructions-toggle:hover svg,
.client-instructions-toggle:focus-visible svg,
.client-local-rules-toggle:hover svg,
.client-local-rules-toggle:focus-visible svg,
.client-instructions-toggle.is-open svg,
.client-local-rules-toggle.is-open svg {
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 52%, transparent));
}
.client-rail-device-primary,
.client-rail-device-secondary,
.client-rail-rule-knob {
transform-box: fill-box;
transform-origin: center;
}
.client-rail-info-ring {
stroke-dasharray: 1;
}
.client-rail-diagnostics-pulse {
stroke-width: 2.4;
opacity: 0;
stroke-dasharray: 0.16 0.84;
}
.client-instructions-toggle.is-open:not(.client-devices-toggle):not(.client-diagnostics-toggle) .client-rail-info-ring {
animation: client-rail-info-refill 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-devices-toggle.is-open .client-rail-device-primary {
animation: client-rail-device-left 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-devices-toggle.is-open .client-rail-device-secondary {
animation: client-rail-device-right 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-diagnostics-toggle.is-open .client-rail-diagnostics-pulse {
animation: client-rail-diagnostics-pulse 760ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-toggle.is-open .client-rail-rule-knob.is-top {
animation: client-rail-rule-top 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rules-toggle.is-open .client-rail-rule-knob.is-bottom {
animation: client-rail-rule-bottom 680ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes client-rail-info-refill {
from { opacity: 0.28; stroke-dashoffset: 1; }
to { opacity: 1; stroke-dashoffset: 0; }
}
@keyframes client-rail-device-left {
0%, 100% { transform: translate(0, 0); }
38%, 58% { transform: translate(-0.6px, -2.25px); }
82% { transform: translate(0.15px, 0.35px); }
}
@keyframes client-rail-device-right {
0%, 100% { transform: translate(0, 0); }
38%, 58% { transform: translate(0.6px, -2.25px); }
82% { transform: translate(-0.15px, 0.35px); }
}
@keyframes client-rail-diagnostics-pulse {
0% { opacity: 0; stroke-dashoffset: 1; }
16%, 84% { opacity: 1; }
100% { opacity: 0; stroke-dashoffset: 0; }
}
@keyframes client-rail-rule-top {
0%, 100% { transform: translateX(0); }
42% { transform: translateX(-3px); }
72% { transform: translateX(1px); }
}
@keyframes client-rail-rule-bottom {
0%, 100% { transform: translateX(0); }
42% { transform: translateX(3px); }
72% { transform: translateX(-1px); }
}
.client-local-rules-toggle:disabled {
cursor: default;
color: var(--client-muted);
transform: none;
}
.client-local-rules-toggle:disabled span {
opacity: 0;
filter: blur(5px);
transform: translateX(8px);
}
.client-local-rules-toggle:disabled:hover span {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
.client-instructions-toggle.is-open,
.client-local-rules-toggle.is-open {
color: var(--client-accent);
}
.client-local-rules-toggle.has-pending:not(.is-open) {
color: oklch(0.68 0.14 72);
}
.client-local-rules-toggle.has-pending:not(.is-open) svg {
filter: drop-shadow(0 0 7px oklch(0.68 0.14 72 / 0.42));
}
.client-instructions-toggle.is-open span,
.client-local-rules-toggle.is-open span {
opacity: 0;
visibility: hidden;
}
.client-drawer {
position: fixed;
inset: 0 0 0 auto;
z-index: 50;
overflow-y: auto;
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
color: var(--client-text);
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
opacity: 0;
visibility: hidden;
transform: translateX(104%);
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
}
.client-drawer.is-open {
opacity: 1;
visibility: visible;
transform: translateX(0);
transition-delay: 0s;
}
.client-drawer-sheet {
position: relative;
min-height: 100%;
padding: 54px 72px 72px 34px;
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}
.client-delete-strike {
--client-delete-strike-y: 50%;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 12px;
z-index: 100;
background: transparent;
opacity: 0;
pointer-events: none;
transform: scaleX(0);
transform-origin: left center;
}
.client-delete-strike::after {
content: '';
position: absolute;
left: 0;
right: 0;
top: calc(var(--client-delete-strike-y) - 1px);
height: 2px;
background: oklch(0.68 0.15 28);
clip-path: polygon(0 24%, 100% 50%, 0 76%);
}
.client-deletable-row.is-removing > .client-delete-strike {
animation: client-delete-strike 820ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-deletable-row.is-removing > :not(.client-delete-strike) {
position: relative;
z-index: 0;
}
.client-deletable-row.is-removing > :not(.client-delete-strike):not(.client-local-rule-enabled) {
animation: client-delete-content-dim 820ms ease-out both;
}
.client-deletable-row.is-removing .client-local-rule-enabled circle,
.client-deletable-row.is-removing .client-rule-check {
opacity: 0;
}
.client-local-rule-delete {
width: 28px;
height: 32px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
font-size: 17px;
cursor: pointer;
transition: color 180ms ease, filter 260ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-delete:hover {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 7px oklch(0.68 0.15 28 / 0.42));
transform: rotate(8deg) scale(1.1);
}
.client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible,
.client-local-rule-delete:focus-visible,
.client-local-rule-add:focus-visible,
.client-local-rules-save:focus-visible,
.client-local-rules-actions button:focus-visible {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 48%, transparent);
}
.client-local-rule-add {
width: fit-content;
padding: 7px 0;
border: 0;
background: transparent;
color: var(--client-accent);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: opacity 220ms ease, color 220ms ease, text-shadow 300ms ease;
}
.client-local-rule-add:hover {
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 55%, transparent);
}
.client-local-rule-add:disabled {
opacity: 0.3;
cursor: not-allowed;
text-shadow: none;
}
.client-local-rule-add-slot {
min-height: 40px;
display: flex;
align-items: center;
gap: 12px;
}
.client-local-rule-add-slot > span {
color: var(--client-muted);
font-size: 8px;
opacity: 0;
filter: blur(5px);
transform: translateX(-6px);
transition: opacity 220ms ease, filter 320ms ease, transform 380ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-local-rule-add-slot > span.is-visible {
opacity: 1;
filter: blur(0);
transform: translateX(0);
}
@keyframes client-local-rule-enter {
from { opacity: 0; filter: blur(8px); transform: translateY(-10px) scale(0.985); }
to { opacity: 1; filter: blur(0); transform: translateY(0) scale(1); }
}
@keyframes client-local-rule-leave {
0%, 52% { opacity: 1; filter: blur(0); transform: translateX(0); }
100% { opacity: 0; filter: blur(8px); transform: translateX(28px); }
}
@keyframes client-delete-strike {
0% { opacity: 0.58; transform: scaleX(0); transform-origin: left center; }
42%, 52% { opacity: 0.58; transform: scaleX(1); transform-origin: left center; }
53% { opacity: 0.58; transform: scaleX(1); transform-origin: right center; }
100% { opacity: 0; transform: scaleX(0); transform-origin: right center; }
}
@keyframes client-delete-content-dim {
0%, 52% { opacity: 0.58; filter: brightness(0.55) saturate(0.65) blur(1.4px); }
100% { opacity: 0; filter: brightness(0.42) saturate(0.5) blur(2.6px); }
}
.client-instructions-header {
display: grid;
gap: 9px;
margin: 0 8px 38px;
}
.client-drawer-close {
position: absolute;
top: 10px;
right: 10px;
width: 44px;
height: 44px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
font: 400 24px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
transition: color 220ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-drawer-close:hover {
color: var(--client-text);
transform: scale(1.08);
}
.client-drawer-close:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-instructions-header span,
.client-instruction-summary > span {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-instructions-header h2 {
margin: 0;
font-size: 22px;
letter-spacing: -0.045em;
}
.client-instructions-intro {
display: grid;
gap: 12px;
margin-top: 15px;
color: var(--client-muted);
font-size: 11px;
line-height: 1.7;
}
.client-instructions-intro p {
margin: 0;
}
.client-instruction-copy-button {
width: 104px;
min-width: 104px;
min-height: 34px;
padding: 0;
color: var(--client-accent);
opacity: 1;
}
.client-tooltip-anchor {
position: relative;
}
.client-tooltip {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
z-index: 1000;
width: max-content;
max-width: 220px;
padding: 6px 8px;
border-radius: 8px;
background: oklch(0.14 0.012 145);
box-shadow: 0 8px 26px oklch(0.08 0.015 145 / 0.16);
color: oklch(0.94 0.008 145);
font: 600 10px/1.35 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0;
text-align: center;
white-space: normal;
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translate(-50%, 2px);
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
}
.client-tooltip-anchor:hover > .client-tooltip,
.client-tooltip-anchor:focus-visible > .client-tooltip,
.client-tooltip-anchor:has(> :focus-visible) > .client-tooltip {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translate(-50%, 0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.client-duration-toggle > .client-tooltip {
top: 50%;
right: calc(100% + 12px);
bottom: auto;
left: auto;
transform: translate(2px, -50%);
}
.client-duration-toggle:hover > .client-tooltip,
.client-duration-toggle:focus-visible > .client-tooltip {
transform: translate(0, -50%);
}
.client-form {
position: static;
grid-column: 3;
align-self: center;
justify-self: start;
min-width: 0;
width: min(360px, 100%);
max-height: min(640px, calc(100vh - 120px));
overflow: visible;
display: grid;
gap: 36px;
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);
}
.client-subscription-sheet {
min-height: 100%;
align-content: start;
padding: 82px 72px 72px 42px;
}
.client-confirmation-popup {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 24px;
background: color-mix(in oklch, var(--client-bg) 76%, transparent);
-webkit-backdrop-filter: blur(0);
backdrop-filter: blur(0);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 260ms ease, -webkit-backdrop-filter 520ms cubic-bezier(0.16, 1, 0.3, 1), backdrop-filter 520ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 520ms;
}
.client-confirmation-popup.is-open {
-webkit-backdrop-filter: blur(18px);
backdrop-filter: blur(18px);
opacity: 1;
visibility: visible;
pointer-events: auto;
transition-delay: 0s;
}
.client-confirmation-dialog {
width: min(430px, calc(100vw - 48px));
display: grid;
gap: 14px;
padding: 32px 34px;
background: color-mix(in oklch, var(--client-bg) 82%, transparent);
box-shadow: 0 0 86px color-mix(in oklch, var(--client-bg) 86%, transparent);
color: var(--client-text);
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
opacity: 0;
filter: blur(18px);
transform: scale(0.9);
transition: opacity 300ms ease, filter 520ms cubic-bezier(0.16, 1, 0.3, 1), transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-confirmation-popup.is-open .client-confirmation-dialog {
opacity: 1;
filter: blur(0);
transform: scale(1);
transition-delay: 90ms;
}
.client-confirmation-dialog > * {
opacity: 0;
filter: blur(7px);
transform: translateY(7px);
transition: opacity 260ms ease, filter 420ms cubic-bezier(0.16, 1, 0.3, 1), transform 460ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-confirmation-popup.is-open .client-confirmation-kicker {
opacity: 1;
filter: blur(0);
transform: translateY(0);
transition-delay: 170ms;
}
.client-confirmation-popup.is-open .client-confirmation-dialog h2 {
opacity: 1;
filter: blur(0);
transform: translateY(0);
transition-delay: 210ms;
}
.client-confirmation-popup.is-open .client-confirmation-dialog p {
opacity: 1;
filter: blur(0);
transform: translateY(0);
transition-delay: 270ms;
}
.client-confirmation-popup.is-open .client-confirmation-actions {
opacity: 1;
filter: blur(0);
transform: translateY(0);
transition-delay: 330ms;
}
.client-confirmation-kicker {
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-confirmation-dialog h2 {
color: oklch(0.68 0.15 28);
font-size: 18px;
letter-spacing: -0.035em;
}
.client-confirmation-dialog p {
max-width: 54ch;
color: var(--client-muted);
font-size: 11px;
line-height: 1.7;
}
.client-confirmation-actions {
display: flex;
gap: 18px;
margin-top: 4px;
}
.client-confirmation-actions button {
padding: 6px 0;
border: 0;
background: transparent;
color: var(--client-text);
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: color 200ms ease, filter 300ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-confirmation-actions button:hover:not(:disabled),
.client-confirmation-actions button:focus-visible {
outline: none;
filter: drop-shadow(0 0 7px currentColor);
transform: translateY(-1px);
}
.client-confirmation-actions .is-danger {
color: oklch(0.68 0.15 28);
}
.client-confirmation-actions button:disabled {
opacity: 0.4;
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) {
opacity: 0.34;
filter: grayscale(1) saturate(0);
}
@keyframes client-spin {
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);
outline-offset: 3px;
}
.client-inline-error {
position: absolute;
left: 50%;
z-index: 3;
width: min(360px, 90vw);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
color: oklch(0.62 0.2 28);
font: 600 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
text-align: center;
transform: translateX(-50%);
}
.client-inline-error small {
color: var(--client-muted);
font-size: 9px;
white-space: nowrap;
}
.client-inline-error button {
padding: 4px 7px;
border: 0;
border-radius: 6px;
background: color-mix(in oklch, var(--client-control) 72%, transparent);
color: var(--client-text);
font-size: 9px;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.client-inline-error button:focus-visible {
outline: 2px solid oklch(0.68 0.15 28);
outline-offset: 2px;
}
.client-operation-progress {
color: var(--client-accent);
}
.client-operation-progress::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
content: '';
animation: client-operation-pulse 900ms ease-in-out infinite alternate;
}
@keyframes client-operation-pulse {
to { opacity: 0.35; transform: scale(0.72); }
}
.client-copy-button {
position: relative;
width: 86px;
padding: 6px 10px;
border: 0;
background: transparent;
color: var(--client-muted);
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
letter-spacing: 0.06em;
cursor: pointer;
opacity: 0.76;
transition: color 220ms ease, opacity 220ms ease, transform 220ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-copy-label {
transition: opacity 100ms ease;
}
.client-copy-button.is-copied .client-copy-label,
.client-copy-button.is-copy-error .client-copy-label {
opacity: 0;
}
.client-copy-feedback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: var(--client-accent);
animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
.client-copy-button.is-copy-error .client-copy-feedback {
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 5px oklch(0.68 0.15 28 / 0.45));
}
@keyframes client-copy-fade {
0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); }
100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); }
}
.client-copy-button:hover {
color: var(--client-text);
opacity: 1;
transform: translateY(-2px);
}
.client-copy-button:active {
transform: translateY(0) scale(0.98);
}
.client-copy-button.is-copied {
opacity: 1;
color: var(--client-accent);
}
.client-copy-button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
+195
View File
@@ -0,0 +1,195 @@
@media (prefers-color-scheme: dark) {
.app.client-app {
--client-bg: oklch(0.18 0.012 145);
--client-panel: oklch(0.22 0.012 145);
--client-control: oklch(0.26 0.012 145);
--client-border: oklch(0.34 0.015 145);
--client-text: oklch(0.93 0.008 145);
--client-muted: oklch(0.68 0.012 145);
--harbor-word: oklch(0.78 0.07 232);
--harbor-connect: oklch(0.75 0.1 185);
--harbor-gateway: oklch(0.79 0.11 72);
--client-accent: var(--harbor-connect);
--client-accent-soft: color-mix(in oklch, var(--client-accent) 22%, transparent);
color-scheme: dark;
}
}@media (prefers-reduced-motion: reduce) {
.client-inline-error.is-subscription > *,
.client-operation-progress::before,
.client-delete-strike,
.client-power,
.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,
.client-server-health > svg,
.client-server-check,
.client-server-check svg,
.client-server-mode-toggle,
.client-server-mode-toggle svg,
.client-server-mode-panel,
.client-server-mode-panel .client-server-tools,
.client-server-mode-panel .client-server-pinned,
.client-server-mode-panel .client-server-scroll,
.client-server-favorite,
.client-server-group-toggle,
.client-server-more,
.client-copy-button,
.client-access-point,
.client-copy-feedback,
.client-duration,
.client-duration-seconds-value,
.client-duration-second-digit,
.client-instructions,
.client-drawer-close,
.client-instructions-toggle,
.client-instructions-toggle svg,
.client-instructions-toggle circle,
.client-instructions-toggle path,
.client-instructions-toggle rect,
.client-instructions-toggle span,
.client-instruction-block,
.client-instruction-reveal,
.client-instruction-summary > i::before,
.client-instruction-summary > i::after,
.client-device,
.client-device-pin,
.client-device-pin svg,
.client-device-policy,
.client-device-policy svg,
.client-device-alias-trigger,
.client-device-alias-input,
.client-device-ip,
.client-device-traffic-value > span,
.client-device-traffic-breakdown,
.client-device-traffic-breakdown > span,
.client-device-traffic-lines,
.client-device-traffic-point-tooltip,
.client-device-traffic-chart,
.client-device-edit,
.client-device-edit svg,
.client-device-edit-wrap,
.client-devices-refresh,
.client-devices-sort,
.client-devices-sort-icon,
.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;
}
.client-diagnostics-refresh,
.client-diagnostics-refresh svg,
.client-diagnostics-table,
.client-diagnostics-active-marker,
.client-diagnostics-dots::after,
.client-diagnostics-service-row,
.client-diagnostics-service-name,
.client-diagnostics-service-draft input,
.client-diagnostics-service-url button {
transition: none;
animation: none;
}
.client-diagnostics-dots::after {
clip-path: inset(0);
}
.client-devices-refresh-ring circle {
stroke-dashoffset: 0;
}
.client-local-rules,
.client-local-rules-toggle,
.client-local-rules-toggle svg,
.client-local-rules-toggle circle,
.client-local-rules-toggle span,
.client-local-rule,
.client-local-rule-enabled svg,
.client-local-rule-enabled circle,
.client-local-rule-enabled path,
.client-rule-type-trigger,
.client-rule-type-trigger svg,
.client-rule-type-list,
.client-rule-type-list button,
.client-local-rule input,
.client-local-rule-delete,
.client-local-rule-add,
.client-local-rule-add-slot > span,
.client-local-rules-save,
.client-local-rules-runtime,
.client-local-rules-actions button {
transition: none;
animation: none;
}
.harbor-brand {
transition: none;
animation: none;
}
.harbor-brand-content {
animation: none;
}
.client-shell.is-intro .client-panel,
.client-shell.is-intro .client-secondary-menu,
.client-shell.is-intro .harbor-versions {
animation: none;
}
.harbor-mode-stack em,
.harbor-mode-gateway > span,
.harbor-mode-swap g,
.harbor-mode-swap path,
.harbor-brand .harbor-mode-swap,
.harbor-mode-tooltip {
transition: none;
animation: none;
}
.client-form-content,
.client-confirmation-popup,
.client-confirmation-dialog,
.client-confirmation-dialog > *,
.client-confirmation-actions button,
.client-subscription-delete .client-trash-lid {
transition: none;
animation: none;
}
.client-tooltip {
transition: none;
}
.harbor-version,
.harbor-version-tooltip {
transition: none;
}
.client-state-copy h2,
.client-connection-title > span,
.client-state-detail > * {
transition: none;
animation: none;
}
}
+25
View File
@@ -0,0 +1,25 @@
:root {
color-scheme: light dark;
}
.app.client-app {
--client-bg: oklch(0.965 0.006 145);
--client-panel: oklch(0.995 0.003 145);
--client-control: oklch(0.955 0.006 145);
--client-border: oklch(0.86 0.012 145);
--client-text: oklch(0.24 0.014 145);
--client-muted: oklch(0.53 0.014 145);
--harbor-word: oklch(0.39 0.075 232);
--harbor-connect: oklch(0.57 0.11 185);
--harbor-gateway: oklch(0.61 0.12 72);
--client-accent: var(--harbor-connect);
--client-accent-soft: color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.app.client-app.is-gateway-app {
--client-accent: var(--harbor-gateway);
}
.client-shell:has(.harbor-brand.is-gateway-active) {
--client-accent: var(--harbor-gateway);
}
@@ -1,9 +1,22 @@
import React, { useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), [tabindex]:not([tabindex="-1"])';
export function ConfirmationPopup({
interface ConfirmationDialogProps {
open: boolean;
id: string;
kicker?: string;
title: string;
description: string;
cancelLabel: string;
confirmLabel: string;
busy?: boolean;
onCancel: () => unknown;
onConfirm: () => unknown;
}
export function ConfirmationDialog({
open,
id,
kicker,
@@ -14,10 +27,10 @@ export function ConfirmationPopup({
busy = false,
onCancel,
onConfirm,
}) {
const overlayRef = useRef(null);
const dialogRef = useRef(null);
const cancelRef = useRef(null);
}: ConfirmationDialogProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const busyRef = useRef(busy);
const onCancelRef = useRef(onCancel);
busyRef.current = busy;
@@ -25,22 +38,25 @@ export function ConfirmationPopup({
useEffect(() => {
if (!open) return undefined;
const previousFocus = document.activeElement;
const background = [...(overlayRef.current?.parentElement?.children || [])]
const previousFocus = document.activeElement as (Element & { focus?: () => void }) | null;
const background = Array.from(overlayRef.current?.parentElement?.children || [])
.filter((element) => !element.classList.contains('client-confirmation-popup'))
.map((element) => [element, element.inert]);
background.forEach(([element]) => { element.inert = true; });
.map((element) => {
const inertElement = element as HTMLElement;
return { element: inertElement, inert: inertElement.inert };
});
background.forEach(({ element }) => { element.inert = true; });
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const frame = requestAnimationFrame(() => cancelRef.current?.focus());
const onKeyDown = (event) => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !busyRef.current) {
event.preventDefault();
onCancelRef.current();
return;
}
if (event.key !== 'Tab') return;
const controls = [...(dialogRef.current?.querySelectorAll(FOCUSABLE) || [])];
const controls = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) || []);
if (!controls.length) return;
const first = controls[0];
const last = controls.at(-1);
@@ -49,7 +65,7 @@ export function ConfirmationPopup({
first.focus();
} else if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
@@ -59,7 +75,7 @@ export function ConfirmationPopup({
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', onKeyDown);
background.forEach(([element, inert]) => { element.inert = inert; });
background.forEach(({ element, inert }) => { element.inert = inert; });
document.body.style.overflow = previousOverflow;
requestAnimationFrame(() => previousFocus?.focus?.());
};
@@ -1,11 +1,21 @@
export function connectionAction({ connected, selectedServerId, configExists }) {
interface ConnectionActionInput {
connected: boolean;
selectedServerId: string;
configExists: boolean;
}
export function connectionAction({ connected, selectedServerId, configExists }: 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;
}
export function formatConnectionDuration(startedAt, now = Date.now()) {
export function formatConnectionDuration(startedAt: string | null | undefined, now = Date.now()) {
const { totalHours, minutes, seconds } = connectionDurationParts(startedAt, now);
return [totalHours, minutes.value, seconds.value]
@@ -13,7 +23,7 @@ export function formatConnectionDuration(startedAt, now = Date.now()) {
.join(':');
}
function durationLabel(value, forms) {
function durationLabel(value: number, forms: readonly [string, string, string]) {
const mod10 = value % 10;
const mod100 = value % 100;
return mod10 === 1 && mod100 !== 11
@@ -21,8 +31,8 @@ function durationLabel(value, forms) {
: mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14) ? forms[1] : forms[2];
}
export function connectionDurationParts(startedAt, now = Date.now()) {
const started = Date.parse(startedAt);
export function connectionDurationParts(startedAt: string | null | undefined, now = Date.now()) {
const started = Date.parse(startedAt || '');
const totalSeconds = Number.isFinite(started)
? Math.max(0, Math.floor((now - started) / 1000))
: 0;
@@ -41,7 +51,7 @@ export function connectionDurationParts(startedAt, now = Date.now()) {
};
}
export function formatConnectionDurationWords(startedAt, now = Date.now()) {
export function formatConnectionDurationWords(startedAt: string | null | undefined, now = Date.now()) {
const { days, hours, minutes, seconds } = connectionDurationParts(startedAt, now);
return [
@@ -52,7 +62,7 @@ export function formatConnectionDurationWords(startedAt, now = Date.now()) {
].filter(Boolean).join(' ');
}
export function subscriptionDomain(subscriptionHost) {
export function subscriptionDomain(subscriptionHost: unknown) {
const value = String(subscriptionHost || '');
try {
return new URL(value).host;
@@ -61,7 +71,7 @@ export function subscriptionDomain(subscriptionHost) {
}
}
export function isSubscriptionUrlValid(value) {
export function isSubscriptionUrlValid(value: unknown) {
try {
return ['http:', 'https:'].includes(new URL(String(value).trim()).protocol);
} catch {
@@ -77,7 +87,10 @@ export function localProxyUrls(port = 8082, host = '127.0.0.1') {
};
}
export async function copyText(text, options = {}) {
export async function copyText(text: string, options: {
clipboard?: Pick<Clipboard, 'writeText'>;
documentRef?: Document;
} = {}) {
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
const documentRef = options.documentRef ?? globalThis.document;
@@ -98,10 +111,13 @@ export async function copyText(text, options = {}) {
await clipboard.writeText(text);
}
export function subscriptionUsage(userInfo = {}) {
const upload = Math.max(0, Number(userInfo.upload) || 0);
const download = Math.max(0, Number(userInfo.download) || 0);
const total = Math.max(0, Number(userInfo.total) || 0);
export function subscriptionUsage(userInfo: unknown = {}) {
const value = userInfo && typeof userInfo === 'object' && !Array.isArray(userInfo)
? userInfo as Record<string, unknown>
: {};
const upload = Math.max(0, Number(value.upload) || 0);
const download = Math.max(0, Number(value.download) || 0);
const total = Math.max(0, Number(value.total) || 0);
const used = upload + download;
return {
@@ -110,12 +126,12 @@ export function subscriptionUsage(userInfo = {}) {
total,
used,
percent: total ? Math.min(100, (used / total) * 100) : null,
expiresAt: userInfo.expire ? new Date(Number(userInfo.expire) * 1000) : null,
expiresAt: value.expire ? new Date(Number(value.expire) * 1000) : null,
};
}
export function subscriptionDaysLeft(expiresAt, now = Date.now()) {
const days = Math.ceil((expiresAt?.getTime() - now) / 86_400_000);
export function subscriptionDaysLeft(expiresAt: Date | null | undefined, now = Date.now()) {
const days = Math.ceil(((expiresAt ? expiresAt.getTime() : Number.NaN) - now) / 86_400_000);
if (!Number.isFinite(days)) return '';
if (days <= 0) return 'срок истёк';
const mod10 = days % 10;
@@ -1,4 +1,6 @@
export function formatBytes(value) {
import type { Device } from '../features/devices/deviceSnapshot.js';
export function formatBytes(value: number) {
if (!value) return "0 Б";
const units = ["Б", "КБ", "МБ", "ГБ", "ТБ"];
let size = value;
@@ -12,12 +14,12 @@ export function formatBytes(value) {
const BYTE_STRING_PATTERN = /^\d+$/;
export function byteString(value) {
export function byteString(value: unknown) {
const normalized = String(value ?? '0');
return BYTE_STRING_PATTERN.test(normalized) ? BigInt(normalized) : 0n;
}
export function formatByteString(value) {
export function formatByteString(value: unknown) {
const bytes = byteString(value);
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ', 'ПБ', 'ЭБ'];
let unit = 0;
@@ -31,13 +33,13 @@ export function formatByteString(value) {
return `${tenths / 10n},${tenths % 10n} ${units[unit]}`;
}
export function positiveByteDelta(previous, current) {
export function positiveByteDelta(previous: unknown, current: unknown) {
const before = byteString(previous);
const after = byteString(current);
return after > before ? formatByteString((after - before).toString()) : '';
}
export function trafficScaleRatio(value, maxValue, scale = 'linear') {
export function trafficScaleRatio(value: unknown, maxValue: unknown, scale: 'linear' | 'log' = 'linear') {
const current = byteString(value);
const max = byteString(maxValue);
if (!current || !max) return 0;
@@ -46,14 +48,14 @@ export function trafficScaleRatio(value, maxValue, scale = 'linear') {
: Number(current * 10_000n / max) / 10_000;
}
export function trafficAxisMid(maxValue, scale = 'linear') {
export function trafficAxisMid(maxValue: unknown, scale: 'linear' | 'log' = 'linear') {
const max = byteString(maxValue);
if (!max) return 0n;
if (scale !== 'log') return max / 2n;
return BigInt(Math.max(1, Math.round(Math.expm1(Math.log1p(Number(max)) / 2))));
}
export function sortDevicesByTraffic(devices, direction = 'desc') {
export function sortDevicesByTraffic(devices: Device[] | undefined, direction: 'asc' | 'desc' = 'desc') {
const factor = direction === 'asc' ? 1 : -1;
return (Array.isArray(devices) ? devices : [])
.map((device) => ({ device }))
@@ -72,7 +74,11 @@ export function sortDevicesByTraffic(devices, direction = 'desc') {
.map(({ device }) => device);
}
export function stabilizeDevicesByTraffic(devices, direction, previousIds = []) {
export function stabilizeDevicesByTraffic(
devices: Device[] | undefined,
direction: 'asc' | 'desc',
previousIds: string[] = [],
) {
const ranked = sortDevicesByTraffic(devices, direction);
const byId = new Map(ranked.map((device) => [device.id, device]));
const ids = previousIds.filter((id) => byId.has(id));
@@ -82,12 +88,12 @@ export function stabilizeDevicesByTraffic(devices, direction, previousIds = [])
ids.push(id);
seen.add(id);
}
const ordered = ids.map((id) => byId.get(id));
const ordered = ids.map((id) => byId.get(id)).filter((device): device is Device => Boolean(device));
const stable = [...ordered.filter(({ pinned }) => pinned), ...ordered.filter(({ pinned }) => !pinned)];
return { devices: stable, ids: stable.map(({ id }) => id) };
}
export function formatRelative(iso) {
export function formatRelative(iso: string | null | undefined) {
if (!iso) return "";
const ts = new Date(iso).getTime();
if (Number.isNaN(ts)) return "";
@@ -102,16 +108,16 @@ export function formatRelative(iso) {
return `${days} дн назад`;
}
export function formatTime(iso) {
export function formatTime(iso: string | null | undefined) {
if (!iso) return "";
return new Date(iso).toLocaleTimeString("ru-RU", { hour12: false });
}
export function formatLastSeen(iso, now = new Date()) {
const date = new Date(iso);
export function formatLastSeen(iso: string | null | undefined, now: Date | string | number = new Date()) {
const date = new Date(iso || '');
if (Number.isNaN(date.getTime())) return { label: "Нет данных", relative: "Нет данных", tooltip: "Нет данных" };
const current = new Date(now);
const day = (value) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate());
const day = (value: Date) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate());
const daysAgo = Math.round((day(current) - day(date)) / 86_400_000);
const time = date.toLocaleTimeString("ru-RU", { hour: "2-digit", minute: "2-digit" });
const dateLabel = daysAgo === 0
@@ -124,7 +130,7 @@ export function formatLastSeen(iso, now = new Date()) {
...(date.getFullYear() === current.getFullYear() ? {} : { year: "numeric" }),
});
const elapsedMs = Math.max(0, current.getTime() - date.getTime());
const units = elapsedMs < 60 * 60 * 1000
const units: [number, Intl.RelativeTimeFormatUnit] = elapsedMs < 60 * 60 * 1000
? [Math.max(1, Math.floor(elapsedMs / 60_000)), "minute"]
: elapsedMs < 24 * 60 * 60 * 1000
? [Math.floor(elapsedMs / 3_600_000), "hour"]
-31
View File
@@ -1,31 +0,0 @@
export const SERVER_RESULT_WINDOW = 60;
const searchable = (server) => [
server.label,
server.host,
server.country,
server.city,
server.provider,
server.protocol,
].filter(Boolean).join(' ').toLocaleLowerCase('ru');
export function filterServers(servers, query) {
const needle = String(query || '').trim().toLocaleLowerCase('ru');
return needle ? servers.filter((server) => searchable(server).includes(needle)) : servers;
}
export function serverGroup(server) {
return server.country || server.provider || 'Другие';
}
export function groupServers(servers) {
return [...servers.reduce((groups, server) => {
const name = serverGroup(server);
groups.set(name, [...(groups.get(name) || []), server]);
return groups;
}, new Map())];
}
export function autoServer(servers) {
return [...servers].sort((left, right) => left.id.localeCompare(right.id))[0] || null;
}