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
+50 -8
View File
@@ -1,10 +1,16 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { isDeepStrictEqual } from 'node:util';
import {
assertLiveTrafficSnapshot,
type LiveTrafficSnapshot,
} from '../../../shared/liveTraffic.js';
import type { StoredState } from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import {
normalizeTrafficSettings,
type TrafficSettings,
} from '../../../shared/trafficSettings.js';
import { sendJson } from '../response.js';
interface LiveTrafficReader {
@@ -15,6 +21,11 @@ interface DeviceInventoryReader {
snapshot(): unknown;
}
interface TrafficSettingsState {
read(): { revision?: unknown; traffic?: TrafficSettings };
update(mutator: (state: StoredState) => Record<string, unknown>): unknown;
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
@@ -53,21 +64,52 @@ export function enrichLiveTrafficDeviceLabels(
export function createLiveTrafficRoute({
traffic,
deviceInventory = null,
settingsState = null,
readBody = null,
sendState = null,
}: {
traffic: LiveTrafficReader | null;
deviceInventory?: DeviceInventoryReader | null;
settingsState?: TrafficSettingsState | null;
readBody?: ((req: IncomingMessage) => Promise<Record<string, unknown>>) | null;
sendState?: ((res: ServerResponse) => Promise<void>) | null;
}) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
if (pathname !== '/api/traffic/live') return false;
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
const enriched = deviceInventory
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
: snapshot;
sendJson(res, 200, enriched);
return true;
if (pathname === '/api/traffic/live') {
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
const enriched = deviceInventory
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
: snapshot;
sendJson(res, 200, enriched);
return true;
}
if (pathname === '/api/traffic/settings') {
if (req.method !== 'PUT' || !settingsState || !readBody || !sendState) {
throw new HarborError('ENDPOINT_NOT_FOUND');
}
const body = await readBody(req);
const expectedRevision = body.expectedRevision;
if (!Number.isSafeInteger(expectedRevision) || Number(expectedRevision) < 0) {
throw new HarborError('REQUEST_INVALID');
}
let settings: TrafficSettings;
try {
settings = normalizeTrafficSettings(body.settings, { strict: true });
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
const current = settingsState.read();
if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
if (!isDeepStrictEqual(current.traffic, settings)) {
settingsState.update((state) => ({ ...state, traffic: settings }));
}
await sendState(res);
return true;
}
return false;
},
};
}
+6
View File
@@ -529,6 +529,12 @@ const versionRoute = createVersionRoute({
const liveTrafficRoute = createLiveTrafficRoute({
traffic: liveTraffic,
deviceInventory: remoteDataplane ? deviceInventory : null,
settingsState: {
read: () => normalizeStoredState(stateStore.read()),
update: updateStoredState,
},
readBody,
sendState: (res) => stateRoute.send(res),
});
const subscriptionValidationRoute = createSubscriptionValidationRoute({
validateSubscription: createValidateSubscription(fetchSubscription),
+1 -1
View File
@@ -13,7 +13,7 @@ import {
type NormalizedServer,
} from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 8;
export const STATE_SCHEMA_VERSION = 9;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
+23 -3
View File
@@ -18,6 +18,10 @@ import {
type FailoverRuntimeState,
type FailoverSnapshot,
} from '../failover.js';
import {
normalizeTrafficSettings,
type TrafficSettings,
} from '../trafficSettings.js';
export type HarborMode = 'client' | 'gateway';
export type ConnectionState = 'running' | 'stopped';
@@ -93,6 +97,7 @@ export interface StateSnapshot {
lastError: string | null;
};
diagnostics: DiagnosticSettings;
traffic: TrafficSettings;
failover: FailoverSnapshot;
route: {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
@@ -133,6 +138,7 @@ export interface PersistedState extends Record<string, unknown> {
connectionDesired?: ConnectionState;
gatewayAutoEnabled?: boolean;
diagnostics: DiagnosticSettings;
traffic: TrafficSettings;
failoverPolicy: FailoverPolicy;
failoverRuntimeState: FailoverRuntimeState;
appliedFailoverPolicy: AppliedFailoverPolicy | null;
@@ -307,6 +313,7 @@ export function normalizeStoredState(value: unknown): StoredState {
? state.routeRulesRevision
: 0,
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
traffic: normalizeTrafficSettings(state.traffic),
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
@@ -410,6 +417,7 @@ export function createStateSnapshot({
lastError: null,
},
diagnostics: stored.diagnostics,
traffic: stored.traffic,
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
route: {
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
@@ -449,12 +457,15 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
: rawCandidate;
const candidateWithFailover = candidateWithDiagnostics && candidateWithDiagnostics.failover === undefined
const candidateWithTraffic = candidateWithDiagnostics && candidateWithDiagnostics.traffic === undefined
? { ...candidateWithDiagnostics, traffic: normalizeTrafficSettings(null) }
: candidateWithDiagnostics;
const candidateWithFailover = candidateWithTraffic && candidateWithTraffic.failover === undefined
? {
...candidateWithDiagnostics,
...candidateWithTraffic,
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
}
: candidateWithDiagnostics;
: candidateWithTraffic;
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
&& Array.isArray(candidateWithFailover?.route?.localRules)
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
@@ -512,6 +523,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
return false;
}
};
const validTraffic = (traffic: TrafficSettings) => {
try {
normalizeTrafficSettings(traffic, { strict: true });
return true;
} catch {
return false;
}
};
const validFailover = (value: FailoverSnapshot) => {
const channel = (item: FailoverSnapshot['primary']) => (
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
@@ -573,6 +592,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!nullableDate(candidate.connection.startedAt) ||
!nullableString(candidate.connection.lastError) ||
!validDiagnostics(candidate.diagnostics) ||
!validTraffic(candidate.traffic) ||
!candidate.failover ||
typeof candidate.failover.observationEpoch !== 'string' ||
!Number.isSafeInteger(candidate.failover.observationSequence) ||
+41
View File
@@ -0,0 +1,41 @@
export const TRAFFIC_RETENTION_OPTIONS = [5, 10, 30] as const;
export type TrafficGrouping = 'site' | 'device';
export type TrafficSort = 'popular' | 'recent';
export type TrafficRetentionSeconds = typeof TRAFFIC_RETENTION_OPTIONS[number];
export interface TrafficSettings {
grouping: TrafficGrouping;
sort: TrafficSort;
retentionSeconds: TrafficRetentionSeconds;
}
export const DEFAULT_TRAFFIC_SETTINGS: TrafficSettings = {
grouping: 'site',
sort: 'popular',
retentionSeconds: 10,
};
export function normalizeTrafficSettings(
value: unknown,
{ strict = false }: { strict?: boolean } = {},
): TrafficSettings {
const candidate = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const grouping = candidate.grouping;
const sort = candidate.sort;
const retentionSeconds = candidate.retentionSeconds;
if (strict && (
!['site', 'device'].includes(String(grouping))
|| !['popular', 'recent'].includes(String(sort))
|| !TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
)) throw new TypeError('Invalid traffic settings');
return {
grouping: grouping === 'device' ? 'device' : 'site',
sort: sort === 'recent' ? 'recent' : 'popular',
retentionSeconds: TRAFFIC_RETENTION_OPTIONS.includes(Number(retentionSeconds) as TrafficRetentionSeconds)
? Number(retentionSeconds) as TrafficRetentionSeconds
: DEFAULT_TRAFFIC_SETTINGS.retentionSeconds,
};
}
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.34.2',
gatewayClient: '0.36.2',
gatewayBackend: '0.36.3',
macClient: '0.35.0',
gatewayClient: '0.37.0',
gatewayBackend: '0.37.0',
});
export interface ParsedVersion {
+6
View File
@@ -56,6 +56,7 @@ const operationErrorContext: Record<string, string> = {
'subscription-refresh': 'subscription',
'subscription-forget': 'subscription',
'route-rules': 'routing',
'traffic-settings': 'traffic',
'failover-save': 'failover',
'failover-pause': 'failover',
'failover-resume': 'failover',
@@ -313,6 +314,11 @@ export function App() {
() => api.diagnostics.updateSettings(settings, revisionRef.current),
'diagnostics',
)}
onUpdateTrafficSettings={(settings: unknown) => run(
'trafficSettings',
() => api.traffic.updateSettings(settings, revisionRef.current),
'traffic',
)}
onSaveFailover={(policy: unknown) => run(
'failover',
() => api.failover.save(policy, revisionRef.current),
+8
View File
@@ -232,6 +232,13 @@ export const api = {
},
traffic: {
live: () => request('/api/traffic/live'),
updateSettings: (settings: unknown, expectedRevision: number) => request(
'/api/traffic/settings',
{
method: 'PUT',
body: JSON.stringify({ settings, expectedRevision }),
},
),
},
singbox: {
stop: () => request('/api/singbox/stop', { method: 'POST' }),
@@ -278,6 +285,7 @@ export function parseHarborState(value: unknown): HarborClientState {
selection: snapshot.selection,
connection: snapshot.connection,
diagnostics: snapshot.diagnostics,
traffic: snapshot.traffic,
failover: snapshot.failover,
route: snapshot.route,
operation: snapshot.operation,
@@ -160,6 +160,7 @@ interface ClientOverviewPageProps {
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
onUpdateTrafficSettings: (settings: unknown) => Promise<unknown>;
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
onPauseFailover: (paused: boolean) => Promise<unknown>;
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
@@ -475,6 +476,7 @@ export function ClientOverviewPage({
onSetGatewayAuto,
onSaveRouteRules,
onUpdateDiagnosticsSettings,
onUpdateTrafficSettings,
onSaveFailover,
onPauseFailover,
onSwitchFailover,
@@ -596,6 +598,8 @@ export function ClientOverviewPage({
enabled: true,
isGateway,
loadLiveTraffic: actions.loadLiveTraffic,
settings: state.traffic,
updateSettings: onUpdateTrafficSettings,
});
const diagnosticsAvailable = hasSubscription;
const drawerControls = {
+180 -68
View File
@@ -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>}
+80 -5
View File
@@ -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
+2 -1
View File
@@ -1,6 +1,6 @@
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'failover';
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'trafficSettings' | 'failover';
export interface OperationState {
status: 'running';
@@ -22,6 +22,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
'gatewayAuto',
'routeRules',
'diagnosticsSettings',
'trafficSettings',
'failover',
];
+135 -6
View File
@@ -185,22 +185,132 @@
outline-offset: 2px;
}
.client-traffic-retention {
.client-traffic-option-row {
min-height: 36px;
display: flex;
flex-wrap: wrap;
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
align-items: center;
justify-content: space-between;
gap: 14px;
}
.client-traffic-retention > span {
.client-traffic-option-row > span {
color: var(--client-muted);
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-option-row .client-traffic-filters {
justify-content: flex-end;
}
.client-traffic-filters button:disabled {
cursor: wait;
opacity: 0.55;
}
.client-traffic-devices {
display: grid;
gap: 8px;
margin: 0 8px 24px;
}
.client-traffic-devices-heading {
min-height: 36px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.client-traffic-devices-heading h3,
.client-traffic-devices-heading button,
.client-traffic-devices-more {
margin: 0;
border: 0;
background: transparent;
font: var(--type-control);
letter-spacing: var(--type-control-tracking);
text-transform: var(--type-control-transform);
}
.client-traffic-devices-heading h3 {
color: var(--client-muted);
}
.client-traffic-devices-heading button,
.client-traffic-devices-more {
padding: 5px 0;
color: var(--client-muted);
cursor: pointer;
}
.client-traffic-devices-heading button:hover,
.client-traffic-devices-heading button:focus-visible,
.client-traffic-devices-heading button[aria-pressed='true'],
.client-traffic-devices-more:hover,
.client-traffic-devices-more:focus-visible {
color: var(--client-accent);
}
.client-traffic-devices-heading button:focus-visible,
.client-traffic-devices-more:focus-visible,
.client-traffic-device-list button:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-traffic-device-list {
display: grid;
animation: client-traffic-details-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.client-traffic-device-list button {
min-width: 0;
display: grid;
gap: 3px;
padding: 9px 0;
border: 0;
border-bottom: 1px solid color-mix(in oklch, var(--client-border) 46%, transparent);
background: transparent;
color: var(--client-text);
text-align: left;
cursor: pointer;
}
.client-traffic-device-list button:hover,
.client-traffic-device-list button[aria-pressed='true'] {
color: var(--client-accent);
}
.client-traffic-device-list strong,
.client-traffic-device-list small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-traffic-device-list strong {
font: var(--type-item-title);
letter-spacing: var(--type-item-title-tracking);
text-transform: var(--type-item-title-transform);
}
.client-traffic-device-list small {
color: var(--client-muted);
font: var(--type-micro);
letter-spacing: var(--type-micro-tracking);
text-transform: var(--type-micro-transform);
}
.client-traffic-devices-more {
justify-self: start;
}
.client-traffic-list-search {
margin: 0 8px 12px;
}
.client-traffic-notice,
.client-traffic-state,
.client-traffic-truncated,
@@ -395,6 +505,15 @@
color: var(--client-muted);
}
.client-traffic-details .client-traffic-origin-breakdown {
grid-template-columns: minmax(120px, 1fr) minmax(0, 1.5fr);
padding-bottom: 5px;
}
.client-traffic-origin-breakdown dt {
color: var(--client-text);
}
.client-traffic-details dd {
min-width: 0;
overflow-wrap: anywhere;
@@ -459,11 +578,21 @@
grid-template-columns: 1fr;
gap: 2px;
}
.client-traffic-option-row {
grid-template-columns: 1fr;
gap: 0;
}
.client-traffic-option-row .client-traffic-filters {
justify-content: flex-start;
}
}
@media (prefers-reduced-motion: reduce) {
.client-traffic-connection,
.client-traffic-details {
.client-traffic-details,
.client-traffic-device-list {
animation: none;
}
}