Persist traffic settings and support multi-device traffic views
This commit is contained in:
@@ -5,6 +5,10 @@ import {
|
||||
type LiveTrafficConnection,
|
||||
type LiveTrafficSnapshot,
|
||||
} from '../../../shared/liveTraffic.js';
|
||||
import {
|
||||
TRAFFIC_RETENTION_OPTIONS,
|
||||
type TrafficSettings,
|
||||
} from '../../../shared/trafficSettings.js';
|
||||
import { Drawer } from '../../ui/Drawer.js';
|
||||
import { RailAction } from '../../ui/RailAction.js';
|
||||
import { formatByteString } from '../../utils/format.js';
|
||||
@@ -12,25 +16,26 @@ import {
|
||||
groupTrafficConnections,
|
||||
reconcileTrafficGroups,
|
||||
sortTrafficGroups,
|
||||
summarizeTrafficOrigins,
|
||||
trafficConnectionMatchesFilters,
|
||||
trafficGroupMatches,
|
||||
trafficOriginId,
|
||||
type DisplayedTrafficGroup,
|
||||
type TrafficConnectionGroup,
|
||||
type TrafficQualityFilter,
|
||||
type TrafficRouteFilter,
|
||||
type TrafficSort,
|
||||
} from './trafficRows.js';
|
||||
|
||||
const POLL_MS = 1_000;
|
||||
const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds';
|
||||
const RETENTION_OPTIONS = [5, 10, 30] as const;
|
||||
|
||||
type RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
|
||||
|
||||
interface TrafficFeatureOptions {
|
||||
enabled: boolean;
|
||||
isGateway: boolean;
|
||||
loadLiveTraffic: () => Promise<unknown>;
|
||||
settings: TrafficSettings;
|
||||
updateSettings: (settings: TrafficSettings) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||
@@ -39,15 +44,6 @@ const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||
other: 'Другое',
|
||||
};
|
||||
|
||||
function storedRetentionSeconds(): RetentionSeconds {
|
||||
try {
|
||||
const value = Number(localStorage.getItem(RETENTION_STORAGE_KEY));
|
||||
return RETENTION_OPTIONS.includes(value as RetentionSeconds) ? value as RetentionSeconds : 10;
|
||||
} catch {
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
function address(ip: string | null, port: number | null) {
|
||||
if (!ip) return '—';
|
||||
return port === null ? ip : `${ip}:${port}`;
|
||||
@@ -65,7 +61,13 @@ function updatedAt(value: string | null | undefined) {
|
||||
}).format(date)}`;
|
||||
}
|
||||
|
||||
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
|
||||
export function useTrafficFeature({
|
||||
enabled,
|
||||
isGateway,
|
||||
loadLiveTraffic,
|
||||
settings,
|
||||
updateSettings,
|
||||
}: TrafficFeatureOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||
@@ -146,6 +148,8 @@ export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: Traff
|
||||
paused,
|
||||
snapshot,
|
||||
requestState,
|
||||
settings,
|
||||
updateSettings,
|
||||
panelRef,
|
||||
toggleRef,
|
||||
closeRef,
|
||||
@@ -187,6 +191,9 @@ export function TrafficToggle({
|
||||
}
|
||||
|
||||
function groupStatus(group: TrafficConnectionGroup) {
|
||||
if (group.origins.length > 1) {
|
||||
return `${group.connections.length} соединений · ${group.origins.length} устройств · ${group.protocol}`;
|
||||
}
|
||||
if (group.connections.length === 1) {
|
||||
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
||||
}
|
||||
@@ -226,7 +233,9 @@ function TrafficGroupRow({
|
||||
const onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
||||
const source = onlyConnection
|
||||
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
||||
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||
: group.origins.length > 1
|
||||
? `${group.origins.length} устройств · соединений: ${group.connections.length}`
|
||||
: `${group.origin.label} · соединений: ${group.connections.length}`;
|
||||
const chain = group.route.chain.length
|
||||
? group.route.chain.join(' → ')
|
||||
: group.route.outbound || '—';
|
||||
@@ -274,6 +283,15 @@ function TrafficGroupRow({
|
||||
</svg>
|
||||
</button>
|
||||
{expanded && <dl id={detailsId} className="client-traffic-details">
|
||||
{group.origins.length > 1 && group.origins.map((origin) => <div
|
||||
className="client-traffic-origin-breakdown"
|
||||
key={origin.id}
|
||||
>
|
||||
<dt>{origin.label}</dt>
|
||||
<dd>
|
||||
{origin.connections} соединений · ↓ {formatByteString(origin.traffic.downloadBytes)} · ↑ {formatByteString(origin.traffic.uploadBytes)}
|
||||
</dd>
|
||||
</div>)}
|
||||
<div><dt>Источник</dt><dd>{source}</dd></div>
|
||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
|
||||
@@ -310,26 +328,46 @@ function TrafficState({ feature }: { feature: TrafficFeature }) {
|
||||
|
||||
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [deviceQuery, setDeviceQuery] = useState('');
|
||||
const [selectedOriginId, setSelectedOriginId] = useState('');
|
||||
const [devicesExpanded, setDevicesExpanded] = useState(false);
|
||||
const [settingsPending, setSettingsPending] = useState(false);
|
||||
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(() => (
|
||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
));
|
||||
const [expandedId, setExpandedId] = useState('');
|
||||
const snapshot = feature.snapshot;
|
||||
const { grouping, sort: sortMode, retentionSeconds } = feature.settings;
|
||||
const sourceState = snapshot?.source.state;
|
||||
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
||||
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
||||
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
||||
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
||||
}), [snapshot, snapshotTime, retentionSeconds]);
|
||||
const trafficGroups = useMemo(() => groupTrafficConnections(retainedConnections), [retainedConnections]);
|
||||
const filteredConnections = useMemo(() => retainedConnections.filter((connection) => (
|
||||
trafficConnectionMatchesFilters(connection, routeFilter, qualityFilter)
|
||||
)), [retainedConnections, routeFilter, qualityFilter]);
|
||||
const origins = useMemo(() => summarizeTrafficOrigins(filteredConnections), [filteredConnections]);
|
||||
const matchingOrigins = useMemo(() => {
|
||||
const needle = deviceQuery.trim().toLocaleLowerCase('ru-RU');
|
||||
return needle ? origins.filter((origin) => (
|
||||
[origin.label, origin.ip].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle))
|
||||
)) : origins;
|
||||
}, [origins, deviceQuery]);
|
||||
const visibleOrigins = devicesExpanded ? matchingOrigins : matchingOrigins.slice(0, 3);
|
||||
const hiddenOriginCount = Math.max(0, matchingOrigins.length - 3);
|
||||
const selectedConnections = useMemo(() => selectedOriginId
|
||||
? filteredConnections.filter((connection) => trafficOriginId(connection) === selectedOriginId)
|
||||
: filteredConnections, [filteredConnections, selectedOriginId]);
|
||||
const trafficGroups = useMemo(() => (
|
||||
groupTrafficConnections(selectedConnections, grouping)
|
||||
), [selectedConnections, grouping]);
|
||||
const groups = useMemo(() => sortTrafficGroups(trafficGroups.filter((group) => (
|
||||
trafficGroupMatches(group, query, routeFilter, qualityFilter)
|
||||
)), sortMode), [trafficGroups, query, routeFilter, qualityFilter, sortMode]);
|
||||
trafficGroupMatches(group, query, 'all', 'all')
|
||||
)), sortMode), [trafficGroups, query, sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||
@@ -349,12 +387,19 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
||||
}, [groups, reducedMotion, snapshot, sourceState]);
|
||||
|
||||
function selectRetention(seconds: RetentionSeconds) {
|
||||
setRetentionSeconds(seconds);
|
||||
useEffect(() => {
|
||||
if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) {
|
||||
setSelectedOriginId('');
|
||||
}
|
||||
}, [origins, selectedOriginId]);
|
||||
|
||||
async function saveSettings(settings: TrafficSettings) {
|
||||
if (settingsPending) return;
|
||||
setSettingsPending(true);
|
||||
try {
|
||||
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
|
||||
} catch {
|
||||
// The setting remains available for this session when storage is unavailable.
|
||||
await feature.updateSettings(settings);
|
||||
} finally {
|
||||
setSettingsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,46 +447,53 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
</div>}
|
||||
|
||||
<div className="client-traffic-tools">
|
||||
<label className="client-traffic-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="10.5" cy="10.5" r="6" />
|
||||
<path d="m15 15 5 5" />
|
||||
</svg>
|
||||
<span className="client-live-region">Поиск трафика</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label="Найти домен, сервис или IP"
|
||||
placeholder="Домен, сервис или IP"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['vpn', 'VPN'],
|
||||
['direct', 'Direct'],
|
||||
['other', 'Другое'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={routeFilter === value}
|
||||
onClick={() => setRouteFilter(value)}
|
||||
>{label}</button>)}
|
||||
<div className="client-traffic-option-row">
|
||||
<span>Маршрут</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['vpn', 'VPN'],
|
||||
['direct', 'Direct'],
|
||||
['other', 'Другое'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={routeFilter === value}
|
||||
onClick={() => setRouteFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['recognized', 'Распознано'],
|
||||
['attention', 'Требует внимания'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={qualityFilter === value}
|
||||
onClick={() => setQualityFilter(value)}
|
||||
>{label}</button>)}
|
||||
<div className="client-traffic-option-row">
|
||||
<span>Распознано</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||
{([
|
||||
['all', 'Все'],
|
||||
['recognized', 'Распознано'],
|
||||
['attention', 'Требует внимания'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
aria-pressed={qualityFilter === value}
|
||||
onClick={() => setQualityFilter(value)}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<div className="client-traffic-option-row">
|
||||
<span>Группировка</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Группировка соединений">
|
||||
{([
|
||||
['site', 'По сайтам'],
|
||||
['device', 'По устройствам'],
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
disabled={settingsPending}
|
||||
aria-pressed={grouping === value}
|
||||
onClick={() => void saveSettings({ ...feature.settings, grouping: value })}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-traffic-option-row">
|
||||
<span>Сортировка</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
|
||||
{([
|
||||
@@ -450,24 +502,84 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||
] as const).map(([value, label]) => <button
|
||||
type="button"
|
||||
key={value}
|
||||
disabled={settingsPending}
|
||||
aria-pressed={sortMode === value}
|
||||
onClick={() => setSortMode(value)}
|
||||
onClick={() => void saveSettings({ ...feature.settings, sort: value })}
|
||||
>{label}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="client-traffic-retention">
|
||||
<span>Показывать завершённые</span>
|
||||
<div className="client-traffic-option-row">
|
||||
<span>Завершённые</span>
|
||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||
{RETENTION_OPTIONS.map((seconds) => <button
|
||||
{TRAFFIC_RETENTION_OPTIONS.map((seconds) => <button
|
||||
type="button"
|
||||
key={seconds}
|
||||
disabled={settingsPending}
|
||||
aria-pressed={retentionSeconds === seconds}
|
||||
onClick={() => selectRetention(seconds)}
|
||||
onClick={() => void saveSettings({ ...feature.settings, retentionSeconds: seconds })}
|
||||
>{seconds} с</button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feature.isGateway && canShowList && <section className="client-traffic-devices" aria-labelledby="client-traffic-devices-title">
|
||||
<div className="client-traffic-devices-heading">
|
||||
<h3 id="client-traffic-devices-title">Устройства</h3>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!selectedOriginId}
|
||||
onClick={() => setSelectedOriginId('')}
|
||||
>Все устройства · {origins.length}</button>
|
||||
</div>
|
||||
<label className="client-traffic-search is-device-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="10.5" cy="10.5" r="6" />
|
||||
<path d="m15 15 5 5" />
|
||||
</svg>
|
||||
<span className="client-live-region">Поиск устройства</span>
|
||||
<input
|
||||
type="search"
|
||||
value={deviceQuery}
|
||||
aria-label="Найти устройство"
|
||||
placeholder="Найти устройство"
|
||||
onChange={(event) => setDeviceQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="client-traffic-device-list" id="client-traffic-device-list">
|
||||
{visibleOrigins.map((origin) => <button
|
||||
type="button"
|
||||
key={origin.id}
|
||||
aria-pressed={selectedOriginId === origin.id}
|
||||
onClick={() => setSelectedOriginId((current) => current === origin.id ? '' : origin.id)}
|
||||
>
|
||||
<strong>{origin.label}</strong>
|
||||
<small>{origin.connections} соединений · Устройство{origin.ip ? ` · ${origin.ip}` : ''}</small>
|
||||
</button>)}
|
||||
</div>
|
||||
{hiddenOriginCount > 0 && <button
|
||||
className="client-traffic-devices-more"
|
||||
type="button"
|
||||
aria-expanded={devicesExpanded}
|
||||
aria-controls="client-traffic-device-list"
|
||||
onClick={() => setDevicesExpanded((current) => !current)}
|
||||
>{devicesExpanded ? 'Свернуть устройства' : `Ещё ${hiddenOriginCount} устройств`}</button>}
|
||||
</section>}
|
||||
|
||||
{canShowList && <label className="client-traffic-search client-traffic-list-search">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="10.5" cy="10.5" r="6" />
|
||||
<path d="m15 15 5 5" />
|
||||
</svg>
|
||||
<span className="client-live-region">Поиск соединений</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
aria-label="Найти сайт, IP или сервис"
|
||||
placeholder="Найти сайт, IP или сервис"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</label>}
|
||||
|
||||
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||
</p>}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
||||
import type { TrafficGrouping, TrafficSort } from '../../../shared/trafficSettings.js';
|
||||
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 type { TrafficSort } from '../../../shared/trafficSettings.js';
|
||||
|
||||
export interface TrafficOriginSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
ip: string | null;
|
||||
kind: LiveTrafficConnection['origin']['kind'];
|
||||
connections: number;
|
||||
traffic: Pick<LiveTrafficConnection['traffic'], 'uploadBytes' | 'downloadBytes'>;
|
||||
}
|
||||
|
||||
export interface TrafficConnectionGroup {
|
||||
id: string;
|
||||
@@ -16,6 +26,7 @@ export interface TrafficConnectionGroup {
|
||||
origin: LiveTrafficConnection['origin'];
|
||||
destination: LiveTrafficConnection['destination'];
|
||||
destinationIps: string[];
|
||||
origins: TrafficOriginSummary[];
|
||||
traffic: LiveTrafficConnection['traffic'];
|
||||
}
|
||||
|
||||
@@ -30,7 +41,11 @@ function normalizedDestination(connection: LiveTrafficConnection) {
|
||||
return { domain, ip };
|
||||
}
|
||||
|
||||
function trafficGroupId(connection: LiveTrafficConnection) {
|
||||
export function trafficOriginId(connection: LiveTrafficConnection) {
|
||||
return connection.origin.id || `${connection.origin.kind}:${connection.source.ip || 'unknown'}`;
|
||||
}
|
||||
|
||||
function trafficGroupId(connection: LiveTrafficConnection, grouping: TrafficGrouping) {
|
||||
const { domain, ip } = normalizedDestination(connection);
|
||||
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
||||
return JSON.stringify([
|
||||
@@ -38,7 +53,9 @@ function trafficGroupId(connection: LiveTrafficConnection) {
|
||||
connection.destination.port,
|
||||
connection.network,
|
||||
connection.protocol?.trim().toLowerCase() || null,
|
||||
[connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance],
|
||||
grouping === 'device'
|
||||
? [connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance]
|
||||
: null,
|
||||
[
|
||||
connection.route.kind,
|
||||
connection.route.scope,
|
||||
@@ -57,6 +74,13 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
||||
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
||||
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
||||
.toString();
|
||||
const origins = new Map<string, LiveTrafficConnection[]>();
|
||||
for (const connection of connections) {
|
||||
const originId = trafficOriginId(connection);
|
||||
const members = origins.get(originId);
|
||||
if (members) members.push(connection);
|
||||
else origins.set(originId, [connection]);
|
||||
}
|
||||
return {
|
||||
id,
|
||||
label: domain || ip || 'Назначение не определено',
|
||||
@@ -71,6 +95,19 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
||||
const address = destination.ip?.trim().toLowerCase();
|
||||
return address ? [address] : [];
|
||||
}))].sort((left, right) => left.localeCompare(right)),
|
||||
origins: [...origins.entries()].map(([originId, members]) => ({
|
||||
id: originId,
|
||||
label: members[0].origin.label,
|
||||
ip: members[0].source.ip || null,
|
||||
kind: members[0].origin.kind,
|
||||
connections: members.length,
|
||||
traffic: {
|
||||
uploadBytes: sum('uploadBytes', members),
|
||||
downloadBytes: sum('downloadBytes', members),
|
||||
},
|
||||
})).sort((left, right) => (
|
||||
right.connections - left.connections || left.label.localeCompare(right.label)
|
||||
)),
|
||||
traffic: {
|
||||
uploadBytes: sum('uploadBytes'),
|
||||
downloadBytes: sum('downloadBytes'),
|
||||
@@ -80,10 +117,13 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
||||
};
|
||||
}
|
||||
|
||||
export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
||||
export function groupTrafficConnections(
|
||||
connections: LiveTrafficConnection[],
|
||||
grouping: TrafficGrouping = 'device',
|
||||
) {
|
||||
const grouped = new Map<string, LiveTrafficConnection[]>();
|
||||
for (const connection of connections) {
|
||||
const id = trafficGroupId(connection);
|
||||
const id = trafficGroupId(connection, grouping);
|
||||
const members = grouped.get(id);
|
||||
if (members) members.push(connection);
|
||||
else grouped.set(id, [connection]);
|
||||
@@ -91,6 +131,41 @@ export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
||||
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
|
||||
}
|
||||
|
||||
export function summarizeTrafficOrigins(connections: LiveTrafficConnection[]): TrafficOriginSummary[] {
|
||||
const groups = new Map<string, LiveTrafficConnection[]>();
|
||||
for (const connection of connections) {
|
||||
const id = trafficOriginId(connection);
|
||||
const members = groups.get(id);
|
||||
if (members) members.push(connection);
|
||||
else groups.set(id, [connection]);
|
||||
}
|
||||
return [...groups.entries()].map(([id, members]) => ({
|
||||
id,
|
||||
label: members[0].origin.label,
|
||||
ip: members[0].source.ip || null,
|
||||
kind: members[0].origin.kind,
|
||||
connections: members.length,
|
||||
traffic: {
|
||||
uploadBytes: members.reduce((total, item) => total + byteString(item.traffic.uploadBytes), 0n).toString(),
|
||||
downloadBytes: members.reduce((total, item) => total + byteString(item.traffic.downloadBytes), 0n).toString(),
|
||||
},
|
||||
})).sort((left, right) => (
|
||||
right.connections - left.connections || left.label.localeCompare(right.label) || left.id.localeCompare(right.id)
|
||||
));
|
||||
}
|
||||
|
||||
export function trafficConnectionMatchesFilters(
|
||||
connection: LiveTrafficConnection,
|
||||
route: TrafficRouteFilter,
|
||||
quality: TrafficQualityFilter,
|
||||
) {
|
||||
if (route !== 'all' && connection.route.kind !== route) return false;
|
||||
const recognized = connection.destination.domain !== null;
|
||||
return quality === 'all'
|
||||
|| (quality === 'recognized' && recognized)
|
||||
|| (quality === 'attention' && !recognized);
|
||||
}
|
||||
|
||||
function newestStartedAt(group: TrafficConnectionGroup) {
|
||||
return group.connections.reduce((latest, connection) => (
|
||||
connection.startedAt > latest ? connection.startedAt : latest
|
||||
|
||||
Reference in New Issue
Block a user