Add DNS diagnostics across dataplane and client
Build and Deploy Gateway / build-and-push (push) Successful in 31s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-09-01 04:16:15 +03:00
parent e0bdafd25e
commit 76a99f098a
23 changed files with 1888 additions and 35 deletions
@@ -24,6 +24,7 @@ import {
} from './connectivityResult.js';
import type { DiagnosticsFeature } from './DiagnosticsFeature.js';
import { saveCustomDiagnosticService } from './customServiceAction.js';
import { DnsDiagnosticsSection } from './DnsDiagnosticsSection.js';
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
@@ -36,7 +37,7 @@ interface IpSourceDefinition {
type StatusValue = [className: string, label: string];
type RunConnectivityDiagnostics = (target: string) => Promise<unknown>;
type UpdateSettings = (settings: Pick<DiagnosticSettings, 'customServices' | 'hiddenServiceIds'>) => Promise<unknown>;
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -236,12 +237,16 @@ function mergeResult(previous: ConnectivityResult | null, incoming: Connectivity
export function ConnectivityDiagnosticsPanel({
feature,
runConnectivityDiagnostics,
loadDnsDiagnosticsCatalog,
runDnsDiagnostics,
settings,
updateSettings,
isGateway,
}: {
feature: DiagnosticsFeature;
runConnectivityDiagnostics: RunConnectivityDiagnostics;
loadDnsDiagnosticsCatalog: () => Promise<unknown>;
runDnsDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
settings: DiagnosticSettings;
updateSettings: UpdateSettings;
isGateway: boolean;
@@ -618,6 +623,14 @@ export function ConnectivityDiagnosticsPanel({
</div>
</section>
<DnsDiagnosticsSection
open={open}
settings={settings}
updateSettings={updateSettings}
loadCatalog={loadDnsDiagnosticsCatalog}
runDiagnostics={runDnsDiagnostics}
/>
</Drawer>
);
}
@@ -0,0 +1,444 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import {
MAX_CUSTOM_DNS_DOMAINS,
MAX_CUSTOM_DNS_RESOLVERS,
type DiagnosticSettings,
type DnsDomainDefinition,
type DnsResolverDefinition,
} from '../../../shared/connectivityDiagnostics.js';
import { Tooltip } from '../../ui/Tooltip.js';
interface DnsPathResult {
status: string;
rcode: string | null;
ipv4: string[];
ipv6: string[];
latencyMs: number | null;
transport: string | null;
error: string | null;
}
interface DnsRowResult {
resolver: DnsResolverDefinition;
direct: DnsPathResult;
vpn: DnsPathResult;
comparison: 'same' | 'different' | 'direct-only' | 'vpn-only' | 'failed';
warning: string | null;
}
interface DnsRunResult {
checkedAt: string;
domain: DnsDomainDefinition;
results: DnsRowResult[];
}
interface DnsCatalog {
domains: DnsDomainDefinition[];
resolvers: DnsResolverDefinition[];
}
type UpdateSettings = (settings: Partial<DiagnosticSettings>) => Promise<unknown>;
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringArray(value: unknown) {
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) throw new TypeError('Invalid DNS addresses');
return value as string[];
}
function resolver(value: unknown): DnsResolverDefinition {
const item = record(value);
if (
typeof item.id !== 'string' || !item.id
|| typeof item.label !== 'string' || !item.label
|| !['dns', 'doh'].includes(String(item.kind))
|| typeof item.endpoint !== 'string' || !item.endpoint
) throw new TypeError('Invalid DNS resolver');
return {
id: item.id,
label: item.label,
kind: item.kind as 'dns' | 'doh',
endpoint: item.endpoint,
...(item.bootstrap ? { bootstrap: String(item.bootstrap) } : {}),
system: item.system === true,
custom: item.custom === true,
};
}
function domain(value: unknown): DnsDomainDefinition {
const item = record(value);
if (
typeof item.id !== 'string' || !item.id
|| typeof item.label !== 'string' || !item.label
|| typeof item.hostname !== 'string' || !item.hostname
) throw new TypeError('Invalid DNS domain');
return {
id: item.id,
label: item.label,
hostname: item.hostname,
builtIn: item.builtIn === true,
custom: item.custom === true,
};
}
function pathResult(value: unknown): DnsPathResult {
const item = record(value);
if (
typeof item.status !== 'string'
|| !(item.rcode === null || typeof item.rcode === 'string')
|| !(item.latencyMs === null || Number.isFinite(item.latencyMs))
|| !(item.transport === null || typeof item.transport === 'string')
|| !(item.error === null || typeof item.error === 'string')
) throw new TypeError('Invalid DNS path result');
return {
status: item.status,
rcode: item.rcode as string | null,
ipv4: stringArray(item.ipv4),
ipv6: stringArray(item.ipv6),
latencyMs: item.latencyMs as number | null,
transport: item.transport as string | null,
error: item.error as string | null,
};
}
function parseCatalog(value: unknown): DnsCatalog {
const item = record(value);
if (!Array.isArray(item.domains) || !Array.isArray(item.resolvers)) throw new TypeError('Invalid DNS catalog');
return { domains: item.domains.map(domain), resolvers: item.resolvers.map(resolver) };
}
function parseRun(value: unknown): DnsRunResult {
const item = record(value);
if (typeof item.checkedAt !== 'string' || !Array.isArray(item.results)) throw new TypeError('Invalid DNS result');
return {
checkedAt: item.checkedAt,
domain: domain(item.domain),
results: item.results.map((value) => {
const row = record(value);
if (!['same', 'different', 'direct-only', 'vpn-only', 'failed'].includes(String(row.comparison))) {
throw new TypeError('Invalid DNS comparison');
}
return {
resolver: resolver(row.resolver),
direct: pathResult(row.direct),
vpn: pathResult(row.vpn),
comparison: row.comparison as DnsRowResult['comparison'],
warning: row.warning === null ? null : String(row.warning),
};
}),
};
}
function requestMessage(value: unknown) {
const item = record(value);
return typeof item.message === 'string' ? item.message : 'DNS checker временно недоступен.';
}
function Refresh({
label,
running,
disabled,
compact = false,
onClick,
}: {
label: string;
running: boolean;
disabled: boolean;
compact?: boolean;
onClick: () => void;
}) {
return <span className={`client-diagnostics-refresh-wrap client-tooltip-anchor${compact ? ' client-diagnostics-row-refresh-wrap' : ''}`}>
<button
className={`client-diagnostics-refresh${compact ? ' client-diagnostics-row-refresh' : ''}${running ? ' is-running' : ''}`}
type="button"
aria-label={label}
aria-busy={running}
disabled={disabled}
onClick={onClick}
>
<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>{label}</Tooltip>
</span>;
}
function pathStatus(result: DnsPathResult | undefined) {
if (!result) return '—';
const labels: Record<string, string> = {
'vpn-off': 'VPN выключен',
timeout: 'Timeout',
nxdomain: 'NXDOMAIN',
servfail: 'SERVFAIL',
error: 'Ошибка ответа',
'no-addresses': result.rcode || 'Нет адресов',
};
return labels[result.status] || result.error || result.rcode || '';
}
function ResultCell({ result, running, route }: {
result: DnsPathResult | undefined;
running: boolean;
route: string;
}) {
const failed = result && !['answered', 'no-addresses', 'nxdomain'].includes(result.status);
return <span
className={`client-dns-result${failed ? ' is-error' : ''}${running ? ' is-running' : ''}`}
aria-label={`${route}: ${running ? 'Тестируем' : pathStatus(result)}`}
>
<span><b>A</b>{running && !result ? 'Тестируем' : result?.ipv4.join(', ') || '—'}</span>
<span><b>AAAA</b>{running && !result ? 'Тестируем' : result?.ipv6.join(', ') || '—'}</span>
<small>{running
? 'Тестируем'
: result?.latencyMs !== null && result?.latencyMs !== undefined
? `${result.latencyMs} мс · ${result.transport || 'DNS'}`
: pathStatus(result)}</small>
</span>;
}
const comparisonLabel: Record<DnsRowResult['comparison'], string> = {
same: 'совпадают',
different: 'различаются',
'direct-only': 'только Direct',
'vpn-only': 'только VPN',
failed: 'нет ответа',
};
function preserveAddresses(previous: DnsPathResult | undefined, next: DnsPathResult) {
return previous && !['answered', 'no-addresses', 'nxdomain'].includes(next.status)
? { ...next, ipv4: previous.ipv4, ipv6: previous.ipv6 }
: next;
}
function mergeRow(previous: DnsRowResult | undefined, next: DnsRowResult): DnsRowResult {
return previous ? {
...next,
direct: preserveAddresses(previous.direct, next.direct),
vpn: preserveAddresses(previous.vpn, next.vpn),
} : next;
}
export function DnsDiagnosticsSection({
open,
settings,
updateSettings,
loadCatalog,
runDiagnostics,
}: {
open: boolean;
settings: DiagnosticSettings;
updateSettings: UpdateSettings;
loadCatalog: () => Promise<unknown>;
runDiagnostics: (domainId: string, resolverId?: string | null) => Promise<unknown>;
}) {
const [catalog, setCatalog] = useState<DnsCatalog | null>(null);
const [activeDomainId, setActiveDomainId] = useState('youtube');
const [cache, setCache] = useState<Record<string, Record<string, DnsRowResult>>>({});
const [running, setRunning] = useState(false);
const [activeResolverId, setActiveResolverId] = useState<string | null>(null);
const [error, setError] = useState('');
const [addingDomain, setAddingDomain] = useState(false);
const [domainName, setDomainName] = useState('');
const [domainHostname, setDomainHostname] = useState('');
const [addingResolver, setAddingResolver] = useState(false);
const [resolverName, setResolverName] = useState('');
const [resolverEndpoint, setResolverEndpoint] = useState('');
const [saving, setSaving] = useState(false);
const rows = useMemo(() => cache[activeDomainId] || {}, [cache, activeDomainId]);
async function refreshCatalog() {
const next = parseCatalog(await loadCatalog());
setCatalog(next);
if (!next.domains.some(({ id }) => id === activeDomainId)) setActiveDomainId(next.domains[0]?.id || '');
}
useEffect(() => {
if (!open || catalog) return;
void refreshCatalog().catch((requestError) => setError(requestMessage(requestError)));
}, [open, catalog]);
async function run(resolverId: string | null = null) {
if (!catalog || !activeDomainId) return;
setRunning(true);
setActiveResolverId(resolverId);
setError('');
try {
const result = parseRun(await runDiagnostics(activeDomainId, resolverId));
setCache((current) => {
const previous = current[activeDomainId] || {};
const incoming = Object.fromEntries(result.results.map((row) => [
row.resolver.id,
mergeRow(previous[row.resolver.id], row),
]));
return { ...current, [activeDomainId]: resolverId ? { ...previous, ...incoming } : incoming };
});
} catch (requestError) {
setError(requestMessage(requestError));
} finally {
setRunning(false);
setActiveResolverId(null);
}
}
async function saveDomain(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS) return;
setSaving(true);
setError('');
try {
const hostname = new URL(`http://${domainHostname.trim().replace(/\.$/, '')}/`).hostname;
const id = `custom-domain-${globalThis.crypto?.randomUUID?.() || Date.now()}`;
const saved = await updateSettings({
customDnsDomains: [...settings.customDnsDomains, {
id,
label: domainName.trim() || hostname,
hostname,
}],
});
if (saved === false) throw new Error('Не удалось сохранить домен.');
setDomainName('');
setDomainHostname('');
setAddingDomain(false);
setCatalog(null);
setActiveDomainId(id);
} catch (validationError) {
setError(validationError instanceof Error ? validationError.message : 'Проверьте домен.');
} finally {
setSaving(false);
}
}
async function saveResolver(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS) return;
setSaving(true);
setError('');
try {
const endpoint = resolverEndpoint.trim();
const kind = endpoint.startsWith('https://') ? 'doh' : 'dns';
const saved = await updateSettings({
customDnsResolvers: [...settings.customDnsResolvers, {
id: `custom-dns-${globalThis.crypto?.randomUUID?.() || Date.now()}`,
label: resolverName.trim() || endpoint,
kind,
endpoint,
}],
});
if (saved === false) throw new Error('Не удалось сохранить DNS.');
setResolverName('');
setResolverEndpoint('');
setAddingResolver(false);
setCatalog(null);
} catch (validationError) {
setError(validationError instanceof Error ? validationError.message : 'Проверьте DNS или DoH endpoint.');
} finally {
setSaving(false);
}
}
async function remove(kind: 'domain' | 'resolver', id: string) {
setSaving(true);
setError('');
try {
const saved = await updateSettings(kind === 'domain'
? { customDnsDomains: settings.customDnsDomains.filter((item) => item.id !== id) }
: { customDnsResolvers: settings.customDnsResolvers.filter((item) => item.id !== id) });
if (saved === false) throw new Error('Не удалось сохранить DNS settings.');
setCatalog(null);
setCache({});
} catch (requestError) {
setError(requestMessage(requestError));
} finally {
setSaving(false);
}
}
const blocked = running || saving;
return <section className="client-diagnostics-section client-dns-section" aria-labelledby="diagnostic-dns-title">
<div className="client-diagnostics-section-title client-dns-title-row">
<span id="diagnostic-dns-title">DNS</span>
<Refresh label="Проверить DNS" running={running && !activeResolverId} disabled={blocked || !catalog} onClick={() => void run()} />
</div>
<div className="client-dns-domain-row">
<label htmlFor="client-dns-domain">Домен</label>
<select
id="client-dns-domain"
value={activeDomainId}
disabled={blocked || !catalog?.domains.length}
onChange={(event) => setActiveDomainId(event.target.value)}
>
{(catalog?.domains || []).map((item) => <option key={item.id} value={item.id}>{item.label} · {item.hostname}</option>)}
</select>
<button
type="button"
disabled={blocked || addingDomain || settings.customDnsDomains.length >= MAX_CUSTOM_DNS_DOMAINS}
onClick={() => setAddingDomain(true)}
>+ Домен</button>
{catalog?.domains.find(({ id }) => id === activeDomainId)?.custom && <button
className="client-row-delete"
type="button"
aria-label="Удалить выбранный домен"
disabled={blocked}
onClick={() => void remove('domain', activeDomainId)}
>×</button>}
</div>
{addingDomain && <form className="client-dns-inline-form" onSubmit={saveDomain}>
<input value={domainName} maxLength={40} placeholder="Название" aria-label="Название DNS-домена" onChange={(event) => setDomainName(event.target.value)} />
<input value={domainHostname} placeholder="example.com" aria-label="DNS-домен" required onChange={(event) => setDomainHostname(event.target.value)} />
<button type="submit" disabled={blocked}>Добавить</button>
<button type="button" aria-label="Отменить добавление домена" onClick={() => setAddingDomain(false)}>×</button>
</form>}
{error && <div className="client-diagnostics-error" role="alert"><span>{error}</span></div>}
<div className="client-dns-table" role="table" aria-busy={running}>
<div className="client-dns-header" role="row">
<span role="columnheader">DNS</span><span role="columnheader">Напрямую</span><span role="columnheader">VPN</span><span aria-hidden="true" />
</div>
{(catalog?.resolvers || []).map((item) => {
const result = rows[item.id];
const rowRunning = running && (!activeResolverId || activeResolverId === item.id);
return <div key={item.id} className={`client-dns-row${rowRunning ? ' is-running' : ''}`} role="row" data-diagnostic-target={`dns:${item.id}`}>
<span className="client-dns-resolver client-diagnostics-row-name" role="rowheader">
<span><b>{item.label}</b><small>{item.kind === 'doh' ? 'DoH' : 'DNS'} · {item.endpoint}</small><small>{result?.warning === 'private-address' ? 'получен частный адрес' : result ? comparisonLabel[result.comparison] : 'не проверено'}</small></span>
<Refresh label={`Проверить: ${item.label}`} running={rowRunning && Boolean(activeResolverId)} disabled={blocked} compact onClick={() => void run(item.id)} />
</span>
<span role="cell"><ResultCell result={result?.direct} running={rowRunning} route={`Напрямую, ${item.label}`} /></span>
<span role="cell"><ResultCell result={result?.vpn} running={rowRunning} route={`VPN, ${item.label}`} /></span>
{item.custom ? <button
className="client-row-delete"
type="button"
aria-label={`Удалить DNS ${item.label}`}
disabled={blocked}
onClick={() => void remove('resolver', item.id)}
>×</button> : <span aria-hidden="true" />}
</div>;
})}
</div>
{!catalog && !error && <p className="client-diagnostics-services-empty">Загружаем DNS</p>}
<div className="client-row-add-slot client-diagnostics-add-slot">
<button
className="client-row-add"
type="button"
disabled={blocked || addingResolver || settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS}
onClick={() => setAddingResolver(true)}
>{settings.customDnsResolvers.length >= MAX_CUSTOM_DNS_RESOLVERS ? 'Лимит 5 DNS' : '+ Добавить DNS'}</button>
</div>
{addingResolver && <form className="client-dns-inline-form" onSubmit={saveResolver}>
<input value={resolverName} maxLength={40} placeholder="Название" aria-label="Название DNS-резолвера" onChange={(event) => setResolverName(event.target.value)} />
<input value={resolverEndpoint} placeholder="8.8.8.8 или https://…/dns-query" aria-label="DNS или DoH endpoint" required onChange={(event) => setResolverEndpoint(event.target.value)} />
<button type="submit" disabled={blocked}>Добавить</button>
<button type="button" aria-label="Отменить добавление DNS" onClick={() => setAddingResolver(false)}>×</button>
</form>}
<p className="client-dns-note">Разные ответы могут быть нормой для CDN.</p>
</section>;
}