Refactor VPN proxy client implementation
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user