Add native traffic inspection to Harbor Connect and Gateway

This commit is contained in:
2026-08-31 05:19:15 +03:00
parent 116686a138
commit 4d066cb879
62 changed files with 10975 additions and 220 deletions
+348
View File
@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraffic.js';
import {
groupTrafficConnections,
reconcileTrafficGroups,
trafficGroupMatches,
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
const root = path.resolve(import.meta.dirname, '../..');
const source = (file) => fs.readFileSync(path.join(root, file), 'utf8');
const app = source('src/web/App.tsx');
const api = source('src/web/api/harborClient.ts');
const page = source('src/web/components/ClientOverviewPage.tsx');
const feature = source('src/web/features/traffic/TrafficFeature.tsx');
const rowModel = source('src/web/features/traffic/trafficRows.ts');
const boundary = source('src/web/features/traffic/index.ts');
const styles = source('src/web/styles/features/traffic.css');
const primitives = source('src/web/styles/primitives.css');
const validSnapshot = {
apiVersion: 1,
epoch: 'epoch-1',
sequence: 1,
observedAt: '2026-08-31T10:00:00.000Z',
capabilities: { lifecycle: true, deviceAttribution: false, applicationAttribution: false },
source: {
transport: 'native',
state: 'live',
completeness: 'lifecycle',
singBoxVersion: '1.14.0-rc.5',
singBoxApiVersion: 1,
error: null,
unattributedUploadBytes: '0',
unattributedDownloadBytes: '0',
},
summary: {
active: 1,
recent: 0,
visible: 1,
recognized: 1,
unresolved: 0,
unresolvedOrigin: 0,
truncated: false,
},
connections: [{
id: 'connection-1',
startedAt: '2026-08-31T09:59:59.000Z',
closedAt: null,
inbound: { tag: 'mixed-in', type: 'mixed' },
network: 'tcp',
protocol: 'tls',
source: { ip: '127.0.0.1', port: 53000 },
destination: { domain: 'example.com', ip: '203.0.113.1', port: 443, provenance: 'sing-box' },
origin: { kind: 'this-mac', id: null, label: 'Этот Mac', provenance: 'client-runtime' },
route: {
kind: 'vpn',
scope: 'local-sing-box',
outbound: 'proxy',
outboundType: 'selector',
chain: ['proxy'],
rule: 'default',
},
traffic: {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
},
}],
};
function trafficConnection(id, overrides = {}) {
const base = validSnapshot.connections[0];
return {
...base,
...overrides,
id,
inbound: { ...base.inbound, ...overrides.inbound },
source: { ...base.source, ...overrides.source },
destination: { ...base.destination, ...overrides.destination },
origin: { ...base.origin, ...overrides.origin },
route: { ...base.route, ...overrides.route },
traffic: { ...base.traffic, ...overrides.traffic },
};
}
test('live traffic runtime boundary accepts the DTO and rejects inconsistent or malformed snapshots', () => {
assert.equal(assertLiveTrafficSnapshot(validSnapshot), validSnapshot);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
summary: { ...validSnapshot.summary, visible: 0 },
}), /Inconsistent traffic summary/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
connections: [{
...validSnapshot.connections[0],
route: { ...validSnapshot.connections[0].route, outbound: 42 },
}],
}), /Invalid traffic connection/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
summary: { ...validSnapshot.summary, active: 0, recent: 1, recognized: 0 },
connections: [{
...validSnapshot.connections[0],
closedAt: '2026-08-31T10:00:00.000Z',
}],
}), /Invalid closed traffic rate/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
summary: { ...validSnapshot.summary, active: 2, visible: 2, recognized: 2 },
connections: [validSnapshot.connections[0], validSnapshot.connections[0]],
}), /Invalid traffic connection identity/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
connections: [{ ...validSnapshot.connections[0], closedAt: '2026-08-31 10:00:00Z' }],
}), /Invalid traffic connection identity/);
assert.throws(() => assertLiveTrafficSnapshot({
...validSnapshot,
connections: [{
...validSnapshot.connections[0],
traffic: { ...validSnapshot.connections[0].traffic, uploadBytes: 10 },
}],
}), /Invalid traffic byte value/);
});
test('Mac and Gateway traffic drawers use one feature boundary and the cached read-only endpoint', () => {
assert.match(boundary, /TrafficPanel,[\s\S]*TrafficToggle,[\s\S]*useTrafficFeature/);
assert.match(api, /traffic: \{[\s\S]*live: \(\) => request\('\/api\/traffic\/live'\)/);
assert.match(app, /loadLiveTraffic: api\.traffic\.live/);
assert.match(page, /useTrafficFeature\(\{[\s\S]*enabled: true,[\s\S]*isGateway,[\s\S]*loadLiveTraffic: actions\.loadLiveTraffic/);
assert.match(feature, /interface TrafficFeatureOptions \{[\s\S]*isGateway: boolean/);
assert.match(feature, /return \{[\s\S]*isGateway,[\s\S]*isOpen/);
const rail = page.slice(page.indexOf('<nav'), page.indexOf('</nav>'));
const devices = rail.indexOf('<DevicesToggle');
const traffic = rail.indexOf('<TrafficToggle');
const diagnostics = rail.indexOf('<DiagnosticsToggle');
assert.ok(devices >= 0 && traffic > devices && diagnostics > traffic);
assert.equal((rail.match(/<TrafficToggle/g) || []).length, 1);
assert.doesNotMatch(rail, /!isGateway\s*&&\s*<TrafficToggle/);
assert.match(page, /\{hasSubscription && <TrafficPanel feature=\{trafficFeature\} \/>}/);
assert.doesNotMatch(page, /!isGateway && hasSubscription && <TrafficPanel/);
assert.doesNotMatch(page, /if \(isGateway\) trafficFeature\.close\(\)/);
assert.match(page, /DRAWER_ORDER = \['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'\]/);
assert.doesNotMatch(feature, /from ['"][^'"]*\/api(?:\/|\.js)/);
});
test('traffic polling runs every second only while the drawer is open and unpaused', () => {
assert.match(feature, /const POLL_MS = 1_000/);
assert.match(feature, /if \(!enabled \|\| !isOpen \|\| paused\) return undefined/);
assert.match(feature, /assertLiveTrafficSnapshot\(await loadLiveTraffic\(\)\)/);
assert.match(feature, /setSnapshot\(next\)[\s\S]*setRequestState\('ready'\)/);
assert.match(feature, /catch \{[\s\S]*setRequestState\('error'\)/);
assert.match(feature, /timer = setTimeout\(poll, POLL_MS\)/);
assert.match(feature, /cancelled = true[\s\S]*clearTimeout\(timer\)/);
assert.match(feature, /aria-pressed=\{feature\.paused\}[\s\S]*Продолжить[\s\S]*Пауза/);
assert.doesNotMatch(feature, /setInterval|WebSocket|EventSource/);
});
test('traffic drawer exposes the requested truthful states and accessible controls', () => {
for (const copy of [
'VPN остановлен',
'Активных соединений пока нет',
'Инспектор трафика временно недоступен',
'Эта версия sing-box не поддерживает инспектор трафика',
'Инспектор трафика выключен в настройках Harbor Connect.',
'Инспектор трафика выключен в настройках Harbor Gateway.',
'Показан последний полученный снимок',
'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.',
'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.',
]) assert.match(feature, new RegExp(copy.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
assert.match(feature, /type="search"[\s\S]*aria-label="Найти домен, сервис или IP"/);
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
assert.match(feature, /aria-pressed=\{qualityFilter === value\}/);
assert.match(feature, /const \[expandedId, setExpandedId\] = useState\(''\)/);
assert.match(feature, /aria-expanded=\{expanded\}[\s\S]*aria-controls=\{detailsId\}/);
assert.match(feature, /Источник[\s\S]*Назначение[\s\S]*Правило[\s\S]*Цепочка/);
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
});
test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => {
assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/);
assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/);
assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/);
assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/);
assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/);
assert.match(feature, /Показывать завершённые/);
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/);
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
});
test('traffic groups combine compatible UUIDs with exact byte sums and whole-group search', () => {
const groups = groupTrafficConnections([
trafficConnection('active', {
destination: { domain: 'yandex.ru', ip: '203.0.113.1' },
traffic: {
uploadBytes: '10',
downloadBytes: '20',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
},
}),
trafficConnection('closed-a', {
closedAt: '2026-08-31T09:59:59.500Z',
destination: { domain: 'yandex.ru', ip: '203.0.113.2' },
traffic: {
uploadBytes: '30',
downloadBytes: '40',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
},
}),
trafficConnection('closed-b', {
closedAt: '2026-08-31T09:59:59.700Z',
destination: { domain: 'yandex.ru', ip: '203.0.113.2' },
traffic: {
uploadBytes: '9007199254740993',
downloadBytes: '9007199254740995',
uploadBytesPerSecond: '0',
downloadBytesPerSecond: '0',
},
}),
]);
assert.equal(groups.length, 1);
assert.equal(groups[0].label, 'yandex.ru');
assert.equal(groups[0].activeCount, 1);
assert.equal(groups[0].recentCount, 2);
assert.deepEqual(groups[0].connections.map(({ id }) => id), ['active', 'closed-a', 'closed-b']);
assert.deepEqual(groups[0].destinationIps, ['203.0.113.1', '203.0.113.2']);
assert.deepEqual(groups[0].traffic, {
uploadBytes: '9007199254741033',
downloadBytes: '9007199254741055',
uploadBytesPerSecond: '1',
downloadBytesPerSecond: '2',
});
assert.equal(trafficGroupMatches(groups[0], '203.0.113.2', 'all', 'all'), true);
assert.equal(trafficGroupMatches(groups[0], 'missing.test', 'all', 'all'), false);
assert.equal(trafficGroupMatches(groups[0], '', 'vpn', 'recognized'), true);
assert.equal(trafficGroupMatches(groups[0], '', 'direct', 'all'), false);
});
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
assert.equal(groupTrafficConnections([base, same]).length, 1);
const variants = [
['domain', { destination: { domain: 'www.example.com', ip: '203.0.113.1' } }],
['port', { destination: { domain: 'example.com', ip: '203.0.113.1', port: 80 } }],
['network', { network: 'udp' }],
['protocol', { protocol: 'http' }],
['origin', { origin: { kind: 'device', id: 'device-1', label: 'iPhone', provenance: 'source-ip' } }],
['route kind', { route: { kind: 'other' } }],
['route outbound', { route: { outbound: 'other-proxy' } }],
['route outbound type', { route: { outboundType: 'vless' } }],
['route chain', { route: { chain: ['proxy', 'hop'] } }],
['route rule', { route: { rule: 'other-rule' } }],
];
for (const [name, overrides] of variants) {
assert.equal(groupTrafficConnections([base, trafficConnection(String(name), overrides)]).length, 2, name);
}
assert.equal(groupTrafficConnections([
trafficConnection('ip-a', { destination: { domain: null, ip: '203.0.113.1' } }),
trafficConnection('ip-b', { destination: { domain: null, ip: '203.0.113.2' } }),
]).length, 2);
assert.equal(groupTrafficConnections([
trafficConnection('unknown-a', { destination: { domain: null, ip: null, provenance: 'unknown' } }),
trafficConnection('unknown-b', { destination: { domain: null, ip: null, provenance: 'unknown' } }),
]).length, 2);
const deviceOrigin = { kind: 'device', id: 'device-1', label: 'iPhone', provenance: 'source-ip' };
const [deviceGroup] = groupTrafficConnections([
trafficConnection('device', { origin: deviceOrigin }),
]);
assert.deepEqual(deviceGroup.origin, deviceOrigin);
assert.match(feature, /const source = onlyConnection[\s\S]*group\.origin\.label[\s\S]*onlyConnection\.source\.ip[\s\S]*group\.connections\.length/);
});
test('traffic groups stay mounted and inert through exit while the same group cancels removal', () => {
const group = (id, count = 1) => ({ id, connections: Array.from({ length: count }) });
let rows = reconcileTrafficGroups([], [group('a'), group('b'), group('c')], false);
rows = reconcileTrafficGroups(rows, [group('b', 2)], false);
assert.deepEqual(rows.map((row) => [row.group.id, row.group.connections.length, row.exiting]), [
['a', 1, true],
['b', 2, false],
['c', 1, true],
]);
rows = reconcileTrafficGroups(rows, [group('a'), group('b', 3), group('c')], false);
assert.deepEqual(rows.map((row) => [row.group.id, row.group.connections.length, row.exiting]), [
['a', 1, false],
['b', 3, false],
['c', 1, false],
]);
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/);
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
assert.match(feature, /event\.target === event\.currentTarget[\s\S]*event\.animationName === 'client-traffic-connection-out'/);
assert.match(feature, /row\.group\.id !== id \|\| !row\.exiting/);
assert.match(feature, /matchMedia\('\(prefers-reduced-motion: reduce\)'\)[\s\S]*media\.addEventListener\('change', update\)[\s\S]*media\.removeEventListener\('change', update\)/);
assert.match(feature, /const immediate = reducedMotion[\s\S]*\['disabled', 'incompatible', 'stopped'\]\.includes/);
assert.match(feature, /aria-label="Группы активных и недавно завершённых соединений"/);
assert.match(feature, /group\.connections\.length > 1[\s\S]*×\$\{group\.connections\.length\}/);
assert.match(feature, /group\.activeCount > 0 && <strong>/);
assert.match(feature, /displayedGroups\.map\(\(row\) => <TrafficGroupRow[\s\S]*key=\{row\.group\.id\}/);
assert.match(feature, /<b>Активных распознано<\/b>/);
assert.match(feature, /<b>Активных требует внимания<\/b>/);
});
test('traffic styling preserves the shared drawer geometry and minimal motion', () => {
assert.match(styles, /\.client-traffic-toggle svg \{[\s\S]*width: 24px;[\s\S]*height: 24px/);
assert.match(primitives, /\.client-drawer \{[\s\S]*width: min\(580px, 100vw\)/);
assert.match(styles, /@media \(max-width: 768px\) \{[\s\S]*\.client-traffic \{[\s\S]*width: 100vw/);
assert.doesNotMatch(styles, /overflow-y:\s*(?:auto|scroll)/);
assert.match(styles, /\.client-traffic-meta button \{[\s\S]*width: 96px/);
assert.match(styles, /\.client-traffic-details \{[\s\S]*animation: client-traffic-details-in 180ms/);
assert.match(styles, /\.client-traffic-connection \{[\s\S]*animation: client-traffic-connection-in 420ms/);
assert.match(styles, /\.client-traffic-connection\.is-exiting \{[\s\S]*animation: client-traffic-connection-out 240ms/);
const detailMotion = /@keyframes client-traffic-details-in \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
assert.match(detailMotion, /opacity:/);
assert.match(detailMotion, /translateY/);
assert.doesNotMatch(detailMotion, /height|width|margin|padding|scale|filter/);
for (const name of ['client-traffic-connection-in', 'client-traffic-connection-out']) {
const rowMotion = new RegExp(`@keyframes ${name} \\{([\\s\\S]*?)\\n\\}`).exec(styles)?.[1] || '';
assert.match(rowMotion, /opacity:/);
assert.match(rowMotion, /translateY/);
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
}
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details \{[\s\S]*animation: none/);
});