From d060e3bada6c3da6caa9b90c6060ad392e98868a Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 31 Aug 2026 05:58:13 +0300 Subject: [PATCH] Add traffic group sorting controls --- src/shared/versions.ts | 4 +-- src/web/features/traffic/TrafficFeature.tsx | 21 +++++++++++-- src/web/features/traffic/trafficRows.ts | 17 ++++++++++ test/web/live-traffic-contract.test.js | 35 +++++++++++++++++++++ test/web/style-boundaries.test.js | 4 +-- 5 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/shared/versions.ts b/src/shared/versions.ts index f62fdd9..ce156fb 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.34.1', - gatewayClient: '0.36.1', + macClient: '0.34.2', + gatewayClient: '0.36.2', gatewayBackend: '0.36.2', }); diff --git a/src/web/features/traffic/TrafficFeature.tsx b/src/web/features/traffic/TrafficFeature.tsx index 9f55bc9..434a115 100644 --- a/src/web/features/traffic/TrafficFeature.tsx +++ b/src/web/features/traffic/TrafficFeature.tsx @@ -11,11 +11,13 @@ import { formatByteString } from '../../utils/format.js'; import { groupTrafficConnections, reconcileTrafficGroups, + sortTrafficGroups, trafficGroupMatches, type DisplayedTrafficGroup, type TrafficConnectionGroup, type TrafficQualityFilter, type TrafficRouteFilter, + type TrafficSort, } from './trafficRows.js'; const POLL_MS = 1_000; @@ -310,6 +312,7 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) { const [query, setQuery] = useState(''); const [routeFilter, setRouteFilter] = useState('all'); const [qualityFilter, setQualityFilter] = useState('all'); + const [sortMode, setSortMode] = useState('popular'); const [retentionSeconds, setRetentionSeconds] = useState(storedRetentionSeconds); const [displayedGroups, setDisplayedGroups] = useState([]); const [reducedMotion, setReducedMotion] = useState(() => ( @@ -324,9 +327,9 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) { return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000; }), [snapshot, snapshotTime, retentionSeconds]); const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]); - const groups = useMemo(() => trafficGroups.filter((group) => ( + const groups = useMemo(() => sortTrafficGroups(trafficGroups.filter((group) => ( trafficGroupMatches(group, query, routeFilter, qualityFilter) - )), [trafficGroups, query, routeFilter, qualityFilter]); + )), sortMode), [trafficGroups, query, routeFilter, qualityFilter, sortMode]); useEffect(() => { const media = matchMedia('(prefers-reduced-motion: reduce)'); @@ -438,6 +441,20 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) { onClick={() => setQualityFilter(value)} >{label})} +
+ Сортировка +
+ {([ + ['popular', 'Популярные'], + ['recent', 'Последние'], + ] as const).map(([value, label]) => )} +
+
Показывать завершённые
diff --git a/src/web/features/traffic/trafficRows.ts b/src/web/features/traffic/trafficRows.ts index 4656ac3..e2bcfae 100644 --- a/src/web/features/traffic/trafficRows.ts +++ b/src/web/features/traffic/trafficRows.ts @@ -3,6 +3,7 @@ import { byteString } from '../../utils/format.js'; export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other'; export type TrafficQualityFilter = 'all' | 'recognized' | 'attention'; +export type TrafficSort = 'popular' | 'recent'; export interface TrafficConnectionGroup { id: string; @@ -90,6 +91,22 @@ export function groupTrafficConnections(connections: LiveTrafficConnection[]) { return [...grouped].map(([id, members]) => buildTrafficGroup(id, members)); } +function newestStartedAt(group: TrafficConnectionGroup) { + return group.connections.reduce((latest, connection) => ( + connection.startedAt > latest ? connection.startedAt : latest + ), ''); +} + +export function sortTrafficGroups(groups: TrafficConnectionGroup[], mode: TrafficSort) { + return [...groups].sort((left, right) => { + const frequency = right.connections.length - left.connections.length; + const recent = newestStartedAt(right).localeCompare(newestStartedAt(left)); + const primary = mode === 'popular' ? frequency || recent : recent || frequency; + if (primary) return primary; + return left.id < right.id ? -1 : left.id > right.id ? 1 : 0; + }); +} + export function trafficGroupMatches( group: TrafficConnectionGroup, query: string, diff --git a/test/web/live-traffic-contract.test.js b/test/web/live-traffic-contract.test.js index f5e7242..016b6d1 100644 --- a/test/web/live-traffic-contract.test.js +++ b/test/web/live-traffic-contract.test.js @@ -7,6 +7,7 @@ import { assertLiveTrafficSnapshot } from '../../.test-dist/src/shared/liveTraff import { groupTrafficConnections, reconcileTrafficGroups, + sortTrafficGroups, trafficGroupMatches, } from '../../.test-dist/src/web/features/traffic/trafficRows.js'; @@ -254,6 +255,40 @@ test('traffic groups combine compatible UUIDs with exact byte sums and whole-gro assert.equal(trafficGroupMatches(groups[0], '', 'direct', 'all'), false); }); +test('traffic groups sort by bounded frequency or latest start without changing the groups', () => { + const groups = groupTrafficConnections([ + trafficConnection('latest', { + startedAt: '2026-08-31T09:59:00.000Z', + destination: { domain: 'latest.test' }, + }), + ...['a', 'b', 'c'].map((id, index) => trafficConnection(`popular-${id}`, { + startedAt: `2026-08-31T09:0${index}:00.000Z`, + destination: { domain: 'popular.test' }, + })), + ...['a', 'b'].map((id) => trafficConnection(`frequent-recent-${id}`, { + startedAt: '2026-08-31T09:59:00.000Z', + destination: { domain: 'frequent-recent.test' }, + })), + ]); + const original = groups.map(({ label }) => label); + + assert.deepEqual(sortTrafficGroups(groups, 'popular').map(({ label }) => label), [ + 'popular.test', + 'frequent-recent.test', + 'latest.test', + ]); + assert.deepEqual(sortTrafficGroups(groups, 'recent').map(({ label }) => label), [ + 'frequent-recent.test', + 'latest.test', + 'popular.test', + ]); + assert.deepEqual(groups.map(({ label }) => label), original); + assert.match(feature, /const \[sortMode, setSortMode\] = useState\('popular'\)/); + assert.match(feature, /role="group" aria-label="Сортировка соединений"/); + assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/); + assert.match(feature, /aria-pressed=\{sortMode === value\}/); +}); + 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' } }); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index c038ff9..c9ba333 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -59,7 +59,7 @@ const acceptedLedger = { ruleDeclarationSequences: '6226141f0ff66b775008149ccf7d7a55e952c3f5d75961e275f0509ee352a4d6', selectors: '3a165769801a80e9bee5082728a58f38bb427a19ac2c764de8586a5831c9ec87', variableReferences: 'bdae48e482697d7cff6fdc434860cdacf19c8e3cee24b35574e461c3cff73a51', - witnesses: 'aa6334ad87124e5c0dc56af0f8927523aad3002444395adb1c50c0b8a36601bb', + witnesses: '94e1f95de681072ed27f7c576e682893faf55c7489e6a35abb78a5c0d1c62cfb', }, }; @@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner', test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 1281); + assert.equal(witnesses.length, 1285); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts);