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
+1
View File
@@ -29,6 +29,7 @@ const componentActions = {
pingServers: api.servers.ping,
runConnectivityDiagnostics: api.diagnostics.connectivity,
loadActivityJournal: api.activityJournal.page,
loadLiveTraffic: api.traffic.live,
};
interface UiError {
+3
View File
@@ -230,6 +230,9 @@ export const api = {
activityJournal: {
page: (cursor: string | null = null) => request(`/api/activity-journal?limit=50${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`),
},
traffic: {
live: () => request('/api/traffic/live'),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
restart: () => request('/api/singbox/restart', { method: 'POST' }),
+27 -2
View File
@@ -47,6 +47,11 @@ import {
InstructionsToggle,
useInstructionsFeature,
} from '../features/instructions/index.js';
import {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
} from '../features/traffic/index.js';
import { FailoverPanel, FailoverToggle, useFailoverFeature } from '../features/failover/index.js';
import {
ActivityJournalPanel,
@@ -72,7 +77,7 @@ const VERSION_PARTS = [
] as const;
const DRAWER_SWITCH_MS = 620;
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'diagnostics', 'routing', 'journal'] as const;
const DRAWER_ORDER = ['subscription', 'failover', 'instructions', 'devices', 'traffic', 'diagnostics', 'routing', 'journal'] as const;
type DrawerKey = typeof DRAWER_ORDER[number];
const failoverReasonLabel = (reason: string | null) => ({
@@ -128,6 +133,7 @@ interface ComponentActions {
pingServers: (profileId: string, ids: string[]) => Promise<unknown>;
runConnectivityDiagnostics: (target?: unknown) => Promise<unknown>;
loadActivityJournal: (cursor?: string | null) => Promise<unknown>;
loadLiveTraffic: () => Promise<unknown>;
}
interface ClientViewState extends StateSnapshot {
@@ -586,6 +592,11 @@ export function ClientOverviewPage({
});
const failoverFeature = useFailoverFeature();
const activityJournalFeature = useActivityJournalFeature();
const trafficFeature = useTrafficFeature({
enabled: true,
isGateway,
loadLiveTraffic: actions.loadLiveTraffic,
});
const diagnosticsAvailable = hasSubscription;
const drawerControls = {
subscription: {
@@ -612,6 +623,12 @@ export function ClientOverviewPage({
show: devicesFeature.toggle,
close: devicesFeature.close,
},
traffic: {
isOpen: trafficFeature.isOpen,
panelRef: trafficFeature.panelRef,
show: trafficFeature.toggle,
close: trafficFeature.close,
},
diagnostics: {
isOpen: diagnosticsFeature.isOpen,
panelRef: diagnosticsFeature.panelRef,
@@ -654,8 +671,9 @@ export function ClientOverviewPage({
diagnosticsFeature.close();
failoverFeature.close();
activityJournalFeature.close();
trafficFeature.close();
}
}, [hasSubscription, isGateway]);
}, [hasSubscription]);
useEffect(() => {
if (!diagnosticsAvailable) diagnosticsFeature.close();
@@ -851,6 +869,11 @@ export function ClientOverviewPage({
open={activeRailDrawer === 'devices'}
onToggle={() => switchDrawer('devices')}
/>}
<TrafficToggle
feature={trafficFeature}
open={activeRailDrawer === 'traffic'}
onToggle={() => switchDrawer('traffic')}
/>
<DiagnosticsToggle
feature={diagnosticsFeature}
open={activeRailDrawer === 'diagnostics'}
@@ -936,6 +959,8 @@ export function ClientOverviewPage({
{isGateway && hasSubscription && <DevicesPanel feature={devicesFeature} />}
{hasSubscription && <TrafficPanel feature={trafficFeature} />}
{diagnosticsAvailable && <ConnectivityDiagnosticsPanel
feature={diagnosticsFeature}
runConnectivityDiagnostics={actions.runConnectivityDiagnostics}
+503
View File
@@ -0,0 +1,503 @@
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import {
assertLiveTrafficSnapshot,
type LiveTrafficConnection,
type LiveTrafficSnapshot,
} from '../../../shared/liveTraffic.js';
import { Drawer } from '../../ui/Drawer.js';
import { RailAction } from '../../ui/RailAction.js';
import { formatByteString } from '../../utils/format.js';
import {
groupTrafficConnections,
reconcileTrafficGroups,
trafficGroupMatches,
type DisplayedTrafficGroup,
type TrafficConnectionGroup,
type TrafficQualityFilter,
type TrafficRouteFilter,
} 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>;
}
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
vpn: 'VPN',
direct: 'Direct',
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}`;
}
function updatedAt(value: string | null | undefined) {
if (!value) return 'обновлений ещё нет';
const date = new Date(value);
return Number.isNaN(date.getTime())
? 'время неизвестно'
: `обновлено ${new Intl.DateTimeFormat('ru-RU', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)}`;
}
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
const [isOpen, setIsOpen] = useState(false);
const [paused, setPaused] = useState(false);
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
const [requestState, setRequestState] = useState<RequestState>('idle');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (enabled) return;
setIsOpen(false);
setPaused(false);
}, [enabled]);
useEffect(() => {
if (!enabled || !isOpen || paused) return undefined;
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
setRequestState((current) => current === 'idle' ? 'loading' : current);
const poll = async () => {
try {
const next = assertLiveTrafficSnapshot(await loadLiveTraffic());
if (!cancelled) {
setSnapshot(next);
setRequestState('ready');
}
} catch {
if (!cancelled) setRequestState('error');
} finally {
if (!cancelled) timer = setTimeout(poll, POLL_MS);
}
};
void poll();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
};
}, [enabled, isOpen, paused, loadLiveTraffic]);
useEffect(() => {
if (!isOpen) return undefined;
const frame = requestAnimationFrame(() => closeRef.current?.focus());
const closeTraffic = (event: PointerEvent | KeyboardEvent) => {
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
if (event.type !== 'keydown' && (
panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node)
)) return;
setIsOpen(false);
setPaused(false);
};
document.addEventListener('pointerdown', closeTraffic);
document.addEventListener('keydown', closeTraffic);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('pointerdown', closeTraffic);
document.removeEventListener('keydown', closeTraffic);
requestAnimationFrame(() => {
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
});
};
}, [isOpen]);
function close() {
setIsOpen(false);
setPaused(false);
}
function toggle() {
if (isOpen) close();
else if (enabled) setIsOpen(true);
}
return {
isGateway,
isOpen,
paused,
snapshot,
requestState,
panelRef,
toggleRef,
closeRef,
close,
toggle,
togglePause: () => setPaused((current) => !current),
};
}
export type TrafficFeature = ReturnType<typeof useTrafficFeature>;
export function TrafficToggle({
feature,
open,
onToggle,
}: {
feature: TrafficFeature;
open: boolean;
onToggle: () => void;
}) {
return <RailAction
buttonRef={feature.toggleRef}
className="client-traffic-toggle"
open={open}
controls="client-traffic"
ariaLabel={open ? 'Закрыть трафик' : 'Открыть трафик'}
label="Трафик"
onClick={onToggle}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.5 19.5h17" />
<path d="m5 16 4-4 3 2 6-7" />
<circle cx="5" cy="16" r=".7" />
<circle cx="9" cy="12" r=".7" />
<circle cx="12" cy="14" r=".7" />
<circle cx="18" cy="7" r=".7" />
</svg>
</RailAction>;
}
function groupStatus(group: TrafficConnectionGroup) {
if (group.connections.length === 1) {
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
}
const states = [];
if (group.activeCount > 0) states.push(`Активно: ${group.activeCount}`);
if (group.recentCount > 0) {
states.push(`${group.activeCount > 0 ? 'завершено' : 'Завершено'}: ${group.recentCount}`);
}
states.push(group.protocol);
return states.join(' · ');
}
function groupDestination(group: TrafficConnectionGroup) {
const { domain, ip, port } = group.destination;
if (!domain) return address(ip, port);
if (group.destinationIps.length === 1) return `${domain} · ${address(group.destinationIps[0], port)}`;
if (group.destinationIps.length > 1) {
return `${domain} · IP: ${group.destinationIps.length}${port === null ? '' : ` · порт ${port}`}`;
}
return port === null ? domain : `${domain} · порт ${port}`;
}
function TrafficGroupRow({
group,
expanded,
exiting,
onExited,
onToggle,
}: {
group: TrafficConnectionGroup;
expanded: boolean;
exiting: boolean;
onExited: () => void;
onToggle: () => void;
}) {
const detailsId = useId();
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}`;
const chain = group.route.chain.length
? group.route.chain.join(' → ')
: group.route.outbound || '—';
return <div
className={`client-traffic-connection${exiting ? ' is-exiting' : ''}`}
role="listitem"
inert={exiting || undefined}
aria-hidden={exiting || undefined}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget && event.animationName === 'client-traffic-connection-out') {
onExited();
}
}}
>
<button
className="client-traffic-connection-summary"
type="button"
aria-expanded={expanded}
aria-controls={detailsId}
onClick={onToggle}
>
<span className="client-traffic-identity">
<strong aria-label={group.connections.length > 1
? `${group.label}, соединений: ${group.connections.length}`
: undefined}
>{group.label}{group.connections.length > 1 ? ` ×${group.connections.length}` : ''}</strong>
<small>{groupStatus(group)}</small>
</span>
<span className="client-traffic-route" data-route={group.route.kind}>
{routeLabels[group.route.kind]}
</span>
<span className="client-traffic-values">
{group.activeCount > 0 && <strong>
<span> {formatByteString(group.traffic.downloadBytesPerSecond)}/с</span>
<span> {formatByteString(group.traffic.uploadBytesPerSecond)}/с</span>
</strong>}
<small>
<span> {formatByteString(group.traffic.downloadBytes)}</span>
<span> {formatByteString(group.traffic.uploadBytes)}</span>
</small>
</span>
<svg className="client-traffic-chevron" viewBox="0 0 16 16" aria-hidden="true">
<path d="m5 6 3 3 3-3" />
</svg>
</button>
{expanded && <dl id={detailsId} className="client-traffic-details">
<div><dt>Источник</dt><dd>{source}</dd></div>
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</dd></div>
<div><dt>Цепочка</dt><dd>{chain}</dd></div>
</dl>}
</div>;
}
function TrafficState({ feature }: { feature: TrafficFeature }) {
const { snapshot, requestState } = feature;
const sourceState = snapshot?.source.state;
if (!snapshot && (requestState === 'idle' || requestState === 'loading')) {
return <div className="client-traffic-skeleton" role="status" aria-label="Загружаем трафик">
{[0, 1, 2, 3].map((item) => <span key={item} />)}
</div>;
}
if (!snapshot) return <p className="client-traffic-state" role="status">Инспектор трафика временно недоступен.</p>;
if (sourceState === 'disabled') {
return <p className="client-traffic-state" role="status">{feature.isGateway
? 'Инспектор трафика выключен в настройках Harbor Gateway.'
: 'Инспектор трафика выключен в настройках Harbor Connect.'}</p>;
}
if (sourceState === 'incompatible') {
return <p className="client-traffic-state" role="status">Эта версия sing-box не поддерживает инспектор трафика.</p>;
}
if (sourceState === 'stopped') {
return <p className="client-traffic-state" role="status">VPN остановлен. Данные появятся после запуска.</p>;
}
if (sourceState === 'connecting' && snapshot.connections.length === 0) {
return <p className="client-traffic-state" role="status">Подключаем инспектор трафика</p>;
}
return null;
}
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
const [query, setQuery] = useState('');
const [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('all');
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 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 groups = useMemo(() => trafficGroups.filter((group) => (
trafficGroupMatches(group, query, routeFilter, qualityFilter)
)), [trafficGroups, query, routeFilter, qualityFilter]);
useEffect(() => {
const media = matchMedia('(prefers-reduced-motion: reduce)');
const update = () => setReducedMotion(media.matches);
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, []);
useEffect(() => {
const immediate = reducedMotion
|| !snapshot
|| ['disabled', 'incompatible', 'stopped'].includes(sourceState || '');
if (immediate) {
const desiredIds = new Set(groups.map((group) => group.id));
setExpandedId((current) => desiredIds.has(current) ? current : '');
}
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
}, [groups, reducedMotion, snapshot, sourceState]);
function selectRetention(seconds: RetentionSeconds) {
setRetentionSeconds(seconds);
try {
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
} catch {
// The setting remains available for this session when storage is unavailable.
}
}
function finishExit(id: string) {
setDisplayedGroups((current) => current.filter((row) => (
row.group.id !== id || !row.exiting
)));
setExpandedId((current) => current === id ? '' : current);
}
const canShowList = snapshot
&& !['disabled', 'incompatible', 'stopped'].includes(sourceState || '')
&& (sourceState !== 'connecting' || retainedConnections.length > 0);
const stale = feature.requestState === 'error' || sourceState === 'stale';
const degraded = sourceState === 'degraded';
return <Drawer
panelRef={feature.panelRef}
closeRef={feature.closeRef}
id="client-traffic"
className="client-traffic"
sheetClassName="client-traffic-sheet"
open={feature.isOpen}
labelledBy="client-traffic-title"
closeLabel="Закрыть трафик"
onClose={feature.close}
>
<header className="client-traffic-header">
<div className="client-traffic-meta">
<span>{feature.isGateway ? 'GATEWAY' : 'MAC'} · {snapshot?.summary.active || 0} АКТИВНЫХ</span>
<time dateTime={snapshot?.observedAt || undefined}>{updatedAt(snapshot?.observedAt)}</time>
<button
type="button"
aria-pressed={feature.paused}
onClick={feature.togglePause}
>{feature.paused ? 'Продолжить' : 'Пауза'}</button>
</div>
<h2 id="client-traffic-title">Трафик</h2>
<p>Соединения сгруппированы по назначению, протоколу и маршруту.</p>
</header>
{snapshot && <div className="client-traffic-summary" aria-label="Качество распознавания трафика">
<span><b>Активных распознано</b> {snapshot.summary.recognized}</span>
<span><b>Активных требует внимания</b> {snapshot.summary.unresolved}</span>
</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>
<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 className="client-traffic-retention">
<span>Показывать завершённые</span>
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
{RETENTION_OPTIONS.map((seconds) => <button
type="button"
key={seconds}
aria-pressed={retentionSeconds === seconds}
onClick={() => selectRetention(seconds)}
>{seconds} с</button>)}
</div>
</div>
</div>
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
</p>}
{!feature.paused && stale && snapshot && <p className="client-traffic-notice is-warning" role="status">
Данные временно не обновляются. Показан последний полученный снимок.
</p>}
{!feature.paused && !stale && snapshot && sourceState === 'connecting' && snapshot.connections.length > 0 && <p
className="client-traffic-notice"
role="status"
>
Инспектор переподключается. Показан последний полученный снимок.
</p>}
{degraded && snapshot && <p className="client-traffic-notice is-warning" role="status">
Часть трафика не удалось сопоставить с соединениями: {formatByteString(snapshot.source.unattributedDownloadBytes)} · {formatByteString(snapshot.source.unattributedUploadBytes)}.
</p>}
<TrafficState feature={feature} />
{canShowList && retainedConnections.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
Активных соединений пока нет.
</p>}
{canShowList && retainedConnections.length > 0 && groups.length === 0 && displayedGroups.length === 0 && <p className="client-traffic-state">
По выбранным фильтрам ничего не найдено.
</p>}
{canShowList && displayedGroups.length > 0 && <div
className="client-traffic-list"
role="list"
aria-label="Группы активных и недавно завершённых соединений"
aria-busy={feature.requestState === 'loading'}
>
{displayedGroups.map((row) => <TrafficGroupRow
key={row.group.id}
group={row.group}
expanded={expandedId === row.group.id}
exiting={row.exiting}
onExited={() => finishExit(row.group.id)}
onToggle={() => setExpandedId((current) => current === row.group.id ? '' : row.group.id)}
/>)}
</div>}
{snapshot?.summary.truncated && <p className="client-traffic-truncated" role="status">
Снимок ограничен 256 соединениями; активные показаны первыми.
</p>}
<p className="client-traffic-honesty">
{feature.isGateway
? 'Только соединения, прошедшие через sing-box Gateway. Трафик, обходящий sing-box напрямую, здесь не виден.'
: 'Только трафик через Harbor Connect. Приложения macOS недоступны внутри Docker.'}
</p>
</Drawer>;
}
+6
View File
@@ -0,0 +1,6 @@
export {
TrafficPanel,
TrafficToggle,
useTrafficFeature,
type TrafficFeature,
} from './TrafficFeature.js';
+133
View File
@@ -0,0 +1,133 @@
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
import { byteString } from '../../utils/format.js';
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
export interface TrafficConnectionGroup {
id: string;
label: string;
connections: LiveTrafficConnection[];
activeCount: number;
recentCount: number;
protocol: string;
route: LiveTrafficConnection['route'];
origin: LiveTrafficConnection['origin'];
destination: LiveTrafficConnection['destination'];
destinationIps: string[];
traffic: LiveTrafficConnection['traffic'];
}
export interface DisplayedTrafficGroup {
group: TrafficConnectionGroup;
exiting: boolean;
}
function normalizedDestination(connection: LiveTrafficConnection) {
const domain = connection.destination.domain?.trim().toLowerCase() || null;
const ip = connection.destination.ip?.trim().toLowerCase() || null;
return { domain, ip };
}
function trafficGroupId(connection: LiveTrafficConnection) {
const { domain, ip } = normalizedDestination(connection);
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
return JSON.stringify([
destination,
connection.destination.port,
connection.network,
connection.protocol?.trim().toLowerCase() || null,
[connection.origin.kind, connection.origin.id, connection.origin.label, connection.origin.provenance],
[
connection.route.kind,
connection.route.scope,
connection.route.outbound,
connection.route.outboundType,
connection.route.chain,
connection.route.rule,
],
]);
}
function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): TrafficConnectionGroup {
const first = connections[0];
const { domain, ip } = normalizedDestination(first);
const active = connections.filter(({ closedAt }) => closedAt === null);
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
.toString();
return {
id,
label: domain || ip || 'Назначение не определено',
connections,
activeCount: active.length,
recentCount: connections.length - active.length,
protocol: first.protocol || first.network.toUpperCase(),
route: first.route,
origin: first.origin,
destination: { ...first.destination, domain, ip },
destinationIps: [...new Set(connections.flatMap(({ destination }) => {
const address = destination.ip?.trim().toLowerCase();
return address ? [address] : [];
}))].sort((left, right) => left.localeCompare(right)),
traffic: {
uploadBytes: sum('uploadBytes'),
downloadBytes: sum('downloadBytes'),
uploadBytesPerSecond: sum('uploadBytesPerSecond', active),
downloadBytesPerSecond: sum('downloadBytesPerSecond', active),
},
};
}
export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
const grouped = new Map<string, LiveTrafficConnection[]>();
for (const connection of connections) {
const id = trafficGroupId(connection);
const members = grouped.get(id);
if (members) members.push(connection);
else grouped.set(id, [connection]);
}
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
}
export function trafficGroupMatches(
group: TrafficConnectionGroup,
query: string,
route: TrafficRouteFilter,
quality: TrafficQualityFilter,
) {
if (route !== 'all' && group.route.kind !== route) return false;
const recognized = group.destination.domain !== null;
if (quality === 'recognized' && !recognized) return false;
if (quality === 'attention' && recognized) return false;
const needle = query.trim().toLocaleLowerCase('ru-RU');
if (!needle) return true;
return group.connections.some((connection) => [
connection.destination.domain,
connection.destination.ip,
connection.source.ip,
connection.protocol,
connection.inbound.tag,
connection.inbound.type,
connection.route.outbound,
connection.route.outboundType,
...connection.route.chain,
].some((value) => String(value || '').toLocaleLowerCase('ru-RU').includes(needle)));
}
export function reconcileTrafficGroups(
current: DisplayedTrafficGroup[],
desired: TrafficConnectionGroup[],
immediate: boolean,
) {
const next = desired.map((group) => ({ group, exiting: false }));
if (immediate) return next;
const desiredIds = new Set(desired.map((group) => group.id));
current.forEach((row, index) => {
if (!desiredIds.has(row.group.id)) {
next.splice(Math.min(index, next.length), 0, { ...row, exiting: true });
}
});
return next;
}
+469
View File
@@ -0,0 +1,469 @@
.client-traffic-toggle svg {
width: 24px;
height: 24px;
}
.client-traffic-sheet {
padding: 54px 72px 72px 34px;
}
@media (max-width: 768px) {
.client-traffic {
width: 100vw;
}
}
.client-traffic-header {
display: grid;
gap: 9px;
margin: 0 8px 28px;
}
.client-traffic-meta {
min-height: 28px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
padding-right: 28px;
}
.client-traffic-meta > span,
.client-traffic-meta time {
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-meta time {
text-transform: var(--type-label-transform);
white-space: nowrap;
}
.client-traffic-meta button {
width: 96px;
min-height: 32px;
padding: 0;
border: 0;
background: transparent;
color: var(--client-text);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
text-align: right;
cursor: pointer;
}
.client-traffic-meta button:hover,
.client-traffic-meta button:focus-visible,
.client-traffic-meta button[aria-pressed='true'] {
color: var(--client-accent);
}
.client-traffic-meta button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 3px;
}
.client-traffic-header h2 {
margin: 4px 0 0;
font: var(--type-drawer-title);
letter-spacing: var(--type-drawer-title-tracking);
text-transform: var(--type-drawer-title-transform);
}
.client-traffic-header p {
max-width: 44ch;
color: var(--client-muted);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
}
.client-traffic-summary {
display: grid;
gap: 6px;
margin: 0 8px 24px;
color: var(--client-text);
font: var(--type-data);
letter-spacing: var(--type-data-tracking);
text-transform: var(--type-data-transform);
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-summary span {
display: flex;
gap: 8px;
}
.client-traffic-summary b {
min-width: 112px;
color: var(--client-muted);
font: inherit;
}
.client-traffic-tools {
display: grid;
gap: 4px;
margin: 0 8px 22px;
}
.client-traffic-search {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
border-bottom: 1px solid var(--client-border);
color: var(--client-muted);
}
.client-traffic-search:focus-within {
border-bottom-color: var(--client-accent);
color: var(--client-accent);
}
.client-traffic-search svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.client-traffic-search input {
width: 100%;
height: 42px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--client-text);
font: var(--type-body);
letter-spacing: var(--type-body-tracking);
text-transform: var(--type-body-transform);
}
.client-traffic-search input::placeholder {
color: var(--client-muted);
opacity: 1;
}
.client-traffic-filters {
display: flex;
flex-wrap: wrap;
gap: 4px 18px;
}
.client-traffic-filters button {
min-height: 36px;
padding: 4px 0 2px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
cursor: pointer;
}
.client-traffic-filters button:hover,
.client-traffic-filters button:focus-visible,
.client-traffic-filters button[aria-pressed='true'] {
color: var(--client-accent);
}
.client-traffic-filters button[aria-pressed='true'] {
border-bottom-color: var(--client-accent);
}
.client-traffic-filters button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-traffic-retention {
min-height: 36px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.client-traffic-retention > span {
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-notice,
.client-traffic-state,
.client-traffic-truncated,
.client-traffic-honesty {
margin: 0 8px;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-notice {
margin-bottom: 14px;
color: var(--client-accent);
}
.client-traffic-notice.is-warning {
color: oklch(0.68 0.14 72);
}
.client-traffic-state {
min-height: 118px;
display: grid;
place-items: center;
text-align: center;
}
.client-traffic-skeleton {
display: grid;
gap: 10px;
margin: 0 8px;
}
.client-traffic-skeleton span {
height: 58px;
background: color-mix(in oklch, var(--client-border) 24%, transparent);
opacity: 0.55;
}
.client-traffic-list {
display: grid;
margin: 0 8px;
padding: 0;
}
.client-traffic-connection {
min-width: 0;
box-shadow: inset 0 -1px 0 color-mix(in oklch, var(--client-border) 58%, transparent);
animation: client-traffic-connection-in 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-connection.is-exiting {
pointer-events: none;
animation: client-traffic-connection-out 240ms cubic-bezier(0.4, 0, 1, 1) both;
}
.client-traffic-connection-summary {
width: 100%;
min-height: 68px;
display: grid;
grid-template-columns: minmax(0, 1fr) 54px 154px 18px;
align-items: center;
gap: 10px;
padding: 10px 0;
border: 0;
background: transparent;
color: var(--client-text);
text-align: left;
cursor: pointer;
}
.client-traffic-connection-summary:hover,
.client-traffic-connection-summary:focus-visible {
outline: 0;
color: var(--client-accent);
}
.client-traffic-connection-summary:focus-visible {
box-shadow: inset 0 0 0 2px var(--client-accent);
}
.client-traffic-identity,
.client-traffic-values {
min-width: 0;
display: grid;
gap: 4px;
}
.client-traffic-identity strong {
overflow: hidden;
font: var(--type-item-title);
letter-spacing: var(--type-item-title-tracking);
text-overflow: ellipsis;
text-transform: var(--type-item-title-transform);
white-space: nowrap;
}
.client-traffic-identity small {
overflow: hidden;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-overflow: ellipsis;
text-transform: var(--type-control-transform);
white-space: nowrap;
}
.client-traffic-route {
justify-self: start;
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-route[data-route='vpn'] {
color: var(--harbor-connect);
}
.client-traffic-route[data-route='direct'] {
color: var(--harbor-gateway);
}
.client-traffic-values {
justify-items: end;
font-variant-numeric: var(--numeric-tabular);
}
.client-traffic-values strong,
.client-traffic-values small {
display: flex;
justify-content: flex-end;
gap: 10px;
white-space: nowrap;
}
.client-traffic-values strong {
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-values small {
color: var(--client-muted);
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
}
.client-traffic-values span:first-child {
color: var(--harbor-connect);
}
.client-traffic-values span:last-child {
color: var(--harbor-gateway);
}
.client-traffic-chevron {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.6;
}
.client-traffic-details {
display: grid;
gap: 7px;
margin: 0;
padding: 4px 0 16px;
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-details > div {
min-width: 0;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 10px;
}
.client-traffic-details dt,
.client-traffic-details dd {
margin: 0;
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-details dt {
color: var(--client-muted);
}
.client-traffic-details dd {
min-width: 0;
overflow-wrap: anywhere;
color: var(--client-text);
}
.client-traffic-truncated {
padding-top: 16px;
text-align: center;
}
.client-traffic-honesty {
margin-top: 26px;
padding-top: 16px;
box-shadow: inset 0 1px 0 color-mix(in oklch, var(--client-border) 46%, transparent);
}
@keyframes client-traffic-details-in {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes client-traffic-connection-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes client-traffic-connection-out {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(4px); }
}
@media (max-width: 560px) {
.client-traffic-sheet {
padding: 40px 58px 60px 18px;
}
.client-traffic-meta {
grid-template-columns: minmax(0, 1fr) auto;
}
.client-traffic-meta > span {
grid-column: 1 / -1;
}
.client-traffic-connection-summary {
grid-template-columns: minmax(0, 1fr) auto 18px;
}
.client-traffic-values {
grid-column: 1 / -1;
grid-row: 2;
justify-items: start;
}
.client-traffic-values strong,
.client-traffic-values small {
justify-content: flex-start;
}
.client-traffic-details > div {
grid-template-columns: 1fr;
gap: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.client-traffic-connection,
.client-traffic-details {
animation: none;
}
}
+1
View File
@@ -10,5 +10,6 @@
@import './features/diagnostics.css';
@import './features/failover.css';
@import './features/activity-journal.css';
@import './features/traffic.css';
@import './layout.css';
@import './themes.css';