diff --git a/.gitea/workflows/gateway-build.yml b/.gitea/workflows/gateway-build.yml index a99c138..e99ea75 100644 --- a/.gitea/workflows/gateway-build.yml +++ b/.gitea/workflows/gateway-build.yml @@ -111,7 +111,7 @@ jobs: DATAPLANE_IMAGE="${IMAGE}-dataplane:${{ gitea.sha }}" UPDATE_DATAPLANE=false 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 fi diff --git a/src/server/dataplane.js b/src/server/dataplane.js index 8e23f2c..593e669 100644 --- a/src/server/dataplane.js +++ b/src/server/dataplane.js @@ -91,7 +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') { - 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') { return sendJson(res, 200, await runtime.apply()); diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js index 0cc8354..22d6bb0 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 () => { + runConnectivityDiagnostics: async (services = []) => { try { - return await send(socketPath, '/diagnostics/connectivity', 'POST', null, 15_000); + return await send(socketPath, '/diagnostics/connectivity', 'POST', { services }, 15_000); } catch (cause) { throw new HarborError('DIAGNOSTICS_FAILED', { cause }); } diff --git a/src/server/index.js b/src/server/index.js index 44e527b..cac443d 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -700,12 +700,16 @@ async function handleApi(req, res) { if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') { if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND'); + const { services = [] } = 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() - : await localConnectivityDiagnostics.run({ vpnAvailable: (await singboxRuntime.refresh()).running }); + ? await singboxRuntime.runConnectivityDiagnostics(services) + : await localConnectivityDiagnostics.run({ + vpnAvailable: (await singboxRuntime.refresh()).running, + services, + }); return sendJson(res, 200, { ...result, vpn: { diff --git a/src/server/services/connectivityDiagnosticsService.js b/src/server/services/connectivityDiagnosticsService.js index 18d140d..aa0c57b 100644 --- a/src/server/services/connectivityDiagnosticsService.js +++ b/src/server/services/connectivityDiagnosticsService.js @@ -1,34 +1,40 @@ import { execFile } from 'node:child_process'; +import { lookup as dnsLookup } from 'node:dns/promises'; 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__'; -const IP_PROBES = [ - { - id: 'cloudflare', - url: 'https://www.cloudflare.com/cdn-cgi/trace', - address: (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim(), - }, - { id: 'ipify', url: 'https://api.ipify.org', address: (body) => body.trim() }, -]; -const IP_FALLBACK = { - id: 'aws', - url: 'https://checkip.amazonaws.com', - address: (body) => body.trim(), -}; -const IPV6_PROBE = { - id: 'ipify-v6', - url: 'https://api6.ipify.org', - address: (body) => body.trim(), -}; -const SITE_PROBES = [ - { id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' }, - { id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' }, -]; +const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({ + ...probe, + address: probe.id === 'cloudflare' + ? (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] + : (body) => body.trim(), +})); +const SITE_PROBES = CONNECTIVITY_SITES; + +const BLOCKED_IPV4_ADDRESSES = new net.BlockList(); +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], + ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], + ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], + ['224.0.0.0', 4], ['240.0.0.0', 4], +]) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4'); +const BLOCKED_IPV6_ADDRESSES = new net.BlockList(); +for (const [address, prefix] of [ + ['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7], + ['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32], +]) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6'); function runCurl(args) { 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({ exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0, error: error?.message || '', @@ -52,11 +58,20 @@ function milliseconds(value) { 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 = [ '--silent', '--show-error', - '--location', + ...(follow ? ['--location'] : []), + '--proto', + '=https', + '--proto-redir', + '=https', '--connect-timeout', '3', '--max-time', @@ -71,6 +86,7 @@ async function request(probe, path, proxyPort, execute, { body = false, ipv4 = f ? ['--proxy', `http://127.0.0.1:${proxyPort}`] : ['--noproxy', '*']), ...(ipv4 ? ['--ipv4'] : []), + ...(resolve ? ['--resolve', resolve] : []), probe.url, ]; 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) { - const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: version === 4 }); +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) === version; + const valid = typeof address === 'string' && net.isIP(address) === probe.family; 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', @@ -109,29 +127,78 @@ async function ipProbe(probe, path, proxyPort, execute, version) { } async function publicIps(path, proxyPort, execute) { - const [primary, ipv6] = await Promise.all([ - Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute, 4))), - ipProbe(IPV6_PROBE, path, proxyPort, execute, 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)]; + const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute))); + const ipv4 = probes.filter((probe) => probe.family === 4); + const ipv6 = probes.find((probe) => probe.family === 6); return { ipv4: { - addresses: [...new Set(probes.map((probe) => probe.address).filter(Boolean))], - sources: probes, + addresses: [...new Set(ipv4.map((probe) => probe.address).filter(Boolean))], + sources: ipv4, }, - ipv6: ipv6.address, + ipv6: ipv6?.address || null, 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) { - 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; if (!result.ok) { - result = await request(probe, path, proxyPort, execute); + result = await request(probe, path, proxyPort, execute, options); attempts = 2; } const status = !result.ok @@ -152,23 +219,23 @@ async function siteProbe(probe, path, proxyPort, execute) { }; } -async function probePath(path, proxyPort, execute) { - const [ip, sites] = await Promise.all([ +async function probePath(path, proxyPort, execute, sites) { + const [ip, siteResults] = await Promise.all([ 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 { available: true, 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, - sites, + sites: siteResults, }; } 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 vpnSite = vpn.sites?.find((site) => site.id === id); let assessment = 'inconclusive'; @@ -211,13 +278,15 @@ export function assessConnectivity(direct, vpn) { export function createConnectivityDiagnosticsService({ proxyPort, execute = runCurl, + lookup = dnsLookup, now = () => new Date().toISOString(), }) { - let inFlight = null; - async function runOnce({ vpnAvailable }) { - const directPromise = probePath('direct', proxyPort, execute); + async function runOnce({ vpnAvailable, services = [] }) { + const customProbes = await prepareCustomProbes(services, lookup); + const siteProbes = [...SITE_PROBES, ...customProbes]; + const directPromise = probePath('direct', proxyPort, execute, siteProbes); const vpnPromise = vpnAvailable - ? probePath('vpn', proxyPort, execute) + ? probePath('vpn', proxyPort, execute, siteProbes) : Promise.resolve({ available: false, reason: 'vpn-off', @@ -236,9 +305,6 @@ export function createConnectivityDiagnosticsService({ }; } return { - run(options) { - if (!inFlight) inFlight = runOnce(options).finally(() => { inFlight = null; }); - return inFlight; - }, + run: runOnce, }; } diff --git a/src/shared/connectivityDiagnostics.js b/src/shared/connectivityDiagnostics.js new file mode 100644 index 0000000..b28aea3 --- /dev/null +++ b/src/shared/connectivityDiagnostics.js @@ -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; diff --git a/src/shared/versions.js b/src/shared/versions.js index 71ecc44..8ca8604 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.15.1', - gatewayClient: '0.16.1', - gatewayBackend: '0.16.1', + macClient: '0.16.1', + gatewayClient: '0.17.1', + gatewayBackend: '0.17.1', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index bb3e35c..a6c882c 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -92,7 +92,10 @@ export const api = { }), }, diagnostics: { - connectivity: () => request('/api/diagnostics/connectivity', { method: 'POST' }), + connectivity: (services = []) => request('/api/diagnostics/connectivity', { + method: 'POST', + body: JSON.stringify({ services }), + }), }, singbox: { stop: () => request('/api/singbox/stop', { method: 'POST' }), diff --git a/src/web/components/ConnectivityDiagnosticsPanel.jsx b/src/web/components/ConnectivityDiagnosticsPanel.jsx index ec2a7f4..815f133 100644 --- a/src/web/components/ConnectivityDiagnosticsPanel.jsx +++ b/src/web/components/ConnectivityDiagnosticsPanel.jsx @@ -1,32 +1,46 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; 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 = { - available: ['is-good', 'Оба маршрута работают, ограничений не видно.'], - 'likely-direct-restriction': ['is-warning', 'Похоже на ограничение прямого маршрута: через VPN сервис доступен.'], - 'vpn-problem': ['is-error', 'Прямой маршрут работает, а VPN требует проверки.'], - 'direct-offline': ['is-error', 'Прямой выход в интернет недоступен.'], - offline: ['is-error', 'Интернет недоступен по обоим маршрутам.'], - 'same-ip': ['is-warning', 'Внешний IP не изменился — проверьте, что трафик действительно идёт через VPN.'], - 'vpn-off': ['is-muted', 'VPN выключен: прямой маршрут проверен отдельно.'], - inconclusive: ['is-muted', 'Результата недостаточно для уверенного вывода.'], + available: ['is-good', 'Оба маршрута работают.'], + 'likely-direct-restriction': ['is-warning', 'Прямой маршрут, похоже, ограничен.'], + 'vpn-problem': ['is-error', 'VPN-маршрут требует проверки.'], + 'direct-offline': ['is-error', 'Прямой маршрут недоступен.'], + offline: ['is-error', 'Оба маршрута недоступны.'], + 'same-ip': ['is-warning', 'Внешний IP не изменился.'], + 'vpn-off': ['is-muted', 'VPN выключен.'], + inconclusive: ['is-muted', 'Недостаточно данных.'], }; -function ipText(path) { - const addresses = [...(path?.ipv4?.addresses || []), path?.ipv6].filter(Boolean); - return addresses.length ? addresses.join(' · ') : 'Не определён'; +function readCustomServices() { + try { + 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) { - if (!path?.available) return ['is-muted', 'Не проверено']; - return path.internetAvailable ? ['is-good', 'Доступен'] : ['is-error', 'Нет доступа']; -} - -function siteStatus(site) { - if (!site) return ['is-muted', 'Не проверено']; +function resultStatus(site, pending, available = true) { + if (pending && !site) return ['is-running', 'Проверяем']; + if (!available || !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} мс`}`]; + return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`]; } function Status({ value, route }) { @@ -34,12 +48,25 @@ function Status({ value, route }) { return {label}; } +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 ; + if (!path?.available) return ; + if (!value?.address) return ; + return {value.address}; +} + function PathDetails({ title, path }) { if (!path?.available) return
{title}

VPN не запущен.

; return
{title} -

IPv4: {path.ipv4.addresses.join(', ') || 'нет'}

-

IPv6: {path.ipv6 || 'нет'}

{path.sites.map((site) => (

{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 [status, setStatus] = useState('idle'); 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() { setStatus('running'); setError(null); try { - setResult(await api.diagnostics.connectivity()); + setResult(await api.diagnostics.connectivity(customServices)); setStatus('ready'); } catch (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 checkedAt = result?.checkedAt ? 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 >×

Gateway · Direct ↔ VPN -

Проверка маршрутов

-
-

Сравнивает внешний IP и доступность сайтов напрямую и через выбранный VPN, не меняя правила.

+
+

Маршруты

+ + + Проверить маршруты +
- +
+ {error ?
+ {error.message} + {error.retryable && } +
: result && <> + {summary[1]} + {checkedAt} + } +
- {error &&
- {error.message} - {error.retryable && } -
} +
+
+ IP-адреса +
+ + + + + + + {CONNECTIVITY_IP_SOURCES.map((source) => + + + + )} +
ИсточникНапрямуюVPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}
{source.label}
+
- {!result && status !== 'running' && !error && ( -

Результаты появятся здесь после ручного запуска.

- )} +
+
+ Сервисы + +
- {result &&
- -
- Внешний IP - {ipText(result.direct)} - - {result.vpn.available ? ipText(result.vpn) : 'VPN выключен'} - -
-
- Интернет - - -
- {result.assessment.comparisons.map((comparison) => ( -
- {comparison.label} - - + {adding &&
+ setServiceName(event.target.value)} + /> + { + setServiceUrl(event.target.value); + setFormError(''); + }} + /> +
+ {formError} +
- ))} -

{summary[1]}

-

Проверено в {checkedAt}

-
- Технические детали -
- - -
-
-
} + } + + + + + + + + {sites.map((site) => { + const direct = result?.direct?.sites?.find((item) => item.id === site.id); + const vpn = result?.vpn?.sites?.find((item) => item.id === site.id); + const custom = site.id.startsWith('custom-'); + return + + + + ; + })} +
СервисНапрямуюVPN
+ {site.label} + {custom && } +
+
+ + {result &&
+ Технические детали +
+ + +
+
} ); diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 3a08e25..8f1162a 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -14,7 +14,7 @@ import { const AUTO_REFRESH_MS = 15_000; const DEVICE_MOVE_MS = 520; -const COPY_FEEDBACK_MS = 5_000; +const COPY_FEEDBACK_MS = 800; const TRAFFIC_DELTA_MS = 2_200; function Tooltip({ children }) { @@ -45,13 +45,14 @@ function chartTime(value) { function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { const [hovered, setHovered] = useState(null); + const previousPoints = useRef([]); + const previousScale = useRef(scale); const max = samples.reduce((largest, sample) => { const gateway = byteString(sample.gatewayBytes); const proxy = byteString(sample.proxyBytes); return gateway > largest ? gateway : proxy > largest ? proxy : largest; }, 0n); const mid = trafficAxisMid(max, scale); - const latest = samples.at(-1)?.observedAt || 'empty'; const firstSlot = capacity - samples.length; const points = samples.map((sample, index) => { const gateway = byteString(sample.gatewayBytes); @@ -69,6 +70,15 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { const penultimate = points.at(-2); const newest = points.at(-1); 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) { const bounds = event.currentTarget.getBoundingClientRect(); @@ -111,12 +121,48 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { } - - {previous.length > 0 && `${x},${gatewayY}`).join(' ')} />} - {hasProxy && previous.length > 0 && `${x},${proxyY}`).join(' ')} />} - {penultimate && newest && } - {hasProxy && penultimate && newest && } - {!penultimate && newest && } + + {previous.length > 0 && `${x},${gatewayY}`).join(' ')}> + {animateScale && `${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" + />} + } + {hasProxy && previous.length > 0 && `${x},${proxyY}`).join(' ')}> + {animateScale && `${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" + />} + } + {penultimate && newest && + {animateScale && <> + + + } + } + {hasProxy && penultimate && newest && + {animateScale && <> + + + } + } + {!penultimate && newest && + {animateScale && } + } {hovered && @@ -335,6 +381,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS); } + function startEditing(device) { + setEditingId(device.id); + setAlias(device.alias || device.hostname || ''); + } + return (