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

This commit is contained in:
2026-08-09 00:41:52 +03:00
parent 32be4380a3
commit 34d8b681ad
190 changed files with 20064 additions and 9761 deletions
@@ -0,0 +1,283 @@
import { useState, type ReactNode } from 'react';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import {
connectionAction,
connectionDurationParts,
localProxyUrls,
} from '../../utils/clientControls.js';
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
type CopyKind = 'gateway' | 'socks5' | 'http';
interface CopyFeedback {
kind: CopyKind;
failed: boolean;
}
interface DurationUnit {
value: number;
label: string;
}
interface ConnectionPanelProps {
visible: boolean;
isGateway: boolean;
connected: boolean;
gatewayDirect: boolean;
selectedServerId: string;
configured: boolean;
startedAt?: string | null;
gatewayAddress: string;
gatewayUiOrigin?: string | null;
gatewayRouteAddress?: string | null;
proxyPort?: number;
now: number;
blocked: boolean;
copyFeedback?: CopyFeedback | null;
routingSlot?: ReactNode;
serverSlot?: ReactNode;
statusSlot?: ReactNode;
onCopyProxy: (kind: CopyKind) => unknown;
onApply: (serverId: string) => unknown;
onRestart: () => unknown;
onStop: () => unknown;
}
function DurationPart({ name, children }: { name: string; children: ReactNode }) {
return (
<span
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
>
{children}
</span>
);
}
function AnimatedSeconds({ value, padded = true }: { value: number; padded?: boolean }) {
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
<span className="client-duration-second-digit" key={`${index}-${digit}`}>{digit}</span>
));
}
export function ConnectionPanel({
visible,
isGateway,
connected,
gatewayDirect,
selectedServerId,
configured,
startedAt,
gatewayAddress,
gatewayUiOrigin,
gatewayRouteAddress,
proxyPort,
now,
blocked,
copyFeedback,
routingSlot,
serverSlot,
statusSlot,
onCopyProxy,
onApply,
onRestart,
onStop,
}: ConnectionPanelProps) {
const [durationMode, setDurationMode] = useState(() => {
try {
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
} catch {
return 'digital';
}
});
const [confirmingStop, setConfirmingStop] = useState(false);
const canStart = Boolean(selectedServerId || configured);
const powerUnavailable = isGateway && !connected && !canStart;
const proxyUrls = localProxyUrls(proxyPort, gatewayAddress);
const duration = connectionDurationParts(startedAt, now);
const clockUnits: Array<[string, DurationUnit]> = [
['hours', duration.hours],
['minutes', duration.minutes],
['seconds', duration.seconds],
];
const wordClockDuration = clockUnits
.filter(([name, part]) => duration.days.value || part.value || name === 'seconds');
const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено';
const proxyKinds: Array<[CopyKind, string]> = isGateway
? [
['gateway', 'GATEWAY'],
['socks5', 'SOCKS5'],
['http', 'HTTP'],
]
: [
['socks5', 'SOCKS5'],
['http', 'HTTP'],
];
function toggleConnection() {
const action = connectionAction({ connected, selectedServerId, configExists: configured });
if (action?.type === 'stop') {
setConfirmingStop(true);
return;
}
if (action?.type === 'apply') return onApply(action.serverId);
if (action?.type === 'restart') return onRestart();
}
async function stopConnection() {
if (!await onStop()) return;
setConfirmingStop(false);
}
function toggleDurationMode() {
setDurationMode((mode) => {
const nextMode = mode === 'digital' ? 'words' : 'digital';
try {
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
} catch {
// The visual preference still works for this session.
}
return nextMode;
});
}
const powerButton = <button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={isGateway
? connected ? 'Остановить VPN' : 'Запустить VPN'
: connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
disabled={blocked || (!connected && !canStart)}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
</svg>
</button>;
return <>
{visible && <section className="client-power-section" aria-labelledby="connection-title">
{isGateway ? <span
className="client-power-control client-tooltip-anchor"
tabIndex={powerUnavailable ? 0 : undefined}
aria-label={powerUnavailable ? 'VPN недоступен' : undefined}
aria-describedby={powerUnavailable ? 'gateway-power-unavailable' : undefined}
>
{powerButton}
{powerUnavailable && <span className="client-tooltip" id="gateway-power-unavailable" role="tooltip">
Сначала добавьте подписку и выберите сервер
</span>}
</span> : powerButton}
{routingSlot}
<div className="client-state-copy" aria-live="polite">
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
</h2>
{serverSlot}
<div className="client-state-detail">
{connected ? (
<button
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
type="button"
key="duration"
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
onClick={toggleDurationMode}
>
<span className="client-duration-stack">
<time
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'digital'}
>
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
</time>
<time
className={`client-duration client-duration-words${durationMode === 'words' ? ' is-active' : ''}`}
aria-hidden={durationMode !== 'words'}
>
{duration.days.value > 0 && (
<span className="client-duration-word-row is-calendar">
<span className="client-duration-unit" data-unit="days">
<DurationPart name="days-value">{duration.days.value}</DurationPart>{' '}
<DurationPart name="days-label">{duration.days.label}</DurationPart>
</span>
</span>
)}
<span className="client-duration-word-row is-clock">
{wordClockDuration.map(([name, part]) => (
<span className="client-duration-unit" data-unit={name} key={name}>
<DurationPart name={`${name}-value`}>{name === 'seconds'
? <AnimatedSeconds value={part.value} padded={false} />
: part.value}</DurationPart>{' '}
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
</span>
))}
</span>
</time>
</span>
<span className="client-tooltip" role="tooltip">
{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
</span>
</button>
) : (
<p key="hint">
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
</p>
)}
</div>
</div>
<section className={`client-proxies${isGateway ? ' is-gateway' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
<div className="client-access-point">
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
<span className={gatewayDirect ? 'is-active' : ''}>
Через <a href={gatewayUiOrigin || `http://${gatewayRouteAddress}:3456`}>Harbor Gateway</a> · {gatewayRouteAddress}
</span>
</span>
)}
<strong className="client-proxy-address">
{isGateway ? gatewayAddress : proxyUrls.http.replace(/^https?:\/\//, '')}
</strong>
<div className="client-proxy-actions">
{proxyKinds.map(([kind, label]) => (
<button
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${kind === 'gateway' ? gatewayAddress : proxyUrls[kind]}`}
onClick={() => onCopyProxy(kind)}
>
<span className="client-copy-label">{label}</span>
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'Ошибка' : 'Скопировано'}</span>}
</button>
))}
</div>
</div>
</section>
{statusSlot}
</section>}
<ConfirmationDialog
open={confirmingStop}
id="stop-connection"
kicker="Защита от случайного отключения"
title="Отключить VPN?"
description="Harbor остановит текущее VPN-подключение. Локальный прокси перестанет передавать трафик до повторного включения."
cancelLabel="Оставить включённым"
confirmLabel="Отключить VPN"
busy={blocked}
onCancel={() => setConfirmingStop(false)}
onConfirm={stopConnection}
/>
</>;
}
+1
View File
@@ -0,0 +1 @@
export { ConnectionPanel } from './ConnectionPanel.js';
+235
View File
@@ -0,0 +1,235 @@
import { useEffect, useRef, useState } from 'react';
import { formatByteString, formatLastSeen } from '../../utils/format.js';
import { TrafficChart } from './TrafficChart.js';
import {
parseDeviceSnapshot,
type Device,
type DevicePolicy,
type DeviceSnapshot,
} from './deviceSnapshot.js';
const DEVICE_AUTO_REFRESH_MS = 15_000;
interface DevicesFeatureOptions {
isGateway: boolean;
listDevices: () => Promise<unknown>;
refreshDevices: () => Promise<unknown>;
updateDevice: (id: string, patch: Record<string, unknown>, expectedRevision: number) => Promise<unknown>;
setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise<unknown>;
}
interface RequestError {
code?: string;
}
function record(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function requestError(value: unknown): RequestError {
if (!record(value)) return {};
return { code: typeof value.code === 'string' ? value.code : undefined };
}
export function useDevicesFeature({
isGateway,
listDevices,
refreshDevices,
updateDevice: requestDeviceUpdate,
setDevicePolicy,
}: DevicesFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [snapshot, setSnapshot] = useState<DeviceSnapshot | null>(null);
const [status, setStatus] = useState<'idle' | 'loading' | 'refreshing' | 'ready' | 'error'>('idle');
const [error, setError] = useState<unknown>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshCycle, setRefreshCycle] = useState(0);
const [savingId, setSavingId] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
function publish(value: unknown) {
const next = parseDeviceSnapshot(value);
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
return next;
}
async function load(quiet = false, discover = false) {
if (!isGateway) return;
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
setRefreshing(true);
try {
publish(await (discover ? refreshDevices() : listDevices()));
setError(null);
setStatus('ready');
} catch (requestError) {
setError(requestError);
setStatus('error');
} finally {
setRefreshing(false);
setRefreshCycle((cycle) => cycle + 1);
}
}
async function updateDevice(device: Device, patch: Record<string, unknown>) {
if (!snapshot) return false;
setSavingId(device.id);
try {
let next: DeviceSnapshot;
try {
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, snapshot.revision));
} catch (caught) {
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
const latest = parseDeviceSnapshot(await listDevices());
publish(latest);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) {
throw caught;
}
next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, latest.revision));
}
publish(next);
setError(null);
return true;
} catch (caught) {
setError(caught);
return false;
} finally {
setSavingId('');
}
}
async function updatePolicy(device: Device, mode: DevicePolicy) {
if (!snapshot) return;
setSavingId(device.id);
try {
let next: DeviceSnapshot;
try {
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, snapshot.revision));
} catch (caught) {
if (requestError(caught).code !== 'STATE_CONFLICT') throw caught;
const latest = parseDeviceSnapshot(await listDevices());
publish(latest);
const latestDevice = latest.devices.find((candidate) => candidate.id === device.id);
if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw caught;
next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, latest.revision));
}
publish(next);
setError(null);
} catch (caught) {
if (requestError(caught).code === 'DEVICE_POLICY_APPLY_FAILED') {
try {
publish(parseDeviceSnapshot(await listDevices()));
} catch {
// Keep the policy error as the actionable result.
}
}
setError(caught);
} finally {
setSavingId('');
}
}
useEffect(() => {
if (!isGateway) return undefined;
load();
return undefined;
}, [isGateway]);
useEffect(() => {
if (!isGateway || refreshing || status === 'loading') return undefined;
const timer = setTimeout(() => load(true), DEVICE_AUTO_REFRESH_MS);
return () => clearTimeout(timer);
}, [isGateway, refreshCycle, refreshing, status]);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeDevices = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeDevices);
document.addEventListener('keydown', closeDevices);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeDevices);
document.removeEventListener('keydown', closeDevices);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
return {
isOpen,
snapshot,
status,
error,
refreshing,
refreshCycle,
savingId,
panelRef,
toggleRef,
closeRef,
load,
updateDevice,
updatePolicy,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
};
}
export type DevicesFeature = ReturnType<typeof useDevicesFeature>;
export function DevicesToggle({ feature, onToggle }: { feature: DevicesFeature; onToggle: () => void }) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle client-devices-toggle${feature.isOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.isOpen}
aria-controls="client-devices"
aria-label={feature.isOpen ? 'Закрыть устройства' : 'Устройства Gateway'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect className="client-rail-device-primary" x="3.5" y="5" width="7" height="10" rx="1.5" />
<rect className="client-rail-device-secondary" x="13.5" y="8" width="7" height="7" rx="1.5" />
<path className="client-rail-device-link" d="M6 19h12M7 15v4M17 15v4" />
</svg>
<span>Устройства</span>
</button>;
}
export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) {
const globalTraffic = feature.snapshot?.traffic;
const trafficSourceError = feature.snapshot?.source?.traffic?.error
|| feature.snapshot?.source?.traffic?.proxy?.error
|| (feature.status === 'error' ? feature.error : null);
const trafficFreshness = globalTraffic?.observedAt
? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative
: 'Нет данных';
return <section className="client-gateway-summary" aria-label="Общий трафик Harbor">
<div className="client-gateway-traffic-heading">
<span>Учтено Harbor</span>
<strong>{formatByteString(globalTraffic?.totalBytes || '0')}</strong>
</div>
<div className="client-gateway-traffic-chart">
<TrafficChart
samples={globalTraffic?.history || []}
capacity={feature.snapshot?.trafficHistoryCapacity || 120}
routeLabel="Gateway"
/>
</div>
<div className={`client-gateway-traffic-freshness${trafficSourceError ? ' is-stale' : ''}`} role="status" aria-live="polite">
{trafficSourceError
? `Трафик не обновляется · последние данные ${trafficFreshness}`
: globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
</div>
</section>;
}
+477
View File
@@ -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>
);
}
+176
View File
@@ -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>;
}
+172
View File
@@ -0,0 +1,172 @@
export type ByteValue = string;
export type TrafficScale = 'linear' | 'log';
export type DevicePolicy = 'vpn' | 'direct';
type DeviceStatus = 'online' | 'recent' | 'offline';
type DevicePolicyStatus = 'applied' | 'applying' | 'pending' | 'failed';
type DeviceConfidence = 'high' | 'medium' | 'ambiguous';
export interface TrafficSample extends Record<string, unknown> {
observedAt: string;
gatewayBytes: ByteValue;
proxyBytes: ByteValue;
}
export interface Device extends Record<string, unknown> {
id: string;
alias: string | null;
hostname: string | null;
ip: string | null;
lastSeenAt: string | null;
status: DeviceStatus;
pinned: boolean;
downloadBytes: ByteValue;
uploadBytes: ByteValue;
proxyDownloadBytes: ByteValue;
proxyUploadBytes: ByteValue;
policyStatus: DevicePolicyStatus;
policyError: string | null;
desiredPolicy: DevicePolicy;
appliedPolicy: DevicePolicy;
confidence: DeviceConfidence;
trafficHistory: TrafficSample[];
}
interface SnapshotSource extends Record<string, unknown> {
kind: 'neighbor';
error: unknown;
lastObservedAt: string | null;
traffic: {
error: unknown;
lastObservedAt: string | null;
proxy: {
error: unknown;
lastObservedAt: string | null;
[key: string]: unknown;
};
[key: string]: unknown;
};
policy: {
error: unknown;
lastAppliedAt: string | null;
[key: string]: unknown;
};
}
export interface DeviceSnapshot extends Record<string, unknown> {
revision: number;
devices: Device[];
trafficHistoryCapacity: number;
traffic: {
gatewayBytes: ByteValue;
proxyBytes: ByteValue;
totalBytes: ByteValue;
gatewayObservedAt: string | null;
proxyObservedAt: string | null;
observedAt: string | null;
history: TrafficSample[];
[key: string]: unknown;
};
source: SnapshotSource;
}
function record(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function nullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function timestamp(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && Number.isFinite(Date.parse(value));
}
function nullableTimestamp(value: unknown): value is string | null {
return value === null || timestamp(value);
}
function bytes(value: unknown): value is ByteValue {
return typeof value === 'string' && /^\d+$/.test(value);
}
function validTrafficSample(value: unknown): value is TrafficSample {
return record(value)
&& timestamp(value.observedAt)
&& bytes(value.gatewayBytes)
&& bytes(value.proxyBytes);
}
function validHistory(value: unknown): value is TrafficSample[] {
return Array.isArray(value) && value.every(validTrafficSample);
}
function validDevice(value: unknown): value is Device {
return record(value)
&& typeof value.id === 'string'
&& /^dev_[a-f0-9]{16}$/.test(value.id)
&& nullableString(value.alias)
&& nullableString(value.hostname)
&& nullableString(value.ip)
&& nullableTimestamp(value.lastSeenAt)
&& (value.status === 'online' || value.status === 'recent' || value.status === 'offline')
&& typeof value.pinned === 'boolean'
&& bytes(value.downloadBytes)
&& bytes(value.uploadBytes)
&& bytes(value.proxyDownloadBytes)
&& bytes(value.proxyUploadBytes)
&& (value.policyStatus === 'applied' || value.policyStatus === 'applying'
|| value.policyStatus === 'pending' || value.policyStatus === 'failed')
&& nullableString(value.policyError)
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
&& validHistory(value.trafficHistory);
}
function validSource(value: unknown): value is SnapshotSource {
return record(value)
&& value.kind === 'neighbor'
&& Object.hasOwn(value, 'error')
&& nullableTimestamp(value.lastObservedAt)
&& record(value.traffic)
&& Object.hasOwn(value.traffic, 'error')
&& nullableTimestamp(value.traffic.lastObservedAt)
&& record(value.traffic.proxy)
&& Object.hasOwn(value.traffic.proxy, 'error')
&& nullableTimestamp(value.traffic.proxy.lastObservedAt)
&& record(value.policy)
&& Object.hasOwn(value.policy, 'error')
&& nullableTimestamp(value.policy.lastAppliedAt);
}
function validTraffic(value: unknown): value is DeviceSnapshot['traffic'] {
return record(value)
&& bytes(value.gatewayBytes)
&& bytes(value.proxyBytes)
&& bytes(value.totalBytes)
&& nullableTimestamp(value.gatewayObservedAt)
&& nullableTimestamp(value.proxyObservedAt)
&& nullableTimestamp(value.observedAt)
&& validHistory(value.history);
}
function assertDeviceSnapshot(value: unknown): asserts value is DeviceSnapshot {
if (!record(value)
|| !Number.isSafeInteger(value.revision)
|| typeof value.revision !== 'number'
|| value.revision < 0
|| !Array.isArray(value.devices)
|| !value.devices.every(validDevice)
|| !Number.isSafeInteger(value.trafficHistoryCapacity)
|| typeof value.trafficHistoryCapacity !== 'number'
|| value.trafficHistoryCapacity <= 0
|| !validTraffic(value.traffic)
|| !validSource(value.source)) {
throw new TypeError('Harbor device inventory returned an invalid snapshot');
}
}
export function parseDeviceSnapshot(value: unknown): DeviceSnapshot {
assertDeviceSnapshot(value);
return value;
}
+6
View File
@@ -0,0 +1,6 @@
export { DevicesPanel } from './DevicesPanel.js';
export {
DevicesToggle,
GatewayTrafficSummary,
useDevicesFeature,
} from './DevicesFeature.js';
@@ -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;
}
+6
View File
@@ -0,0 +1,6 @@
export { ConnectivityDiagnosticsPanel } from './ConnectivityDiagnosticsPanel.js';
export {
DiagnosticsToggle,
useDiagnosticsFeature,
type DiagnosticsFeature,
} from './DiagnosticsFeature.js';
@@ -0,0 +1,275 @@
import { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { copyText } from '../../utils/clientControls.js';
import { instructionBlocks } from './instructionBlocks.js';
interface InstructionLinkStep {
before?: string;
link: [string, string];
after?: string;
}
interface InstructionCopyAction {
id: string;
label: string;
text: string;
}
interface InstructionBlockData {
id: string;
label: string;
title: string;
summary: string;
paragraphs?: string[];
steps?: Array<string | InstructionLinkStep>;
code?: string;
multilineCode?: boolean;
copies?: InstructionCopyAction[];
note?: string;
}
interface InstructionsFeatureOptions {
isGateway: boolean;
host: string;
port: number;
controlHost: string;
}
function InstructionStep({ step }: { step: string | InstructionLinkStep }) {
if (typeof step === 'string') return step;
return (
<>
{step.before}
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
{step.after}
</>
);
}
function InstructionBlock({
block,
open,
onToggle,
}: {
block: InstructionBlockData;
open: boolean;
onToggle: () => void;
}) {
const [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
if (copyTimer.current) clearTimeout(copyTimer.current);
}, []);
async function copyInstruction(action: InstructionCopyAction) {
if (copyTimer.current) clearTimeout(copyTimer.current);
try {
await copyText(action.text);
setCopyFeedback({ id: action.id, failed: false });
} catch {
setCopyFeedback({ id: action.id, failed: true });
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
}
return (
<section
className={`client-instruction-block${open ? ' is-open' : ''}`}
style={{ viewTransitionName: `instruction-${block.id}` }}
>
<button
className="client-instruction-summary"
type="button"
aria-expanded={open}
onClick={onToggle}
>
<span>{block.label}</span>
<strong>{block.title}</strong>
<small>{block.summary}</small>
<i aria-hidden="true" />
</button>
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
<div className="client-instruction-body">
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
{block.steps && (
<ol>
{block.steps.map((step) => (
<li key={typeof step === 'string' ? step : step.link[1]}>
<InstructionStep step={step} />
</li>
))}
</ol>
)}
{block.code && (block.multilineCode
? <pre className="client-instruction-code"><code>{block.code}</code></pre>
: <code>{block.code}</code>)}
{block.copies && <div className="client-instruction-copies">
{block.copies.map((action) => {
const feedback = copyFeedback?.id === action.id ? copyFeedback : null;
return <div className="client-instruction-copy" key={action.id}>
<span>{action.label}</span>
<button
className={`client-copy-button client-instruction-copy-button${feedback ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
onClick={() => copyInstruction(action)}
>
<span className="client-copy-label">Скопировать</span>
{feedback && <span className="client-copy-feedback" aria-hidden="true">
{feedback.failed ? 'Ошибка' : 'Скопировано'}
</span>}
</button>
</div>;
})}
<span className="client-live-region" role="status" aria-live="polite">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</span>
</div>}
{block.note && <p className="client-instruction-note">{block.note}</p>}
</div>
</div>
</section>
);
}
export function useInstructionsFeature({
isGateway,
host,
port,
controlHost,
}: InstructionsFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [openInstructionId, setOpenInstructionId] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const [intro, ...guides] = instructionBlocks({ isGateway, host, port, controlHost }) as InstructionBlockData[];
const openInstruction = guides.find((block) => block.id === openInstructionId);
const orderedGuides = openInstruction
? [openInstruction, ...guides.filter((block) => block.id !== openInstructionId)]
: guides;
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false);
};
document.addEventListener('keydown', closeOnEscape);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('keydown', closeOnEscape);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeOutside = (event: PointerEvent) => {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
setIsOpen(false);
};
document.addEventListener('pointerdown', closeOutside);
return () => document.removeEventListener('pointerdown', closeOutside);
}, [isOpen]);
function toggleInstruction(id: string) {
const update = () => flushSync(() => {
setOpenInstructionId((current) => current === id ? '' : id);
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
return {
isOpen,
openInstructionId,
intro,
guides: orderedGuides,
panelRef,
toggleRef,
closeRef,
close: () => setIsOpen(false),
toggle: () => setIsOpen((open) => !open),
toggleInstruction,
};
}
export type InstructionsFeature = ReturnType<typeof useInstructionsFeature>;
export function InstructionsToggle({
feature,
onToggle,
}: {
feature: InstructionsFeature;
onToggle: () => void;
}) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle${feature.isOpen ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.isOpen}
aria-controls="client-instructions"
aria-label={feature.isOpen ? 'Закрыть инструкции' : 'Как использовать'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle className="client-rail-info-ring" cx="12" cy="12" r="8.5" pathLength="1" />
<path d="M12 11v5M12 8h.01" />
</svg>
<span>Как использовать</span>
</button>;
}
export function InstructionsPanel({
feature,
isGateway,
}: {
feature: InstructionsFeature;
isGateway: boolean;
}) {
return <aside
ref={feature.panelRef}
id="client-instructions"
className={`client-drawer client-instructions${feature.isOpen ? ' is-open' : ''}`}
aria-labelledby="instructions-title"
aria-hidden={!feature.isOpen}
inert={!feature.isOpen ? true : undefined}
>
<div className="client-drawer-sheet client-instructions-sheet">
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть инструкции"
onClick={feature.close}
>×</button>
<header className="client-instructions-header">
<span>Подключение</span>
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
<div className="client-instructions-intro">
{feature.intro.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
</div>
</header>
<div className="client-instruction-list">
{feature.guides.map((block) => (
<InstructionBlock
block={block}
key={block.id}
open={block.id === feature.openInstructionId}
onToggle={() => feature.toggleInstruction(block.id)}
/>
))}
</div>
</div>
</aside>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
InstructionsPanel,
InstructionsToggle,
useInstructionsFeature,
type InstructionsFeature,
} from './InstructionsFeature.js';
@@ -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}"]`;
}
+584
View File
@@ -0,0 +1,584 @@
import {
useEffect,
useRef,
useState,
type FormEvent,
type ReactNode,
type RefObject,
} from 'react';
import { flushSync } from 'react-dom';
import { canAppendRouteRule } from '../../../shared/routingRules.js';
import type { RouteRule } from '../../../shared/contracts/state.js';
import { operationBlocked } from '../../state/operations.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'],
['domain_suffix', 'Суффикс'],
['domain_keyword', 'Содержит'],
];
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
domain: 'example.com или полный URL',
domain_suffix: 'example.org',
domain_keyword: 'cdn',
};
interface DraftRule extends RouteRule {
_key: string;
removing?: boolean;
}
interface RoutingState {
localRules?: RouteRule[];
activeLocalRules?: RouteRule[];
localRulesRevision?: number;
localRulesPendingRestart?: boolean;
}
interface RoutingFeatureOptions {
route?: RoutingState | null;
connected: boolean;
operations: Record<string, { status?: string } | undefined>;
onSave: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onDismissError: () => void;
}
interface RoutingSaveState {
localRulesRevision: number;
localRulesPendingRestart: boolean;
}
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule,
enabled: rule?.enabled !== false,
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled })),
);
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
);
function localRuleStatus(
rule: DraftRule,
savedRules: RouteRule[],
activeRules: RouteRule[],
runtimeActive: boolean,
) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
function routingSaveState(result: unknown): RoutingSaveState | null {
if (!result) return null;
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const state = (result as Record<string, unknown>).state;
if (!state || typeof state !== 'object' || Array.isArray(state)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const route = (state as Record<string, unknown>).route;
if (!route || typeof route !== 'object' || Array.isArray(route)) {
throw new TypeError('Harbor route mutation returned invalid state');
}
const { localRulesRevision, localRulesPendingRestart } = route as Record<string, unknown>;
if (
!Number.isSafeInteger(localRulesRevision)
|| (localRulesRevision as number) < 0
|| typeof localRulesPendingRestart !== 'boolean'
) {
throw new TypeError('Harbor route mutation returned invalid state');
}
return { localRulesRevision: localRulesRevision as number, localRulesPendingRestart };
}
export function useRoutingFeature({
route,
connected,
operations,
onSave,
onDismissError,
}: RoutingFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false);
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]');
const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || [];
const dirty = localRulesSignature(rules) !== baselineRef.current;
const pendingRestart = connected && route?.localRulesPendingRestart === true;
const pendingCount = pendingRestart
? savedRules.filter((rule) => (
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
: 0;
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
return () => {
cancelAnimationFrame(frame);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return undefined;
const closeRouting = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
} else {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
}
requestClose();
};
document.addEventListener('pointerdown', closeRouting);
document.addEventListener('keydown', closeRouting);
return () => {
document.removeEventListener('pointerdown', closeRouting);
document.removeEventListener('keydown', closeRouting);
};
}, [isOpen, dirty]);
useEffect(() => {
if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = '';
};
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isOpen, dirty]);
function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
setRules(savedRules.map(createLocalRuleDraft));
setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false);
onDismissError();
setIsOpen(true);
}
function forceClose() {
setIsOpen(false);
}
function requestClose() {
if (dirty) {
setConfirmingClose(true);
return false;
}
setIsOpen(false);
return true;
}
function discard() {
setConfirmingClose(false);
setIsOpen(false);
}
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
setRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
)));
}
function add() {
setRules((current) => [
...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
]);
}
function remove(ruleKey: string) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
return;
}
setRules((current) => current.map((rule) => (
rule._key === ruleKey ? { ...rule, removing: true } : rule
)));
}
function finishRemove(ruleKey: string) {
const update = () => flushSync(() => {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
return;
}
document.startViewTransition(update);
}
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const values = localRuleValues(rules);
const result = routingSaveState(await onSave(values, revision));
if (!result) return;
baselineRef.current = JSON.stringify(values);
setRevision(result.localRulesRevision);
setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
}
return {
isOpen,
rules,
savedRules,
activeRules,
connected,
dirty,
pendingRestart,
pendingCount,
blocked,
confirmingClose,
panelRef,
toggleRef,
closeRef,
open,
forceClose,
requestClose,
setConfirmingClose,
discard,
change,
add,
remove,
finishRemove,
save,
};
}
type RoutingFeature = ReturnType<typeof useRoutingFeature>;
interface RuleTypePickerProps {
value: RouteRule['type'];
ruleKey: string;
index: number;
disabled?: boolean;
onChange: (value: RouteRule['type']) => void;
}
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypePickerProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const listId = `${ruleKey}-types`;
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
useEffect(() => {
if (!open) return undefined;
optionRefs.current[selectedIndex]?.focus();
const close = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (rootRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', close);
};
}, [open, selectedIndex]);
function choose(type: RouteRule['type']) {
onChange(type);
setOpen(false);
triggerRef.current?.focus();
}
function moveOption(event: React.KeyboardEvent<HTMLButtonElement>, offset: number) {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
event.preventDefault();
if (event.key === 'Escape') {
setOpen(false);
triggerRef.current?.focus();
return;
}
const current = optionRefs.current.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === 'Home'
? 0
: event.key === 'End'
? ROUTE_RULE_OPTIONS.length - 1
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
optionRefs.current[next]?.focus();
}
return (
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
<button
ref={triggerRef}
className="client-rule-type-trigger"
type="button"
aria-label={`Тип правила ${index + 1}`}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault();
setOpen(false);
return;
}
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
event.preventDefault();
setOpen(true);
}}
>
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
</button>
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
<button
ref={(node) => { optionRefs.current[optionIndex] = node; }}
type="button"
role="option"
aria-selected={type === value}
tabIndex={open ? 0 : -1}
key={type}
onClick={() => choose(type)}
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
>
{label}
</button>
))}
</div>
</div>
);
}
export function RoutingToggle({
feature,
gatewayDirect,
isGateway,
hasSubscription,
onOpen,
}: {
feature: RoutingFeature;
gatewayDirect: boolean;
isGateway: boolean;
hasSubscription: boolean;
onOpen: () => void;
}) {
const disabled = gatewayDirect || (isGateway && !hasSubscription);
return (
<button
ref={feature.toggleRef}
className={`client-local-rules-toggle${feature.isOpen ? ' is-open' : ''}${feature.pendingRestart ? ' has-pending' : ''}`}
type="button"
disabled={disabled}
aria-expanded={feature.isOpen}
aria-controls="client-local-rules"
aria-label={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: feature.isOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
onClick={() => feature.isOpen ? feature.requestClose() : onOpen()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
</svg>
<span>{disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
</button>
);
}
export function RoutingPendingStatus({
feature,
blocked,
onRestart,
}: {
feature: RoutingFeature;
blocked: boolean;
onRestart: () => unknown;
}) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && (
<>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
</div>;
}
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const draftRules = feature.rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
<aside
ref={feature.panelRef as RefObject<HTMLElement>}
id="client-local-rules"
className={`client-drawer client-local-rules${feature.isOpen ? ' is-open' : ''}`}
aria-labelledby="local-rules-title"
aria-hidden={!feature.isOpen}
inert={!feature.isOpen ? true : undefined}
>
<div className="client-drawer-sheet client-local-rules-sheet">
<button
ref={feature.closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть локальные правила"
onClick={feature.requestClose}
>×</button>
<header className="client-local-rules-header">
<span>Маршрутизация</span>
<button
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
<span id="local-rules-list-title">Правила</span>
<div className="client-local-rules-list">
{feature.rules.map((rule, index) => {
const [status, statusLabel] = localRuleStatus(
rule,
feature.savedRules,
feature.activeRules,
feature.connected,
);
return (
<div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
key={rule._key}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle cx="10" cy="10" r="6" />
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
</svg>
</button>
<RuleTypePicker
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
onChange={(type) => feature.change(index, 'type', type)}
/>
<input
type="text"
inputMode="url"
autoComplete="off"
spellCheck={false}
required
aria-label={`Значение правила ${index + 1}`}
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
value={rule.value}
onChange={(event) => feature.change(index, 'value', event.target.value)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
<button
className="client-local-rule-delete"
type="button"
aria-label={`Удалить правило ${index + 1}`}
onClick={() => feature.remove(rule._key)}
>
×
</button>
<span
className="client-delete-strike"
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
</div>
);
})}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
</div>
<div className="client-local-rule-add-slot">
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={feature.add}>
+ Добавить правило
</button>
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
</div>
</section>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
{statusSlot}
<div className="client-local-rules-actions">
<button type="button" onClick={feature.requestClose}>Отмена</button>
</div>
</form>
</div>
</aside>
);
}
export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) {
return <ConfirmationDialog
open={feature.confirmingClose}
id="discard-local-rules"
title="Есть несохранённые настройки"
description="Закрыть редактор и потерять изменения?"
cancelLabel="Остаться"
confirmLabel="Закрыть без сохранения"
onCancel={() => feature.setConfirmingClose(false)}
onConfirm={feature.discard}
/>;
}
+7
View File
@@ -0,0 +1,7 @@
export {
RoutingDiscardDialog,
RoutingPanel,
RoutingPendingStatus,
RoutingToggle,
useRoutingFeature,
} from './RoutingFeature.js';
+430
View File
@@ -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>;
}
+1
View File
@@ -0,0 +1 @@
export { ServerPicker } from './ServerPicker.js';
@@ -0,0 +1,73 @@
import type { HarborServer } from '../../../shared/contracts/state.js';
export const SERVER_RESULT_WINDOW = 60;
type PickerServer = HarborServer & {
country?: string;
city?: string;
provider?: string;
};
export interface ParsedPingResult extends Record<string, unknown> {
id: string;
ok?: boolean;
latency?: number | null;
checkedAt?: string;
}
const searchable = (server: PickerServer) => [
server.label,
server.host,
server.country,
server.city,
server.provider,
server.protocol,
].filter(Boolean).join(' ').toLocaleLowerCase('ru');
export function filterServers(servers: PickerServer[], query: unknown) {
const needle = String(query || '').trim().toLocaleLowerCase('ru');
return needle ? servers.filter((server) => searchable(server).includes(needle)) : servers;
}
export function serverGroup(server: PickerServer) {
return server.country || server.provider || 'Другие';
}
export function groupServers(servers: PickerServer[]) {
return [...servers.reduce((groups, server) => {
const name = serverGroup(server);
groups.set(name, [...(groups.get(name) || []), server]);
return groups;
}, new Map<string, PickerServer[]>())];
}
export function autoServer(servers: PickerServer[]) {
return [...servers].sort((left, right) => left.id.localeCompare(right.id))[0] || null;
}
export function parseServerPingResults(value: unknown): ParsedPingResult[] {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new TypeError('Expected server ping response object');
}
const response = value as Record<string, unknown>;
if (!Object.hasOwn(response, 'results') || response.results === undefined) return [];
if (!Array.isArray(response.results)) throw new TypeError('Expected server ping results array');
for (const result of response.results) {
if (!result || typeof result !== 'object' || Array.isArray(result) || typeof result.id !== 'string' || !result.id) {
throw new TypeError('Expected server ping result ID');
}
if (Object.hasOwn(result, 'ok') && typeof result.ok !== 'boolean') {
throw new TypeError('Expected server ping result status');
}
if (Object.hasOwn(result, 'latency') && result.latency !== null && (
typeof result.latency !== 'number' || !Number.isFinite(result.latency) || result.latency < 0
)) {
throw new TypeError('Expected server ping result latency');
}
if (Object.hasOwn(result, 'checkedAt') && typeof result.checkedAt !== 'string') {
throw new TypeError('Expected server ping result timestamp');
}
}
return response.results as ParsedPingResult[];
}
@@ -0,0 +1,557 @@
import {
useEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from 'react';
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
import { operationBlocked } from '../../state/operations.js';
import {
isSubscriptionUrlValid,
subscriptionDaysLeft,
subscriptionDomain,
subscriptionUsage,
} from '../../utils/clientControls.js';
import { formatBytes } from '../../utils/format.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { normalizeRequestError, type RequestError } from './requestError.js';
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
interface SubscriptionState {
status?: string;
host?: string | null;
userInfo?: Record<string, unknown> | null;
}
interface SubscriptionFeatureOptions {
subscription?: SubscriptionState | null;
subscriptionUrl: string;
setSubscriptionUrl: (value: string) => void;
operations: Record<string, { status?: string } | undefined>;
error?: RequestError | null;
serverCount: number;
isGateway: boolean;
gatewayDirect: boolean;
validateSubscription: (url: string, options: { signal: AbortSignal }) => Promise<unknown>;
onImport: () => Promise<unknown>;
onRefresh: () => Promise<unknown>;
onForget: () => Promise<unknown>;
onDismissError: () => void;
}
interface SubscriptionValidation {
url: string;
status: 'idle' | 'checking' | 'valid' | 'invalid';
error: RequestError | null;
}
function CloudTooltip({ children }: { children: ReactNode }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
export function useSubscriptionFeature({
subscription,
subscriptionUrl,
setSubscriptionUrl,
operations,
error,
serverCount,
isGateway,
gatewayDirect,
validateSubscription,
onImport,
onRefresh,
onForget,
onDismissError,
}: SubscriptionFeatureOptions) {
const hasSubscription = subscription?.status === 'ready';
const [editing, setEditing] = useState(!hasSubscription);
const [contentReady, setContentReady] = useState(hasSubscription);
const [validation, setValidation] = useState<SubscriptionValidation>({
url: '',
status: 'idle',
error: null,
});
const [validationAttempt, setValidationAttempt] = useState(0);
const [refreshing, setRefreshing] = useState(false);
const [usageUpdated, setUsageUpdated] = useState(false);
const [serverRevealVersion, setServerRevealVersion] = useState(0);
const [serversLeaving, setServersLeaving] = useState(false);
const [open, setOpen] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const subscriptionRef = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const confirmingDeleteRef = useRef(confirmingDelete);
const previousHasSubscriptionRef = useRef(hasSubscription);
confirmingDeleteRef.current = confirmingDelete;
const usage = subscriptionUsage(subscription?.userInfo || undefined);
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean(
subscription?.userInfo
&& ['upload', 'download', 'total', 'expire'].some((key) => key in subscription.userInfo!),
);
const normalizedUrl = subscriptionUrl.trim();
const currentValidation = validation.url === normalizedUrl ? validation : null;
const localError = normalizedUrl && !isSubscriptionUrlValid(normalizedUrl)
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
: null;
const subscriptionError = currentValidation?.error
|| localError
|| (error?.context === 'subscription' ? error : null);
const validationStatus = !normalizedUrl
? 'idle'
: subscriptionError || !isSubscriptionUrlValid(normalizedUrl)
? 'invalid'
: currentValidation?.status || 'checking';
const waiting = hasSubscription && !contentReady;
const importBlocked = operationBlocked(operations, 'subscriptionImport');
const refreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
const deleteBlocked = operationBlocked(operations, 'subscriptionDelete');
useEffect(() => {
if (editing) inputRef.current?.focus();
}, [editing]);
useEffect(() => {
if (!normalizedUrl || !isSubscriptionUrlValid(normalizedUrl)) return undefined;
const controller = new AbortController();
setValidation({ url: normalizedUrl, status: 'checking', error: null });
const timer = setTimeout(async () => {
try {
await validateSubscription(normalizedUrl, { signal: controller.signal });
setValidation({ url: normalizedUrl, status: 'valid', error: null });
} catch (caught) {
const requestError = normalizeRequestError(caught);
if (requestError.name === 'AbortError') return;
setValidation({
url: normalizedUrl,
status: 'invalid',
error: {
context: 'subscription',
message: requestError.message,
correlationId: requestError.correlationId,
retry: requestError.retryable
? () => setValidationAttempt((attempt) => attempt + 1)
: null,
},
});
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [normalizedUrl, validationAttempt, validateSubscription]);
useEffect(() => {
const previouslyHadSubscription = previousHasSubscriptionRef.current;
previousHasSubscriptionRef.current = hasSubscription;
if (!hasSubscription) {
setContentReady(false);
return undefined;
}
if (previouslyHadSubscription) {
setContentReady(true);
return undefined;
}
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setContentReady(true);
return undefined;
}
const timer = setTimeout(() => setContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
return () => clearTimeout(timer);
}, [hasSubscription]);
useEffect(() => {
if (!hasSubscription) setEditing(true);
}, [hasSubscription]);
useEffect(() => {
if (!editing || !hasSubscription || subscriptionUrl) return undefined;
const timer = setTimeout(() => setEditing(false), 5000);
return () => clearTimeout(timer);
}, [editing, hasSubscription, subscriptionUrl]);
useEffect(() => {
if (!editing || !hasSubscription) return undefined;
const closeOnOutsideClick = (event: PointerEvent) => {
if (subscriptionRef.current?.contains(event.target as Node | null)) return;
setSubscriptionUrl('');
setEditing(false);
};
document.addEventListener('pointerdown', closeOnOutsideClick);
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
}, [editing, hasSubscription, setSubscriptionUrl]);
useEffect(() => {
if (!hasSubscription) return undefined;
onRefresh();
return undefined;
}, [hasSubscription]);
useEffect(() => {
const from = displayedUsed;
const to = usage.used;
if (from === to) return undefined;
const startedAt = performance.now();
let frame: number;
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / 900);
const eased = 1 - Math.pow(1 - progress, 4);
setDisplayedUsed(from + (to - from) * eased);
if (progress < 1) frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [usage.used]);
useEffect(() => {
if (!open) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeSubscription = (event: PointerEvent | KeyboardEvent) => {
if (confirmingDeleteRef.current) return;
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
const target = event.target as Node | null;
if (event.type !== 'keydown' && (
panelRef.current?.contains(target) || toggleRef.current?.contains(target)
)) return;
setOpen(false);
};
document.addEventListener('pointerdown', closeSubscription);
document.addEventListener('keydown', closeSubscription);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeSubscription);
document.removeEventListener('keydown', closeSubscription);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [open]);
async function submit() {
if (validationStatus !== 'valid') return;
if (!await onImport()) return;
setSubscriptionUrl('');
setEditing(false);
}
async function refresh() {
const startedAt = performance.now();
setRefreshing(true);
try {
if (!await onRefresh()) return;
setUsageUpdated(false);
requestAnimationFrame(() => setUsageUpdated(true));
setTimeout(() => setUsageUpdated(false), 900);
setServersLeaving(true);
await new Promise((resolve) => setTimeout(
resolve,
420 + Math.min(7, Math.max(0, serverCount - 1)) * 90,
));
setServerRevealVersion((version) => version + 1);
setServersLeaving(false);
} finally {
const elapsed = performance.now() - startedAt;
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
setRefreshing(false);
}
}
async function forget() {
if (!await onForget()) return;
setConfirmingDelete(false);
}
function changeUrl(value: string) {
if (error?.context === 'subscription') onDismissError();
setValidation({ url: '', status: 'idle', error: null });
setSubscriptionUrl(value);
}
function cancelEditing() {
if (!hasSubscription) return;
setSubscriptionUrl('');
setEditing(false);
}
return {
subscription,
subscriptionUrl,
hasSubscription,
hasUsage,
isGateway,
gatewayDirect,
editing,
contentReady,
waiting,
open,
confirmingDelete,
refreshing,
usageUpdated,
usage,
displayedUsed,
validationStatus,
normalizedUrl,
error: subscriptionError,
importBlocked,
refreshBlocked,
deleteBlocked,
serversLeaving,
serverRevealVersion,
inputRef,
subscriptionRef,
panelRef,
toggleRef,
closeRef,
toggle: () => setOpen((current) => !current),
close: () => setOpen(false),
edit: () => setEditing(true),
changeUrl,
cancelEditing,
submit,
refresh,
requestDelete: () => setConfirmingDelete(true),
cancelDelete: () => setConfirmingDelete(false),
forget,
};
}
type SubscriptionFeatureController = ReturnType<typeof useSubscriptionFeature>;
interface SubscriptionToggleProps {
feature: SubscriptionFeatureController;
onToggle: () => void;
}
export function SubscriptionToggle({ feature, onToggle }: SubscriptionToggleProps) {
return <button
ref={feature.toggleRef}
className={`client-instructions-toggle client-subscription-toggle${feature.open ? ' is-open' : ''}`}
type="button"
aria-expanded={feature.open}
aria-controls="client-subscription-drawer"
aria-label={feature.open ? 'Закрыть подписку' : 'Управление подпиской'}
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 5h14v14H5zM8 9h8M8 13h5" />
</svg>
<span>Подписка</span>
</button>;
}
interface SubscriptionPanelProps {
feature: SubscriptionFeatureController;
statusSlot?: ReactNode;
serverSlot?: ReactNode;
}
export function SubscriptionPanel({ feature, statusSlot, serverSlot }: SubscriptionPanelProps) {
const {
subscription,
subscriptionUrl,
hasSubscription,
hasUsage,
isGateway,
gatewayDirect,
editing,
contentReady,
waiting,
open,
refreshing,
usageUpdated,
usage,
displayedUsed,
validationStatus,
normalizedUrl,
error,
importBlocked,
refreshBlocked,
deleteBlocked,
inputRef,
subscriptionRef,
panelRef,
closeRef,
} = feature;
return <div
ref={isGateway ? panelRef : undefined}
id={isGateway ? 'client-subscription-drawer' : undefined}
className={isGateway
? `client-drawer client-subscription-drawer${open ? ' is-open' : ''}`
: `client-form${waiting ? ' is-waiting' : ''}`}
aria-label={isGateway ? 'Управление подпиской' : undefined}
aria-hidden={isGateway ? !open : waiting}
aria-disabled={gatewayDirect}
inert={(isGateway && !open) || waiting || gatewayDirect ? true : undefined}
>
{isGateway && <button
ref={closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть подписку"
onClick={feature.close}
>×</button>}
<div className={`client-form-content${isGateway ? ' client-drawer-sheet client-subscription-sheet' : ''}`}>
<div
ref={subscriptionRef}
className={`client-subscription ${editing ? 'is-editing' : ''}${editing && hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
>
<div
className="client-subscription-summary"
aria-hidden={editing}
inert={editing ? true : undefined}
>
<div className="client-subscription-heading">
<span className="client-subscription-label">Ваша подписка</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-refresh"
type="button"
aria-label="Обновить подписку"
disabled={refreshing || refreshBlocked}
onClick={feature.refresh}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
</svg>
</button>
<CloudTooltip>Обновить подписку</CloudTooltip>
</span>
<span className="client-icon-tooltip client-tooltip-anchor">
<button
className="client-subscription-delete"
type="button"
aria-label="Удалить подписку"
disabled={deleteBlocked}
onClick={feature.requestDelete}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
</svg>
</button>
<CloudTooltip>Удалить подписку</CloudTooltip>
</span>
</div>
<button
className="client-subscription-domain-button"
type="button"
tabIndex={editing ? -1 : 0}
onClick={feature.edit}
>
<strong>{subscriptionDomain(subscription?.host)}</strong>
</button>
</div>
<form
className={`client-subscription-edit is-${validationStatus}`}
autoComplete="off"
aria-hidden={!editing}
inert={!editing ? true : undefined}
onSubmit={(event) => {
event.preventDefault();
feature.submit();
}}
>
<input
ref={inputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="off"
tabIndex={editing ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
className={subscriptionUrl ? 'has-value' : ''}
value={subscriptionUrl}
onChange={(event) => feature.changeUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') feature.cancelEditing();
}}
/>
{subscriptionUrl && (
<span className="client-subscription-domain">
{subscriptionDomain(subscriptionUrl)}
</span>
)}
{normalizedUrl && (
<button
className="client-subscription-submit"
type="submit"
aria-live="polite"
aria-label={validationStatus === 'valid'
? 'Сохранить подписку'
: validationStatus === 'checking'
? 'Проверяем подписку'
: error?.message || 'Ссылка подписки не распознана'}
disabled={importBlocked || validationStatus !== 'valid'}
>
{validationStatus === 'valid'
? '✓'
: validationStatus === 'checking' ? '…' : '×'}
</button>
)}
</form>
{statusSlot}
</div>
{hasSubscription && contentReady && hasUsage && (
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
<span>Использовано</span>
<strong>
{formatBytes(displayedUsed)}
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
</strong>
{usage.percent !== null && (
<div
className="client-usage-bar"
role="progressbar"
aria-label="Использованный трафик"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(usage.percent)}
>
<i style={{ width: `${usage.percent}%` }} />
</div>
)}
<div className="client-usage-details">
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
<span>
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
</span>
)}
</div>
</section>
)}
{serverSlot}
</div>
</div>;
}
export function SubscriptionDeleteDialog({ feature }: { feature: SubscriptionFeatureController }) {
return <ConfirmationDialog
open={feature.confirmingDelete}
id="delete-subscription"
kicker="Необратимое действие"
title="Удалить подписку?"
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
cancelLabel="Отмена"
confirmLabel="Удалить"
busy={feature.deleteBlocked}
onCancel={feature.cancelDelete}
onConfirm={feature.forget}
/>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
SubscriptionDeleteDialog,
SubscriptionPanel,
SubscriptionToggle,
useSubscriptionFeature,
} from './SubscriptionFeature.js';
@@ -0,0 +1,32 @@
import { ERROR_DEFINITIONS } from '../../../shared/errors.js';
export interface RequestError {
name?: string;
context?: string;
message?: string;
correlationId?: string;
retryable?: boolean;
retry?: (() => unknown) | null;
}
export function normalizeRequestError(value: unknown): RequestError & { message: string } {
const candidate = value && (typeof value === 'object' || typeof value === 'function')
? value
: null;
const property = (key: string): unknown => candidate ? Reflect.get(candidate, key) : undefined;
const name = property('name');
const context = property('context');
const message = property('message');
const correlationId = property('correlationId');
const retry = property('retry');
return {
name: typeof name === 'string' ? name : undefined,
context: typeof context === 'string' ? context : undefined,
message: typeof message === 'string' && message
? message
: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message,
correlationId: typeof correlationId === 'string' ? correlationId : undefined,
retryable: property('retryable') === true,
retry: typeof retry === 'function' ? retry as () => unknown : null,
};
}