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,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>
);
}