Refine connectivity diagnostics service management
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-07 22:27:19 +03:00
parent 2151ff51f0
commit ca0322f93a
4 changed files with 284 additions and 140 deletions
@@ -1,23 +1,14 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { api } from '../api.js';
import {
assessConnectivity,
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js';
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
const SUMMARY_COPY = {
available: ['is-good', 'Оба маршрута работают.'],
'likely-direct-restriction': ['is-warning', 'Прямой маршрут, похоже, ограничен.'],
'vpn-problem': ['is-error', 'VPN-маршрут требует проверки.'],
'direct-offline': ['is-error', 'Прямой маршрут недоступен.'],
offline: ['is-error', 'Оба маршрута недоступны.'],
'same-ip': ['is-warning', 'Внешний IP не изменился.'],
'vpn-off': ['is-muted', 'VPN выключен.'],
inconclusive: ['is-muted', 'Недостаточно данных.'],
};
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
function readCustomServices() {
try {
@@ -36,6 +27,17 @@ function readCustomServices() {
}
}
function readHiddenServices() {
try {
const value = JSON.parse(localStorage.getItem(HIDDEN_SERVICES_KEY) || '[]');
return Array.isArray(value)
? value.filter((id) => CONNECTIVITY_SITES.some((service) => service.id === id))
: [];
} catch {
return [];
}
}
function resultStatus(site, pending, available = true) {
if (!available) return ['is-muted', '—'];
if (pending) return ['is-running', 'Тестируем'];
@@ -101,7 +103,7 @@ function mergePath(previous, incoming) {
function mergeResult(previous, incoming) {
const direct = mergePath(previous?.direct, incoming.direct);
const vpn = mergePath(previous?.vpn, incoming.vpn);
return { ...incoming, direct, vpn, assessment: assessConnectivity(direct, vpn) };
return { ...incoming, direct, vpn };
}
export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) {
@@ -110,7 +112,9 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
const [activeTarget, setActiveTarget] = useState(null);
const [error, setError] = useState(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('');
@@ -126,6 +130,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
}
}, [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;
@@ -157,7 +169,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
let next = result;
const targets = [
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
...[...CONNECTIVITY_SITES, ...customServices].map(({ id }) => `site:${id}`),
...sites.map(({ id }) => `site:${id}`),
];
for (const target of targets) {
setActiveTarget(target);
@@ -191,17 +203,49 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
setServiceUrl('');
setFormError('');
setAdding(false);
setResult(null);
} catch (validationError) {
setFormError(validationError.message || 'Проверьте адрес.');
}
}
function removeService(serviceId) {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
finishRemoveService(serviceId);
return;
}
setRemovingServiceId(serviceId);
}
function finishRemoveService(serviceId) {
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, ...customServices];
const summary = SUMMARY_COPY[result?.assessment?.summary] || SUMMARY_COPY.inconclusive;
const checkedAt = result?.checkedAt
? new Date(result.checkedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
: null;
const sites = [
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
...customServices,
];
const serviceEditorBlocked = pending || Boolean(removingServiceId);
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
return (
<aside
@@ -243,14 +287,11 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
</div>
</header>
{(error || result) && <div className="client-diagnostics-feedback" aria-live="polite">
{error ? <div className="client-diagnostics-error" role="alert">
{error && <div className="client-diagnostics-feedback">
<div className="client-diagnostics-error" role="alert">
<span>{error.message}</span>
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
</div> : result && <>
<strong className={`client-diagnostics-summary ${summary[0]}`}>{summary[1]}</strong>
<span className="client-diagnostics-time">{checkedAt}</span>
</>}
</div>
</div>}
<section className="client-diagnostics-section" aria-labelledby="diagnostic-ip-title">
@@ -277,16 +318,52 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
<section className="client-diagnostics-section" aria-labelledby="diagnostic-sites-title">
<div className="client-diagnostics-section-title">
<span id="diagnostic-sites-title">Сервисы</span>
<button
type="button"
disabled={pending || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
onClick={() => setAdding((value) => !value)}
>
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5' : 'Добавить свой сервис'}
</button>
</div>
{adding && <form className="client-diagnostics-add" onSubmit={addService}>
<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"
@@ -295,51 +372,49 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
value={serviceName}
onChange={(event) => setServiceName(event.target.value)}
/>
<input
type="url"
inputMode="url"
placeholder="https://example.com"
aria-label="HTTPS-адрес сервиса"
required
value={serviceUrl}
onChange={(event) => {
setServiceUrl(event.target.value);
setFormError('');
}}
<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')}
/>
<div className="client-diagnostics-add-action">
<span role="alert">{formError}</span>
<button type="submit">Добавить</button>
</div>
</form>}
<table className="client-diagnostics-table" aria-busy={pending}>
<thead><tr>
<th>Сервис</th>
<th>Напрямую</th>
<th>VPN</th>
</tr></thead>
<tbody>{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 custom = site.id.startsWith('custom-');
const running = activeTarget === `site:${site.id}`;
return <tr key={site.id} data-diagnostic-target={`site:${site.id}`} className={running ? 'is-running' : undefined}>
<th scope="row">
<span className="client-diagnostics-service-name">{site.label}</span>
{custom && <button
className="client-diagnostics-remove"
type="button"
aria-label={`Удалить сервис ${site.label}`}
disabled={pending}
onClick={() => setCustomServices((items) => items.filter((item) => item.id !== site.id))}
>×</button>}
</th>
<td><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></td>
<td><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></td>
</tr>;
})}</tbody>
</table>
{!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>
+123 -60
View File
@@ -4675,9 +4675,7 @@ p {
text-transform: uppercase;
}
.client-diagnostics-section-title button,
.client-diagnostics-add button,
.client-diagnostics-remove {
.client-diagnostics-section-title button {
padding: 0;
border: 0;
background: transparent;
@@ -4693,7 +4691,6 @@ p {
}
.client-diagnostics-section-title button:focus-visible,
.client-diagnostics-add button:focus-visible,
.client-diagnostics-error button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
@@ -4774,6 +4771,60 @@ p {
transition: color 420ms ease, text-shadow 520ms ease;
}
.client-diagnostics-service-table {
display: grid;
}
.client-diagnostics-service-header,
.client-diagnostics-service-row {
min-width: 0;
display: grid;
grid-template-columns: 35% minmax(0, 1fr) minmax(0, 1fr) 28px;
align-items: center;
}
.client-diagnostics-service-header {
padding: 2px 0 7px;
color: var(--client-muted);
font-size: 8px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.client-diagnostics-service-header > span,
.client-diagnostics-service-row > span:not(.client-delete-strike),
.client-diagnostics-service-row > input {
min-width: 0;
padding-inline: 8px;
}
.client-diagnostics-service-row {
position: relative;
isolation: isolate;
min-height: 37px;
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
animation: client-local-rule-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-diagnostics-service-row.is-removing {
pointer-events: none;
animation: client-local-rule-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
.client-diagnostics-service-row.is-running .client-diagnostics-service-name {
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
}
.client-diagnostics-service-name {
overflow-wrap: anywhere;
color: var(--client-text);
font-size: 9px;
font-weight: 600;
transition: color 420ms ease, text-shadow 520ms ease;
}
.client-diagnostics-table code,
.client-diagnostics-status {
display: inline-block;
@@ -4783,23 +4834,19 @@ p {
font: 600 9px/1.4 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
}
.client-diagnostics-status.is-good,
.client-diagnostics-summary.is-good {
.client-diagnostics-status.is-good {
color: var(--client-accent);
}
.client-diagnostics-status.is-warning,
.client-diagnostics-summary.is-warning {
.client-diagnostics-status.is-warning {
color: oklch(0.68 0.14 72);
}
.client-diagnostics-status.is-error,
.client-diagnostics-summary.is-error {
.client-diagnostics-status.is-error {
color: oklch(0.68 0.15 28);
}
.client-diagnostics-status.is-muted,
.client-diagnostics-summary.is-muted {
.client-diagnostics-status.is-muted {
color: var(--client-muted);
}
@@ -4830,71 +4877,65 @@ p {
100% { clip-path: inset(0 100% 0 0); }
}
.client-diagnostics-summary {
font-size: 9px;
font-weight: 700;
line-height: 1.4;
}
.client-diagnostics-time {
color: var(--client-muted);
font-size: 8px;
}
.client-diagnostics-add {
display: grid;
grid-template-columns: minmax(100px, 0.7fr) minmax(0, 1.3fr);
gap: 8px 14px;
padding: 4px 8px 10px;
}
.client-diagnostics-add input {
.client-diagnostics-service-draft input {
min-width: 0;
height: 34px;
padding: 0 2px;
border: 0;
border-bottom: 1px solid var(--client-border);
outline: 0;
background: transparent;
color: var(--client-text);
font: 500 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
box-shadow: 0 1px 0 transparent;
transition: box-shadow 300ms ease, text-shadow 300ms ease;
}
.client-diagnostics-add input:focus {
border-color: var(--client-accent);
.client-diagnostics-service-draft input::placeholder {
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
}
.client-diagnostics-add-action {
min-height: 20px;
grid-column: 1 / -1;
.client-diagnostics-service-draft input:focus {
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.client-diagnostics-service-url {
min-width: 0;
grid-column: 2 / 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 8px;
}
.client-diagnostics-add-action span {
color: oklch(0.68 0.15 28);
font-size: 8px;
.client-diagnostics-service-url input {
flex: 1 1 auto;
}
.client-diagnostics-service-name {
display: inline;
.client-diagnostics-service-url button {
padding: 5px 0;
border: 0;
background: transparent;
color: var(--client-accent);
font: 700 8px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
cursor: pointer;
transition: filter 260ms ease, text-shadow 300ms ease;
}
.client-diagnostics-remove {
width: 24px;
height: 24px;
margin-left: 3px;
color: var(--client-muted);
font-size: 14px;
vertical-align: middle;
}
.client-diagnostics-remove:hover,
.client-diagnostics-remove:focus-visible {
.client-diagnostics-service-url button:hover,
.client-diagnostics-service-url button:focus-visible {
outline: 0;
color: oklch(0.68 0.15 28);
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 42%, transparent));
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-diagnostics-services-empty {
padding: 14px 8px 4px;
color: var(--client-muted);
font-size: 9px;
}
.client-diagnostics-add-slot {
padding-inline: 8px;
}
@media (max-width: 560px) {
@@ -4907,13 +4948,31 @@ p {
width: 32%;
}
.client-diagnostics-add {
grid-template-columns: minmax(0, 1fr);
.client-diagnostics-service-header,
.client-diagnostics-service-row {
grid-template-columns: 32% minmax(0, 1fr) minmax(0, 1fr) 44px;
}
.client-diagnostics-add-action {
.client-diagnostics-service-draft {
grid-template-columns: minmax(0, 1fr) 44px;
padding-block: 5px;
}
.client-diagnostics-service-draft > input {
grid-column: 1;
}
.client-diagnostics-service-url {
grid-column: 1;
grid-row: 2;
}
.client-diagnostics-service-draft > .client-local-rule-delete {
grid-column: 2;
grid-row: 1 / 3;
width: 44px;
height: 44px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -4999,7 +5058,11 @@ p {
.client-diagnostics-refresh svg,
.client-diagnostics-table,
.client-diagnostics-active-marker,
.client-diagnostics-dots::after {
.client-diagnostics-dots::after,
.client-diagnostics-service-row,
.client-diagnostics-service-name,
.client-diagnostics-service-draft input,
.client-diagnostics-service-url button {
transition: none;
animation: none;
}