597 lines
23 KiB
TypeScript
597 lines
23 KiB
TypeScript
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"> </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 : <> </>}</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>
|
||
);
|
||
}
|