Add targeted connectivity diagnostics with sampled results
This commit is contained in:
+2
-2
@@ -92,9 +92,9 @@ export const api = {
|
||||
}),
|
||||
},
|
||||
diagnostics: {
|
||||
connectivity: (services = []) => request('/api/diagnostics/connectivity', {
|
||||
connectivity: (services = [], target = null) => request('/api/diagnostics/connectivity', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ services }),
|
||||
body: JSON.stringify({ services, target }),
|
||||
}),
|
||||
},
|
||||
singbox: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
assessConnectivity,
|
||||
CONNECTIVITY_IP_SOURCES,
|
||||
CONNECTIVITY_SITES,
|
||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||
@@ -36,8 +37,9 @@ function readCustomServices() {
|
||||
}
|
||||
|
||||
function resultStatus(site, pending, available = true) {
|
||||
if (pending && !site) return ['is-running', 'Проверяем'];
|
||||
if (!available || !site) return ['is-muted', '—'];
|
||||
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} мс`];
|
||||
@@ -57,15 +59,52 @@ function ipResult(path, source) {
|
||||
|
||||
function IpCell({ path, source, pending, route }) {
|
||||
const value = ipResult(path, source);
|
||||
if (pending && !value) return <Status value={['is-running', 'Проверяем']} route={route} />;
|
||||
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 mergeItems(previous = [], incoming = [], key) {
|
||||
const merged = [...previous];
|
||||
for (const item of incoming) {
|
||||
const index = merged.findIndex((value) => value[key] === item[key]);
|
||||
if (index >= 0) merged[index] = item;
|
||||
else merged.push(item);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergePath(previous, incoming) {
|
||||
const sources = mergeItems(previous?.ipv4?.sources, incoming.ipv4?.sources, 'source');
|
||||
const sites = mergeItems(previous?.sites, incoming.sites, 'id');
|
||||
const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null;
|
||||
const ipv6 = ipv6Source?.address || null;
|
||||
const addresses = [...new Set(sources.map(({ address }) => address).filter(Boolean))];
|
||||
return {
|
||||
...previous,
|
||||
...incoming,
|
||||
internetAvailable: Boolean(
|
||||
addresses.length || ipv6 || sites.some(({ status }) => status !== 'unavailable'),
|
||||
),
|
||||
ipv4: { addresses, sources },
|
||||
ipv6,
|
||||
ipv6Source,
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
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) };
|
||||
}
|
||||
|
||||
export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) {
|
||||
const [result, setResult] = useState(null);
|
||||
const [status, setStatus] = useState('idle');
|
||||
const [activeTarget, setActiveTarget] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -85,11 +124,25 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
||||
setStatus('running');
|
||||
setError(null);
|
||||
try {
|
||||
setResult(await api.diagnostics.connectivity(customServices));
|
||||
let next = result;
|
||||
const targets = [
|
||||
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
|
||||
...[...CONNECTIVITY_SITES, ...customServices].map(({ id }) => `site:${id}`),
|
||||
];
|
||||
for (const target of targets) {
|
||||
setActiveTarget(target);
|
||||
const partial = await api.diagnostics.connectivity(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,11 +232,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
||||
<th>Напрямую</th>
|
||||
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
|
||||
</tr></thead>
|
||||
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => <tr key={source.id}>
|
||||
<th scope="row">{source.label}</th>
|
||||
<td><IpCell path={result?.direct} source={source} pending={pending && !result} route={`Напрямую, ${source.label}`} /></td>
|
||||
<td><IpCell path={result?.vpn} source={source} pending={pending && !result} route={`VPN, ${source.label}`} /></td>
|
||||
</tr>)}</tbody>
|
||||
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
|
||||
const target = `ip:${source.id}`;
|
||||
const running = activeTarget === target;
|
||||
return <tr key={source.id} className={running ? 'is-running' : undefined}>
|
||||
<th scope="row">{source.label}</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>
|
||||
|
||||
@@ -192,7 +248,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
||||
<span id="diagnostic-sites-title">Сервисы</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||
disabled={pending || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||
onClick={() => setAdding((value) => !value)}
|
||||
>
|
||||
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5' : 'Добавить свой сервис'}
|
||||
@@ -236,18 +292,20 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
||||
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-');
|
||||
return <tr key={site.id}>
|
||||
const running = activeTarget === `site:${site.id}`;
|
||||
return <tr key={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, pending && !result)} route={`Напрямую, ${site.label}`} /></td>
|
||||
<td><Status value={resultStatus(vpn, pending && !result, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></td>
|
||||
<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>
|
||||
|
||||
+24
-9
@@ -365,21 +365,21 @@ p {
|
||||
}
|
||||
|
||||
.client-shell.is-intro .harbor-brand-content {
|
||||
animation: harbor-startup-brand 1200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation: harbor-startup-brand 760ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-shell.is-intro .client-panel,
|
||||
.client-shell.is-intro .client-secondary-menu,
|
||||
.client-shell.is-intro .harbor-versions {
|
||||
animation: harbor-startup-content 680ms 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
animation: harbor-startup-content 720ms 120ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes harbor-startup-brand {
|
||||
from { opacity: 0.72; filter: blur(5px); transform: translateY(53px); }
|
||||
from { opacity: 0.35; filter: blur(5px); }
|
||||
}
|
||||
|
||||
@keyframes harbor-startup-content {
|
||||
from { opacity: 0; filter: blur(10px); }
|
||||
from { opacity: 0; filter: blur(6px); }
|
||||
}
|
||||
|
||||
.harbor-brand svg {
|
||||
@@ -4706,11 +4706,6 @@ p {
|
||||
transition: opacity 220ms ease, filter 320ms ease;
|
||||
}
|
||||
|
||||
.client-diagnostics-table[aria-busy='true'] {
|
||||
opacity: 0.68;
|
||||
filter: saturate(0.72);
|
||||
}
|
||||
|
||||
.client-diagnostics-table th,
|
||||
.client-diagnostics-table td {
|
||||
min-width: 0;
|
||||
@@ -4736,6 +4731,25 @@ p {
|
||||
|
||||
.client-diagnostics-table tbody tr {
|
||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||
transition: background-color 420ms ease, box-shadow 520ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-diagnostics-table tbody tr.is-running {
|
||||
background-color: color-mix(in oklch, var(--client-accent) 8%, transparent);
|
||||
box-shadow: inset 2px 0 var(--client-accent);
|
||||
animation: client-diagnostics-row-pulse 1200ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.client-diagnostics-table tbody tr.is-running th {
|
||||
color: var(--client-accent);
|
||||
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
@keyframes client-diagnostics-row-pulse {
|
||||
to {
|
||||
background-color: color-mix(in oklch, var(--client-accent) 13%, transparent);
|
||||
box-shadow: inset 2px 0 var(--client-accent), 0 0 16px color-mix(in oklch, var(--client-accent) 8%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.client-diagnostics-table tbody th {
|
||||
@@ -4946,6 +4960,7 @@ p {
|
||||
.client-diagnostics-refresh,
|
||||
.client-diagnostics-refresh svg,
|
||||
.client-diagnostics-table,
|
||||
.client-diagnostics-table tbody tr.is-running,
|
||||
.client-diagnostics-status.is-running {
|
||||
transition: none;
|
||||
animation: none;
|
||||
|
||||
Reference in New Issue
Block a user