Update VPN client connection flow
This commit is contained in:
@@ -111,7 +111,7 @@ jobs:
|
|||||||
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}"
|
||||||
UPDATE_DATAPLANE=false
|
UPDATE_DATAPLANE=false
|
||||||
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
|
if git diff-tree --no-commit-id --name-only -r -m HEAD | grep -Eq \
|
||||||
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singbox|singboxRuntime|version)\.js|src/server/(adapters/neighbors|services/(connectivityDiagnosticsService|deviceTrafficService|devicePolicyService))\.js|src/shared/errors\.js)$'; then
|
'^(Dockerfile|entrypoint\.sh|package(-lock)?\.json|scripts/build-runtime-base\.sh|\.gitea/workflows/gateway-build\.yml|src/server/(config|dataplane|gatewayRouting|singbox|singboxRuntime|version)\.js|src/server/(adapters/neighbors|services/(connectivityDiagnosticsService|deviceTrafficService|devicePolicyService))\.js|src/shared/(connectivityDiagnostics|errors)\.js)$'; then
|
||||||
UPDATE_DATAPLANE=true
|
UPDATE_DATAPLANE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,11 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
||||||
return sendJson(res, 200, await connectivityDiagnostics.run({ vpnAvailable: runtime.running }));
|
const { services = [] } = await readJson(req);
|
||||||
|
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||||
|
vpnAvailable: runtime.running,
|
||||||
|
services,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/apply') {
|
if (req.method === 'POST' && req.url === '/apply') {
|
||||||
return sendJson(res, 200, await runtime.apply());
|
return sendJson(res, 200, await runtime.apply());
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ export function createDataplaneClient(socketPath, send = request) {
|
|||||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||||
applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||||
runConnectivityDiagnostics: async () => {
|
runConnectivityDiagnostics: async (services = []) => {
|
||||||
try {
|
try {
|
||||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', null, 15_000);
|
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services }, 15_000);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -700,12 +700,16 @@ async function handleApi(req, res) {
|
|||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
||||||
if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
|
if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
|
const { services = [] } = await readBody(req);
|
||||||
const state = stateStore.read();
|
const state = stateStore.read();
|
||||||
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||||
const selected = (state.servers || []).find((server) => server.id === appliedServerId);
|
const selected = (state.servers || []).find((server) => server.id === appliedServerId);
|
||||||
const result = remoteDataplane
|
const result = remoteDataplane
|
||||||
? await singboxRuntime.runConnectivityDiagnostics()
|
? await singboxRuntime.runConnectivityDiagnostics(services)
|
||||||
: await localConnectivityDiagnostics.run({ vpnAvailable: (await singboxRuntime.refresh()).running });
|
: await localConnectivityDiagnostics.run({
|
||||||
|
vpnAvailable: (await singboxRuntime.refresh()).running,
|
||||||
|
services,
|
||||||
|
});
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
...result,
|
...result,
|
||||||
vpn: {
|
vpn: {
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
import {
|
||||||
|
CONNECTIVITY_IP_SOURCES,
|
||||||
|
CONNECTIVITY_SITES,
|
||||||
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
|
||||||
|
|
||||||
const IP_PROBES = [
|
const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||||
{
|
...probe,
|
||||||
id: 'cloudflare',
|
address: probe.id === 'cloudflare'
|
||||||
url: 'https://www.cloudflare.com/cdn-cgi/trace',
|
? (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
|
||||||
address: (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim(),
|
: probe.id === 'yandex-internet'
|
||||||
},
|
? (body) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
|
||||||
{ id: 'ipify', url: 'https://api.ipify.org', address: (body) => body.trim() },
|
: (body) => body.trim(),
|
||||||
];
|
}));
|
||||||
const IP_FALLBACK = {
|
const SITE_PROBES = CONNECTIVITY_SITES;
|
||||||
id: 'aws',
|
|
||||||
url: 'https://checkip.amazonaws.com',
|
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||||
address: (body) => body.trim(),
|
for (const [address, prefix] of [
|
||||||
};
|
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
|
||||||
const IPV6_PROBE = {
|
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
|
||||||
id: 'ipify-v6',
|
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
|
||||||
url: 'https://api6.ipify.org',
|
['224.0.0.0', 4], ['240.0.0.0', 4],
|
||||||
address: (body) => body.trim(),
|
]) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
|
||||||
};
|
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
|
||||||
const SITE_PROBES = [
|
for (const [address, prefix] of [
|
||||||
{ id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' },
|
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
|
||||||
{ id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' },
|
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
|
||||||
];
|
]) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
|
||||||
|
|
||||||
function runCurl(args) {
|
function runCurl(args) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
execFile('curl', args, { encoding: 'utf8', maxBuffer: 64 * 1024 }, (error, stdout, stderr) => {
|
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
|
||||||
resolve({
|
resolve({
|
||||||
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
|
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
|
||||||
error: error?.message || '',
|
error: error?.message || '',
|
||||||
@@ -52,11 +58,20 @@ function milliseconds(value) {
|
|||||||
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(probe, path, proxyPort, execute, { body = false, ipv4 = false } = {}) {
|
async function request(probe, path, proxyPort, execute, {
|
||||||
|
body = false,
|
||||||
|
ipv4 = false,
|
||||||
|
follow = true,
|
||||||
|
resolve = null,
|
||||||
|
} = {}) {
|
||||||
const args = [
|
const args = [
|
||||||
'--silent',
|
'--silent',
|
||||||
'--show-error',
|
'--show-error',
|
||||||
'--location',
|
...(follow ? ['--location'] : []),
|
||||||
|
'--proto',
|
||||||
|
'=https',
|
||||||
|
'--proto-redir',
|
||||||
|
'=https',
|
||||||
'--connect-timeout',
|
'--connect-timeout',
|
||||||
'3',
|
'3',
|
||||||
'--max-time',
|
'--max-time',
|
||||||
@@ -71,6 +86,7 @@ async function request(probe, path, proxyPort, execute, { body = false, ipv4 = f
|
|||||||
? ['--proxy', `http://127.0.0.1:${proxyPort}`]
|
? ['--proxy', `http://127.0.0.1:${proxyPort}`]
|
||||||
: ['--noproxy', '*']),
|
: ['--noproxy', '*']),
|
||||||
...(ipv4 ? ['--ipv4'] : []),
|
...(ipv4 ? ['--ipv4'] : []),
|
||||||
|
...(resolve ? ['--resolve', resolve] : []),
|
||||||
probe.url,
|
probe.url,
|
||||||
];
|
];
|
||||||
const result = await execute(args);
|
const result = await execute(args);
|
||||||
@@ -96,12 +112,14 @@ async function request(probe, path, proxyPort, execute, { body = false, ipv4 = f
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ipProbe(probe, path, proxyPort, execute, version) {
|
async function ipProbe(probe, path, proxyPort, execute) {
|
||||||
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: version === 4 });
|
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
||||||
const address = result.ok ? probe.address(result.body) : null;
|
const address = result.ok ? probe.address(result.body) : null;
|
||||||
const valid = typeof address === 'string' && net.isIP(address) === version;
|
const valid = typeof address === 'string' && net.isIP(address) === probe.family;
|
||||||
return {
|
return {
|
||||||
source: probe.id,
|
source: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
family: probe.family,
|
||||||
address: valid ? address : null,
|
address: valid ? address : null,
|
||||||
latencyMs: result.latencyMs,
|
latencyMs: result.latencyMs,
|
||||||
error: valid ? null : result.error || 'invalid IP response',
|
error: valid ? null : result.error || 'invalid IP response',
|
||||||
@@ -109,29 +127,78 @@ async function ipProbe(probe, path, proxyPort, execute, version) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function publicIps(path, proxyPort, execute) {
|
async function publicIps(path, proxyPort, execute) {
|
||||||
const [primary, ipv6] = await Promise.all([
|
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
|
||||||
Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute, 4))),
|
const ipv4 = probes.filter((probe) => probe.family === 4);
|
||||||
ipProbe(IPV6_PROBE, path, proxyPort, execute, 6),
|
const ipv6 = probes.find((probe) => probe.family === 6);
|
||||||
]);
|
|
||||||
const primaryAddresses = new Set(primary.map((probe) => probe.address).filter(Boolean));
|
|
||||||
const probes = primary.every((probe) => probe.address) && primaryAddresses.size === 1
|
|
||||||
? primary
|
|
||||||
: [...primary, await ipProbe(IP_FALLBACK, path, proxyPort, execute, 4)];
|
|
||||||
return {
|
return {
|
||||||
ipv4: {
|
ipv4: {
|
||||||
addresses: [...new Set(probes.map((probe) => probe.address).filter(Boolean))],
|
addresses: [...new Set(ipv4.map((probe) => probe.address).filter(Boolean))],
|
||||||
sources: probes,
|
sources: ipv4,
|
||||||
},
|
},
|
||||||
ipv6: ipv6.address,
|
ipv6: ipv6?.address || null,
|
||||||
ipv6Source: ipv6,
|
ipv6Source: ipv6,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPublicAddress(address, family) {
|
||||||
|
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
|
||||||
|
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
|
||||||
|
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareCustomProbes(services, lookup) {
|
||||||
|
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
|
||||||
|
return Promise.all(requested.map(async (service, index) => {
|
||||||
|
const requestedId = String(service?.id || '');
|
||||||
|
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(String(service?.url || '').trim());
|
||||||
|
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
|
||||||
|
throw new Error('Разрешены только публичные HTTPS-адреса');
|
||||||
|
}
|
||||||
|
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
|
||||||
|
const resolved = await lookup(hostname, { all: true, verbatim: true });
|
||||||
|
const addresses = Array.isArray(resolved) ? resolved : [resolved];
|
||||||
|
if (!addresses.length || addresses.some(({ address, family }) => !isPublicAddress(address, family))) {
|
||||||
|
throw new Error('Адрес ведёт во внутреннюю или служебную сеть');
|
||||||
|
}
|
||||||
|
const target = addresses.find(({ family }) => family === 4) || addresses[0];
|
||||||
|
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(service?.label || '').trim().slice(0, 40) || hostname,
|
||||||
|
url: parsed.href,
|
||||||
|
follow: false,
|
||||||
|
resolve: `${hostname}:443:${pinned}`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: String(service?.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
|
||||||
|
validationError: error.message || 'Некорректный адрес',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function siteProbe(probe, path, proxyPort, execute) {
|
async function siteProbe(probe, path, proxyPort, execute) {
|
||||||
let result = await request(probe, path, proxyPort, execute);
|
if (probe.validationError) return {
|
||||||
|
id: probe.id,
|
||||||
|
label: probe.label,
|
||||||
|
status: 'unavailable',
|
||||||
|
attempts: 0,
|
||||||
|
httpStatus: null,
|
||||||
|
latencyMs: null,
|
||||||
|
totalMs: null,
|
||||||
|
stage: 'validation',
|
||||||
|
error: probe.validationError,
|
||||||
|
};
|
||||||
|
const options = { follow: probe.follow !== false, resolve: probe.resolve };
|
||||||
|
let result = await request(probe, path, proxyPort, execute, options);
|
||||||
let attempts = 1;
|
let attempts = 1;
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
result = await request(probe, path, proxyPort, execute);
|
result = await request(probe, path, proxyPort, execute, options);
|
||||||
attempts = 2;
|
attempts = 2;
|
||||||
}
|
}
|
||||||
const status = !result.ok
|
const status = !result.ok
|
||||||
@@ -152,23 +219,23 @@ async function siteProbe(probe, path, proxyPort, execute) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function probePath(path, proxyPort, execute) {
|
async function probePath(path, proxyPort, execute, sites) {
|
||||||
const [ip, sites] = await Promise.all([
|
const [ip, siteResults] = await Promise.all([
|
||||||
publicIps(path, proxyPort, execute),
|
publicIps(path, proxyPort, execute),
|
||||||
Promise.all(SITE_PROBES.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
available: true,
|
available: true,
|
||||||
internetAvailable: Boolean(
|
internetAvailable: Boolean(
|
||||||
ip.ipv4.addresses.length || ip.ipv6 || sites.some((site) => site.status !== 'unavailable'),
|
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
|
||||||
),
|
),
|
||||||
...ip,
|
...ip,
|
||||||
sites,
|
sites: siteResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assessConnectivity(direct, vpn) {
|
export function assessConnectivity(direct, vpn) {
|
||||||
const comparisons = SITE_PROBES.map(({ id, label }) => {
|
const comparisons = direct.sites.map(({ id, label }) => {
|
||||||
const directSite = direct.sites.find((site) => site.id === id);
|
const directSite = direct.sites.find((site) => site.id === id);
|
||||||
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
||||||
let assessment = 'inconclusive';
|
let assessment = 'inconclusive';
|
||||||
@@ -211,13 +278,15 @@ export function assessConnectivity(direct, vpn) {
|
|||||||
export function createConnectivityDiagnosticsService({
|
export function createConnectivityDiagnosticsService({
|
||||||
proxyPort,
|
proxyPort,
|
||||||
execute = runCurl,
|
execute = runCurl,
|
||||||
|
lookup = dnsLookup,
|
||||||
now = () => new Date().toISOString(),
|
now = () => new Date().toISOString(),
|
||||||
}) {
|
}) {
|
||||||
let inFlight = null;
|
async function runOnce({ vpnAvailable, services = [] }) {
|
||||||
async function runOnce({ vpnAvailable }) {
|
const customProbes = await prepareCustomProbes(services, lookup);
|
||||||
const directPromise = probePath('direct', proxyPort, execute);
|
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||||
|
const directPromise = probePath('direct', proxyPort, execute, siteProbes);
|
||||||
const vpnPromise = vpnAvailable
|
const vpnPromise = vpnAvailable
|
||||||
? probePath('vpn', proxyPort, execute)
|
? probePath('vpn', proxyPort, execute, siteProbes)
|
||||||
: Promise.resolve({
|
: Promise.resolve({
|
||||||
available: false,
|
available: false,
|
||||||
reason: 'vpn-off',
|
reason: 'vpn-off',
|
||||||
@@ -236,9 +305,6 @@ export function createConnectivityDiagnosticsService({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
run(options) {
|
run: runOnce,
|
||||||
if (!inFlight) inFlight = runOnce(options).finally(() => { inFlight = null; });
|
|
||||||
return inFlight;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const CONNECTIVITY_IP_SOURCES = Object.freeze([
|
||||||
|
{ id: 'cloudflare', label: 'Cloudflare', family: 4, url: 'https://www.cloudflare.com/cdn-cgi/trace' },
|
||||||
|
{ id: 'ipify', label: 'ipify', family: 4, url: 'https://api.ipify.org' },
|
||||||
|
{ id: 'aws', label: 'AWS', family: 4, url: 'https://checkip.amazonaws.com' },
|
||||||
|
{ id: 'icanhazip', label: 'icanhazip', family: 4, url: 'https://icanhazip.com' },
|
||||||
|
{ id: 'ifconfig-me', label: 'ifconfig.me', family: 4, url: 'https://ifconfig.me/ip' },
|
||||||
|
{ id: 'yandex-internet', label: 'Яндекс Интернетометр', family: 4, url: 'https://yandex.ru/internet/' },
|
||||||
|
{ id: 'ipify-v6', label: 'ipify IPv6', family: 6, url: 'https://api6.ipify.org' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const CONNECTIVITY_SITES = Object.freeze([
|
||||||
|
{ id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' },
|
||||||
|
{ id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' },
|
||||||
|
{ id: 'instagram', label: 'Instagram', url: 'https://www.instagram.com/' },
|
||||||
|
{ id: 'speedtest', label: 'Speedtest', url: 'https://www.speedtest.net/' },
|
||||||
|
{ id: 'yandex', label: 'Яндекс Интернетометр', url: 'https://yandex.ru/internet/' },
|
||||||
|
{ id: '2ip', label: '2IP', url: 'https://2ip.ru/' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.15.1',
|
macClient: '0.16.1',
|
||||||
gatewayClient: '0.16.1',
|
gatewayClient: '0.17.1',
|
||||||
gatewayBackend: '0.16.1',
|
gatewayBackend: '0.17.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
+4
-1
@@ -92,7 +92,10 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
connectivity: () => request('/api/diagnostics/connectivity', { method: 'POST' }),
|
connectivity: (services = []) => request('/api/diagnostics/connectivity', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ services }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
|
|||||||
@@ -1,32 +1,46 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
|
import {
|
||||||
|
CONNECTIVITY_IP_SOURCES,
|
||||||
|
CONNECTIVITY_SITES,
|
||||||
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
|
} from '../../shared/connectivityDiagnostics.js';
|
||||||
|
|
||||||
|
const CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services';
|
||||||
const SUMMARY_COPY = {
|
const SUMMARY_COPY = {
|
||||||
available: ['is-good', 'Оба маршрута работают, ограничений не видно.'],
|
available: ['is-good', 'Оба маршрута работают.'],
|
||||||
'likely-direct-restriction': ['is-warning', 'Похоже на ограничение прямого маршрута: через VPN сервис доступен.'],
|
'likely-direct-restriction': ['is-warning', 'Прямой маршрут, похоже, ограничен.'],
|
||||||
'vpn-problem': ['is-error', 'Прямой маршрут работает, а VPN требует проверки.'],
|
'vpn-problem': ['is-error', 'VPN-маршрут требует проверки.'],
|
||||||
'direct-offline': ['is-error', 'Прямой выход в интернет недоступен.'],
|
'direct-offline': ['is-error', 'Прямой маршрут недоступен.'],
|
||||||
offline: ['is-error', 'Интернет недоступен по обоим маршрутам.'],
|
offline: ['is-error', 'Оба маршрута недоступны.'],
|
||||||
'same-ip': ['is-warning', 'Внешний IP не изменился — проверьте, что трафик действительно идёт через VPN.'],
|
'same-ip': ['is-warning', 'Внешний IP не изменился.'],
|
||||||
'vpn-off': ['is-muted', 'VPN выключен: прямой маршрут проверен отдельно.'],
|
'vpn-off': ['is-muted', 'VPN выключен.'],
|
||||||
inconclusive: ['is-muted', 'Результата недостаточно для уверенного вывода.'],
|
inconclusive: ['is-muted', 'Недостаточно данных.'],
|
||||||
};
|
};
|
||||||
|
|
||||||
function ipText(path) {
|
function readCustomServices() {
|
||||||
const addresses = [...(path?.ipv4?.addresses || []), path?.ipv6].filter(Boolean);
|
try {
|
||||||
return addresses.length ? addresses.join(' · ') : 'Не определён';
|
const value = JSON.parse(localStorage.getItem(CUSTOM_SERVICES_KEY) || '[]');
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((service) => (
|
||||||
|
service
|
||||||
|
&& typeof service.id === 'string'
|
||||||
|
&& service.id.startsWith('custom-')
|
||||||
|
&& typeof service.label === 'string'
|
||||||
|
&& typeof service.url === 'string'
|
||||||
|
)).slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function internetStatus(path) {
|
function resultStatus(site, pending, available = true) {
|
||||||
if (!path?.available) return ['is-muted', 'Не проверено'];
|
if (pending && !site) return ['is-running', 'Проверяем'];
|
||||||
return path.internetAvailable ? ['is-good', 'Доступен'] : ['is-error', 'Нет доступа'];
|
if (!available || !site) return ['is-muted', '—'];
|
||||||
}
|
|
||||||
|
|
||||||
function siteStatus(site) {
|
|
||||||
if (!site) return ['is-muted', 'Не проверено'];
|
|
||||||
if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
|
if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
|
||||||
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
|
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
|
||||||
return ['is-good', `Доступен${site.latencyMs === null ? '' : ` · ${site.latencyMs} мс`}`];
|
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
|
||||||
}
|
}
|
||||||
|
|
||||||
function Status({ value, route }) {
|
function Status({ value, route }) {
|
||||||
@@ -34,12 +48,25 @@ function Status({ value, route }) {
|
|||||||
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>{label}</span>;
|
return <span className={`client-diagnostics-status ${className}`} aria-label={`${route}: ${label}`}>{label}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ipResult(path, source) {
|
||||||
|
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 }) {
|
||||||
|
const value = ipResult(path, source);
|
||||||
|
if (pending && !value) 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 PathDetails({ title, path }) {
|
function PathDetails({ title, path }) {
|
||||||
if (!path?.available) return <section><strong>{title}</strong><p>VPN не запущен.</p></section>;
|
if (!path?.available) return <section><strong>{title}</strong><p>VPN не запущен.</p></section>;
|
||||||
return <section>
|
return <section>
|
||||||
<strong>{title}</strong>
|
<strong>{title}</strong>
|
||||||
<p>IPv4: {path.ipv4.addresses.join(', ') || 'нет'}</p>
|
|
||||||
<p>IPv6: {path.ipv6 || 'нет'}</p>
|
|
||||||
{path.sites.map((site) => (
|
{path.sites.map((site) => (
|
||||||
<p key={site.id}>
|
<p key={site.id}>
|
||||||
{site.label}: {site.stage}{site.httpStatus ? ` · HTTP ${site.httpStatus}` : ''}
|
{site.label}: {site.stage}{site.httpStatus ? ` · HTTP ${site.httpStatus}` : ''}
|
||||||
@@ -53,12 +80,25 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
|
|||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [serviceName, setServiceName] = useState('');
|
||||||
|
const [serviceUrl, setServiceUrl] = useState('');
|
||||||
|
const [formError, setFormError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CUSTOM_SERVICES_KEY, JSON.stringify(customServices));
|
||||||
|
} catch {
|
||||||
|
// The service still works for this session when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [customServices]);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
setStatus('running');
|
setStatus('running');
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setResult(await api.diagnostics.connectivity());
|
setResult(await api.diagnostics.connectivity(customServices));
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(requestError);
|
setError(requestError);
|
||||||
@@ -66,6 +106,28 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addService(event) {
|
||||||
|
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);
|
||||||
|
} catch (validationError) {
|
||||||
|
setFormError(validationError.message || 'Проверьте адрес.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = status === 'running';
|
||||||
|
const sites = [...CONNECTIVITY_SITES, ...customServices];
|
||||||
const summary = SUMMARY_COPY[result?.assessment?.summary] || SUMMARY_COPY.inconclusive;
|
const summary = SUMMARY_COPY[result?.assessment?.summary] || SUMMARY_COPY.inconclusive;
|
||||||
const checkedAt = result?.checkedAt
|
const checkedAt = result?.checkedAt
|
||||||
? new Date(result.checkedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
? new Date(result.checkedAt).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||||
@@ -90,69 +152,127 @@ export function ConnectivityDiagnosticsPanel({ open, panelRef, closeRef, onClose
|
|||||||
>×</button>
|
>×</button>
|
||||||
<header className="client-instructions-header client-diagnostics-header">
|
<header className="client-instructions-header client-diagnostics-header">
|
||||||
<span>Gateway · Direct ↔ VPN</span>
|
<span>Gateway · Direct ↔ VPN</span>
|
||||||
<h2 id="client-diagnostics-title">Проверка маршрутов</h2>
|
<div className="client-diagnostics-title-row">
|
||||||
<div className="client-instructions-intro">
|
<h2 id="client-diagnostics-title">Маршруты</h2>
|
||||||
<p>Сравнивает внешний IP и доступность сайтов напрямую и через выбранный VPN, не меняя правила.</p>
|
<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>
|
||||||
|
<span className="client-tooltip" role="tooltip">Проверить маршруты</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<button
|
<div className="client-diagnostics-feedback" aria-live="polite">
|
||||||
className={`client-diagnostics-run${status === 'running' ? ' is-running' : ''}`}
|
{error ? <div className="client-diagnostics-error" role="alert">
|
||||||
type="button"
|
<span>{error.message}</span>
|
||||||
aria-busy={status === 'running'}
|
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
||||||
disabled={status === 'running'}
|
</div> : result && <>
|
||||||
onClick={run}
|
<strong className={`client-diagnostics-summary ${summary[0]}`}>{summary[1]}</strong>
|
||||||
>
|
<span className="client-diagnostics-time">{checkedAt}</span>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
</>}
|
||||||
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
|
</div>
|
||||||
</svg>
|
|
||||||
<span>{status === 'running' ? 'Проверяем маршруты…' : result ? 'Проверить ещё раз' : 'Проверить сейчас'}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && <div className="client-diagnostics-error" role="alert">
|
<section className="client-diagnostics-section" aria-labelledby="diagnostic-ip-title">
|
||||||
<span>{error.message}</span>
|
<div className="client-diagnostics-section-title">
|
||||||
{error.retryable && <button type="button" onClick={run}>Повторить</button>}
|
<span id="diagnostic-ip-title">IP-адреса</span>
|
||||||
</div>}
|
</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>{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>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
{!result && status !== 'running' && !error && (
|
<section className="client-diagnostics-section" aria-labelledby="diagnostic-sites-title">
|
||||||
<p className="client-diagnostics-empty">Результаты появятся здесь после ручного запуска.</p>
|
<div className="client-diagnostics-section-title">
|
||||||
)}
|
<span id="diagnostic-sites-title">Сервисы</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||||
|
onClick={() => setAdding((value) => !value)}
|
||||||
|
>
|
||||||
|
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5' : 'Добавить свой сервис'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{result && <div className="client-diagnostics-results" aria-live="polite" aria-busy={status === 'running'}>
|
{adding && <form className="client-diagnostics-add" onSubmit={addService}>
|
||||||
<div className="client-diagnostics-grid client-diagnostics-grid-head" aria-hidden="true">
|
<input
|
||||||
<span />
|
type="text"
|
||||||
<strong>Напрямую</strong>
|
maxLength="40"
|
||||||
<strong>VPN{result.vpn.server?.label ? ` · ${result.vpn.server.label}` : ''}</strong>
|
placeholder="Название"
|
||||||
</div>
|
aria-label="Название сервиса"
|
||||||
<div className="client-diagnostics-grid">
|
value={serviceName}
|
||||||
<strong>Внешний IP</strong>
|
onChange={(event) => setServiceName(event.target.value)}
|
||||||
<code aria-label={`Напрямую: ${ipText(result.direct)}`}>{ipText(result.direct)}</code>
|
/>
|
||||||
<code aria-label={`VPN: ${result.vpn.available ? ipText(result.vpn) : 'VPN выключен'}`}>
|
<input
|
||||||
{result.vpn.available ? ipText(result.vpn) : 'VPN выключен'}
|
type="url"
|
||||||
</code>
|
inputMode="url"
|
||||||
</div>
|
placeholder="https://example.com"
|
||||||
<div className="client-diagnostics-grid">
|
aria-label="HTTPS-адрес сервиса"
|
||||||
<strong>Интернет</strong>
|
required
|
||||||
<Status value={internetStatus(result.direct)} route="Напрямую" />
|
value={serviceUrl}
|
||||||
<Status value={internetStatus(result.vpn)} route="VPN" />
|
onChange={(event) => {
|
||||||
</div>
|
setServiceUrl(event.target.value);
|
||||||
{result.assessment.comparisons.map((comparison) => (
|
setFormError('');
|
||||||
<div className="client-diagnostics-grid" key={comparison.id}>
|
}}
|
||||||
<strong>{comparison.label}</strong>
|
/>
|
||||||
<Status value={siteStatus(comparison.direct)} route={`Напрямую, ${comparison.label}`} />
|
<div className="client-diagnostics-add-action">
|
||||||
<Status value={siteStatus(comparison.vpn)} route={`VPN, ${comparison.label}`} />
|
<span role="alert">{formError}</span>
|
||||||
|
<button type="submit">Добавить</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</form>}
|
||||||
<p className={`client-diagnostics-summary ${summary[0]}`} role="status">{summary[1]}</p>
|
|
||||||
<p className="client-diagnostics-time">Проверено в {checkedAt}</p>
|
<table className="client-diagnostics-table" aria-busy={pending}>
|
||||||
<details className="client-diagnostics-details">
|
<thead><tr>
|
||||||
<summary>Технические детали</summary>
|
<th>Сервис</th>
|
||||||
<div>
|
<th>Напрямую</th>
|
||||||
<PathDetails title="Напрямую" path={result.direct} />
|
<th>VPN</th>
|
||||||
<PathDetails title="VPN" path={result.vpn} />
|
</tr></thead>
|
||||||
</div>
|
<tbody>{sites.map((site) => {
|
||||||
</details>
|
const direct = result?.direct?.sites?.find((item) => item.id === site.id);
|
||||||
</div>}
|
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
|
||||||
|
const custom = site.id.startsWith('custom-');
|
||||||
|
return <tr key={site.id}>
|
||||||
|
<th scope="row">
|
||||||
|
<span className="client-diagnostics-service-name">{site.label}</span>
|
||||||
|
{custom && <button
|
||||||
|
className="client-diagnostics-remove"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Удалить сервис ${site.label}`}
|
||||||
|
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>
|
||||||
|
</tr>;
|
||||||
|
})}</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{result && <details className="client-diagnostics-details">
|
||||||
|
<summary>Технические детали</summary>
|
||||||
|
<div>
|
||||||
|
<PathDetails title="Напрямую" path={result.direct} />
|
||||||
|
<PathDetails title="VPN" path={result.vpn} />
|
||||||
|
</div>
|
||||||
|
</details>}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
|
|
||||||
const AUTO_REFRESH_MS = 15_000;
|
const AUTO_REFRESH_MS = 15_000;
|
||||||
const DEVICE_MOVE_MS = 520;
|
const DEVICE_MOVE_MS = 520;
|
||||||
const COPY_FEEDBACK_MS = 5_000;
|
const COPY_FEEDBACK_MS = 800;
|
||||||
const TRAFFIC_DELTA_MS = 2_200;
|
const TRAFFIC_DELTA_MS = 2_200;
|
||||||
|
|
||||||
function Tooltip({ children }) {
|
function Tooltip({ children }) {
|
||||||
@@ -45,13 +45,14 @@ function chartTime(value) {
|
|||||||
|
|
||||||
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
||||||
const [hovered, setHovered] = useState(null);
|
const [hovered, setHovered] = useState(null);
|
||||||
|
const previousPoints = useRef([]);
|
||||||
|
const previousScale = useRef(scale);
|
||||||
const max = samples.reduce((largest, sample) => {
|
const max = samples.reduce((largest, sample) => {
|
||||||
const gateway = byteString(sample.gatewayBytes);
|
const gateway = byteString(sample.gatewayBytes);
|
||||||
const proxy = byteString(sample.proxyBytes);
|
const proxy = byteString(sample.proxyBytes);
|
||||||
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
|
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
|
||||||
}, 0n);
|
}, 0n);
|
||||||
const mid = trafficAxisMid(max, scale);
|
const mid = trafficAxisMid(max, scale);
|
||||||
const latest = samples.at(-1)?.observedAt || 'empty';
|
|
||||||
const firstSlot = capacity - samples.length;
|
const firstSlot = capacity - samples.length;
|
||||||
const points = samples.map((sample, index) => {
|
const points = samples.map((sample, index) => {
|
||||||
const gateway = byteString(sample.gatewayBytes);
|
const gateway = byteString(sample.gatewayBytes);
|
||||||
@@ -69,6 +70,15 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
|||||||
const penultimate = points.at(-2);
|
const penultimate = points.at(-2);
|
||||||
const newest = points.at(-1);
|
const newest = points.at(-1);
|
||||||
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
const hasProxy = points.some(({ proxy }) => proxy > 0n);
|
||||||
|
const scaleFrom = previousPoints.current;
|
||||||
|
const animateScale = previousScale.current !== scale
|
||||||
|
&& scaleFrom.length === points.length
|
||||||
|
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
previousPoints.current = points;
|
||||||
|
previousScale.current = scale;
|
||||||
|
}, [points, scale]);
|
||||||
|
|
||||||
function trackPointer(event) {
|
function trackPointer(event) {
|
||||||
const bounds = event.currentTarget.getBoundingClientRect();
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
@@ -111,12 +121,48 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
|
|||||||
<line x1="0" x2="100" y1="50" y2="50" />
|
<line x1="0" x2="100" y1="50" y2="50" />
|
||||||
<line x1="0" x2="100" y1="100" y2="100" />
|
<line x1="0" x2="100" y1="100" y2="100" />
|
||||||
</g>}
|
</g>}
|
||||||
<g key={latest} className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
|
||||||
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')} />}
|
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}>
|
||||||
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')} />}
|
{animateScale && <animate
|
||||||
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY} />}
|
key={`gateway-${scale}`}
|
||||||
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY} />}
|
attributeName="points"
|
||||||
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5" />}
|
from={scaleFrom.slice(0, -1).map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
|
||||||
|
to={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
|
||||||
|
dur="520ms"
|
||||||
|
calcMode="spline"
|
||||||
|
keyTimes="0;1"
|
||||||
|
keySplines="0.16 1 0.3 1"
|
||||||
|
fill="freeze"
|
||||||
|
/>}
|
||||||
|
</polyline>}
|
||||||
|
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}>
|
||||||
|
{animateScale && <animate
|
||||||
|
key={`proxy-${scale}`}
|
||||||
|
attributeName="points"
|
||||||
|
from={scaleFrom.slice(0, -1).map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
|
||||||
|
to={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
|
||||||
|
dur="520ms"
|
||||||
|
calcMode="spline"
|
||||||
|
keyTimes="0;1"
|
||||||
|
keySplines="0.16 1 0.3 1"
|
||||||
|
fill="freeze"
|
||||||
|
/>}
|
||||||
|
</polyline>}
|
||||||
|
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY}>
|
||||||
|
{animateScale && <>
|
||||||
|
<animate key={`gateway-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).gatewayY} to={penultimate.gatewayY} dur="520ms" fill="freeze" />
|
||||||
|
<animate key={`gateway-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />
|
||||||
|
</>}
|
||||||
|
</line>}
|
||||||
|
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY}>
|
||||||
|
{animateScale && <>
|
||||||
|
<animate key={`proxy-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).proxyY} to={penultimate.proxyY} dur="520ms" fill="freeze" />
|
||||||
|
<animate key={`proxy-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).proxyY} to={newest.proxyY} dur="520ms" fill="freeze" />
|
||||||
|
</>}
|
||||||
|
</line>}
|
||||||
|
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5">
|
||||||
|
{animateScale && <animate key={`gateway-cy-${scale}`} attributeName="cy" from={scaleFrom[0].gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />}
|
||||||
|
</circle>}
|
||||||
</g>
|
</g>
|
||||||
{hovered && <g className="client-device-traffic-cursor">
|
{hovered && <g className="client-device-traffic-cursor">
|
||||||
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
|
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
|
||||||
@@ -335,6 +381,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startEditing(device) {
|
||||||
|
setEditingId(device.id);
|
||||||
|
setAlias(device.alias || device.hostname || '');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
@@ -431,7 +482,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'copied!' : ''}
|
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
|
||||||
{devices.map((device) => {
|
{devices.map((device) => {
|
||||||
@@ -509,36 +560,34 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{device.ip ? <h3 className="client-device-name-heading">
|
<h3 className="client-device-name-heading">
|
||||||
<button
|
{hasName && <button
|
||||||
className={`client-device-name${hasName ? '' : ' is-address-only'}${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
className="client-device-alias-trigger"
|
||||||
|
type="button"
|
||||||
|
aria-label={`Изменить название ${title}`}
|
||||||
|
onClick={() => startEditing(device)}
|
||||||
|
>{title}</button>}
|
||||||
|
{hasName && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
|
||||||
|
{device.ip ? <button
|
||||||
|
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
|
||||||
onClick={() => copyDeviceIp(device)}
|
onClick={() => copyDeviceIp(device)}
|
||||||
>
|
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
|
||||||
<span className="client-device-name-primary">{title}</span>
|
</h3>
|
||||||
{hasName && <span className="client-device-name-ip" aria-hidden="true">{device.ip}</span>}
|
{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">
|
||||||
<span className="client-device-name-feedback" aria-hidden="true">
|
|
||||||
{copied && copyFeedback.failed ? 'Ошибка' : 'copied!'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</h3> : <h3>{title}</h3>}
|
|
||||||
<span className="client-device-edit-wrap client-tooltip-anchor">
|
|
||||||
<button
|
<button
|
||||||
className="client-device-edit"
|
className="client-device-edit"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Изменить название ${title}`}
|
aria-label={`Изменить название ${title}`}
|
||||||
onClick={() => {
|
onClick={() => startEditing(device)}
|
||||||
setEditingId(device.id);
|
|
||||||
setAlias(device.alias || '');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" />
|
<path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<Tooltip>Изменить название</Tooltip>
|
<Tooltip>Изменить название</Tooltip>
|
||||||
</span>
|
</span>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
|
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">
|
||||||
|
|||||||
+267
-153
@@ -951,107 +951,86 @@ p {
|
|||||||
|
|
||||||
.client-device-main > h3 {
|
.client-device-main > h3 {
|
||||||
height: 23px;
|
height: 23px;
|
||||||
flex: 0 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
gap: 5px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.03em;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-name {
|
.client-device-alias-trigger,
|
||||||
height: 23px;
|
.client-device-ip {
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font: 700 14px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
|
||||||
letter-spacing: -0.03em;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: copy;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name-ip {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name > span {
|
|
||||||
grid-area: 1 / 1;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(8px);
|
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transform: translateY(0.18em) scale(0.96);
|
transition: color 180ms ease, filter 300ms ease;
|
||||||
transform-origin: left center;
|
|
||||||
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1), transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-name > .client-device-name-primary {
|
.client-device-alias-trigger {
|
||||||
opacity: 1;
|
flex: 0 1 auto;
|
||||||
filter: blur(0);
|
font: 700 14px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
transform: translateY(0) scale(1);
|
letter-spacing: -0.03em;
|
||||||
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-name:not(.is-address-only):hover .client-device-name-primary,
|
.client-device-alias-trigger:hover,
|
||||||
.client-device-name:not(.is-address-only):focus-visible .client-device-name-primary {
|
.client-device-alias-trigger:focus-visible {
|
||||||
opacity: 0;
|
|
||||||
filter: blur(8px);
|
|
||||||
transform: translateY(-0.18em) scale(1.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name:not(.is-address-only):hover .client-device-name-ip,
|
|
||||||
.client-device-name:not(.is-address-only):focus-visible .client-device-name-ip {
|
|
||||||
opacity: 1;
|
|
||||||
filter: blur(0);
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name.is-copied .client-device-name-primary,
|
|
||||||
.client-device-name.is-copied .client-device-name-ip,
|
|
||||||
.client-device-name.is-copy-error .client-device-name-primary,
|
|
||||||
.client-device-name.is-copy-error .client-device-name-ip {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(8px);
|
|
||||||
transform: translateY(-0.18em) scale(1.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name.is-copied .client-device-name-feedback,
|
|
||||||
.client-device-name.is-copy-error .client-device-name-feedback {
|
|
||||||
opacity: 1;
|
|
||||||
filter: blur(0);
|
|
||||||
transform: translateY(0) scale(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name-feedback {
|
|
||||||
font-size: 9px;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-name.is-copied .client-device-name-feedback {
|
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-name.is-copy-error .client-device-name-feedback {
|
.client-device-name-separator {
|
||||||
color: oklch(0.68 0.15 28);
|
flex: 0 0 auto;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-name:focus-visible {
|
.client-device-ip {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font: 600 12px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
cursor: copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-ip:hover,
|
||||||
|
.client-device-ip:focus-visible {
|
||||||
|
color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-ip.is-copied {
|
||||||
|
--client-device-copy-color: var(--client-accent);
|
||||||
|
animation: client-device-ip-copy 800ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-ip.is-copy-error {
|
||||||
|
--client-device-copy-color: oklch(0.68 0.15 28);
|
||||||
|
animation: client-device-ip-copy 800ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-alias-trigger:focus-visible,
|
||||||
|
.client-device-ip:focus-visible {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
outline: 2px solid var(--client-accent);
|
outline: 2px solid var(--client-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes client-device-ip-copy {
|
||||||
|
35% {
|
||||||
|
color: var(--client-device-copy-color);
|
||||||
|
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-device-copy-color) 48%, transparent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-edit-wrap,
|
.client-device-edit-wrap,
|
||||||
.client-device-pin-wrap {
|
.client-device-pin-wrap {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@@ -1074,14 +1053,6 @@ p {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-name-heading:has(.client-device-name:not(.is-address-only):hover) + .client-device-edit-wrap,
|
|
||||||
.client-device-name-heading:has(.client-device-name:not(.is-address-only):focus-visible) + .client-device-edit-wrap {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(5px);
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateX(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-pin-wrap {
|
.client-device-pin-wrap {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
@@ -4571,28 +4542,40 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-header {
|
.client-diagnostics-header {
|
||||||
margin-bottom: 26px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-run {
|
.client-diagnostics-title-row {
|
||||||
min-width: 190px;
|
|
||||||
min-height: 42px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 12px;
|
||||||
gap: 9px;
|
}
|
||||||
margin: 0 8px 26px;
|
|
||||||
|
.client-diagnostics-title-row h2 {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-refresh-wrap {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-refresh {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--client-accent);
|
color: var(--client-muted);
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 220ms ease, filter 320ms ease, transform 320ms cubic-bezier(0.16, 1, 0.3, 1);
|
transition: color 220ms ease, filter 320ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-run svg {
|
.client-diagnostics-refresh svg {
|
||||||
width: 17px;
|
width: 17px;
|
||||||
height: 17px;
|
height: 17px;
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -4600,30 +4583,52 @@ p {
|
|||||||
stroke-width: 1.7;
|
stroke-width: 1.7;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
|
transition: transform 600ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-run:hover:not(:disabled),
|
.client-diagnostics-refresh:hover:not(:disabled),
|
||||||
.client-diagnostics-run:focus-visible {
|
.client-diagnostics-refresh:focus-visible,
|
||||||
outline: 0;
|
.client-diagnostics-refresh.is-running {
|
||||||
|
color: var(--client-accent);
|
||||||
filter: drop-shadow(0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent));
|
filter: drop-shadow(0 0 8px color-mix(in oklch, var(--client-accent) 48%, transparent));
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-run.is-running svg {
|
.client-diagnostics-refresh:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-refresh:hover:not(:disabled) svg,
|
||||||
|
.client-diagnostics-refresh:focus-visible:not(.is-running) svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-refresh.is-running svg {
|
||||||
animation: client-spin 900ms linear infinite;
|
animation: client-spin 900ms linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-run:disabled {
|
.client-diagnostics-refresh:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-empty,
|
.client-diagnostics-refresh-wrap > .client-tooltip {
|
||||||
.client-diagnostics-error {
|
right: 0;
|
||||||
min-height: 56px;
|
left: auto;
|
||||||
margin: 0 8px;
|
text-transform: none;
|
||||||
color: var(--client-muted);
|
transform: translate(0, 2px);
|
||||||
font-size: 10px;
|
}
|
||||||
line-height: 1.65;
|
|
||||||
|
.client-diagnostics-refresh-wrap:hover > .client-tooltip,
|
||||||
|
.client-diagnostics-refresh-wrap:has(> :focus-visible) > .client-tooltip {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-feedback {
|
||||||
|
min-height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 8px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-error {
|
.client-diagnostics-error {
|
||||||
@@ -4631,6 +4636,8 @@ p {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
color: oklch(0.68 0.15 28);
|
color: oklch(0.68 0.15 28);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-error button {
|
.client-diagnostics-error button {
|
||||||
@@ -4642,54 +4649,101 @@ p {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-results {
|
.client-diagnostics-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
margin: 0 8px;
|
margin: 0 8px 24px;
|
||||||
opacity: 1;
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-section-title {
|
||||||
|
min-height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-section-title button,
|
||||||
|
.client-diagnostics-add button,
|
||||||
|
.client-diagnostics-remove {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-section-title button:disabled {
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
transition: opacity 220ms ease, filter 320ms ease;
|
transition: opacity 220ms ease, filter 320ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-results[aria-busy='true'] {
|
.client-diagnostics-table[aria-busy='true'] {
|
||||||
opacity: 0.58;
|
opacity: 0.68;
|
||||||
filter: saturate(0.7);
|
filter: saturate(0.72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-grid {
|
.client-diagnostics-table th,
|
||||||
min-height: 52px;
|
.client-diagnostics-table td {
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(96px, 0.75fr) repeat(2, minmax(0, 1fr));
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: color-mix(in oklch, var(--client-panel) 48%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-diagnostics-grid-head {
|
|
||||||
min-height: 32px;
|
|
||||||
padding-block: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-diagnostics-grid > strong,
|
|
||||||
.client-diagnostics-grid-head > strong {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
padding: 9px 8px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-table th:first-child {
|
||||||
|
width: 35%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-table thead th {
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 7px;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-grid > code,
|
.client-diagnostics-table tbody tr {
|
||||||
|
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-table tbody th {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-table code,
|
||||||
.client-diagnostics-status {
|
.client-diagnostics-status {
|
||||||
min-width: 0;
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font: 600 10px/1.45 '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,
|
||||||
@@ -4712,23 +4766,80 @@ p {
|
|||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-status.is-running {
|
||||||
|
color: var(--client-accent);
|
||||||
|
animation: client-operation-pulse 900ms ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
.client-diagnostics-summary {
|
.client-diagnostics-summary {
|
||||||
min-height: 46px;
|
font-size: 9px;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 12px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 1.55;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-time {
|
.client-diagnostics-time {
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-add input:focus {
|
||||||
|
border-color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-add-action {
|
||||||
|
min-height: 20px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-add-action span {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-service-name {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
outline: 0;
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-details {
|
.client-diagnostics-details {
|
||||||
margin-top: 12px;
|
margin: 0 8px;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
@@ -4765,21 +4876,22 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.client-diagnostics-grid {
|
.client-diagnostics-table th,
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
.client-diagnostics-table td {
|
||||||
gap: 6px 12px;
|
padding-inline: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-grid > strong:first-child,
|
.client-diagnostics-table th:first-child {
|
||||||
.client-diagnostics-grid-head > span:first-child {
|
width: 32%;
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-grid-head {
|
.client-diagnostics-add {
|
||||||
min-height: 42px;
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-diagnostics-add-action,
|
||||||
.client-diagnostics-details > div {
|
.client-diagnostics-details > div {
|
||||||
|
grid-column: 1;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4835,7 +4947,8 @@ p {
|
|||||||
.client-device-pin svg,
|
.client-device-pin svg,
|
||||||
.client-device-policy,
|
.client-device-policy,
|
||||||
.client-device-policy svg,
|
.client-device-policy svg,
|
||||||
.client-device-name > span,
|
.client-device-alias-trigger,
|
||||||
|
.client-device-ip,
|
||||||
.client-device-traffic-value > span,
|
.client-device-traffic-value > span,
|
||||||
.client-device-traffic-breakdown,
|
.client-device-traffic-breakdown,
|
||||||
.client-device-traffic-breakdown > span,
|
.client-device-traffic-breakdown > span,
|
||||||
@@ -4861,9 +4974,10 @@ p {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-diagnostics-run,
|
.client-diagnostics-refresh,
|
||||||
.client-diagnostics-run svg,
|
.client-diagnostics-refresh svg,
|
||||||
.client-diagnostics-results {
|
.client-diagnostics-table,
|
||||||
|
.client-diagnostics-status.is-running {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ test('connectivity diagnostics skips VPN probes when sing-box is off', async ()
|
|||||||
assert.equal(calls.some((args) => args.includes('--proxy')), false);
|
assert.equal(calls.some((args) => args.includes('--proxy')), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics keeps a partial snapshot and falls back when one IP source fails', async () => {
|
test('connectivity diagnostics keeps every IP source in a partial snapshot', async () => {
|
||||||
const execute = async (args) => {
|
const execute = async (args) => {
|
||||||
const url = args.at(-1);
|
const url = args.at(-1);
|
||||||
if (url.includes('cloudflare')) {
|
if (url.includes('cloudflare')) {
|
||||||
@@ -112,5 +112,42 @@ test('connectivity diagnostics keeps a partial snapshot and falls back when one
|
|||||||
'cloudflare',
|
'cloudflare',
|
||||||
'ipify',
|
'ipify',
|
||||||
'aws',
|
'aws',
|
||||||
|
'icanhazip',
|
||||||
|
'ifconfig-me',
|
||||||
|
'yandex-internet',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('connectivity diagnostics pins public custom services and rejects private destinations', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const execute = async (args) => {
|
||||||
|
calls.push(args);
|
||||||
|
const url = args.at(-1);
|
||||||
|
if (url.includes('cloudflare')) return response('ip=198.51.100.10\n');
|
||||||
|
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
|
||||||
|
if (url.includes('ipify')) return response('198.51.100.10');
|
||||||
|
return response();
|
||||||
|
};
|
||||||
|
const lookup = async (hostname) => hostname === 'router.local'
|
||||||
|
? [{ address: '192.168.50.1', family: 4 }]
|
||||||
|
: [{ address: '93.184.216.34', family: 4 }];
|
||||||
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute, lookup })
|
||||||
|
.run({
|
||||||
|
vpnAvailable: false,
|
||||||
|
services: [
|
||||||
|
{ id: 'custom-public', label: 'Example', url: 'https://example.com/status' },
|
||||||
|
{ id: 'custom-private', label: 'Router', url: 'https://router.local/' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicCall = calls.find((args) => args.at(-1) === 'https://example.com/status');
|
||||||
|
assert.ok(publicCall);
|
||||||
|
assert.equal(publicCall.includes('--location'), false);
|
||||||
|
assert.deepEqual(publicCall.slice(publicCall.indexOf('--resolve'), publicCall.indexOf('--resolve') + 2), [
|
||||||
|
'--resolve',
|
||||||
|
'example.com:443:93.184.216.34',
|
||||||
|
]);
|
||||||
|
assert.equal(calls.some((args) => args.at(-1) === 'https://router.local/'), false);
|
||||||
|
assert.equal(result.direct.sites.find((site) => site.id === 'custom-private').stage, 'validation');
|
||||||
|
assert.equal(result.assessment.comparisons.some((item) => item.id === 'custom-public'), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
assert.equal(traffic.running, true);
|
assert.equal(traffic.running, true);
|
||||||
await client.observeDevicePolicy();
|
await client.observeDevicePolicy();
|
||||||
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
|
await client.applyDevicePolicies([{ id: 'dev_0011223344556677' }]);
|
||||||
await client.runConnectivityDiagnostics();
|
await client.runConnectivityDiagnostics([{ id: 'custom-test', url: 'https://example.com' }]);
|
||||||
assert.equal(client.running, true);
|
assert.equal(client.running, true);
|
||||||
await client.restart();
|
await client.restart();
|
||||||
assert.equal((await client.stop()).running, false);
|
assert.equal((await client.stop()).running, false);
|
||||||
@@ -42,6 +42,9 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
'POST /stop /run/dataplane.sock',
|
'POST /stop /run/dataplane.sock',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(requests[5].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
assert.deepEqual(requests[5].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
||||||
|
assert.deepEqual(requests[6].body, {
|
||||||
|
services: [{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
|
});
|
||||||
assert.equal(requests[6].timeoutMs, 15_000);
|
assert.equal(requests[6].timeoutMs, 15_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test('gateway deploy updates control without recreating dataplane', () => {
|
|||||||
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
|
assert.match(workflow, /UPDATE_DATAPLANE="\$\{UPDATE_DATAPLANE\}"/);
|
||||||
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singbox\|singboxRuntime\|version\)/);
|
assert.match(workflow, /src\/server\/\(config\|dataplane\|gatewayRouting\|singbox\|singboxRuntime\|version\)/);
|
||||||
assert.match(workflow, /src\/server\/\(adapters\/neighbors\|services\/\(connectivityDiagnosticsService\|deviceTrafficService\|devicePolicyService\)\)/);
|
assert.match(workflow, /src\/server\/\(adapters\/neighbors\|services\/\(connectivityDiagnosticsService\|deviceTrafficService\|devicePolicyService\)\)/);
|
||||||
assert.match(workflow, /src\/shared\/errors/);
|
assert.match(workflow, /src\/shared\/\(connectivityDiagnostics\|errors\)/);
|
||||||
assert.doesNotMatch(workflow, /dataplaneClient/);
|
assert.doesNotMatch(workflow, /dataplaneClient/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||||
assert.match(panel, /copyText\(device\.ip\)/);
|
assert.match(panel, /copyText\(device\.ip\)/);
|
||||||
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
|
||||||
assert.match(panel, /client-device-name\$\{hasName \? '' : ' is-address-only'\}/);
|
assert.match(panel, /client-device-alias-trigger[\s\S]*client-device-name-separator[\s\S]*client-device-ip/);
|
||||||
assert.match(panel, /client-device-name-primary[\s\S]*\{hasName && <span className="client-device-name-ip"[\s\S]*client-device-name-feedback/);
|
assert.match(panel, /onClick=\{\(\) => startEditing\(device\)\}/);
|
||||||
assert.match(panel, /client-device-name-feedback[\s\S]*'copied!'/);
|
assert.match(panel, /\{!hasName && <span className="client-device-edit-wrap/);
|
||||||
assert.match(panel, /COPY_FEEDBACK_MS = 5_000/);
|
assert.match(panel, /COPY_FEEDBACK_MS = 800/);
|
||||||
assert.match(panel, /client-device-name-feedback[\s\S]*client-device-edit-wrap[\s\S]*client-device-last-seen/);
|
assert.match(panel, /IP скопирован/);
|
||||||
|
assert.doesNotMatch(panel, /client-device-name-feedback|client-device-name-primary|is-address-only/);
|
||||||
assert.doesNotMatch(panel, /client-device-title/);
|
assert.doesNotMatch(panel, /client-device-title/);
|
||||||
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
|
assert.match(panel, /online \? 'В сети' : <TextMorph from="Не в сети" to=\{seen\.relative\} \/>/);
|
||||||
assert.match(panel, /aria-label=\{`\$\{online \? 'В сети' : 'Не в сети'\}\. Последний контакт: \$\{seen\.tooltip\}`\}/);
|
assert.match(panel, /aria-label=\{`\$\{online \? 'В сети' : 'Не в сети'\}\. Последний контакт: \$\{seen\.tooltip\}`\}/);
|
||||||
@@ -61,6 +62,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
|
||||||
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
|
||||||
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
|
||||||
|
assert.match(panel, /previousScale\.current !== scale[\s\S]*attributeName="points"[\s\S]*dur="520ms"/);
|
||||||
|
assert.doesNotMatch(panel, /key=\{latest\}/);
|
||||||
assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
|
||||||
assert.match(panel, /onPointerMove=\{trackPointer\}/);
|
assert.match(panel, /onPointerMove=\{trackPointer\}/);
|
||||||
assert.match(panel, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
assert.match(panel, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
|
||||||
@@ -101,18 +104,12 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /@keyframes client-device-traffic-line-draw[\s\S]*stroke-dashoffset: 0/);
|
assert.match(styles, /@keyframes client-device-traffic-line-draw[\s\S]*stroke-dashoffset: 0/);
|
||||||
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
|
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
|
||||||
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
|
||||||
assert.match(styles, /\.client-device-name \{[\s\S]*font: 700 14px\/1\.2/);
|
assert.match(styles, /\.client-device-alias-trigger \{[\s\S]*font: 700 14px\/1\.2/);
|
||||||
assert.match(styles, /\.client-device-name-ip \{[\s\S]*font-size: 12px/);
|
assert.match(styles, /\.client-device-ip \{[\s\S]*font: 600 12px\/1\.2/);
|
||||||
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font-size: 8\.5px/);
|
assert.match(styles, /\.client-device-last-seen \{[\s\S]*position: absolute;[\s\S]*top: 0;[\s\S]*left: 0;[\s\S]*font-size: 8\.5px/);
|
||||||
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
|
assert.match(styles, /\.client-device-traffic strong \{[\s\S]*font-size: 10px/);
|
||||||
assert.match(styles, /\.client-device-name > span \{[^}]*opacity: 0/);
|
assert.match(styles, /\.client-device-name-separator \{[\s\S]*color: var\(--client-muted\)/);
|
||||||
assert.match(styles, /\.client-device-name > \.client-device-name-primary \{[^}]*opacity: 1/);
|
assert.match(styles, /\.client-device-ip\.is-copied \{[\s\S]*client-device-ip-copy 800ms/);
|
||||||
assert.match(styles, /\.client-device-name > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/);
|
|
||||||
assert.match(styles, /\.client-device-name:not\(\.is-address-only\):hover \.client-device-name-primary[\s\S]*translateY\(-0\.18em\)/);
|
|
||||||
assert.match(styles, /\.client-device-name:not\(\.is-address-only\):hover \.client-device-name-ip[\s\S]*opacity: 1[\s\S]*translateY\(0\) scale\(1\)/);
|
|
||||||
assert.match(styles, /\.client-device-name-heading:has\(\.client-device-name:not\(\.is-address-only\):hover\) \+ \.client-device-edit-wrap[\s\S]*opacity: 0/);
|
|
||||||
assert.match(styles, /\.client-device-name\.is-copied \.client-device-name-feedback[\s\S]*opacity: 1/);
|
|
||||||
assert.match(styles, /\.client-device-name-feedback \{[^}]*font-size: 9px/);
|
|
||||||
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding-top: 11px/);
|
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding-top: 11px/);
|
||||||
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
|
assert.match(styles, /\.client-device-last-seen \{[^}]*height: 10px[\s\S]*align-items: center/);
|
||||||
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
|
assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/);
|
||||||
@@ -125,7 +122,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/);
|
assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/);
|
||||||
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
|
||||||
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-alias-trigger[\s\S]*\.client-device-ip[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
|
||||||
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
|
||||||
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import test from 'node:test';
|
|||||||
const root = path.resolve(import.meta.dirname, '../..');
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
|
const diagnostics = fs.readFileSync(path.join(root, 'src/web/components/ConnectivityDiagnosticsPanel.jsx'), 'utf8');
|
||||||
|
|
||||||
function rule(selector, source = styles) {
|
function rule(selector, source = styles) {
|
||||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@@ -110,6 +111,17 @@ test('secondary menus share one right rail and both drawers open from the right'
|
|||||||
assert.match(component, /className={`client-drawer client-local-rules/);
|
assert.match(component, /className={`client-drawer client-local-rules/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('connectivity diagnostics render stable compact tables before the first run', () => {
|
||||||
|
assert.match(diagnostics, /CONNECTIVITY_IP_SOURCES\.map/);
|
||||||
|
assert.match(diagnostics, /const sites = \[\.\.\.CONNECTIVITY_SITES, \.\.\.customServices\]/);
|
||||||
|
assert.equal((diagnostics.match(/<table className="client-diagnostics-table"/g) || []).length, 2);
|
||||||
|
assert.match(diagnostics, /Добавить свой сервис/);
|
||||||
|
assert.match(diagnostics, /client-diagnostics-refresh/);
|
||||||
|
assert.doesNotMatch(diagnostics, /Проверить ещё раз|client-diagnostics-empty|client-diagnostics-run/);
|
||||||
|
assert.match(rule('.client-diagnostics-feedback'), /min-height:\s*34px/);
|
||||||
|
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
||||||
|
});
|
||||||
|
|
||||||
test('duration and Gateway access keep stable geometry without tabs', () => {
|
test('duration and Gateway access keep stable geometry without tabs', () => {
|
||||||
assert.match(rule('.client-state-detail'), /min-height:\s*44px/);
|
assert.match(rule('.client-state-detail'), /min-height:\s*44px/);
|
||||||
assert.match(rule('.client-duration-toggle'), /min-height:\s*44px/);
|
assert.match(rule('.client-duration-toggle'), /min-height:\s*44px/);
|
||||||
|
|||||||
Reference in New Issue
Block a user