Add targeted connectivity diagnostics with sampled results
This commit is contained in:
@@ -91,10 +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') {
|
||||||
const { services = [] } = await readJson(req);
|
const { services = [], target = null } = await readJson(req);
|
||||||
return sendJson(res, 200, await connectivityDiagnostics.run({
|
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||||
vpnAvailable: runtime.running,
|
vpnAvailable: runtime.running,
|
||||||
services,
|
services,
|
||||||
|
target,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.url === '/apply') {
|
if (req.method === 'POST' && req.url === '/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 (services = []) => {
|
runConnectivityDiagnostics: async (services = [], target = null) => {
|
||||||
try {
|
try {
|
||||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services }, 15_000);
|
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -699,15 +699,16 @@ async function handleApi(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
||||||
const { services = [] } = await readBody(req);
|
const { services = [], target = null } = 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(services)
|
? await singboxRuntime.runConnectivityDiagnostics(services, target)
|
||||||
: await localConnectivityDiagnostics.run({
|
: await localConnectivityDiagnostics.run({
|
||||||
vpnAvailable: (await singboxRuntime.refresh()).running,
|
vpnAvailable: (await singboxRuntime.refresh()).running,
|
||||||
services,
|
services,
|
||||||
|
target,
|
||||||
});
|
});
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
|
|||||||
import { lookup as dnsLookup } from 'node:dns/promises';
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import {
|
import {
|
||||||
|
assessConnectivity,
|
||||||
CONNECTIVITY_IP_SOURCES,
|
CONNECTIVITY_IP_SOURCES,
|
||||||
CONNECTIVITY_SITES,
|
CONNECTIVITY_SITES,
|
||||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
@@ -18,6 +19,7 @@ const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
|||||||
: (body) => body.trim(),
|
: (body) => body.trim(),
|
||||||
}));
|
}));
|
||||||
const SITE_PROBES = CONNECTIVITY_SITES;
|
const SITE_PROBES = CONNECTIVITY_SITES;
|
||||||
|
const TARGET_SAMPLE_COUNT = 3;
|
||||||
|
|
||||||
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||||
for (const [address, prefix] of [
|
for (const [address, prefix] of [
|
||||||
@@ -58,6 +60,26 @@ function milliseconds(value) {
|
|||||||
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function average(values) {
|
||||||
|
const numbers = values.filter(Number.isFinite);
|
||||||
|
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mostCommon(values) {
|
||||||
|
const counts = new Map();
|
||||||
|
let selected = null;
|
||||||
|
let selectedCount = 0;
|
||||||
|
for (const value of values) {
|
||||||
|
const count = (counts.get(value) || 0) + 1;
|
||||||
|
counts.set(value, count);
|
||||||
|
if (count >= selectedCount) {
|
||||||
|
selected = value;
|
||||||
|
selectedCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
async function request(probe, path, proxyPort, execute, {
|
async function request(probe, path, proxyPort, execute, {
|
||||||
body = false,
|
body = false,
|
||||||
ipv4 = false,
|
ipv4 = false,
|
||||||
@@ -112,17 +134,26 @@ async function request(probe, path, proxyPort, execute, {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ipProbe(probe, path, proxyPort, execute) {
|
async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||||
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
const samples = [];
|
||||||
const address = result.ok ? probe.address(result.body) : null;
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
const valid = typeof address === 'string' && net.isIP(address) === probe.family;
|
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
|
||||||
|
const parsed = result.ok ? probe.address(result.body) : null;
|
||||||
|
samples.push({
|
||||||
|
...result,
|
||||||
|
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const address = mostCommon(samples.map((sample) => sample.address).filter(Boolean));
|
||||||
|
const matching = samples.filter((sample) => sample.address === address);
|
||||||
return {
|
return {
|
||||||
source: probe.id,
|
source: probe.id,
|
||||||
label: probe.label,
|
label: probe.label,
|
||||||
family: probe.family,
|
family: probe.family,
|
||||||
address: valid ? address : null,
|
address,
|
||||||
latencyMs: result.latencyMs,
|
attempts: samples.length,
|
||||||
error: valid ? null : result.error || 'invalid IP response',
|
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||||
|
error: address ? null : samples.at(-1)?.error || 'invalid IP response',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +213,12 @@ async function prepareCustomProbes(services, lookup) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function siteProbe(probe, path, proxyPort, execute) {
|
function siteStatus(result) {
|
||||||
|
if (!result.ok) return 'unavailable';
|
||||||
|
return result.httpStatus >= 200 && result.httpStatus < 400 ? 'available' : 'responded';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||||
if (probe.validationError) return {
|
if (probe.validationError) return {
|
||||||
id: probe.id,
|
id: probe.id,
|
||||||
label: probe.label,
|
label: probe.label,
|
||||||
@@ -195,27 +231,26 @@ async function siteProbe(probe, path, proxyPort, execute) {
|
|||||||
error: probe.validationError,
|
error: probe.validationError,
|
||||||
};
|
};
|
||||||
const options = { follow: probe.follow !== false, resolve: probe.resolve };
|
const options = { follow: probe.follow !== false, resolve: probe.resolve };
|
||||||
let result = await request(probe, path, proxyPort, execute, options);
|
const samples = [];
|
||||||
let attempts = 1;
|
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||||
if (!result.ok) {
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
result = await request(probe, path, proxyPort, execute, options);
|
|
||||||
attempts = 2;
|
|
||||||
}
|
}
|
||||||
const status = !result.ok
|
if (sampleCount === 1 && !samples[0].ok) {
|
||||||
? 'unavailable'
|
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||||
: result.httpStatus >= 200 && result.httpStatus < 400
|
}
|
||||||
? 'available'
|
const status = mostCommon(samples.map(siteStatus));
|
||||||
: 'responded';
|
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
||||||
|
const representative = matching.at(-1);
|
||||||
return {
|
return {
|
||||||
id: probe.id,
|
id: probe.id,
|
||||||
label: probe.label,
|
label: probe.label,
|
||||||
status,
|
status,
|
||||||
attempts,
|
attempts: samples.length,
|
||||||
httpStatus: result.httpStatus,
|
httpStatus: mostCommon(matching.map((sample) => sample.httpStatus)),
|
||||||
latencyMs: result.latencyMs,
|
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||||
totalMs: result.totalMs,
|
totalMs: average(matching.map((sample) => sample.totalMs)),
|
||||||
stage: result.stage,
|
stage: representative.stage,
|
||||||
error: result.error,
|
error: representative.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,68 +269,78 @@ async function probePath(path, proxyPort, execute, sites) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assessConnectivity(direct, vpn) {
|
function unavailablePath() {
|
||||||
const comparisons = direct.sites.map(({ id, label }) => {
|
return {
|
||||||
const directSite = direct.sites.find((site) => site.id === id);
|
available: false,
|
||||||
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
reason: 'vpn-off',
|
||||||
let assessment = 'inconclusive';
|
internetAvailable: false,
|
||||||
if (!vpn.available) assessment = 'not-tested';
|
ipv4: { addresses: [], sources: [] },
|
||||||
else if (directSite?.status === 'available' && vpnSite?.status === 'available') {
|
ipv6: null,
|
||||||
assessment = 'available';
|
ipv6Source: null,
|
||||||
} else if (
|
sites: [],
|
||||||
directSite?.status === 'responded'
|
};
|
||||||
&& [403, 451].includes(directSite.httpStatus)
|
|
||||||
&& vpnSite?.status === 'available'
|
|
||||||
) assessment = 'likely-direct-restriction';
|
|
||||||
else if (
|
|
||||||
directSite?.status === 'unavailable'
|
|
||||||
&& vpnSite?.status === 'available'
|
|
||||||
&& direct.internetAvailable
|
|
||||||
) {
|
|
||||||
assessment = 'likely-direct-restriction';
|
|
||||||
} else if (directSite?.status === 'available' && vpnSite?.status !== 'available') {
|
|
||||||
assessment = 'vpn-problem';
|
|
||||||
} else if (directSite?.status === 'unavailable' && vpnSite?.status === 'unavailable') {
|
|
||||||
assessment = 'unavailable';
|
|
||||||
}
|
|
||||||
return { id, label, assessment, direct: directSite, vpn: vpnSite || null };
|
|
||||||
});
|
|
||||||
const directAddresses = [...direct.ipv4.addresses, direct.ipv6].filter(Boolean);
|
|
||||||
const vpnAddresses = [...(vpn.ipv4?.addresses || []), vpn.ipv6].filter(Boolean);
|
|
||||||
const sameEgress = directAddresses.some((address) => vpnAddresses.includes(address));
|
|
||||||
let summary = 'inconclusive';
|
|
||||||
if (!vpn.available) summary = 'vpn-off';
|
|
||||||
else if (!direct.internetAvailable && !vpn.internetAvailable) summary = 'offline';
|
|
||||||
else if (!direct.internetAvailable && vpn.internetAvailable) summary = 'direct-offline';
|
|
||||||
else if (direct.internetAvailable && !vpn.internetAvailable) summary = 'vpn-problem';
|
|
||||||
else if (comparisons.some((item) => item.assessment === 'likely-direct-restriction')) {
|
|
||||||
summary = 'likely-direct-restriction';
|
|
||||||
} else if (sameEgress) summary = 'same-ip';
|
|
||||||
else if (comparisons.every((item) => item.assessment === 'available')) summary = 'available';
|
|
||||||
return { summary, sameEgress, comparisons };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTarget(targetId, sites) {
|
||||||
|
if (typeof targetId !== 'string') return null;
|
||||||
|
if (targetId.startsWith('ip:')) {
|
||||||
|
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
|
||||||
|
return probe ? { kind: 'ip', probe } : null;
|
||||||
|
}
|
||||||
|
if (targetId.startsWith('site:')) {
|
||||||
|
const probe = sites.find(({ id }) => id === targetId.slice(5));
|
||||||
|
return probe ? { kind: 'site', probe } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeTarget(target, path, proxyPort, execute) {
|
||||||
|
const ip = target.kind === 'ip'
|
||||||
|
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||||
|
: null;
|
||||||
|
const site = target.kind === 'site'
|
||||||
|
? await siteProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
|
||||||
|
: null;
|
||||||
|
const ipv4Sources = ip?.family === 4 ? [ip] : [];
|
||||||
|
const ipv6Source = ip?.family === 6 ? ip : null;
|
||||||
|
const sites = site ? [site] : [];
|
||||||
|
return {
|
||||||
|
available: true,
|
||||||
|
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
|
||||||
|
ipv4: {
|
||||||
|
addresses: ipv4Sources.map(({ address }) => address).filter(Boolean),
|
||||||
|
sources: ipv4Sources,
|
||||||
|
},
|
||||||
|
ipv6: ipv6Source?.address || null,
|
||||||
|
ipv6Source,
|
||||||
|
sites,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { assessConnectivity };
|
||||||
|
|
||||||
export function createConnectivityDiagnosticsService({
|
export function createConnectivityDiagnosticsService({
|
||||||
proxyPort,
|
proxyPort,
|
||||||
execute = runCurl,
|
execute = runCurl,
|
||||||
lookup = dnsLookup,
|
lookup = dnsLookup,
|
||||||
now = () => new Date().toISOString(),
|
now = () => new Date().toISOString(),
|
||||||
}) {
|
}) {
|
||||||
async function runOnce({ vpnAvailable, services = [] }) {
|
async function runOnce({ vpnAvailable, services = [], target: targetId = null }) {
|
||||||
const customProbes = await prepareCustomProbes(services, lookup);
|
const requestedServices = targetId?.startsWith('site:custom-')
|
||||||
|
? (Array.isArray(services) ? services : []).filter(({ id }) => `site:${id}` === targetId)
|
||||||
|
: targetId ? [] : services;
|
||||||
|
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||||
const siteProbes = [...SITE_PROBES, ...customProbes];
|
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||||
const directPromise = probePath('direct', proxyPort, execute, siteProbes);
|
const target = resolveTarget(targetId, siteProbes);
|
||||||
|
if (targetId && !target) throw new Error('Unknown diagnostic target');
|
||||||
|
const directPromise = target
|
||||||
|
? probeTarget(target, 'direct', proxyPort, execute)
|
||||||
|
: probePath('direct', proxyPort, execute, siteProbes);
|
||||||
const vpnPromise = vpnAvailable
|
const vpnPromise = vpnAvailable
|
||||||
? probePath('vpn', proxyPort, execute, siteProbes)
|
? target
|
||||||
: Promise.resolve({
|
? probeTarget(target, 'vpn', proxyPort, execute)
|
||||||
available: false,
|
: probePath('vpn', proxyPort, execute, siteProbes)
|
||||||
reason: 'vpn-off',
|
: Promise.resolve(unavailablePath());
|
||||||
internetAvailable: false,
|
|
||||||
ipv4: { addresses: [], sources: [] },
|
|
||||||
ipv6: null,
|
|
||||||
ipv6Source: null,
|
|
||||||
sites: [],
|
|
||||||
});
|
|
||||||
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
|
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
|
||||||
return {
|
return {
|
||||||
checkedAt: now(),
|
checkedAt: now(),
|
||||||
|
|||||||
@@ -18,3 +18,44 @@ export const CONNECTIVITY_SITES = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
|
||||||
|
|
||||||
|
export function assessConnectivity(direct, vpn) {
|
||||||
|
const comparisons = direct.sites.map(({ id, label }) => {
|
||||||
|
const directSite = direct.sites.find((site) => site.id === id);
|
||||||
|
const vpnSite = vpn.sites?.find((site) => site.id === id);
|
||||||
|
let assessment = 'inconclusive';
|
||||||
|
if (!vpn.available) assessment = 'not-tested';
|
||||||
|
else if (directSite?.status === 'available' && vpnSite?.status === 'available') {
|
||||||
|
assessment = 'available';
|
||||||
|
} else if (
|
||||||
|
directSite?.status === 'responded'
|
||||||
|
&& [403, 451].includes(directSite.httpStatus)
|
||||||
|
&& vpnSite?.status === 'available'
|
||||||
|
) assessment = 'likely-direct-restriction';
|
||||||
|
else if (
|
||||||
|
directSite?.status === 'unavailable'
|
||||||
|
&& vpnSite?.status === 'available'
|
||||||
|
&& direct.internetAvailable
|
||||||
|
) {
|
||||||
|
assessment = 'likely-direct-restriction';
|
||||||
|
} else if (directSite?.status === 'available' && vpnSite?.status !== 'available') {
|
||||||
|
assessment = 'vpn-problem';
|
||||||
|
} else if (directSite?.status === 'unavailable' && vpnSite?.status === 'unavailable') {
|
||||||
|
assessment = 'unavailable';
|
||||||
|
}
|
||||||
|
return { id, label, assessment, direct: directSite, vpn: vpnSite || null };
|
||||||
|
});
|
||||||
|
const directAddresses = [...direct.ipv4.addresses, direct.ipv6].filter(Boolean);
|
||||||
|
const vpnAddresses = [...(vpn.ipv4?.addresses || []), vpn.ipv6].filter(Boolean);
|
||||||
|
const sameEgress = directAddresses.some((address) => vpnAddresses.includes(address));
|
||||||
|
let summary = 'inconclusive';
|
||||||
|
if (!vpn.available) summary = 'vpn-off';
|
||||||
|
else if (!direct.internetAvailable && !vpn.internetAvailable) summary = 'offline';
|
||||||
|
else if (!direct.internetAvailable && vpn.internetAvailable) summary = 'direct-offline';
|
||||||
|
else if (direct.internetAvailable && !vpn.internetAvailable) summary = 'vpn-problem';
|
||||||
|
else if (comparisons.some((item) => item.assessment === 'likely-direct-restriction')) {
|
||||||
|
summary = 'likely-direct-restriction';
|
||||||
|
} else if (sameEgress) summary = 'same-ip';
|
||||||
|
else if (comparisons.every((item) => item.assessment === 'available')) summary = 'available';
|
||||||
|
return { summary, sameEgress, comparisons };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.17.5',
|
macClient: '0.17.6',
|
||||||
gatewayClient: '0.18.5',
|
gatewayClient: '0.18.6',
|
||||||
gatewayBackend: '0.18.0',
|
gatewayBackend: '0.18.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
+2
-2
@@ -92,9 +92,9 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
diagnostics: {
|
diagnostics: {
|
||||||
connectivity: (services = []) => request('/api/diagnostics/connectivity', {
|
connectivity: (services = [], target = null) => request('/api/diagnostics/connectivity', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ services }),
|
body: JSON.stringify({ services, target }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
|
assessConnectivity,
|
||||||
CONNECTIVITY_IP_SOURCES,
|
CONNECTIVITY_IP_SOURCES,
|
||||||
CONNECTIVITY_SITES,
|
CONNECTIVITY_SITES,
|
||||||
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
|
||||||
@@ -36,8 +37,9 @@ function readCustomServices() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resultStatus(site, pending, available = true) {
|
function resultStatus(site, pending, available = true) {
|
||||||
if (pending && !site) return ['is-running', 'Проверяем'];
|
if (!available) return ['is-muted', '—'];
|
||||||
if (!available || !site) return ['is-muted', '—'];
|
if (pending) return ['is-running', 'Проверяем'];
|
||||||
|
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} мс`];
|
||||||
@@ -57,15 +59,52 @@ function ipResult(path, source) {
|
|||||||
|
|
||||||
function IpCell({ path, source, pending, route }) {
|
function IpCell({ path, source, pending, route }) {
|
||||||
const value = ipResult(path, source);
|
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 (!path?.available) return <Status value={['is-muted', '—']} route={route} />;
|
||||||
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
if (!value?.address) return <Status value={['is-error', 'Нет ответа']} route={route} />;
|
||||||
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
|
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 }) {
|
export function ConnectivityDiagnosticsPanel({ isGateway, 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 [activeTarget, setActiveTarget] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [customServices, setCustomServices] = useState(readCustomServices);
|
const [customServices, setCustomServices] = useState(readCustomServices);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
@@ -85,11 +124,25 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
setStatus('running');
|
setStatus('running');
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
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');
|
setStatus('ready');
|
||||||
} catch (requestError) {
|
} catch (requestError) {
|
||||||
setError(requestError);
|
setError(requestError);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
|
} finally {
|
||||||
|
setActiveTarget(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,11 +232,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
<th>Напрямую</th>
|
<th>Напрямую</th>
|
||||||
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
|
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => <tr key={source.id}>
|
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
|
||||||
<th scope="row">{source.label}</th>
|
const target = `ip:${source.id}`;
|
||||||
<td><IpCell path={result?.direct} source={source} pending={pending && !result} route={`Напрямую, ${source.label}`} /></td>
|
const running = activeTarget === target;
|
||||||
<td><IpCell path={result?.vpn} source={source} pending={pending && !result} route={`VPN, ${source.label}`} /></td>
|
return <tr key={source.id} className={running ? 'is-running' : undefined}>
|
||||||
</tr>)}</tbody>
|
<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>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -192,7 +248,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
|
|||||||
<span id="diagnostic-sites-title">Сервисы</span>
|
<span id="diagnostic-sites-title">Сервисы</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
disabled={pending || customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES}
|
||||||
onClick={() => setAdding((value) => !value)}
|
onClick={() => setAdding((value) => !value)}
|
||||||
>
|
>
|
||||||
{customServices.length >= MAX_CUSTOM_DIAGNOSTIC_SERVICES ? 'Лимит 5' : 'Добавить свой сервис'}
|
{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 direct = result?.direct?.sites?.find((item) => item.id === site.id);
|
||||||
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
|
const vpn = result?.vpn?.sites?.find((item) => item.id === site.id);
|
||||||
const custom = site.id.startsWith('custom-');
|
const 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">
|
<th scope="row">
|
||||||
<span className="client-diagnostics-service-name">{site.label}</span>
|
<span className="client-diagnostics-service-name">{site.label}</span>
|
||||||
{custom && <button
|
{custom && <button
|
||||||
className="client-diagnostics-remove"
|
className="client-diagnostics-remove"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Удалить сервис ${site.label}`}
|
aria-label={`Удалить сервис ${site.label}`}
|
||||||
|
disabled={pending}
|
||||||
onClick={() => setCustomServices((items) => items.filter((item) => item.id !== site.id))}
|
onClick={() => setCustomServices((items) => items.filter((item) => item.id !== site.id))}
|
||||||
>×</button>}
|
>×</button>}
|
||||||
</th>
|
</th>
|
||||||
<td><Status value={resultStatus(direct, pending && !result)} route={`Напрямую, ${site.label}`} /></td>
|
<td><Status value={resultStatus(direct, running)} route={`Напрямую, ${site.label}`} /></td>
|
||||||
<td><Status value={resultStatus(vpn, pending && !result, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></td>
|
<td><Status value={resultStatus(vpn, running, result?.vpn?.available !== false)} route={`VPN, ${site.label}`} /></td>
|
||||||
</tr>;
|
</tr>;
|
||||||
})}</tbody>
|
})}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
+24
-9
@@ -365,21 +365,21 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-shell.is-intro .harbor-brand-content {
|
.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-panel,
|
||||||
.client-shell.is-intro .client-secondary-menu,
|
.client-shell.is-intro .client-secondary-menu,
|
||||||
.client-shell.is-intro .harbor-versions {
|
.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 {
|
@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 {
|
@keyframes harbor-startup-content {
|
||||||
from { opacity: 0; filter: blur(10px); }
|
from { opacity: 0; filter: blur(6px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.harbor-brand svg {
|
.harbor-brand svg {
|
||||||
@@ -4706,11 +4706,6 @@ p {
|
|||||||
transition: opacity 220ms ease, filter 320ms ease;
|
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 th,
|
||||||
.client-diagnostics-table td {
|
.client-diagnostics-table td {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -4736,6 +4731,25 @@ p {
|
|||||||
|
|
||||||
.client-diagnostics-table tbody tr {
|
.client-diagnostics-table tbody tr {
|
||||||
border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent);
|
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 {
|
.client-diagnostics-table tbody th {
|
||||||
@@ -4946,6 +4960,7 @@ p {
|
|||||||
.client-diagnostics-refresh,
|
.client-diagnostics-refresh,
|
||||||
.client-diagnostics-refresh svg,
|
.client-diagnostics-refresh svg,
|
||||||
.client-diagnostics-table,
|
.client-diagnostics-table,
|
||||||
|
.client-diagnostics-table tbody tr.is-running,
|
||||||
.client-diagnostics-status.is-running {
|
.client-diagnostics-status.is-running {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -55,6 +55,53 @@ test('connectivity diagnostics force separate direct and VPN paths', async () =>
|
|||||||
test('connectivity diagnostics endpoint is available in Connect and Gateway', () => {
|
test('connectivity diagnostics endpoint is available in Connect and Gateway', () => {
|
||||||
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
|
assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/);
|
||||||
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
|
assert.doesNotMatch(server, /settings\.appMode !== 'gateway'[\s\S]{0,120}ENDPOINT_NOT_FOUND/);
|
||||||
|
assert.match(server, /runConnectivityDiagnostics\(services, target\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a targeted IP row uses three samples and keeps the majority address', async () => {
|
||||||
|
const attempts = { direct: 0, vpn: 0 };
|
||||||
|
const execute = async (args) => {
|
||||||
|
const route = args.includes('--proxy') ? 'vpn' : 'direct';
|
||||||
|
attempts[route] += 1;
|
||||||
|
if (route === 'vpn' && attempts.vpn === 1) {
|
||||||
|
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
|
||||||
|
}
|
||||||
|
const address = route === 'vpn' ? '203.0.113.20' : '198.51.100.10';
|
||||||
|
return response(`{"ipv4":"${address}"}`, {
|
||||||
|
time_starttransfer: route === 'vpn' ? attempts.vpn * 0.1 : attempts.direct * 0.1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
||||||
|
.run({ vpnAvailable: true, target: 'ip:yandex-internet' });
|
||||||
|
|
||||||
|
assert.equal(result.direct.ipv4.sources[0].attempts, 3);
|
||||||
|
assert.equal(result.vpn.ipv4.sources[0].attempts, 3);
|
||||||
|
assert.equal(result.direct.ipv4.sources[0].address, '198.51.100.10');
|
||||||
|
assert.equal(result.vpn.ipv4.sources[0].address, '203.0.113.20');
|
||||||
|
assert.equal(result.direct.ipv4.sources[0].latencyMs, 200);
|
||||||
|
assert.equal(result.vpn.ipv4.sources[0].latencyMs, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a targeted service row averages three measurements and ignores one transient failure', async () => {
|
||||||
|
const attempts = { direct: 0, vpn: 0 };
|
||||||
|
const execute = async (args) => {
|
||||||
|
const route = args.includes('--proxy') ? 'vpn' : 'direct';
|
||||||
|
attempts[route] += 1;
|
||||||
|
if (route === 'direct' && attempts.direct === 1) {
|
||||||
|
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
|
||||||
|
}
|
||||||
|
return response('', {
|
||||||
|
time_starttransfer: route === 'direct' ? attempts.direct * 0.1 : attempts.vpn * 0.2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
|
||||||
|
.run({ vpnAvailable: true, target: 'site:yandex' });
|
||||||
|
|
||||||
|
assert.deepEqual(attempts, { direct: 3, vpn: 3 });
|
||||||
|
assert.equal(result.direct.sites[0].status, 'available');
|
||||||
|
assert.equal(result.direct.sites[0].attempts, 3);
|
||||||
|
assert.equal(result.direct.sites[0].latencyMs, 250);
|
||||||
|
assert.equal(result.vpn.sites[0].latencyMs, 400);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => {
|
test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => {
|
||||||
@@ -160,3 +207,30 @@ test('connectivity diagnostics pins public custom services and rejects private d
|
|||||||
assert.equal(result.direct.sites.find((site) => site.id === 'custom-private').stage, 'validation');
|
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);
|
assert.equal(result.assessment.comparisons.some((item) => item.id === 'custom-public'), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a targeted custom row validates and samples only that service', async () => {
|
||||||
|
const lookups = [];
|
||||||
|
const calls = [];
|
||||||
|
const result = await createConnectivityDiagnosticsService({
|
||||||
|
proxyPort: 18080,
|
||||||
|
execute: async (args) => {
|
||||||
|
calls.push(args.at(-1));
|
||||||
|
return response();
|
||||||
|
},
|
||||||
|
lookup: async (hostname) => {
|
||||||
|
lookups.push(hostname);
|
||||||
|
return [{ address: '93.184.216.34', family: 4 }];
|
||||||
|
},
|
||||||
|
}).run({
|
||||||
|
vpnAvailable: false,
|
||||||
|
services: [
|
||||||
|
{ id: 'custom-first', url: 'https://first.example/' },
|
||||||
|
{ id: 'custom-second', url: 'https://second.example/' },
|
||||||
|
],
|
||||||
|
target: 'site:custom-second',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(lookups, ['second.example']);
|
||||||
|
assert.deepEqual(calls, ['https://second.example/', 'https://second.example/', 'https://second.example/']);
|
||||||
|
assert.equal(result.direct.sites[0].latencyMs, 120);
|
||||||
|
});
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ 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([{ id: 'custom-test', url: 'https://example.com' }]);
|
await client.runConnectivityDiagnostics(
|
||||||
|
[{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
|
'site:custom-test',
|
||||||
|
);
|
||||||
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);
|
||||||
@@ -44,8 +47,9 @@ test('control uses the dataplane socket protocol', async () => {
|
|||||||
assert.deepEqual(requests[5].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
assert.deepEqual(requests[5].body, { devices: [{ id: 'dev_0011223344556677' }] });
|
||||||
assert.deepEqual(requests[6].body, {
|
assert.deepEqual(requests[6].body, {
|
||||||
services: [{ id: 'custom-test', url: 'https://example.com' }],
|
services: [{ id: 'custom-test', url: 'https://example.com' }],
|
||||||
|
target: 'site:custom-test',
|
||||||
});
|
});
|
||||||
assert.equal(requests[6].timeoutMs, 15_000);
|
assert.equal(requests[6].timeoutMs, 25_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('connectivity diagnostics expose a retryable domain error', async () => {
|
test('connectivity diagnostics expose a retryable domain error', async () => {
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ test('page reload plays one stable startup sequence', () => {
|
|||||||
assert.match(component, /const \[showIntro, setShowIntro\] = useState\(true\)/);
|
assert.match(component, /const \[showIntro, setShowIntro\] = useState\(true\)/);
|
||||||
assert.match(component, /\$\{showIntro \? ' is-intro' : ''\}/);
|
assert.match(component, /\$\{showIntro \? ' is-intro' : ''\}/);
|
||||||
assert.doesNotMatch(component, /showIntro && !hasSubscription/);
|
assert.doesNotMatch(component, /showIntro && !hasSubscription/);
|
||||||
assert.match(styles, /\.client-shell\.is-intro \.harbor-brand-content \{[\s\S]*harbor-startup-brand 1200ms/);
|
assert.match(styles, /\.client-shell\.is-intro \.harbor-brand-content \{[\s\S]*harbor-startup-brand 760ms/);
|
||||||
assert.match(styles, /@keyframes harbor-startup-brand[\s\S]*translateY\(53px\)/);
|
assert.match(styles, /@keyframes harbor-startup-brand[\s\S]*opacity: 0\.35;[\s\S]*filter: blur\(5px\)/);
|
||||||
assert.match(styles, /\.client-shell\.is-intro \.client-panel,[\s\S]*\.client-secondary-menu,[\s\S]*\.harbor-versions \{[\s\S]*harbor-startup-content 680ms 520ms/);
|
assert.doesNotMatch(/@keyframes harbor-startup-brand \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '', /translate|scale/);
|
||||||
assert.match(styles, /@keyframes harbor-startup-content[\s\S]*opacity: 0;[\s\S]*filter: blur\(10px\)/);
|
assert.match(styles, /\.client-shell\.is-intro \.client-panel,[\s\S]*\.client-secondary-menu,[\s\S]*\.harbor-versions \{[\s\S]*harbor-startup-content 720ms 120ms/);
|
||||||
|
assert.match(styles, /@keyframes harbor-startup-content[\s\S]*opacity: 0;[\s\S]*filter: blur\(6px\)/);
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-shell\.is-intro \.client-panel,[\s\S]*animation: none/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-shell\.is-intro \.client-panel,[\s\S]*animation: none/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,6 +137,11 @@ test('connectivity diagnostics render stable compact tables before the first run
|
|||||||
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);
|
assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/);
|
||||||
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/);
|
||||||
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
assert.match(rule('.client-diagnostics-table'), /table-layout:\s*fixed/);
|
||||||
|
assert.match(diagnostics, /for \(const target of targets\)/);
|
||||||
|
assert.match(diagnostics, /api\.diagnostics\.connectivity\(customServices, target\)/);
|
||||||
|
assert.match(diagnostics, /const target = `ip:\$\{source\.id\}`;[\s\S]*activeTarget === target/);
|
||||||
|
assert.match(diagnostics, /activeTarget === `site:\$\{site\.id\}`/);
|
||||||
|
assert.match(styles, /\.client-diagnostics-table tbody tr\.is-running \{[\s\S]*client-diagnostics-row-pulse/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('duration and Gateway access keep stable geometry without tabs', () => {
|
test('duration and Gateway access keep stable geometry without tabs', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user