Files
harbor-net/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx
T
dokril df865fbe3d
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
Add per-row connectivity diagnostic refresh controls
2026-08-19 12:59:21 +03:00

597 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useLayoutEffect,
useRef,
useState,
type FormEvent,
} from 'react';
import { flushSync } from 'react-dom';
import { Drawer } from '../../ui/Drawer.js';
import { Tooltip } from '../../ui/Tooltip.js';
import {
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_NETWORK_SOURCE,
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 RowRefresh({
label,
running,
disabled,
onRun,
}: {
label: string;
running: boolean;
disabled: boolean;
onRun: () => void;
}) {
return <span className="client-diagnostics-row-refresh-wrap client-tooltip-anchor">
<button
className={`client-diagnostics-refresh client-diagnostics-row-refresh${running ? ' is-running' : ''}`}
type="button"
aria-label={`Проверить: ${label}`}
aria-busy={running}
disabled={disabled}
onClick={onRun}
>
<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>
<Tooltip>Проверить только эту строку</Tooltip>
</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 NetworkCell({
path,
pending,
route,
}: {
path: DiagnosticPath | undefined;
pending: boolean;
route: string;
}) {
let status: StatusValue | null = null;
if (path?.available === false) status = ['is-muted', '—'];
else if (pending) status = ['is-running', 'Тестируем'];
else if (!path?.available) status = ['is-muted', '—'];
const identity = [path?.network?.asn, path?.network?.provider].filter(Boolean).join(' · ');
const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', ');
if (!status && !identity && !location) status = ['is-error', 'Нет ответа'];
if (status) return <>
<Status value={status} route={route} /><br />
<span className="client-diagnostics-status is-muted" aria-hidden="true">&nbsp;</span>
</>;
return <span aria-label={`${route}: ${[identity, location].filter(Boolean).join(', ')}`}>
<span className="client-diagnostics-status">{identity || location}</span><br />
<span className="client-diagnostics-status is-muted">{identity && location ? location : <>&nbsp;</>}</span>
</span>;
}
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,
network: incoming.available === false ? null : incoming.network ?? previous?.network ?? null,
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 retryTargetRef = useRef<string | undefined>(undefined);
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(onlyTarget?: string) {
retryTargetRef.current = onlyTarget;
setStatus('running');
setError(null);
try {
let next = result;
const targets = onlyTarget ? [onlyTarget] : [
CONNECTIVITY_NETWORK_SOURCE.id,
...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 (
<Drawer
panelRef={panelRef}
closeRef={closeRef}
sheetRef={sheetRef}
id="client-diagnostics"
className="client-instructions client-diagnostics"
sheetClassName="client-instructions-sheet client-diagnostics-sheet"
open={open}
labelledBy="client-diagnostics-title"
closeLabel="Закрыть диагностику"
onClose={onClose}
leading={<span ref={runnerRef} className="client-diagnostics-active-marker" aria-hidden="true" />}
>
<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>
<Tooltip>Проверить маршруты</Tooltip>
</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(retryTargetRef.current)}>Повторить</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>
<tr
data-diagnostic-target={CONNECTIVITY_NETWORK_SOURCE.id}
className={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id ? 'is-running' : undefined}
>
<th scope="row" aria-label={CONNECTIVITY_NETWORK_SOURCE.label}><span className="client-diagnostics-row-name">
<span>{CONNECTIVITY_NETWORK_SOURCE.label}</span>
<RowRefresh
label={CONNECTIVITY_NETWORK_SOURCE.label}
running={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
disabled={pending}
onRun={() => run(CONNECTIVITY_NETWORK_SOURCE.id)}
/>
</span></th>
<td><NetworkCell
path={result?.direct}
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
route="Напрямую, сеть"
/></td>
<td><NetworkCell
path={result?.vpn}
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
route="VPN, сеть"
/></td>
</tr>
{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" aria-label={source.label}><span className="client-diagnostics-row-name">
<span>{source.label}</span>
<RowRefresh
label={source.label}
running={running}
disabled={pending}
onRun={() => run(target)}
/>
</span></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" aria-label={site.label} className="client-diagnostics-service-name client-diagnostics-row-name">
<span>{site.label}</span>
<RowRefresh
label={site.label}
running={running}
disabled={pending}
onRun={() => run(`site:${site.id}`)}
/>
</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-row-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-row-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-row-add-slot client-diagnostics-add-slot">
<button
className="client-row-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>
</Drawer>
);
}