Add traffic group sorting controls
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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<TrafficRouteFilter>('all');
|
||||
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
|
||||
const [sortMode, setSortMode] = useState<TrafficSort>('popular');
|
||||
const [retentionSeconds, setRetentionSeconds] = useState<RetentionSeconds>(storedRetentionSeconds);
|
||||
const [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||
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}</button>)}
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<span>Сортировка</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
|
||||
{([
|
||||
['popular', 'Популярные'],
|
||||
['recent', 'Последние'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={sortMode === value}
|
||||
onClick={() => setSortMode(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<span>Показывать завершённые</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TrafficSort>\('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' } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user