Add Gateway connectivity diagnostics and traffic chart improvements
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 20:48:43 +03:00
parent e6666558b7
commit 12375006d3
18 changed files with 1133 additions and 206 deletions
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createConnectivityDiagnosticsService,
CURL_META_MARKER,
} from '../../src/server/services/connectivityDiagnosticsService.js';
function response(body = '', overrides = {}) {
return {
exitCode: 0,
error: '',
stderr: '',
stdout: `${body}${CURL_META_MARKER}${JSON.stringify({
exitcode: 0,
http_code: 204,
time_starttransfer: 0.12,
time_total: 0.15,
...overrides,
})}`,
};
}
test('connectivity diagnostics force separate direct and VPN paths', async () => {
const calls = [];
const execute = async (args) => {
calls.push(args);
const vpn = args.includes('--proxy');
const url = args.at(-1);
if (url.includes('cloudflare')) return response(`ip=${vpn ? '203.0.113.20' : '198.51.100.10'}\n`);
if (url.includes('api6')) return response(vpn ? '2001:db8::20' : '2001:db8::10');
if (url.includes('ipify')) return response(vpn ? '203.0.113.20' : '198.51.100.10');
return response();
};
const service = createConnectivityDiagnosticsService({
proxyPort: 18080,
execute,
now: () => '2026-08-07T12:00:00.000Z',
});
const result = await service.run({ vpnAvailable: true });
assert.deepEqual(result.direct.ipv4.addresses, ['198.51.100.10']);
assert.deepEqual(result.vpn.ipv4.addresses, ['203.0.113.20']);
assert.equal(result.direct.ipv6, '2001:db8::10');
assert.equal(result.vpn.ipv6, '2001:db8::20');
assert.equal(result.assessment.summary, 'available');
assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*')));
assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080')));
});
test('connectivity diagnostics reports a likely direct restriction without claiming its owner', async () => {
const attempts = new Map();
const execute = async (args) => {
const vpn = args.includes('--proxy');
const url = args.at(-1);
if (url.includes('cloudflare')) return response(`ip=${vpn ? '203.0.113.20' : '198.51.100.10'}\n`);
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response(vpn ? '203.0.113.20' : '198.51.100.10');
const key = `${vpn}:${url}`;
attempts.set(key, (attempts.get(key) || 0) + 1);
if (!vpn && url.includes('youtube')) {
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
}
return response();
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: true });
assert.equal(result.assessment.summary, 'likely-direct-restriction');
assert.equal(
result.assessment.comparisons.find((item) => item.id === 'youtube').assessment,
'likely-direct-restriction',
);
assert.equal(attempts.get('false:https://www.youtube.com/generate_204'), 2);
});
test('connectivity diagnostics skips VPN probes when sing-box is off', async () => {
const calls = [];
const execute = async (args) => {
calls.push(args);
const url = args.at(-1);
if (url.includes('cloudflare')) return response('ip=198.51.100.10\n');
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response('198.51.100.10');
return response();
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: false });
assert.equal(result.vpn.available, false);
assert.equal(result.assessment.summary, 'vpn-off');
assert.equal(calls.some((args) => args.includes('--proxy')), false);
});
test('connectivity diagnostics keeps a partial snapshot and falls back when one IP source fails', async () => {
const execute = async (args) => {
const url = args.at(-1);
if (url.includes('cloudflare')) {
return response('', { exitcode: 28, http_code: 0, errormsg: 'timeout' });
}
if (url.includes('api6')) return response('', { exitcode: 6, http_code: 0, errormsg: 'resolve failed' });
if (url.includes('ipify')) return response('198.51.100.10');
if (url.includes('amazonaws')) return response('198.51.100.10');
return response();
};
const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute })
.run({ vpnAvailable: false });
assert.equal(result.direct.internetAvailable, true);
assert.deepEqual(result.direct.ipv4.addresses, ['198.51.100.10']);
assert.deepEqual(result.direct.ipv4.sources.map((source) => source.source), [
'cloudflare',
'ipify',
'aws',
]);
});
+2
View File
@@ -26,6 +26,7 @@ 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();
assert.equal(client.running, true);
await client.restart();
assert.equal((await client.stop()).running, false);
@@ -36,6 +37,7 @@ test('control uses the dataplane socket protocol', async () => {
'GET /device-traffic /run/dataplane.sock',
'GET /device-policy /run/dataplane.sock',
'PUT /device-policy /run/dataplane.sock',
'POST /diagnostics/connectivity /run/dataplane.sock',
'POST /restart /run/dataplane.sock',
'POST /stop /run/dataplane.sock',
]);
+14
View File
@@ -27,7 +27,21 @@ test('gateway routes .ru domains directly and other traffic through the selected
});
assert.deepEqual(config.route.rule_set, []);
assert.deepEqual(config.inbounds.map((inbound) => inbound.tag), [
'tproxy-in',
'mixed-in',
'diagnostics-vpn-in',
]);
assert.deepEqual(config.inbounds[2], {
type: 'mixed',
tag: 'diagnostics-vpn-in',
listen: '127.0.0.1',
listen_port: 18080,
sniff: true,
set_system_proxy: false,
});
assert.deepEqual(config.route.rules, [
{ inbound: ['diagnostics-vpn-in'], outbound: 'test-vpn' },
{ domain_suffix: ['ru'], outbound: 'direct' },
{ inbound: ['tproxy-in'], outbound: 'test-vpn' },
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
+36 -19
View File
@@ -8,8 +8,9 @@ import {
formatLastSeen,
positiveByteDelta,
sortDevicesByTraffic,
stabilizeDevicesByTraffic,
trafficAxisMid,
trafficSampleMetrics,
trafficScaleRatio,
} from '../../src/web/utils/format.js';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
@@ -34,7 +35,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(server, /\/api\\\/devices\\\/\(dev_\[a-f0-9\]\{16\}\)\\\/policy\$[\s\S]*deviceInventory\.setPolicy/);
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
assert.match(panel, /copyText\(device\.ip\)/);
assert.match(panel, /client-device-name-primary[\s\S]*client-device-name-ip[\s\S]*client-device-name-feedback/);
assert.match(panel, /const hasName = Boolean\(device\.alias \|\| device\.hostname\)/);
assert.match(panel, /client-device-name\$\{hasName \? '' : ' is-address-only'\}/);
assert.match(panel, /client-device-name-primary[\s\S]*\{hasName && <span className="client-device-name-ip"[\s\S]*client-device-name-feedback/);
assert.match(panel, /client-device-name-feedback[\s\S]*'copied!'/);
assert.match(panel, /COPY_FEEDBACK_MS = 5_000/);
assert.match(panel, /client-device-name-feedback[\s\S]*client-device-edit-wrap[\s\S]*client-device-last-seen/);
@@ -48,7 +51,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.doesNotMatch(panel, /device\.mac/);
assert.doesNotMatch(panel, /client-device-identity|Пояснение идентификации устройства|ⓘ/);
assert.match(panel, /device\.confidence === 'ambiguous'/);
assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/);
assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
assert.match(panel, /Трафик временно не обновляется/);
assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/);
assert.match(panel, /client-device-traffic-total[\s\S]*<b>Всего<\/b><TrafficValue value=\{totalTraffic\} delta=\{trafficDelta\.total\}/);
@@ -58,10 +61,12 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/);
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
assert.match(panel, /client-device-traffic-bar-tooltip[\s\S]*<time dateTime=\{sample\.observedAt\}>\{time\}<\/time>[\s\S]*<strong>Всего \{formatByteString\(total\)\}<\/strong>/);
assert.match(panel, /trafficAxisMid\(max, scale\)[\s\S]*client-device-traffic-axis[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
assert.match(panel, /onPointerMove=\{trackPointer\}/);
assert.match(panel, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
assert.match(panel, /client-device-traffic-grid[\s\S]*polyline className="is-gateway"[\s\S]*polyline className="is-proxy"/);
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
assert.match(panel, /\{proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(proxy\)\}<\/span>\}/);
assert.match(panel, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
assert.match(panel, /<time dateTime=\{samples\[0\]\.observedAt\}>[\s\S]*<span>15 с<\/span>/);
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
@@ -88,13 +93,12 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-breakdown > span \{[\s\S]*translateY\(-12px\)[\s\S]*\.client-device-traffic:hover \.client-device-traffic-breakdown > span,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/);
assert.doesNotMatch(styles, /\.client-device-traffic-breakdown \{[^}]*background:|\.client-device-traffic-breakdown \{[^}]*box-shadow:/);
assert.match(styles, /\.client-device-traffic-chart \{[\s\S]*grid-column: 2 \/ 4;[\s\S]*grid-row: 2/);
assert.match(styles, /\.client-device-traffic-track \{[\s\S]*client-device-traffic-shift 560ms/);
assert.match(styles, /\.client-device-traffic-track \{[^}]*gap: 0/);
assert.match(styles, /\.client-device-traffic-bar-fill \{[\s\S]*inset: 0 -2px/);
assert.match(styles, /\.client-device-traffic-bar:hover \.client-device-traffic-bar-tooltip \{[\s\S]*opacity: 1[\s\S]*visibility: visible/);
assert.match(styles, /\.client-device-traffic-bar\.is-edge-left[\s\S]*\.client-device-traffic-bar\.is-edge-right/);
assert.match(styles, /\.client-device\.is-pinned \.client-device-traffic-chart \{[\s\S]*grid-template-columns: 52px minmax\(0, 1fr\)/);
assert.match(styles, /\.client-device-traffic-grid line \{[\s\S]*vector-effect: non-scaling-stroke/);
assert.match(styles, /\.client-device-traffic-lines polyline,[\s\S]*stroke-width: 1\.8/);
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(calc\(100% \/ var\(--sample-count\)\)\)[\s\S]*translateX\(0\)/);
assert.match(styles, /@keyframes client-device-traffic-grow[\s\S]*scaleY\(0\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /@keyframes client-device-traffic-line-draw[\s\S]*stroke-dashoffset: 0/);
assert.match(styles, /@keyframes client-device-chart-expand[\s\S]*clip-path: inset\(calc\(100% - 34px\) 0 0\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/);
assert.match(styles, /\.client-device-name \{[\s\S]*font: 700 14px\/1\.2/);
@@ -104,8 +108,9 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-name > span \{[^}]*opacity: 0/);
assert.match(styles, /\.client-device-name > \.client-device-name-primary \{[^}]*opacity: 1/);
assert.match(styles, /\.client-device-name > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/);
assert.match(styles, /\.client-device-name:hover \.client-device-name-primary[\s\S]*translateY\(-0\.18em\)/);
assert.match(styles, /\.client-device-name:hover \.client-device-name-ip[\s\S]*opacity: 1[\s\S]*translateY\(0\) scale\(1\)/);
assert.match(styles, /\.client-device-name:not\(\.is-address-only\):hover \.client-device-name-primary[\s\S]*translateY\(-0\.18em\)/);
assert.match(styles, /\.client-device-name:not\(\.is-address-only\):hover \.client-device-name-ip[\s\S]*opacity: 1[\s\S]*translateY\(0\) scale\(1\)/);
assert.match(styles, /\.client-device-name-heading:has\(\.client-device-name:not\(\.is-address-only\):hover\) \+ \.client-device-edit-wrap[\s\S]*opacity: 0/);
assert.match(styles, /\.client-device-name\.is-copied \.client-device-name-feedback[\s\S]*opacity: 1/);
assert.match(styles, /\.client-device-name-feedback \{[^}]*font-size: 9px/);
assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: flex-end[\s\S]*padding-top: 11px/);
@@ -120,7 +125,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/);
assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/);
assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-track[\s\S]*\.client-text-morph-value/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-device-traffic-breakdown[\s\S]*\.client-device-traffic-lines[\s\S]*\.client-text-morph-value/);
assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/);
assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/);
});
@@ -145,10 +150,8 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
assert.equal(positiveByteDelta('1048576', '3145728'), '2,0 МБ');
assert.equal(positiveByteDelta('3145728', '3145728'), '');
assert.equal(positiveByteDelta('3145728', '1048576'), '');
assert.deepEqual(trafficSampleMetrics('75', '25', '200'), { height: 50, gatewayShare: 75 });
assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 });
assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 });
assert.deepEqual(trafficSampleMetrics('10', '0', '100', 'log'), { height: 52, gatewayShare: 100 });
assert.equal(trafficScaleRatio('50', '100', 'linear'), 0.5);
assert.equal(Math.round(trafficScaleRatio('9', '99', 'log') * 100), 50);
assert.equal(trafficAxisMid('100', 'linear'), 50n);
assert.equal(trafficAxisMid('99', 'log'), 9n);
@@ -161,4 +164,18 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni
];
assert.deepEqual(sortDevicesByTraffic(devices, 'desc').map(({ id }) => id), ['e', 'b', 'a', 'c', 'd']);
assert.deepEqual(sortDevicesByTraffic(devices, 'asc').map(({ id }) => id), ['e', 'c', 'd', 'a', 'b']);
assert.deepEqual(
stabilizeDevicesByTraffic([
{ id: 'a', uploadBytes: '999' },
{ id: 'b', uploadBytes: '1' },
], 'desc', ['b', 'a']).ids,
['b', 'a'],
);
assert.deepEqual(
stabilizeDevicesByTraffic([
{ id: 'a', uploadBytes: '999' },
{ id: 'b', pinned: true, uploadBytes: '1' },
], 'desc', ['a', 'b']).ids,
['b', 'a'],
);
});
@@ -67,6 +67,7 @@ test('tablet and mobile regions use normal flow with viewport-safe widths', () =
assert.match(mobile, /\.client-server-check \{[\s\S]*width:\s*44px/);
assert.match(styles, /\.client-instructions\s*\{[\s\S]*width:\s*min\(470px, 100vw\)/);
assert.match(styles, /\.client-local-rules\s*\{[\s\S]*width:\s*min\(480px, 100vw\)/);
assert.match(styles, /\.client-instructions\.client-diagnostics\s*\{[\s\S]*width:\s*min\(580px, 100vw\)/);
assert.match(styles, /\.client-confirmation-dialog\s*\{[\s\S]*width:\s*min\(430px, calc\(100vw - 48px\)\)/);
for (const viewport of [320, 390, 768]) {
@@ -87,6 +88,8 @@ test('secondary menus share one right rail and both drawers open from the right'
assert.match(component, /<nav className="client-secondary-menu" aria-label="Дополнительные меню">/);
assert.match(component, /client-instructions-toggle[\s\S]*client-local-rules-toggle/);
assert.match(component, /client-diagnostics-toggle/);
assert.match(component, /<ConnectivityDiagnosticsPanel/);
assert.match(component, /Локальные правила недоступны: сейчас работают правила Gateway/);
assert.match(rule('.client-secondary-menu'), /right:\s*max\(14px, env\(safe-area-inset-right\)\)/);
assert.match(rule('.client-secondary-menu'), /display:\s*grid/);