From e9433e754f44bd4c455bc68b9880fd7862d9520a Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 22:11:04 +0300 Subject: [PATCH] Add targeted connectivity diagnostics with sampled results --- src/server/dataplane.js | 3 +- src/server/dataplaneClient.js | 4 +- src/server/index.js | 5 +- .../connectivityDiagnosticsService.js | 197 +++++++++++------- src/shared/connectivityDiagnostics.js | 41 ++++ src/shared/versions.js | 6 +- src/web/api.js | 4 +- .../ConnectivityDiagnosticsPanel.jsx | 84 ++++++-- src/web/styles.css | 33 ++- test/server/connectivity-diagnostics.test.js | 74 +++++++ test/server/dataplane-client.test.js | 8 +- test/web/responsive-layout-contract.test.js | 14 +- 12 files changed, 359 insertions(+), 114 deletions(-) diff --git a/src/server/dataplane.js b/src/server/dataplane.js index 593e669..5329701 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -91,10 +91,11 @@ const server = http.createServer(async (req, res) => { return sendJson(res, 200, await devicePolicy.apply(body.devices)); } 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({ vpnAvailable: runtime.running, services, + target, })); } if (req.method === 'POST' && req.url === '/apply') { diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js index 22d6bb0..940d8aa 100644 --- a/src/server/dataplaneClient.js +++ b/src/server/dataplaneClient.js @@ -56,9 +56,9 @@ export function createDataplaneClient(socketPath, send = request) { observeTraffic: () => send(socketPath, '/device-traffic', 'GET'), observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'), applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }), - runConnectivityDiagnostics: async (services = []) => { + runConnectivityDiagnostics: async (services = [], target = null) => { try { - return await send(socketPath, '/diagnostics/connectivity', 'POST', { services }, 15_000); + return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000); } catch (cause) { throw new HarborError('DIAGNOSTICS_FAILED', { cause }); } diff --git a/src/server/index.js b/src/server/index.js index 143fe67..925fae1 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -699,15 +699,16 @@ async function handleApi(req, res) { } 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 appliedServerId = state.appliedServerId || state.selectedServerId; const selected = (state.servers || []).find((server) => server.id === appliedServerId); const result = remoteDataplane - ? await singboxRuntime.runConnectivityDiagnostics(services) + ? await singboxRuntime.runConnectivityDiagnostics(services, target) : await localConnectivityDiagnostics.run({ vpnAvailable: (await singboxRuntime.refresh()).running, services, + target, }); return sendJson(res, 200, { ...result, diff --git a/src/server/services/connectivityDiagnosticsService.js b/src/server/services/connectivityDiagnosticsService.js index aa0c57b..f072282 100644 --- a/src/server/services/connectivityDiagnosticsService.js +++ b/src/server/services/connectivityDiagnosticsService.js @@ -2,6 +2,7 @@ import { execFile } from 'node:child_process'; import { lookup as dnsLookup } from 'node:dns/promises'; import net from 'node:net'; import { + assessConnectivity, CONNECTIVITY_IP_SOURCES, CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, @@ -18,6 +19,7 @@ const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({ : (body) => body.trim(), })); const SITE_PROBES = CONNECTIVITY_SITES; +const TARGET_SAMPLE_COUNT = 3; const BLOCKED_IPV4_ADDRESSES = new net.BlockList(); for (const [address, prefix] of [ @@ -58,6 +60,26 @@ function milliseconds(value) { 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, { body = false, ipv4 = false, @@ -112,17 +134,26 @@ async function request(probe, path, proxyPort, execute, { }; } -async function ipProbe(probe, path, proxyPort, execute) { - const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 }); - const address = result.ok ? probe.address(result.body) : null; - const valid = typeof address === 'string' && net.isIP(address) === probe.family; +async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) { + const samples = []; + for (let attempt = 0; attempt < sampleCount; attempt += 1) { + 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 { source: probe.id, label: probe.label, family: probe.family, - address: valid ? address : null, - latencyMs: result.latencyMs, - error: valid ? null : result.error || 'invalid IP response', + address, + attempts: samples.length, + 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 { id: probe.id, label: probe.label, @@ -195,27 +231,26 @@ async function siteProbe(probe, path, proxyPort, execute) { error: probe.validationError, }; const options = { follow: probe.follow !== false, resolve: probe.resolve }; - let result = await request(probe, path, proxyPort, execute, options); - let attempts = 1; - if (!result.ok) { - result = await request(probe, path, proxyPort, execute, options); - attempts = 2; + const samples = []; + for (let attempt = 0; attempt < sampleCount; attempt += 1) { + samples.push(await request(probe, path, proxyPort, execute, options)); } - const status = !result.ok - ? 'unavailable' - : result.httpStatus >= 200 && result.httpStatus < 400 - ? 'available' - : 'responded'; + if (sampleCount === 1 && !samples[0].ok) { + samples.push(await request(probe, path, proxyPort, execute, options)); + } + const status = mostCommon(samples.map(siteStatus)); + const matching = samples.filter((sample) => siteStatus(sample) === status); + const representative = matching.at(-1); return { id: probe.id, label: probe.label, status, - attempts, - httpStatus: result.httpStatus, - latencyMs: result.latencyMs, - totalMs: result.totalMs, - stage: result.stage, - error: result.error, + attempts: samples.length, + httpStatus: mostCommon(matching.map((sample) => sample.httpStatus)), + latencyMs: average(matching.map((sample) => sample.latencyMs)), + totalMs: average(matching.map((sample) => sample.totalMs)), + stage: representative.stage, + error: representative.error, }; } @@ -234,68 +269,78 @@ async function probePath(path, proxyPort, execute, sites) { }; } -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 }; +function unavailablePath() { + return { + available: false, + reason: 'vpn-off', + internetAvailable: false, + ipv4: { addresses: [], sources: [] }, + ipv6: null, + ipv6Source: null, + sites: [], + }; } +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({ proxyPort, execute = runCurl, lookup = dnsLookup, now = () => new Date().toISOString(), }) { - async function runOnce({ vpnAvailable, services = [] }) { - const customProbes = await prepareCustomProbes(services, lookup); + async function runOnce({ vpnAvailable, services = [], target: targetId = null }) { + 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 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 - ? probePath('vpn', proxyPort, execute, siteProbes) - : Promise.resolve({ - available: false, - reason: 'vpn-off', - internetAvailable: false, - ipv4: { addresses: [], sources: [] }, - ipv6: null, - ipv6Source: null, - sites: [], - }); + ? target + ? probeTarget(target, 'vpn', proxyPort, execute) + : probePath('vpn', proxyPort, execute, siteProbes) + : Promise.resolve(unavailablePath()); const [direct, vpn] = await Promise.all([directPromise, vpnPromise]); return { checkedAt: now(), diff --git a/src/shared/connectivityDiagnostics.js b/src/shared/connectivityDiagnostics.js index b28aea3..16de8a5 100644 --- a/src/shared/connectivityDiagnostics.js +++ b/src/shared/connectivityDiagnostics.js @@ -18,3 +18,44 @@ export const CONNECTIVITY_SITES = Object.freeze([ ]); 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 }; +} diff --git a/src/shared/versions.js b/src/shared/versions.js index 305702d..22ac138 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.17.5', - gatewayClient: '0.18.5', - gatewayBackend: '0.18.0', + macClient: '0.17.6', + gatewayClient: '0.18.6', + gatewayBackend: '0.18.1', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index a6c882c..2b5de00 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -92,9 +92,9 @@ export const api = { }), }, diagnostics: { - connectivity: (services = []) => request('/api/diagnostics/connectivity', { + connectivity: (services = [], target = null) => request('/api/diagnostics/connectivity', { method: 'POST', - body: JSON.stringify({ services }), + body: JSON.stringify({ services, target }), }), }, singbox: { diff --git a/src/web/components/ConnectivityDiagnosticsPanel.jsx b/src/web/components/ConnectivityDiagnosticsPanel.jsx index 8a54ba1..b3173c8 100644 --- a/src/web/components/ConnectivityDiagnosticsPanel.jsx +++ b/src/web/components/ConnectivityDiagnosticsPanel.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { api } from '../api.js'; import { + assessConnectivity, CONNECTIVITY_IP_SOURCES, CONNECTIVITY_SITES, MAX_CUSTOM_DIAGNOSTIC_SERVICES, @@ -36,8 +37,9 @@ function readCustomServices() { } function resultStatus(site, pending, available = true) { - if (pending && !site) return ['is-running', 'Проверяем']; - if (!available || !site) return ['is-muted', '—']; + if (!available) return ['is-muted', '—']; + if (pending) return ['is-running', 'Проверяем']; + if (!site) return ['is-muted', '—']; if (site.status === 'unavailable') return ['is-error', 'Нет доступа']; if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`]; return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`]; @@ -57,15 +59,52 @@ function ipResult(path, source) { function IpCell({ path, source, pending, route }) { const value = ipResult(path, source); - if (pending && !value) return ; + if (path?.available === false) return ; + if (pending) return ; if (!path?.available) return ; if (!value?.address) return ; return {value.address}; } +function mergeItems(previous = [], incoming = [], key) { + const merged = [...previous]; + for (const item of incoming) { + const index = merged.findIndex((value) => value[key] === item[key]); + if (index >= 0) merged[index] = item; + else merged.push(item); + } + return merged; +} + +function mergePath(previous, incoming) { + const sources = mergeItems(previous?.ipv4?.sources, incoming.ipv4?.sources, 'source'); + const sites = mergeItems(previous?.sites, incoming.sites, 'id'); + const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null; + const ipv6 = ipv6Source?.address || null; + const addresses = [...new Set(sources.map(({ address }) => address).filter(Boolean))]; + return { + ...previous, + ...incoming, + internetAvailable: Boolean( + addresses.length || ipv6 || sites.some(({ status }) => status !== 'unavailable'), + ), + ipv4: { addresses, sources }, + ipv6, + ipv6Source, + sites, + }; +} + +function mergeResult(previous, incoming) { + const direct = mergePath(previous?.direct, incoming.direct); + const vpn = mergePath(previous?.vpn, incoming.vpn); + return { ...incoming, direct, vpn, assessment: assessConnectivity(direct, vpn) }; +} + export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) { const [result, setResult] = useState(null); const [status, setStatus] = useState('idle'); + const [activeTarget, setActiveTarget] = useState(null); const [error, setError] = useState(null); const [customServices, setCustomServices] = useState(readCustomServices); const [adding, setAdding] = useState(false); @@ -85,11 +124,25 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR setStatus('running'); setError(null); try { - setResult(await api.diagnostics.connectivity(customServices)); + let next = result; + const targets = [ + ...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`), + ...[...CONNECTIVITY_SITES, ...customServices].map(({ id }) => `site:${id}`), + ]; + for (const target of targets) { + setActiveTarget(target); + const partial = await api.diagnostics.connectivity(customServices, target); + const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1; + next = legacyFullResult ? partial : mergeResult(next, partial); + setResult(next); + if (legacyFullResult) break; + } setStatus('ready'); } catch (requestError) { setError(requestError); setStatus('error'); + } finally { + setActiveTarget(null); } } @@ -179,11 +232,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR Напрямую VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''} - {CONNECTIVITY_IP_SOURCES.map((source) => - {source.label} - - - )} + {CONNECTIVITY_IP_SOURCES.map((source) => { + const target = `ip:${source.id}`; + const running = activeTarget === target; + return + {source.label} + + + })} @@ -192,7 +248,7 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR Сервисы } - - + + ; })} diff --git a/src/web/styles.css b/src/web/styles.css index e9bb7c8..5c9218a 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -365,21 +365,21 @@ p { } .client-shell.is-intro .harbor-brand-content { - animation: harbor-startup-brand 1200ms cubic-bezier(0.16, 1, 0.3, 1); + animation: harbor-startup-brand 760ms cubic-bezier(0.16, 1, 0.3, 1); } .client-shell.is-intro .client-panel, .client-shell.is-intro .client-secondary-menu, .client-shell.is-intro .harbor-versions { - animation: harbor-startup-content 680ms 520ms cubic-bezier(0.16, 1, 0.3, 1) both; + animation: harbor-startup-content 720ms 120ms cubic-bezier(0.16, 1, 0.3, 1) both; } @keyframes harbor-startup-brand { - from { opacity: 0.72; filter: blur(5px); transform: translateY(53px); } + from { opacity: 0.35; filter: blur(5px); } } @keyframes harbor-startup-content { - from { opacity: 0; filter: blur(10px); } + from { opacity: 0; filter: blur(6px); } } .harbor-brand svg { @@ -4706,11 +4706,6 @@ p { transition: opacity 220ms ease, filter 320ms ease; } -.client-diagnostics-table[aria-busy='true'] { - opacity: 0.68; - filter: saturate(0.72); -} - .client-diagnostics-table th, .client-diagnostics-table td { min-width: 0; @@ -4736,6 +4731,25 @@ p { .client-diagnostics-table tbody tr { border-top: 1px solid color-mix(in oklch, var(--client-border) 48%, transparent); + transition: background-color 420ms ease, box-shadow 520ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-diagnostics-table tbody tr.is-running { + background-color: color-mix(in oklch, var(--client-accent) 8%, transparent); + box-shadow: inset 2px 0 var(--client-accent); + animation: client-diagnostics-row-pulse 1200ms ease-in-out infinite alternate; +} + +.client-diagnostics-table tbody tr.is-running th { + color: var(--client-accent); + text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 38%, transparent); +} + +@keyframes client-diagnostics-row-pulse { + to { + background-color: color-mix(in oklch, var(--client-accent) 13%, transparent); + box-shadow: inset 2px 0 var(--client-accent), 0 0 16px color-mix(in oklch, var(--client-accent) 8%, transparent); + } } .client-diagnostics-table tbody th { @@ -4946,6 +4960,7 @@ p { .client-diagnostics-refresh, .client-diagnostics-refresh svg, .client-diagnostics-table, + .client-diagnostics-table tbody tr.is-running, .client-diagnostics-status.is-running { transition: none; animation: none; diff --git a/test/server/connectivity-diagnostics.test.js b/test/server/connectivity-diagnostics.test.js index c784135..5e78f0f 100644 --- a/test/server/connectivity-diagnostics.test.js +++ b/test/server/connectivity-diagnostics.test.js @@ -55,6 +55,53 @@ test('connectivity diagnostics force separate direct and VPN paths', async () => test('connectivity diagnostics endpoint is available in Connect and Gateway', () => { assert.match(server, /const localConnectivityDiagnostics = !remoteDataplane/); 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 () => { @@ -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.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); +}); diff --git a/test/server/dataplane-client.test.js b/test/server/dataplane-client.test.js index bdeb8b5..22461f5 100644 --- a/test/server/dataplane-client.test.js +++ b/test/server/dataplane-client.test.js @@ -26,7 +26,10 @@ test('control uses the dataplane socket protocol', async () => { assert.equal(traffic.running, true); await client.observeDevicePolicy(); 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); await client.restart(); 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[6].body, { 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 () => { diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index 42fe603..8ad78bf 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -36,10 +36,11 @@ test('page reload plays one stable startup sequence', () => { assert.match(component, /const \[showIntro, setShowIntro\] = useState\(true\)/); assert.match(component, /\$\{showIntro \? ' is-intro' : ''\}/); assert.doesNotMatch(component, /showIntro && !hasSubscription/); - assert.match(styles, /\.client-shell\.is-intro \.harbor-brand-content \{[\s\S]*harbor-startup-brand 1200ms/); - assert.match(styles, /@keyframes harbor-startup-brand[\s\S]*translateY\(53px\)/); - 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.match(styles, /@keyframes harbor-startup-content[\s\S]*opacity: 0;[\s\S]*filter: blur\(10px\)/); + 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]*opacity: 0\.35;[\s\S]*filter: blur\(5px\)/); + assert.doesNotMatch(/@keyframes harbor-startup-brand \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '', /translate|scale/); + 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/); }); @@ -136,6 +137,11 @@ test('connectivity diagnostics render stable compact tables before the first run assert.doesNotMatch(diagnostics, /PathDetails|client-diagnostics-details|Технические детали/); assert.doesNotMatch(rule('.client-diagnostics-feedback'), /min-height:/); 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', () => {