Refine connectivity diagnostics service management
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.17.8',
|
macClient: '0.17.10',
|
||||||
gatewayClient: '0.18.8',
|
gatewayClient: '0.18.10',
|
||||||
gatewayBackend: '0.18.1',
|
gatewayBackend: '0.18.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
assessConnectivity,
|
|
||||||
CONNECTIVITY_IP_SOURCES,
|
CONNECTIVITY_IP_SOURCES,
|
||||||
CONNECTIVITY_SITES,
|
CONNECTIVITY_SITES,
|
||||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
} from '../../shared/connectivityDiagnostics.js';
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||||
const SUMMARY_COPY = {
|
const HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services';
|
||||||
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', 'Недостаточно данных.'],
|
|
||||||
};
|
|
||||||
|
|
||||||
function readCustomServices() {
|
function readCustomServices() {
|
||||||
try {
|
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) {
|
function resultStatus(site, pending, available = true) {
|
||||||
if (!available) return ['is-muted', '—'];
|
if (!available) return ['is-muted', '—'];
|
||||||
if (pending) return ['is-running', 'Тестируем'];
|
if (pending) return ['is-running', 'Тестируем'];
|
||||||
@@ -101,7 +103,7 @@ function mergePath(previous, incoming) {
|
|||||||
function mergeResult(previous, incoming) {
|
function mergeResult(previous, incoming) {
|
||||||
const direct = mergePath(previous?.direct, incoming.direct);
|
const direct = mergePath(previous?.direct, incoming.direct);
|
||||||
const vpn = mergePath(previous?.vpn, incoming.vpn);
|
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 }) {
|
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 [activeTarget, setActiveTarget] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||||
|
const [hiddenServiceIds, setHiddenServiceIds] = useState(readHiddenServices);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [removingServiceId, setRemovingServiceId] = useState('');
|
||||||
const [serviceName, setServiceName] = useState('');
|
const [serviceName, setServiceName] = useState('');
|
||||||
const [serviceUrl, setServiceUrl] = useState('');
|
const [serviceUrl, setServiceUrl] = useState('');
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState('');
|
||||||
@@ -126,6 +130,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
}
|
}
|
||||||
}, [customServices]);
|
}, [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(() => {
|
useLayoutEffect(() => {
|
||||||
const sheet = sheetRef.current;
|
const sheet = sheetRef.current;
|
||||||
const runner = runnerRef.current;
|
const runner = runnerRef.current;
|
||||||
@@ -157,7 +169,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
let next = result;
|
let next = result;
|
||||||
const targets = [
|
const targets = [
|
||||||
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
|
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
|
||||||
...[...CONNECTIVITY_SITES, ...customServices].map(({ id }) => `site:${id}`),
|
...sites.map(({ id }) => `site:${id}`),
|
||||||
];
|
];
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
setActiveTarget(target);
|
setActiveTarget(target);
|
||||||
@@ -191,17 +203,49 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
setServiceUrl('');
|
setServiceUrl('');
|
||||||
setFormError('');
|
setFormError('');
|
||||||
setAdding(false);
|
setAdding(false);
|
||||||
|
setResult(null);
|
||||||
} catch (validationError) {
|
} catch (validationError) {
|
||||||
setFormError(validationError.message || 'Проверьте адрес.');
|
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 pending = status === 'running';
|
||||||
const sites = [...CONNECTIVITY_SITES, ...customServices];
|
const sites = [
|
||||||
const summary = SUMMARY_COPY[result?.assessment?.summary] || SUMMARY_COPY.inconclusive;
|
...CONNECTIVITY_SITES.filter(({ id }) => !hiddenServiceIds.includes(id)),
|
||||||
const checkedAt = result?.checkedAt
|
...customServices,
|
||||||
? new Date(result.checkedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
];
|
||||||
: null;
|
const serviceEditorBlocked = pending || Boolean(removingServiceId);
|
||||||
|
const addHint = formError || (adding ? 'Введите адрес и нажмите «Добавить»' : '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@@ -243,14 +287,11 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{(error || result) && <div className="client-diagnostics-feedback" aria-live="polite">
|
{error && <div className="client-diagnostics-feedback">
|
||||||
{error ? <div className="client-diagnostics-error" role="alert">
|
<div className="client-diagnostics-error" role="alert">
|
||||||
<span>{error.message}</span>
|
<span>{error.message}</span>
|
||||||
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||||
</div> : result && <>
|
</div>
|
||||||
<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">
|
<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">
|
<section className="client-diagnostics-section" aria-labelledby="diagnostic-sites-title">
|
||||||
<div className="client-diagnostics-section-title">
|
<div className="client-diagnostics-section-title">
|
||||||
<span id="diagnostic-sites-title">Сервисы</span>
|
<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>
|
</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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
maxLength="40"
|
maxLength="40"
|
||||||
@@ -295,9 +372,12 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
value={serviceName}
|
value={serviceName}
|
||||||
onChange={(event) => setServiceName(event.target.value)}
|
onChange={(event) => setServiceName(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<span className="client-diagnostics-service-url">
|
||||||
<input
|
<input
|
||||||
type="url"
|
type="url"
|
||||||
inputMode="url"
|
inputMode="url"
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com"
|
||||||
aria-label="HTTPS-адрес сервиса"
|
aria-label="HTTPS-адрес сервиса"
|
||||||
required
|
required
|
||||||
@@ -307,39 +387,34 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
setFormError('');
|
setFormError('');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="client-diagnostics-add-action">
|
<button type="submit" disabled={serviceEditorBlocked}>Добавить</button>
|
||||||
<span role="alert">{formError}</span>
|
</span>
|
||||||
<button type="submit">Добавить</button>
|
<button
|
||||||
</div>
|
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>}
|
</form>}
|
||||||
|
|
||||||
<table className="client-diagnostics-table" aria-busy={pending}>
|
{!sites.length && !adding && <p className="client-diagnostics-services-empty">Сервисов пока нет.</p>}
|
||||||
<thead><tr>
|
|
||||||
<th>Сервис</th>
|
<div className="client-local-rule-add-slot client-diagnostics-add-slot">
|
||||||
<th>Напрямую</th>
|
<button
|
||||||
<th>VPN</th>
|
className="client-local-rule-add"
|
||||||
</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"
|
type="button"
|
||||||
aria-label={`Удалить сервис ${site.label}`}
|
disabled={serviceEditorBlocked || adding || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||||
disabled={pending}
|
onClick={() => setAdding(true)}
|
||||||
onClick={() => setCustomServices((items) => items.filter((item) => item.id !== site.id))}
|
>
|
||||||
>×</button>}
|
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5 сервисов' : '+ Добавить сервис'}
|
||||||
</th>
|
</button>
|
||||||
<td><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></td>
|
<span className={addHint ? 'is-visible' : ''} role={formError ? 'alert' : 'status'}>{addHint}</span>
|
||||||
<td><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></td>
|
</div>
|
||||||
</tr>;
|
|
||||||
})}</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+123
-60
@@ -4675,9 +4675,7 @@ p {
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-section-title button,
|
.client-diagnostics-section-title button {
|
||||||
.client-diagnostics-add button,
|
|
||||||
.client-diagnostics-remove {
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -4693,7 +4691,6 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-section-title button:focus-visible,
|
.client-diagnostics-section-title button:focus-visible,
|
||||||
.client-diagnostics-add button:focus-visible,
|
|
||||||
.client-diagnostics-error button:focus-visible {
|
.client-diagnostics-error button:focus-visible {
|
||||||
outline: 2px solid var(--client-accent);
|
outline: 2px solid var(--client-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -4774,6 +4771,60 @@ p {
|
|||||||
transition: color 420ms ease, text-shadow 520ms ease;
|
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-table code,
|
||||||
.client-diagnostics-status {
|
.client-diagnostics-status {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -4783,23 +4834,19 @@ p {
|
|||||||
font: 600 9px/1.4 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
font: 600 9px/1.4 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-status.is-good,
|
.client-diagnostics-status.is-good {
|
||||||
.client-diagnostics-summary.is-good {
|
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-status.is-warning,
|
.client-diagnostics-status.is-warning {
|
||||||
.client-diagnostics-summary.is-warning {
|
|
||||||
color: oklch(0.68 0.14 72);
|
color: oklch(0.68 0.14 72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-status.is-error,
|
.client-diagnostics-status.is-error {
|
||||||
.client-diagnostics-summary.is-error {
|
|
||||||
color: oklch(0.68 0.15 28);
|
color: oklch(0.68 0.15 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-status.is-muted,
|
.client-diagnostics-status.is-muted {
|
||||||
.client-diagnostics-summary.is-muted {
|
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4830,71 +4877,65 @@ p {
|
|||||||
100% { clip-path: inset(0 100% 0 0); }
|
100% { clip-path: inset(0 100% 0 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-summary {
|
.client-diagnostics-service-draft input {
|
||||||
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 {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-bottom: 1px solid var(--client-border);
|
|
||||||
outline: 0;
|
outline: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font: 500 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
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 {
|
.client-diagnostics-service-draft input::placeholder {
|
||||||
border-color: var(--client-accent);
|
color: color-mix(in oklch, var(--client-muted) 70%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-add-action {
|
.client-diagnostics-service-draft input:focus {
|
||||||
min-height: 20px;
|
box-shadow: 0 1px 0 color-mix(in oklch, var(--client-accent) 58%, transparent);
|
||||||
grid-column: 1 / -1;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 8px;
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-add-action span {
|
.client-diagnostics-service-url input {
|
||||||
color: oklch(0.68 0.15 28);
|
flex: 1 1 auto;
|
||||||
font-size: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-service-name {
|
.client-diagnostics-service-url button {
|
||||||
display: inline;
|
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 {
|
.client-diagnostics-service-url button:hover,
|
||||||
width: 24px;
|
.client-diagnostics-service-url button:focus-visible {
|
||||||
height: 24px;
|
|
||||||
margin-left: 3px;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font-size: 14px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-diagnostics-remove:hover,
|
|
||||||
.client-diagnostics-remove:focus-visible {
|
|
||||||
outline: 0;
|
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) {
|
@media (max-width: 560px) {
|
||||||
@@ -4907,13 +4948,31 @@ p {
|
|||||||
width: 32%;
|
width: 32%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-add {
|
.client-diagnostics-service-header,
|
||||||
grid-template-columns: minmax(0, 1fr);
|
.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;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
@@ -4999,7 +5058,11 @@ p {
|
|||||||
.client-diagnostics-refresh svg,
|
.client-diagnostics-refresh svg,
|
||||||
.client-diagnostics-table,
|
.client-diagnostics-table,
|
||||||
.client-diagnostics-active-marker,
|
.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;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,13 +127,19 @@ test('secondary menus share one right rail and both drawers open from the right'
|
|||||||
|
|
||||||
test('connectivity diagnostics render stable compact tables before the first run', () => {
|
test('connectivity diagnostics render stable compact tables before the first run', () => {
|
||||||
assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/);
|
assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/);
|
||||||
assert.match(diagnostics, /const sites = \[\.\.\.CONNECTIVITY_SITES, \.\.\.customServices\]/);
|
assert.match(diagnostics, /CONNECTIVITY_SITES\.filter\(\(\{ id \}\) => !hiddenServiceIds\.includes\(id\)\)/);
|
||||||
assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 2);
|
assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 1);
|
||||||
assert.match(diagnostics, /Добавить свой сервис/);
|
assert.match(diagnostics, /className="client-diagnostics-service-table" role="table"/);
|
||||||
|
assert.match(diagnostics, /\+ Добавить сервис/);
|
||||||
|
assert.match(diagnostics, /HIDDEN_SERVICES_KEY/);
|
||||||
|
assert.match(diagnostics, /className="client-local-rule-delete"[\s\S]*Удалить сервис \$\{site\.label\}/);
|
||||||
|
assert.match(diagnostics, /client-deletable-row[\s\S]*className="client-delete-strike"[\s\S]*onAnimationEnd/);
|
||||||
|
assert.match(diagnostics, /document\.startViewTransition\(update\)/);
|
||||||
assert.match(diagnostics, /client-diagnostics-refresh/);
|
assert.match(diagnostics, /client-diagnostics-refresh/);
|
||||||
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
|
assert.match(diagnostics, /isGateway \? 'Gateway' : 'Connect'/);
|
||||||
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
||||||
assert.match(diagnostics, /\{\(error \|\| result\) && <div className="client-diagnostics-feedback"/);
|
assert.match(diagnostics, /\{error && <div className="client-diagnostics-feedback"/);
|
||||||
|
assert.doesNotMatch(diagnostics, /SUMMARY_COPY|client-diagnostics-summary|client-diagnostics-time|checkedAt|Прямой маршрут/);
|
||||||
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);
|
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);
|
||||||
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
||||||
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
||||||
|
|||||||
Reference in New Issue
Block a user