Refactor VPN proxy client implementation
This commit is contained in:
@@ -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
@@ -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 }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ConnectionPanel } from './ConnectionPanel.js';
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DevicesPanel } from './DevicesPanel.js';
|
||||
export {
|
||||
DevicesToggle,
|
||||
GatewayTrafficSummary,
|
||||
useDevicesFeature,
|
||||
} from './DevicesFeature.js';
|
||||
+121
-45
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
/>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user