Refactor VPN proxy client implementation
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
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.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;
|
||||
|
||||
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 }: { 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>
|
||||
<span className="client-text-morph-value is-date">{from}</span>
|
||||
<span className="client-text-morph-value is-relative">{to}</span>
|
||||
</span>;
|
||||
}
|
||||
|
||||
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({ 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 [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<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<string, Animation>());
|
||||
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
|
||||
const aliasBaseline = useRef({ id: '', value: '' });
|
||||
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) as {
|
||||
ids: string[];
|
||||
devices: Device[];
|
||||
};
|
||||
trafficOrder.current = { direction: sortDirection, ids: result.ids };
|
||||
return result.devices;
|
||||
},
|
||||
[snapshot?.devices, sortDirection],
|
||||
);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
previousTraffic.current.clear();
|
||||
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||
setTrafficDeltas({});
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
const previous = previousTraffic.current.get(device.id);
|
||||
next.set(device.id, { gateway, proxy });
|
||||
if (!previous) continue;
|
||||
const gatewayDelta = positiveByteDelta(previous.gateway, gateway);
|
||||
const proxyDelta = positiveByteDelta(previous.proxy, proxy);
|
||||
if (!gatewayDelta && !proxyDelta) continue;
|
||||
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
|
||||
deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta };
|
||||
}
|
||||
previousTraffic.current = next;
|
||||
if (!Object.keys(deltas).length) return;
|
||||
setTrafficDeltas(deltas);
|
||||
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
|
||||
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
|
||||
}, [snapshot?.devices, open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
previousPositions.current.clear();
|
||||
previousOrder.current = [];
|
||||
previousScrollTop.current = 0;
|
||||
for (const animation of movementAnimations.current.values()) animation.cancel();
|
||||
movementAnimations.current.clear();
|
||||
return;
|
||||
}
|
||||
const positions = new Map<string, DOMRect>();
|
||||
for (const [id, node] of deviceNodes.current) {
|
||||
movementAnimations.current.get(id)?.cancel();
|
||||
positions.set(id, node.getBoundingClientRect());
|
||||
}
|
||||
const order = devices.map(({ id }) => id);
|
||||
const orderChanged = previousOrder.current.length > 0
|
||||
&& (order.length !== previousOrder.current.length
|
||||
|| order.some((id, index) => id !== previousOrder.current[index]));
|
||||
const currentScrollTop = panelRef.current?.scrollTop || 0;
|
||||
if (orderChanged && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
for (const [id, after] of positions) {
|
||||
const before = previousPositions.current.get(id);
|
||||
const deltaY = before
|
||||
? before.top - after.top + previousScrollTop.current - currentScrollTop
|
||||
: 0;
|
||||
if (Math.abs(deltaY) < 1) continue;
|
||||
const animation = deviceNodes.current.get(id)?.animate([
|
||||
{ transform: `translateY(${deltaY}px)` },
|
||||
{ transform: 'translateY(0)' },
|
||||
], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' });
|
||||
if (animation) {
|
||||
movementAnimations.current.set(id, animation);
|
||||
animation.onfinish = () => movementAnimations.current.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
previousPositions.current = positions;
|
||||
previousOrder.current = order;
|
||||
previousScrollTop.current = currentScrollTop;
|
||||
}, [devices, open, panelRef]);
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (!await updateDevice(device, { alias: nextAlias })) return;
|
||||
setEditingId((current) => current === device.id ? '' : current);
|
||||
}
|
||||
|
||||
async function copyDeviceIp(device: Device) {
|
||||
if (!device.ip) return;
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
try {
|
||||
await copyText(device.ip);
|
||||
setCopyFeedback({ id: device.id, failed: false });
|
||||
} catch {
|
||||
setCopyFeedback({ id: device.id, failed: true });
|
||||
}
|
||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
||||
}
|
||||
|
||||
function startEditing(device: Device) {
|
||||
const value = device.alias || device.hostname || '';
|
||||
aliasBaseline.current = { id: device.id, value };
|
||||
setEditingId(device.id);
|
||||
setAlias(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
id="client-devices"
|
||||
className={`client-drawer client-instructions client-devices${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="client-devices-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
>
|
||||
<div className="client-drawer-sheet client-instructions-sheet client-devices-sheet">
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть устройства"
|
||||
onClick={onClose}
|
||||
>×</button>
|
||||
<header className="client-instructions-header client-devices-header">
|
||||
<div className="client-devices-kicker">
|
||||
<span>Gateway · {devices.length}</span>
|
||||
<span className="client-devices-refresh-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-devices-refresh${refreshing ? ' is-refreshing' : ''}`}
|
||||
type="button"
|
||||
aria-label={refreshing ? 'Обновляем устройства' : 'Обновить устройства сейчас'}
|
||||
aria-busy={refreshing}
|
||||
disabled={refreshing}
|
||||
onClick={() => onLoad(true, true)}
|
||||
>
|
||||
<svg key={refreshCycle} className="client-devices-refresh-ring" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" pathLength="1" />
|
||||
</svg>
|
||||
<svg className="client-devices-refresh-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>{refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'}</Tooltip>
|
||||
</span>
|
||||
<span className="client-devices-sort-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className="client-devices-sort"
|
||||
type="button"
|
||||
aria-label={`Сортировка по трафику: сначала ${sortDirection === 'desc' ? 'больше' : 'меньше'}. Изменить направление`}
|
||||
onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')}
|
||||
>
|
||||
<span>Трафик</span>
|
||||
<span className="client-devices-sort-icon" aria-hidden="true">
|
||||
{sortDirection === 'desc' ? '↓' : '↑'}
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
|
||||
</span>
|
||||
<span className="client-devices-scale" role="group" aria-label="Масштаб графика трафика">
|
||||
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
|
||||
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
|
||||
</span>
|
||||
</div>
|
||||
<h2 id="client-devices-title">Устройства</h2>
|
||||
<div className="client-instructions-intro">
|
||||
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
|
||||
<p>Учитывается только трафик, который прошёл через Harbor.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{Boolean(snapshot?.source?.error) && (
|
||||
<p className="client-devices-source" role="status">
|
||||
Список временно не обновляется. Показаны последние сохранённые данные.
|
||||
</p>
|
||||
)}
|
||||
{Boolean(snapshot?.source?.traffic?.error) && (
|
||||
<p className="client-devices-source" role="status">
|
||||
Трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||
</p>
|
||||
)}
|
||||
{Boolean(snapshot?.source?.traffic?.proxy?.error) && (
|
||||
<p className="client-devices-source" role="status">
|
||||
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
|
||||
</p>
|
||||
)}
|
||||
{Boolean(snapshot?.source?.policy?.error) && (
|
||||
<p className="client-devices-source" role="status">
|
||||
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
|
||||
</p>
|
||||
)}
|
||||
{Boolean(error) && (
|
||||
<div className="client-devices-error" role="alert">
|
||||
<span>{requestMessage(error)}</span>
|
||||
<button type="button" onClick={() => onLoad()}>Повторить</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства…</p>}
|
||||
{status !== 'loading' && !devices.length && !error && (
|
||||
<p className="client-devices-empty">Gateway пока не видит устройств.</p>
|
||||
)}
|
||||
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
|
||||
</div>
|
||||
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
||||
{devices.map((device) => {
|
||||
const hasName = Boolean(device.alias || device.hostname);
|
||||
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
|
||||
const editing = editingId === device.id;
|
||||
const saving = savingId === device.id;
|
||||
const seen = formatLastSeen(device.lastSeenAt);
|
||||
const online = device.status === 'online';
|
||||
const gatewayTotal = byteString(device.downloadBytes) + byteString(device.uploadBytes);
|
||||
const proxyTotal = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
|
||||
const gatewayTraffic = formatByteString(gatewayTotal.toString());
|
||||
const proxyTraffic = formatByteString(proxyTotal.toString());
|
||||
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
|
||||
const hasProxyTraffic = proxyTotal > 0n;
|
||||
const trafficDelta = trafficDeltas[device.id] || {};
|
||||
const copied = copyFeedback?.id === device.id;
|
||||
const policyBusy = device.policyStatus === 'applying';
|
||||
const policyFailed = device.policyStatus === 'failed';
|
||||
const policyPending = device.policyStatus === 'pending';
|
||||
const displayPolicy = device.appliedPolicy;
|
||||
const policyTarget = device.policyStatus === 'applied'
|
||||
? device.appliedPolicy === 'direct' ? 'vpn' : 'direct'
|
||||
: device.appliedPolicy;
|
||||
const cannotEnableDirect = device.policyStatus === 'applied'
|
||||
&& device.appliedPolicy !== 'direct'
|
||||
&& device.confidence === 'ambiguous';
|
||||
const policyTooltip = policyBusy
|
||||
? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}`
|
||||
: policyFailed
|
||||
? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут`
|
||||
: policyPending
|
||||
? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание'
|
||||
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
|
||||
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
|
||||
: displayPolicy === 'direct'
|
||||
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
|
||||
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
|
||||
return <article
|
||||
ref={(node) => {
|
||||
if (node) deviceNodes.current.set(device.id, node);
|
||||
else deviceNodes.current.delete(device.id);
|
||||
}}
|
||||
className={`client-device is-${device.status}${device.pinned ? ' is-pinned' : ''}`}
|
||||
key={device.id}
|
||||
>
|
||||
<span className="client-device-pin-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className="client-device-pin"
|
||||
type="button"
|
||||
aria-pressed={device.pinned}
|
||||
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
|
||||
disabled={saving}
|
||||
onClick={() => updateDevice(device, { pinned: !device.pinned })}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
|
||||
</span>
|
||||
|
||||
<div className="client-device-main">
|
||||
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
|
||||
{editing ? (
|
||||
<input
|
||||
className="client-device-alias-input"
|
||||
value={alias}
|
||||
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` } as CSSProperties}
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
aria-label="Название устройства"
|
||||
aria-busy={saving}
|
||||
disabled={saving}
|
||||
onChange={(event) => setAlias(event.target.value)}
|
||||
onBlur={() => saveAlias(device)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur();
|
||||
}}
|
||||
/>
|
||||
) : hasName && <button
|
||||
className="client-device-alias-trigger"
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
onClick={() => startEditing(device)}
|
||||
>{title}</button>}
|
||||
{(hasName || (editing && Boolean(alias))) && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
|
||||
{device.ip ? <button
|
||||
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||
onClick={() => copyDeviceIp(device)}
|
||||
>{device.ip}</button> : !hasName && !editing && <span>Неизвестное устройство</span>}
|
||||
</h3>
|
||||
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
onPointerEnter={() => setPencilAnimationId(device.id)}
|
||||
onFocus={() => setPencilAnimationId(device.id)}
|
||||
onClick={() => startEditing(device)}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => setPencilAnimationId((id) => id === device.id ? '' : id)}
|
||||
>
|
||||
<path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" />
|
||||
</svg>
|
||||
</button>
|
||||
<Tooltip>Изменить название</Tooltip>
|
||||
</span>}
|
||||
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex={0}>
|
||||
<time
|
||||
dateTime={device.lastSeenAt || undefined}
|
||||
aria-label={`${online ? 'В сети' : 'Не в сети'}. Последний контакт: ${seen.tooltip}`}
|
||||
>
|
||||
{online ? 'В сети' : <TextMorph from="Не в сети" to={seen.relative} />}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span
|
||||
className="client-device-traffic"
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
|
||||
>
|
||||
<span className="client-device-traffic-total" aria-hidden="true">
|
||||
<b>Всего</b><TrafficValue value={totalTraffic} delta={trafficDelta.total} />
|
||||
</span>
|
||||
<span className="client-device-traffic-breakdown" aria-hidden="true">
|
||||
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
|
||||
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="client-device-policy-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
|
||||
type="button"
|
||||
aria-label={`Маршрут устройства: ${displayPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}. ${policyTooltip}`}
|
||||
aria-pressed={displayPolicy === 'direct'}
|
||||
aria-busy={policyBusy}
|
||||
disabled={saving || policyBusy || cannotEnableDirect}
|
||||
onClick={() => updatePolicy(device, policyTarget)}
|
||||
>
|
||||
{displayPolicy === 'direct' ? <svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 12h15M14 7l5 5-5 5" />
|
||||
</svg> : <svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 19 6v5c0 4.4-2.8 8-7 10-4.2-2-7-5.6-7-10V6l7-3Z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>}
|
||||
</button>
|
||||
<Tooltip>{policyTooltip}</Tooltip>
|
||||
</span>
|
||||
<TrafficChart
|
||||
samples={device.trafficHistory || []}
|
||||
scale={trafficScale}
|
||||
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
|
||||
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
|
||||
pinned={device.pinned}
|
||||
/>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
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';
|
||||
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
|
||||
|
||||
const TRAFFIC_CHART_HEADROOM = 10;
|
||||
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
|
||||
|
||||
function chartTime(value: string) {
|
||||
return new Date(value).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
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];
|
||||
const midX = (previous.x + point.x) / 2;
|
||||
return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`;
|
||||
}, `M ${points[0].x},${points[0][valueKey]}`);
|
||||
}
|
||||
|
||||
function trafficSeriesMax(samples: TrafficSample[]) {
|
||||
return samples.reduce((largest, sample) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
const proxy = byteString(sample.proxyBytes);
|
||||
return gateway > largest
|
||||
? (proxy > gateway ? proxy : gateway)
|
||||
: (proxy > largest ? proxy : largest);
|
||||
}, 0n);
|
||||
}
|
||||
|
||||
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;
|
||||
const points = samples.map((sample, index) => {
|
||||
const gateway = byteString(sample.gatewayBytes);
|
||||
const proxy = byteString(sample.proxyBytes);
|
||||
return {
|
||||
sample,
|
||||
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
|
||||
gateway,
|
||||
proxy,
|
||||
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
|
||||
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
|
||||
};
|
||||
});
|
||||
const previous = points.slice(0, -1);
|
||||
const penultimate = points.at(-2);
|
||||
const newest = points.at(-1);
|
||||
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
||||
const scaleFrom = previousPoints.current;
|
||||
const animateScale = previousScale.current !== scale
|
||||
&& scaleFrom.length === points.length
|
||||
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
previousPoints.current = points;
|
||||
previousScale.current = scale;
|
||||
}, [points, scale]);
|
||||
|
||||
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;
|
||||
if (index < 0 || index >= points.length) {
|
||||
setHovered(null);
|
||||
return;
|
||||
}
|
||||
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
|
||||
}
|
||||
|
||||
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
|
||||
<span
|
||||
className="client-device-traffic-point-tooltip"
|
||||
style={{
|
||||
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
|
||||
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
|
||||
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
|
||||
}}
|
||||
>
|
||||
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
|
||||
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
|
||||
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
|
||||
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
|
||||
</span>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
return <span
|
||||
className="client-device-traffic-chart"
|
||||
role="img"
|
||||
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
|
||||
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>
|
||||
<span className="is-mid">{formatByteString(mid)}</span>
|
||||
<span className="is-zero">0</span>
|
||||
</span>}
|
||||
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
|
||||
{pinned && <g className="client-device-traffic-grid">
|
||||
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
|
||||
<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) } 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>}
|
||||
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}>
|
||||
{animateScale && <animate key={`proxy-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
|
||||
</path>}
|
||||
{penultimate && newest && <path className="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
|
||||
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
|
||||
</path>}
|
||||
{hasProxy && penultimate && newest && <path className="is-proxy is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
|
||||
{animateScale && <animate key={`proxy-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
|
||||
</path>}
|
||||
{!penultimate && newest && <line className="is-gateway is-point" x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
|
||||
</g>
|
||||
{hovered && <g className="client-device-traffic-cursor">
|
||||
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
|
||||
<line className="is-point is-gateway" x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
|
||||
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
|
||||
</g>}
|
||||
</svg>
|
||||
</span>
|
||||
{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[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';
|
||||
@@ -0,0 +1,499 @@
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import {
|
||||
CONNECTIVITY_IP_SOURCES,
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
} 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';
|
||||
|
||||
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: unknown = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
|
||||
return Array.isArray(value)
|
||||
? value.filter(validCustomService).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function readHiddenServices(): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]');
|
||||
return Array.isArray(value)
|
||||
? value.filter((id): id is string => (
|
||||
typeof id === 'string' && CONNECTIVITY_SITES.some((service) => service.id === id)
|
||||
))
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
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', '—'];
|
||||
if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
|
||||
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
|
||||
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
|
||||
}
|
||||
|
||||
function Status({ value, route }: { value: StatusValue; route: string }) {
|
||||
const [className, label] = value;
|
||||
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>
|
||||
{label}
|
||||
{className === 'is-running' && <span className="client-diagnostics-dots" aria-hidden="true">...</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
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,
|
||||
}: {
|
||||
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} />;
|
||||
if (!path?.available) return <Status value={['is-muted', '—']} route={route} />;
|
||||
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
||||
}
|
||||
|
||||
function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) {
|
||||
const merged = [...previous];
|
||||
for (const item of incoming) {
|
||||
const index = merged.findIndex((value) => key(value) === key(item));
|
||||
if (index >= 0) merged[index] = item;
|
||||
else merged.push(item);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
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((address): address is string => Boolean(address)))];
|
||||
return {
|
||||
...previous,
|
||||
...incoming,
|
||||
internetAvailable: Boolean(
|
||||
addresses.length || ipv6 || sites.some(({ status }) => status !== 'unavailable'),
|
||||
),
|
||||
ipv4: { addresses, sources },
|
||||
ipv6,
|
||||
ipv6Source,
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeResult(previous: ConnectivityResult | null, incoming: ConnectivityResult): ConnectivityResult {
|
||||
const direct = mergePath(previous?.direct, incoming.direct);
|
||||
const vpn = { ...mergePath(previous?.vpn, incoming.vpn), server: incoming.vpn.server };
|
||||
return { ...incoming, direct, vpn };
|
||||
}
|
||||
|
||||
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);
|
||||
const [removingServiceId, setRemovingServiceId] = useState('');
|
||||
const [serviceName, setServiceName] = useState('');
|
||||
const [serviceUrl, setServiceUrl] = useState('');
|
||||
const [formError, setFormError] = useState('');
|
||||
const sheetRef = useRef<HTMLDivElement>(null);
|
||||
const runnerRef = useRef<HTMLSpanElement>(null);
|
||||
const previousTargetRef = useRef<string | null>(null);
|
||||
const requestError = requestDetails(error);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices));
|
||||
} catch {
|
||||
// The service still works for this session when browser storage is unavailable.
|
||||
}
|
||||
}, [customServices]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_SERVICES_KEY, JSON.stringify(hiddenServiceIds));
|
||||
} catch {
|
||||
// The service list still works for this session when browser storage is unavailable.
|
||||
}
|
||||
}, [hiddenServiceIds]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const sheet = sheetRef.current;
|
||||
const runner = runnerRef.current;
|
||||
if (!sheet || !runner) return;
|
||||
if (!activeTarget) {
|
||||
runner.classList.remove('is-visible', 'is-moving');
|
||||
previousTargetRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const row = [...sheet.querySelectorAll<HTMLElement>('[data-diagnostic-target]')]
|
||||
.find((item) => item.dataset.diagnosticTarget === activeTarget);
|
||||
if (!row) return;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
const sheetRect = sheet.getBoundingClientRect();
|
||||
runner.style.setProperty('--diagnostics-runner-x', `${rowRect.left - sheetRect.left}px`);
|
||||
runner.style.setProperty('--diagnostics-runner-y', `${rowRect.top - sheetRect.top}px`);
|
||||
runner.style.width = `${rowRect.width}px`;
|
||||
runner.style.height = `${rowRect.height}px`;
|
||||
runner.classList.toggle('is-moving', previousTargetRef.current !== null);
|
||||
runner.classList.add('is-visible');
|
||||
previousTargetRef.current = activeTarget;
|
||||
}, [activeTarget]);
|
||||
|
||||
async function run() {
|
||||
setStatus('running');
|
||||
setError(null);
|
||||
try {
|
||||
let next = result;
|
||||
const targets = [
|
||||
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
|
||||
...sites.map(({ id }) => `site:${id}`),
|
||||
];
|
||||
for (const target of targets) {
|
||||
setActiveTarget(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);
|
||||
if (legacyFullResult) break;
|
||||
}
|
||||
setStatus('ready');
|
||||
} catch (requestError) {
|
||||
setError(requestError);
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setActiveTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
function addService(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES) return;
|
||||
const parsed = new URL(serviceUrl.trim());
|
||||
if (parsed.protocol !== 'https:') throw new Error('Нужен публичный HTTPS-адрес.');
|
||||
setCustomServices((services) => [...services, {
|
||||
id: `custom-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
|
||||
label: serviceName.trim() || parsed.hostname,
|
||||
url: parsed.href,
|
||||
}]);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
setAdding(false);
|
||||
setResult(null);
|
||||
} catch (validationError) {
|
||||
const message = validationError && typeof validationError === 'object' && !Array.isArray(validationError)
|
||||
? Reflect.get(validationError, 'message')
|
||||
: undefined;
|
||||
setFormError(typeof message === 'string' ? message : 'Проверьте адрес.');
|
||||
}
|
||||
}
|
||||
|
||||
function removeService(serviceId: string) {
|
||||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
finishRemoveService(serviceId);
|
||||
return;
|
||||
}
|
||||
setRemovingServiceId(serviceId);
|
||||
}
|
||||
|
||||
function finishRemoveService(serviceId: string) {
|
||||
const update = () => flushSync(() => {
|
||||
if (serviceId === 'draft') {
|
||||
setAdding(false);
|
||||
setServiceName('');
|
||||
setServiceUrl('');
|
||||
setFormError('');
|
||||
} else if (serviceId.startsWith('custom-')) {
|
||||
setCustomServices((services) => services.filter((service) => service.id !== serviceId));
|
||||
} else {
|
||||
setHiddenServiceIds((ids) => [...new Set([...ids, serviceId])]);
|
||||
}
|
||||
setRemovingServiceId('');
|
||||
setResult(null);
|
||||
});
|
||||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
document.startViewTransition(update);
|
||||
}
|
||||
|
||||
const pending = status === 'running';
|
||||
const sites = [
|
||||
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
|
||||
...customServices,
|
||||
];
|
||||
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
||||
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
||||
const { isOpen: open, panelRef, closeRef, close: onClose } = feature;
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
id="client-diagnostics"
|
||||
className={`client-drawer client-instructions client-diagnostics${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="client-diagnostics-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
>
|
||||
<div ref={sheetRef} className="client-drawer-sheet client-instructions-sheet client-diagnostics-sheet">
|
||||
<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
type="button"
|
||||
aria-label="Закрыть диагностику"
|
||||
onClick={onClose}
|
||||
>×</button>
|
||||
<header className="client-instructions-header client-diagnostics-header">
|
||||
<span>{isGateway ? 'Gateway' : 'Connect'} · Direct ↔ VPN</span>
|
||||
<div className="client-diagnostics-title-row">
|
||||
<h2 id="client-diagnostics-title">Маршруты</h2>
|
||||
<span className="client-diagnostics-refresh-wrap client-tooltip-anchor">
|
||||
<button
|
||||
className={`client-diagnostics-refresh${pending ? ' is-running' : ''}`}
|
||||
type="button"
|
||||
aria-label="Проверить маршруты"
|
||||
aria-busy={pending}
|
||||
disabled={pending}
|
||||
onClick={run}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="client-tooltip" role="tooltip">Проверить маршруты</span>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{Boolean(error) && <div className="client-diagnostics-feedback">
|
||||
<div className="client-diagnostics-error" role="alert">
|
||||
<span>{requestError.message}</span>
|
||||
{requestError.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<section className="client-diagnostics-section" aria-labelledby="diagnostic-ip-title">
|
||||
<div className="client-diagnostics-section-title">
|
||||
<span id="diagnostic-ip-title">IP-адреса</span>
|
||||
</div>
|
||||
<table className="client-diagnostics-table" aria-busy={pending}>
|
||||
<thead><tr>
|
||||
<th>Источник</th>
|
||||
<th>Напрямую</th>
|
||||
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
|
||||
</tr></thead>
|
||||
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
|
||||
const target = `ip:${source.id}`;
|
||||
const running = activeTarget === target;
|
||||
return <tr key={source.id} data-diagnostic-target={target} className={running ? 'is-running' : undefined}>
|
||||
<th scope="row">{source.label}</th>
|
||||
<td><IpCell path={result?.direct} source={source} pending={running} route={`Напрямую, ${source.label}`} /></td>
|
||||
<td><IpCell path={result?.vpn} source={source} pending={running} route={`VPN, ${source.label}`} /></td>
|
||||
</tr>})}</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="client-diagnostics-section" aria-labelledby="diagnostic-sites-title">
|
||||
<div className="client-diagnostics-section-title">
|
||||
<span id="diagnostic-sites-title">Сервисы</span>
|
||||
</div>
|
||||
|
||||
<div className="client-diagnostics-service-table" role="table" aria-busy={pending}>
|
||||
<div className="client-diagnostics-service-header" role="row">
|
||||
<span role="columnheader">Сервис</span>
|
||||
<span role="columnheader">Напрямую</span>
|
||||
<span role="columnheader">VPN</span>
|
||||
<span aria-hidden="true" />
|
||||
</div>
|
||||
{sites.map((site) => {
|
||||
const direct = result?.direct?.sites?.find((item) => item.id === site.id);
|
||||
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
|
||||
const running = activeTarget === `site:${site.id}`;
|
||||
const removing = removingServiceId === site.id;
|
||||
return <div
|
||||
key={site.id}
|
||||
role="row"
|
||||
data-diagnostic-target={`site:${site.id}`}
|
||||
className={`client-diagnostics-service-row client-deletable-row${running ? ' is-running' : ''}${removing ? ' is-removing' : ''}`}
|
||||
style={{ viewTransitionName: removing ? 'none' : `diagnostic-service-${site.id}` }}
|
||||
inert={removing ? true : undefined}
|
||||
>
|
||||
<span role="rowheader" className="client-diagnostics-service-name">{site.label}</span>
|
||||
<span role="cell"><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></span>
|
||||
<span role="cell"><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
type="button"
|
||||
aria-label={`Удалить сервис ${site.label}`}
|
||||
disabled={serviceEditorBlocked}
|
||||
onClick={() => removeService(site.id)}
|
||||
>×</button>
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => finishRemoveService(site.id)}
|
||||
/>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
|
||||
{adding && <form
|
||||
className={`client-diagnostics-service-row client-diagnostics-service-draft client-deletable-row${removingServiceId === 'draft' ? ' is-removing' : ''}`}
|
||||
onSubmit={addService}
|
||||
inert={serviceEditorBlocked ? true : undefined}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={40}
|
||||
placeholder="Название"
|
||||
aria-label="Название сервиса"
|
||||
value={serviceName}
|
||||
onChange={(event) => setServiceName(event.target.value)}
|
||||
/>
|
||||
<span className="client-diagnostics-service-url">
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="https://example.com"
|
||||
aria-label="HTTPS-адрес сервиса"
|
||||
required
|
||||
value={serviceUrl}
|
||||
onChange={(event) => {
|
||||
setServiceUrl(event.target.value);
|
||||
setFormError('');
|
||||
}}
|
||||
/>
|
||||
<button type="submit" disabled={serviceEditorBlocked}>Добавить</button>
|
||||
</span>
|
||||
<button
|
||||
className="client-local-rule-delete"
|
||||
type="button"
|
||||
aria-label="Отменить добавление сервиса"
|
||||
onClick={() => removeService('draft')}
|
||||
>×</button>
|
||||
<span
|
||||
className="client-delete-strike"
|
||||
aria-hidden="true"
|
||||
onAnimationEnd={() => finishRemoveService('draft')}
|
||||
/>
|
||||
</form>}
|
||||
|
||||
{!sites.length && !adding && <p className="client-diagnostics-services-empty">Сервисов пока нет.</p>}
|
||||
|
||||
<div className="client-local-rule-add-slot client-diagnostics-add-slot">
|
||||
<button
|
||||
className="client-local-rule-add"
|
||||
type="button"
|
||||
disabled={serviceEditorBlocked || adding || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||
onClick={() => setAdding(true)}
|
||||
>
|
||||
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5 сервисов' : '+ Добавить сервис'}
|
||||
</button>
|
||||
<span className={addHint ? 'is-visible' : ''} role={formError ? 'alert' : 'status'}>{addHint}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,116 @@
|
||||
import { grafanaDashboardJson, prometheusScrapeConfig } from './prometheus.js';
|
||||
|
||||
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}`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'about',
|
||||
label: 'Основы',
|
||||
title: isGateway ? 'Gateway и прокси' : 'Что такое прокси',
|
||||
summary: isGateway
|
||||
? 'Два способа направить трафик через это устройство.'
|
||||
: 'Способ направить трафик выбранного приложения через VPN.',
|
||||
paragraphs: isGateway
|
||||
? [
|
||||
`Gateway (${host}) заменяет основной шлюз устройства и проводит через VPN весь его интернет-трафик.`,
|
||||
`Gateway Proxy (${host}:${port}) работает точечно: его указывают в браузере, редакторе или другом приложении. Если приложение не умеет работать с прокси, можно использовать ProxyBridge.`,
|
||||
]
|
||||
: [
|
||||
`Локальный прокси (${host}:${port}) не перенаправляет приложения автоматически. Каждое приложение должно использовать этот адрес само — напрямую или через ProxyBridge.`,
|
||||
'HTTP обычно проще для браузеров и редакторов. SOCKS5 подходит приложениям и инструментам, которым нужен более универсальный транспорт.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'proxybridge',
|
||||
label: 'Приложения',
|
||||
title: 'ProxyBridge',
|
||||
summary: 'Направляет через прокси отдельные приложения, даже если у них нет своей настройки.',
|
||||
steps: [
|
||||
{
|
||||
link: ['Установите ProxyBridge', 'https://interceptsuite.com/download/proxybridge'],
|
||||
after: ' с официальной страницы проекта.',
|
||||
},
|
||||
`Добавьте прокси типа SOCKS5: сервер ${host}, порт ${port}.`,
|
||||
'Создайте правило, выберите нужное приложение и действие Proxy.',
|
||||
'Включите ProxyBridge и запустите приложение заново.',
|
||||
],
|
||||
note: 'Не добавляйте в правило сам ProxyBridge и VPN-клиент: это может создать прокси-цикл.',
|
||||
},
|
||||
{
|
||||
id: 'switchyomega',
|
||||
label: 'Браузер',
|
||||
title: 'SwitchyOmega',
|
||||
summary: 'Переключает прокси-профили только для браузера.',
|
||||
steps: [
|
||||
{
|
||||
link: ['Установите расширение', 'https://chromewebstore.google.com/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif'],
|
||||
after: ' и откройте его настройки.',
|
||||
},
|
||||
'Создайте профиль Proxy Profile.',
|
||||
`Выберите HTTP, укажите сервер ${host} и порт ${port}.`,
|
||||
'Создайте профиль Auto Switch, выберите созданный прокси для нужных сайтов, а для остальных оставьте Direct.',
|
||||
'Если сайт не загрузился, откройте SwitchyOmega: расширение покажет проблемные ресурсы. Добавьте домен текущего сайта в Auto Switch и назначьте ему прокси-профиль.',
|
||||
],
|
||||
note: 'Проект больше не поддерживается. Используйте его только если расширение уже подходит вашему браузеру.',
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
label: 'Редактор',
|
||||
title: 'Visual Studio Code',
|
||||
summary: 'VS Code использует системный прокси или адрес, переданный при запуске.',
|
||||
steps: [
|
||||
'Если прокси уже настроен в системе, полностью перезапустите VS Code — обычно он подхватит настройку автоматически.',
|
||||
'Для отдельного запуска через SOCKS5 используйте команду ниже.',
|
||||
{
|
||||
link: ['Документация VS Code', 'https://code.visualstudio.com/docs/setup/network'],
|
||||
after: ' описывает также системный прокси, HTTP и параметры исключений.',
|
||||
},
|
||||
],
|
||||
code: `code --proxy-server="${socksProxy}"`,
|
||||
note: `VS Code не поддерживает логин и пароль для SOCKS5. Здесь прокси ${host}:${port} локальный и без авторизации, поэтому этот вариант подходит. HTTP-адрес ${httpProxy} остаётся альтернативой.`,
|
||||
},
|
||||
...(isGateway ? [{
|
||||
id: 'router',
|
||||
label: 'Вся сеть',
|
||||
title: 'Заменить Gateway в роутере',
|
||||
summary: 'Роутер будет выдавать этот Gateway устройствам как основной шлюз.',
|
||||
steps: [
|
||||
`Закрепите за Gateway постоянный адрес ${host} в настройках DHCP роутера.`,
|
||||
'Откройте настройки локальной сети или DHCP. Не меняйте шлюз WAN/интернет-подключения.',
|
||||
`В поле Default Gateway, Router или Основной шлюз укажите ${host}.`,
|
||||
'Сохраните настройки и переподключите устройства к сети, чтобы они получили новый маршрут.',
|
||||
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
|
||||
],
|
||||
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
|
||||
}, {
|
||||
id: 'prometheus',
|
||||
label: 'Мониторинг',
|
||||
title: 'Prometheus и Grafana',
|
||||
summary: 'Готовые traffic и domain metrics для Gateway и отдельных устройств.',
|
||||
paragraphs: [
|
||||
`Prometheus забирает накопленные Harbor counters с http://${controlHost}/metrics. Ручка читает готовый snapshot и не запускает новый сбор трафика.`,
|
||||
'Harbor обновляет traffic snapshot раз в 15 секунд, поэтому начальный scrape interval и refresh dashboard в 30 секунд не создают лишних одинаковых выборок.',
|
||||
'Единый фильтр «Устройства» управляет скоростью, накопленным трафиком, сервисами и доменами для всех или одного устройства. Отдельный график показывает текущую скорость каждого активного устройства; нулевые series скрыты.',
|
||||
],
|
||||
steps: [
|
||||
'Добавьте блок ниже в prometheus.yml и перезагрузите Prometheus.',
|
||||
'В Grafana добавьте этот Prometheus как data source.',
|
||||
'Скопируйте dashboard JSON, откройте Dashboards → New → Import и вставьте его.',
|
||||
],
|
||||
code: prometheusScrapeConfig(controlHost),
|
||||
multilineCode: true,
|
||||
copies: [
|
||||
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
|
||||
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
|
||||
],
|
||||
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками, устройства с policy Direct и трафик без распознанного домена в domain series не входят.',
|
||||
}] : []),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import dashboard from '../../../../monitoring/grafana/harbor-gateway.json' with { type: 'json' };
|
||||
|
||||
export const grafanaDashboardJson = JSON.stringify(dashboard, null, 2);
|
||||
|
||||
export function prometheusScrapeConfig(controlHost: string) {
|
||||
return `scrape_configs:
|
||||
- job_name: harbor_gateway
|
||||
scrape_interval: 30s
|
||||
scrape_timeout: 3s
|
||||
metrics_path: /metrics
|
||||
static_configs:
|
||||
- targets: ["${controlHost}"]`;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,430 @@
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import {
|
||||
autoServer,
|
||||
filterServers,
|
||||
groupServers,
|
||||
parseServerPingResults,
|
||||
SERVER_RESULT_WINDOW,
|
||||
} 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;
|
||||
|
||||
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) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function write(key: string, value: string | string[]) {
|
||||
try {
|
||||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
} catch {
|
||||
// Preferences remain available for this session.
|
||||
}
|
||||
}
|
||||
|
||||
function readAuto() {
|
||||
try {
|
||||
return localStorage.getItem(AUTO_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function serverHealthText(ping?: PingResult) {
|
||||
if (ping?.error) return 'Проверка недоступна';
|
||||
if (ping?.ok) return `${ping.latency} мс`;
|
||||
return ping ? 'Недоступен' : null;
|
||||
}
|
||||
|
||||
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 || undefined}
|
||||
>
|
||||
<span aria-hidden="true">{health}</span>
|
||||
<svg className="client-server-health-checking" 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>
|
||||
</small>;
|
||||
}
|
||||
|
||||
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"
|
||||
aria-label={checking ? 'Проверяем пинг серверов' : 'Проверить пинг серверов'}
|
||||
disabled={checking || disabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
<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>
|
||||
<span className="client-tooltip" role="tooltip">{checking ? 'Проверяем пинг…' : 'Проверить пинг'}</span>
|
||||
</button>;
|
||||
}
|
||||
|
||||
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' : ''}`}>
|
||||
<button
|
||||
className={`client-server${selected ? ' is-selected' : ''}`}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${server.label}, ${server.host}:${server.port}${health ? `, ${health}` : ''}`}
|
||||
style={{ '--server-index': Math.min(index, 7) } as CSSProperties}
|
||||
onClick={() => onSelect(server.id)}
|
||||
>
|
||||
<strong>{server.label}</strong>
|
||||
</button>
|
||||
{(ping || onFavorite) && <div className="client-server-meta">
|
||||
<ServerHealth ping={ping} />
|
||||
{onFavorite && <button
|
||||
className={`client-server-favorite${favorite ? ' is-active' : ''}`}
|
||||
type="button"
|
||||
aria-pressed={favorite}
|
||||
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
|
||||
onClick={() => onFavorite(server.id)}
|
||||
>★</button>}
|
||||
</div>}
|
||||
</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,
|
||||
prompt,
|
||||
leaving,
|
||||
revealVersion,
|
||||
onSelect,
|
||||
}: ServerPickerProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [view, setView] = useState<'all' | 'favorites' | 'recent'>('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
|
||||
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
|
||||
const [autoActive, setAutoActive] = useState(readAuto);
|
||||
const [collapsed, setCollapsed] = useState<string[]>([]);
|
||||
const [pings, setPings] = useState<PingState>({});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const serverKey = servers.map(({ id }) => id).join('|');
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [query, view, serverKey]);
|
||||
|
||||
const selected = servers.find(({ id }) => id === selectedServerId);
|
||||
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;
|
||||
}, [servers, query, view, favorites, recent]);
|
||||
const results = filtered.filter(({ id }) => id !== selectedServerId);
|
||||
const pageCount = Math.max(1, Math.ceil(results.length / SERVER_RESULT_WINDOW));
|
||||
const visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
|
||||
const grouped = servers.length >= 10;
|
||||
|
||||
useEffect(() => {
|
||||
setPage((current) => Math.min(current, pageCount - 1));
|
||||
}, [pageCount]);
|
||||
|
||||
function toggleFavorite(id: string) {
|
||||
setFavorites((current) => {
|
||||
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
|
||||
write(FAVORITES_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function select(id: string, automatic = false) {
|
||||
setAutoActive(automatic);
|
||||
write(AUTO_KEY, String(automatic));
|
||||
if (!automatic) {
|
||||
setRecent((current) => {
|
||||
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
|
||||
write(RECENT_KEY, next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
onSelect(id);
|
||||
}
|
||||
|
||||
async function checkVisible() {
|
||||
const ids = [...new Set([selectedServerId, ...visible.map(({ id }) => id)].filter(Boolean))].slice(0, 30);
|
||||
if (!ids.length) return;
|
||||
const startedAt = performance.now();
|
||||
setChecking(true);
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: true }])),
|
||||
}));
|
||||
try {
|
||||
const results = parseServerPingResults(await pingServers(ids)) as PingResult[];
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...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(),
|
||||
}])),
|
||||
}));
|
||||
} finally {
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.max(0, 900 - (performance.now() - startedAt))));
|
||||
setPings((current) => ({
|
||||
...current,
|
||||
...Object.fromEntries(ids.map((id) => [id, { ...current[id], checking: false }])),
|
||||
}));
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (servers.length === 1) {
|
||||
return <section className="client-servers" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
<div className="client-server-toolbar is-single">
|
||||
<span className="client-server-toolbar-title">Список серверов</span>
|
||||
<ServerCheckButton checking={checking} onClick={checkVisible} />
|
||||
</div>
|
||||
<div className="client-server-grid">
|
||||
<ServerRow
|
||||
server={servers[0]}
|
||||
selected={servers[0].id === selectedServerId}
|
||||
favorite={false}
|
||||
ping={pings[servers[0].id]}
|
||||
disabled={disabled}
|
||||
index={0}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
const renderRows = (items: PickerServer[], offset = 0) => items.map((server, index) => (
|
||||
<ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={!autoActive && server.id === selectedServerId}
|
||||
favorite={favorites.includes(server.id)}
|
||||
ping={pings[server.id]}
|
||||
disabled={disabled}
|
||||
index={offset + index}
|
||||
onSelect={select}
|
||||
onFavorite={toggleFavorite}
|
||||
/>
|
||||
));
|
||||
|
||||
const simpleServers = [
|
||||
...(selected ? [selected] : []),
|
||||
...servers.filter(({ id }) => id !== selectedServerId),
|
||||
].slice(0, SIMPLE_SERVER_LIMIT);
|
||||
|
||||
return <section className="client-servers is-scalable" aria-label="Выберите сервер">
|
||||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||||
<div className="client-server-toolbar">
|
||||
<span className="client-server-toolbar-title">Список серверов</span>
|
||||
<ServerCheckButton checking={checking} disabled={!servers.length} onClick={checkVisible} />
|
||||
<button
|
||||
className={`client-server-mode-toggle${advanced ? ' is-open' : ''}`}
|
||||
type="button"
|
||||
aria-expanded={advanced}
|
||||
aria-label={advanced ? 'Скрыть поиск и фильтры' : 'Показать поиск и фильтры'}
|
||||
onClick={() => setAdvanced((current) => !current)}
|
||||
>
|
||||
<span>Поиск и фильтры</span>
|
||||
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="client-server-mode-panels">
|
||||
<div
|
||||
className={`client-server-mode-panel is-simple${advanced ? '' : ' is-open'}`}
|
||||
aria-hidden={advanced}
|
||||
inert={advanced ? true : undefined}
|
||||
>
|
||||
<div className="client-server-mode-panel-inner">
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`simple:${serverKey}:${revealVersion}`}>
|
||||
<div className="client-server-grid">
|
||||
{simpleServers.map((server, index) => <ServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
selected={server.id === selectedServerId}
|
||||
ping={pings[server.id]}
|
||||
disabled={disabled}
|
||||
index={index}
|
||||
onSelect={select}
|
||||
/>)}
|
||||
</div>
|
||||
{servers.length > simpleServers.length && <p className="client-server-overflow-note">
|
||||
Ещё {servers.length - simpleServers.length} — доступны через поиск
|
||||
</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`client-server-mode-panel is-advanced${advanced ? ' is-open' : ''}`}
|
||||
aria-hidden={!advanced}
|
||||
inert={!advanced ? true : undefined}
|
||||
>
|
||||
<div className="client-server-mode-panel-inner">
|
||||
<div className="client-server-tools">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label="Найти сервер"
|
||||
placeholder="Поиск сервера"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div className="client-server-filters" aria-label="Фильтр серверов">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['favorites', '★'],
|
||||
['recent', 'Недавние'],
|
||||
] as const).map(([id, label]) => <button
|
||||
type="button"
|
||||
className={view === id ? 'is-active' : ''}
|
||||
aria-pressed={view === id}
|
||||
key={id}
|
||||
onClick={() => setView(id)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="client-server-pinned">
|
||||
<button
|
||||
className={`client-server-auto${autoActive ? ' is-selected' : ''}`}
|
||||
type="button"
|
||||
aria-pressed={autoActive}
|
||||
disabled={disabled || !servers.length}
|
||||
onClick={() => {
|
||||
const automatic = autoServer(servers);
|
||||
if (automatic) select(automatic.id, true);
|
||||
}}
|
||||
>
|
||||
<strong>Auto</strong>
|
||||
<ServerHealth
|
||||
ping={autoActive ? pings[selectedServerId] : undefined}
|
||||
fallback={autoActive ? undefined : 'Первый стабильный сервер'}
|
||||
/>
|
||||
</button>
|
||||
{selected && <ServerRow
|
||||
server={selected}
|
||||
selected
|
||||
favorite={favorites.includes(selected.id)}
|
||||
ping={pings[selected.id]}
|
||||
disabled={disabled}
|
||||
index={0}
|
||||
onSelect={select}
|
||||
onFavorite={toggleFavorite}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`advanced:${serverKey}:${revealVersion}`}>
|
||||
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
|
||||
{grouped ? groupServers(visible).map(([group, items]) => {
|
||||
const isCollapsed = collapsed.includes(group);
|
||||
return <section className="client-server-group" key={group}>
|
||||
<button
|
||||
className="client-server-group-toggle"
|
||||
type="button"
|
||||
aria-expanded={!isCollapsed}
|
||||
onClick={() => setCollapsed((current) => current.includes(group)
|
||||
? current.filter((item) => item !== group)
|
||||
: [...current, group])}
|
||||
>{group} <small>{items.length}</small></button>
|
||||
{!isCollapsed && <div className="client-server-grid">{renderRows(items)}</div>}
|
||||
</section>;
|
||||
}) : <div className="client-server-grid">{renderRows(visible)}</div>}
|
||||
{pageCount > 1 && <nav className="client-server-pages" aria-label="Страницы серверов">
|
||||
<button className="client-server-more" type="button" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Назад</button>
|
||||
<span>{page + 1} / {pageCount}</span>
|
||||
<button className="client-server-more" type="button" disabled={page + 1 === pageCount} onClick={() => setPage((current) => current + 1)}>Дальше</button>
|
||||
</nav>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user