Persist traffic settings and support multi-device traffic views
Build and Deploy Gateway / build-and-push (push) Successful in 32s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-31 06:46:59 +03:00
parent 6777422a27
commit 3c2eefe108
18 changed files with 653 additions and 124 deletions
+52 -14
View File
@@ -8,6 +8,8 @@ import {
groupTrafficConnections,
reconcileTrafficGroups,
sortTrafficGroups,
summarizeTrafficOrigins,
trafficConnectionMatchesFilters,
trafficGroupMatches,
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
@@ -176,7 +178,8 @@ test('traffic drawer exposes the requested truthful states and accessible contro
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, /aria-label="Найти устройство"/);
assert.match(feature, /aria-label="Найти сайт, IP или сервис"/);
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
@@ -187,19 +190,19 @@ test('traffic drawer exposes the requested truthful states and accessible contro
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, /Показывать завершённые/);
test('traffic retention and grouping use canonical server settings and the frozen server observation clock', () => {
assert.doesNotMatch(feature, /localStorage|sessionStorage/);
assert.match(feature, /TRAFFIC_RETENTION_OPTIONS\.map/);
assert.match(feature, /feature\.updateSettings\(settings\)/);
assert.match(api, /updateSettings:[\s\S]*\/api\/traffic\/settings[\s\S]*expectedRevision/);
assert.match(app, /onUpdateTrafficSettings=[\s\S]*api\.traffic\.updateSettings/);
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, /groupTrafficConnections\(selectedConnections, grouping\)/);
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
});
@@ -283,12 +286,47 @@ test('traffic groups sort by bounded frequency or latest start without changing
'popular.test',
]);
assert.deepEqual(groups.map(({ label }) => label), original);
assert.match(feature, /const \[sortMode, setSortMode\] = useState<TrafficSort>\('popular'\)/);
assert.match(feature, /const \{ grouping, sort: sortMode, retentionSeconds \} = feature\.settings/);
assert.match(feature, /role="group" aria-label="Сортировка соединений"/);
assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/);
assert.match(feature, /aria-pressed=\{sortMode === value\}/);
});
test('site grouping combines devices with exact per-device totals while device grouping keeps them separate', () => {
const first = trafficConnection('phone', {
origin: { kind: 'device', id: 'dev_0000000000000001', label: 'Телефон', provenance: 'source-ip' },
source: { ip: '192.168.50.10' },
destination: { domain: 'youtube.com' },
traffic: { uploadBytes: '10', downloadBytes: '20' },
});
const second = trafficConnection('tv', {
origin: { kind: 'device', id: 'dev_0000000000000002', label: 'ТВ', provenance: 'source-ip' },
source: { ip: '192.168.50.11' },
destination: { domain: 'youtube.com' },
traffic: { uploadBytes: '30', downloadBytes: '40' },
});
assert.equal(groupTrafficConnections([first, second], 'device').length, 2);
const [site] = groupTrafficConnections([first, second], 'site');
assert.equal(site.connections.length, 2);
assert.equal(site.origins.length, 2);
assert.deepEqual(site.traffic, {
uploadBytes: '40',
downloadBytes: '60',
uploadBytesPerSecond: '2',
downloadBytesPerSecond: '4',
});
assert.deepEqual(site.origins.map(({ label, connections, traffic }) => ({ label, connections, traffic })), [
{ label: 'ТВ', connections: 1, traffic: { uploadBytes: '30', downloadBytes: '40' } },
{ label: 'Телефон', connections: 1, traffic: { uploadBytes: '10', downloadBytes: '20' } },
]);
const ranked = summarizeTrafficOrigins([first, first, second]);
assert.deepEqual(ranked.map(({ label, connections }) => [label, connections]), [['Телефон', 2], ['ТВ', 1]]);
assert.equal(trafficConnectionMatchesFilters(first, 'vpn', 'recognized'), true);
assert.equal(trafficConnectionMatchesFilters(first, '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' } });
@@ -343,8 +381,8 @@ test('traffic groups stay mounted and inert through exit while the same group ca
]);
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, /groupTrafficConnections\(selectedConnections, grouping\)/);
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
@@ -379,5 +417,5 @@ test('traffic styling preserves the shared drawer geometry and minimal motion',
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/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details,[\s\S]*\.client-traffic-device-list \{[\s\S]*animation: none/);
});