Add DNS diagnostics across dataplane and client
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user