Persist traffic settings and support multi-device traffic views
This commit is contained in:
@@ -1,10 +1,16 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
assertLiveTrafficSnapshot,
|
assertLiveTrafficSnapshot,
|
||||||
type LiveTrafficSnapshot,
|
type LiveTrafficSnapshot,
|
||||||
} from '../../../shared/liveTraffic.js';
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import type { StoredState } from '../../../shared/contracts/state.js';
|
||||||
import { HarborError } from '../../../shared/errors.js';
|
import { HarborError } from '../../../shared/errors.js';
|
||||||
|
import {
|
||||||
|
normalizeTrafficSettings,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../../../shared/trafficSettings.js';
|
||||||
import { sendJson } from '../response.js';
|
import { sendJson } from '../response.js';
|
||||||
|
|
||||||
interface LiveTrafficReader {
|
interface LiveTrafficReader {
|
||||||
@@ -15,6 +21,11 @@ interface DeviceInventoryReader {
|
|||||||
snapshot(): unknown;
|
snapshot(): unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TrafficSettingsState {
|
||||||
|
read(): { revision?: unknown; traffic?: TrafficSettings };
|
||||||
|
update(mutator: (state: StoredState) => Record<string, unknown>): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
function record(value: unknown): Record<string, unknown> {
|
function record(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? value as Record<string, unknown>
|
||||||
@@ -53,21 +64,52 @@ export function enrichLiveTrafficDeviceLabels(
|
|||||||
export function createLiveTrafficRoute({
|
export function createLiveTrafficRoute({
|
||||||
traffic,
|
traffic,
|
||||||
deviceInventory = null,
|
deviceInventory = null,
|
||||||
|
settingsState = null,
|
||||||
|
readBody = null,
|
||||||
|
sendState = null,
|
||||||
}: {
|
}: {
|
||||||
traffic: LiveTrafficReader | null;
|
traffic: LiveTrafficReader | null;
|
||||||
deviceInventory?: DeviceInventoryReader | null;
|
deviceInventory?: DeviceInventoryReader | null;
|
||||||
|
settingsState?: TrafficSettingsState | null;
|
||||||
|
readBody?: ((req: IncomingMessage) => Promise<Record<string, unknown>>) | null;
|
||||||
|
sendState?: ((res: ServerResponse) => Promise<void>) | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
async handle(req: IncomingMessage, res: ServerResponse) {
|
async handle(req: IncomingMessage, res: ServerResponse) {
|
||||||
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
const pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
if (pathname !== '/api/traffic/live') return false;
|
if (pathname === '/api/traffic/live') {
|
||||||
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
if (req.method !== 'GET' || !traffic) throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||||
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
const snapshot = assertLiveTrafficSnapshot(await traffic.snapshot());
|
||||||
const enriched = deviceInventory
|
const enriched = deviceInventory
|
||||||
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
? enrichLiveTrafficDeviceLabels(snapshot, deviceInventory.snapshot())
|
||||||
: snapshot;
|
: snapshot;
|
||||||
sendJson(res, 200, enriched);
|
sendJson(res, 200, enriched);
|
||||||
return true;
|
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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -529,6 +529,12 @@ const versionRoute = createVersionRoute({
|
|||||||
const liveTrafficRoute = createLiveTrafficRoute({
|
const liveTrafficRoute = createLiveTrafficRoute({
|
||||||
traffic: liveTraffic,
|
traffic: liveTraffic,
|
||||||
deviceInventory: remoteDataplane ? deviceInventory : null,
|
deviceInventory: remoteDataplane ? deviceInventory : null,
|
||||||
|
settingsState: {
|
||||||
|
read: () => normalizeStoredState(stateStore.read()),
|
||||||
|
update: updateStoredState,
|
||||||
|
},
|
||||||
|
readBody,
|
||||||
|
sendState: (res) => stateRoute.send(res),
|
||||||
});
|
});
|
||||||
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
const subscriptionValidationRoute = createSubscriptionValidationRoute({
|
||||||
validateSubscription: createValidateSubscription(fetchSubscription),
|
validateSubscription: createValidateSubscription(fetchSubscription),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type NormalizedServer,
|
type NormalizedServer,
|
||||||
} from '../../shared/serverIdentity.js';
|
} from '../../shared/serverIdentity.js';
|
||||||
|
|
||||||
export const STATE_SCHEMA_VERSION = 8;
|
export const STATE_SCHEMA_VERSION = 9;
|
||||||
|
|
||||||
export interface AtomicWriteOptions {
|
export interface AtomicWriteOptions {
|
||||||
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
beforeRename?: (temporaryPath: string, filePath: string) => void;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ import {
|
|||||||
type FailoverRuntimeState,
|
type FailoverRuntimeState,
|
||||||
type FailoverSnapshot,
|
type FailoverSnapshot,
|
||||||
} from '../failover.js';
|
} from '../failover.js';
|
||||||
|
import {
|
||||||
|
normalizeTrafficSettings,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../trafficSettings.js';
|
||||||
|
|
||||||
export type HarborMode = 'client' | 'gateway';
|
export type HarborMode = 'client' | 'gateway';
|
||||||
export type ConnectionState = 'running' | 'stopped';
|
export type ConnectionState = 'running' | 'stopped';
|
||||||
@@ -93,6 +97,7 @@ export interface StateSnapshot {
|
|||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
};
|
};
|
||||||
diagnostics: DiagnosticSettings;
|
diagnostics: DiagnosticSettings;
|
||||||
|
traffic: TrafficSettings;
|
||||||
failover: FailoverSnapshot;
|
failover: FailoverSnapshot;
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
|
||||||
@@ -133,6 +138,7 @@ export interface PersistedState extends Record<string, unknown> {
|
|||||||
connectionDesired?: ConnectionState;
|
connectionDesired?: ConnectionState;
|
||||||
gatewayAutoEnabled?: boolean;
|
gatewayAutoEnabled?: boolean;
|
||||||
diagnostics: DiagnosticSettings;
|
diagnostics: DiagnosticSettings;
|
||||||
|
traffic: TrafficSettings;
|
||||||
failoverPolicy: FailoverPolicy;
|
failoverPolicy: FailoverPolicy;
|
||||||
failoverRuntimeState: FailoverRuntimeState;
|
failoverRuntimeState: FailoverRuntimeState;
|
||||||
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
appliedFailoverPolicy: AppliedFailoverPolicy | null;
|
||||||
@@ -307,6 +313,7 @@ export function normalizeStoredState(value: unknown): StoredState {
|
|||||||
? state.routeRulesRevision
|
? state.routeRulesRevision
|
||||||
: 0,
|
: 0,
|
||||||
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
diagnostics: normalizeDiagnosticSettings(state.diagnostics),
|
||||||
|
traffic: normalizeTrafficSettings(state.traffic),
|
||||||
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
failoverPolicy: normalizeFailoverPolicy(state.failoverPolicy),
|
||||||
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
failoverRuntimeState: normalizeFailoverRuntimeState(state.failoverRuntimeState),
|
||||||
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
appliedFailoverPolicy: normalizeAppliedFailoverPolicy(state.appliedFailoverPolicy),
|
||||||
@@ -410,6 +417,7 @@ export function createStateSnapshot({
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
diagnostics: stored.diagnostics,
|
diagnostics: stored.diagnostics,
|
||||||
|
traffic: stored.traffic,
|
||||||
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
failover: failoverSnapshot || createIdleFailoverSnapshot(stored.failoverPolicy),
|
||||||
route: {
|
route: {
|
||||||
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
|
||||||
@@ -449,12 +457,15 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
const candidateWithDiagnostics = rawCandidate && rawCandidate.diagnostics === undefined
|
||||||
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
? { ...rawCandidate, diagnostics: normalizeDiagnosticSettings(null) }
|
||||||
: rawCandidate;
|
: 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)),
|
failover: createIdleFailoverSnapshot(normalizeFailoverPolicy(null)),
|
||||||
}
|
}
|
||||||
: candidateWithDiagnostics;
|
: candidateWithTraffic;
|
||||||
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
const candidate = candidateWithFailover?.route?.rulesContractVersion === undefined
|
||||||
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
&& Array.isArray(candidateWithFailover?.route?.localRules)
|
||||||
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
&& Array.isArray(candidateWithFailover?.route?.activeLocalRules)
|
||||||
@@ -512,6 +523,14 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const validTraffic = (traffic: TrafficSettings) => {
|
||||||
|
try {
|
||||||
|
normalizeTrafficSettings(traffic, { strict: true });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
const validFailover = (value: FailoverSnapshot) => {
|
const validFailover = (value: FailoverSnapshot) => {
|
||||||
const channel = (item: FailoverSnapshot['primary']) => (
|
const channel = (item: FailoverSnapshot['primary']) => (
|
||||||
item && typeof item.target?.profileId === 'string' && typeof item.target?.serverId === 'string'
|
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) ||
|
!nullableDate(candidate.connection.startedAt) ||
|
||||||
!nullableString(candidate.connection.lastError) ||
|
!nullableString(candidate.connection.lastError) ||
|
||||||
!validDiagnostics(candidate.diagnostics) ||
|
!validDiagnostics(candidate.diagnostics) ||
|
||||||
|
!validTraffic(candidate.traffic) ||
|
||||||
!candidate.failover ||
|
!candidate.failover ||
|
||||||
typeof candidate.failover.observationEpoch !== 'string' ||
|
typeof candidate.failover.observationEpoch !== 'string' ||
|
||||||
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
!Number.isSafeInteger(candidate.failover.observationSequence) ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.34.2',
|
macClient: '0.35.0',
|
||||||
gatewayClient: '0.36.2',
|
gatewayClient: '0.37.0',
|
||||||
gatewayBackend: '0.36.3',
|
gatewayBackend: '0.37.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const operationErrorContext: Record<string, string> = {
|
|||||||
'subscription-refresh': 'subscription',
|
'subscription-refresh': 'subscription',
|
||||||
'subscription-forget': 'subscription',
|
'subscription-forget': 'subscription',
|
||||||
'route-rules': 'routing',
|
'route-rules': 'routing',
|
||||||
|
'traffic-settings': 'traffic',
|
||||||
'failover-save': 'failover',
|
'failover-save': 'failover',
|
||||||
'failover-pause': 'failover',
|
'failover-pause': 'failover',
|
||||||
'failover-resume': 'failover',
|
'failover-resume': 'failover',
|
||||||
@@ -313,6 +314,11 @@ export function App() {
|
|||||||
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
() => api.diagnostics.updateSettings(settings, revisionRef.current),
|
||||||
'diagnostics',
|
'diagnostics',
|
||||||
)}
|
)}
|
||||||
|
onUpdateTrafficSettings={(settings: unknown) => run(
|
||||||
|
'trafficSettings',
|
||||||
|
() => api.traffic.updateSettings(settings, revisionRef.current),
|
||||||
|
'traffic',
|
||||||
|
)}
|
||||||
onSaveFailover={(policy: unknown) => run(
|
onSaveFailover={(policy: unknown) => run(
|
||||||
'failover',
|
'failover',
|
||||||
() => api.failover.save(policy, revisionRef.current),
|
() => api.failover.save(policy, revisionRef.current),
|
||||||
|
|||||||
@@ -232,6 +232,13 @@ export const api = {
|
|||||||
},
|
},
|
||||||
traffic: {
|
traffic: {
|
||||||
live: () => request('/api/traffic/live'),
|
live: () => request('/api/traffic/live'),
|
||||||
|
updateSettings: (settings: unknown, expectedRevision: number) => request(
|
||||||
|
'/api/traffic/settings',
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ settings, expectedRevision }),
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
@@ -278,6 +285,7 @@ export function parseHarborState(value: unknown): HarborClientState {
|
|||||||
selection: snapshot.selection,
|
selection: snapshot.selection,
|
||||||
connection: snapshot.connection,
|
connection: snapshot.connection,
|
||||||
diagnostics: snapshot.diagnostics,
|
diagnostics: snapshot.diagnostics,
|
||||||
|
traffic: snapshot.traffic,
|
||||||
failover: snapshot.failover,
|
failover: snapshot.failover,
|
||||||
route: snapshot.route,
|
route: snapshot.route,
|
||||||
operation: snapshot.operation,
|
operation: snapshot.operation,
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ interface ClientOverviewPageProps {
|
|||||||
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
onSetGatewayAuto: (enabled: boolean) => Promise<unknown>;
|
||||||
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||||||
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
onUpdateDiagnosticsSettings: (settings: unknown) => Promise<unknown>;
|
||||||
|
onUpdateTrafficSettings: (settings: unknown) => Promise<unknown>;
|
||||||
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
onSaveFailover: (policy: FailoverPolicy) => Promise<unknown>;
|
||||||
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
onPauseFailover: (paused: boolean) => Promise<unknown>;
|
||||||
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
onSwitchFailover: (role: 'primary' | 'reserve') => Promise<unknown>;
|
||||||
@@ -475,6 +476,7 @@ export function ClientOverviewPage({
|
|||||||
onSetGatewayAuto,
|
onSetGatewayAuto,
|
||||||
onSaveRouteRules,
|
onSaveRouteRules,
|
||||||
onUpdateDiagnosticsSettings,
|
onUpdateDiagnosticsSettings,
|
||||||
|
onUpdateTrafficSettings,
|
||||||
onSaveFailover,
|
onSaveFailover,
|
||||||
onPauseFailover,
|
onPauseFailover,
|
||||||
onSwitchFailover,
|
onSwitchFailover,
|
||||||
@@ -596,6 +598,8 @@ export function ClientOverviewPage({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
isGateway,
|
isGateway,
|
||||||
loadLiveTraffic: actions.loadLiveTraffic,
|
loadLiveTraffic: actions.loadLiveTraffic,
|
||||||
|
settings: state.traffic,
|
||||||
|
updateSettings: onUpdateTrafficSettings,
|
||||||
});
|
});
|
||||||
const diagnosticsAvailable = hasSubscription;
|
const diagnosticsAvailable = hasSubscription;
|
||||||
const drawerControls = {
|
const drawerControls = {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
type LiveTrafficConnection,
|
type LiveTrafficConnection,
|
||||||
type LiveTrafficSnapshot,
|
type LiveTrafficSnapshot,
|
||||||
} from '../../../shared/liveTraffic.js';
|
} from '../../../shared/liveTraffic.js';
|
||||||
|
import {
|
||||||
|
TRAFFIC_RETENTION_OPTIONS,
|
||||||
|
type TrafficSettings,
|
||||||
|
} from '../../../shared/trafficSettings.js';
|
||||||
import { Drawer } from '../../ui/Drawer.js';
|
import { Drawer } from '../../ui/Drawer.js';
|
||||||
import { RailAction } from '../../ui/RailAction.js';
|
import { RailAction } from '../../ui/RailAction.js';
|
||||||
import { formatByteString } from '../../utils/format.js';
|
import { formatByteString } from '../../utils/format.js';
|
||||||
@@ -12,25 +16,26 @@ import {
|
|||||||
groupTrafficConnections,
|
groupTrafficConnections,
|
||||||
reconcileTrafficGroups,
|
reconcileTrafficGroups,
|
||||||
sortTrafficGroups,
|
sortTrafficGroups,
|
||||||
|
summarizeTrafficOrigins,
|
||||||
|
trafficConnectionMatchesFilters,
|
||||||
trafficGroupMatches,
|
trafficGroupMatches,
|
||||||
|
trafficOriginId,
|
||||||
type DisplayedTrafficGroup,
|
type DisplayedTrafficGroup,
|
||||||
type TrafficConnectionGroup,
|
type TrafficConnectionGroup,
|
||||||
type TrafficQualityFilter,
|
type TrafficQualityFilter,
|
||||||
type TrafficRouteFilter,
|
type TrafficRouteFilter,
|
||||||
type TrafficSort,
|
|
||||||
} from './trafficRows.js';
|
} from './trafficRows.js';
|
||||||
|
|
||||||
const POLL_MS = 1_000;
|
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 RequestState = 'idle' | 'loading' | 'ready' | 'error';
|
||||||
type RetentionSeconds = typeof RETENTION_OPTIONS[number];
|
|
||||||
|
|
||||||
interface TrafficFeatureOptions {
|
interface TrafficFeatureOptions {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
isGateway: boolean;
|
isGateway: boolean;
|
||||||
loadLiveTraffic: () => Promise<unknown>;
|
loadLiveTraffic: () => Promise<unknown>;
|
||||||
|
settings: TrafficSettings;
|
||||||
|
updateSettings: (settings: TrafficSettings) => Promise<unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
||||||
@@ -39,15 +44,6 @@ const routeLabels: Record<LiveTrafficConnection['route']['kind'], string> = {
|
|||||||
other: 'Другое',
|
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) {
|
function address(ip: string | null, port: number | null) {
|
||||||
if (!ip) return '—';
|
if (!ip) return '—';
|
||||||
return port === null ? ip : `${ip}:${port}`;
|
return port === null ? ip : `${ip}:${port}`;
|
||||||
@@ -65,7 +61,13 @@ function updatedAt(value: string | null | undefined) {
|
|||||||
}).format(date)}`;
|
}).format(date)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: TrafficFeatureOptions) {
|
export function useTrafficFeature({
|
||||||
|
enabled,
|
||||||
|
isGateway,
|
||||||
|
loadLiveTraffic,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
|
}: TrafficFeatureOptions) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<LiveTrafficSnapshot | null>(null);
|
||||||
@@ -146,6 +148,8 @@ export function useTrafficFeature({ enabled, isGateway, loadLiveTraffic }: Traff
|
|||||||
paused,
|
paused,
|
||||||
snapshot,
|
snapshot,
|
||||||
requestState,
|
requestState,
|
||||||
|
settings,
|
||||||
|
updateSettings,
|
||||||
panelRef,
|
panelRef,
|
||||||
toggleRef,
|
toggleRef,
|
||||||
closeRef,
|
closeRef,
|
||||||
@@ -187,6 +191,9 @@ export function TrafficToggle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function groupStatus(group: TrafficConnectionGroup) {
|
function groupStatus(group: TrafficConnectionGroup) {
|
||||||
|
if (group.origins.length > 1) {
|
||||||
|
return `${group.connections.length} соединений · ${group.origins.length} устройств · ${group.protocol}`;
|
||||||
|
}
|
||||||
if (group.connections.length === 1) {
|
if (group.connections.length === 1) {
|
||||||
return group.activeCount > 0 ? group.protocol : `Завершено · ${group.protocol}`;
|
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 onlyConnection = group.connections.length === 1 ? group.connections[0] : null;
|
||||||
const source = onlyConnection
|
const source = onlyConnection
|
||||||
? `${group.origin.label} · ${address(onlyConnection.source.ip, onlyConnection.source.port)}`
|
? `${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
|
const chain = group.route.chain.length
|
||||||
? group.route.chain.join(' → ')
|
? group.route.chain.join(' → ')
|
||||||
: group.route.outbound || '—';
|
: group.route.outbound || '—';
|
||||||
@@ -274,6 +283,15 @@ function TrafficGroupRow({
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{expanded && <dl id={detailsId} className="client-traffic-details">
|
{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>{source}</dd></div>
|
||||||
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
<div><dt>Назначение</dt><dd>{groupDestination(group)}</dd></div>
|
||||||
<div><dt>Правило</dt><dd>{group.route.rule || '—'}</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 }) {
|
export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
||||||
const [query, setQuery] = useState('');
|
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 [routeFilter, setRouteFilter] = useState<TrafficRouteFilter>('all');
|
||||||
const [qualityFilter, setQualityFilter] = useState<TrafficQualityFilter>('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 [displayedGroups, setDisplayedGroups] = useState<DisplayedTrafficGroup[]>([]);
|
||||||
const [reducedMotion, setReducedMotion] = useState(() => (
|
const [reducedMotion, setReducedMotion] = useState(() => (
|
||||||
matchMedia('(prefers-reduced-motion: reduce)').matches
|
matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
));
|
));
|
||||||
const [expandedId, setExpandedId] = useState('');
|
const [expandedId, setExpandedId] = useState('');
|
||||||
const snapshot = feature.snapshot;
|
const snapshot = feature.snapshot;
|
||||||
|
const { grouping, sort: sortMode, retentionSeconds } = feature.settings;
|
||||||
const sourceState = snapshot?.source.state;
|
const sourceState = snapshot?.source.state;
|
||||||
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
const snapshotTime = snapshot?.observedAt ? Date.parse(snapshot.observedAt) : Number.NaN;
|
||||||
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
const retainedConnections = useMemo(() => (snapshot?.connections || []).filter((connection) => {
|
||||||
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
if (connection.closedAt === null || !Number.isFinite(snapshotTime)) return true;
|
||||||
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
return snapshotTime - Date.parse(connection.closedAt) < retentionSeconds * 1_000;
|
||||||
}), [snapshot, snapshotTime, retentionSeconds]);
|
}), [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) => (
|
const groups = useMemo(() => sortTrafficGroups(trafficGroups.filter((group) => (
|
||||||
trafficGroupMatches(group, query, routeFilter, qualityFilter)
|
trafficGroupMatches(group, query, 'all', 'all')
|
||||||
)), sortMode), [trafficGroups, query, routeFilter, qualityFilter, sortMode]);
|
)), sortMode), [trafficGroups, query, sortMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
const media = matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
@@ -349,12 +387,19 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
setDisplayedGroups((current) => reconcileTrafficGroups(current, groups, immediate));
|
||||||
}, [groups, reducedMotion, snapshot, sourceState]);
|
}, [groups, reducedMotion, snapshot, sourceState]);
|
||||||
|
|
||||||
function selectRetention(seconds: RetentionSeconds) {
|
useEffect(() => {
|
||||||
setRetentionSeconds(seconds);
|
if (selectedOriginId && !origins.some(({ id }) => id === selectedOriginId)) {
|
||||||
|
setSelectedOriginId('');
|
||||||
|
}
|
||||||
|
}, [origins, selectedOriginId]);
|
||||||
|
|
||||||
|
async function saveSettings(settings: TrafficSettings) {
|
||||||
|
if (settingsPending) return;
|
||||||
|
setSettingsPending(true);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(RETENTION_STORAGE_KEY, String(seconds));
|
await feature.updateSettings(settings);
|
||||||
} catch {
|
} finally {
|
||||||
// The setting remains available for this session when storage is unavailable.
|
setSettingsPending(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -402,46 +447,53 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) {
|
|||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
<div className="client-traffic-tools">
|
<div className="client-traffic-tools">
|
||||||
<label className="client-traffic-search">
|
<div className="client-traffic-option-row">
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<span>Маршрут</span>
|
||||||
<circle cx="10.5" cy="10.5" r="6" />
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
||||||
<path d="m15 15 5 5" />
|
{([
|
||||||
</svg>
|
['all', 'Все'],
|
||||||
<span className="client-live-region">Поиск трафика</span>
|
['vpn', 'VPN'],
|
||||||
<input
|
['direct', 'Direct'],
|
||||||
type="search"
|
['other', 'Другое'],
|
||||||
value={query}
|
] as const).map(([value, label]) => <button
|
||||||
aria-label="Найти домен, сервис или IP"
|
type="button"
|
||||||
placeholder="Домен, сервис или IP"
|
key={value}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
aria-pressed={routeFilter === value}
|
||||||
/>
|
onClick={() => setRouteFilter(value)}
|
||||||
</label>
|
>{label}</button>)}
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Фильтр по маршруту">
|
</div>
|
||||||
{([
|
|
||||||
['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="Фильтр по качеству распознавания">
|
<div className="client-traffic-option-row">
|
||||||
{([
|
<span>Распознано</span>
|
||||||
['all', 'Все'],
|
<div className="client-traffic-filters" role="group" aria-label="Фильтр по качеству распознавания">
|
||||||
['recognized', 'Распознано'],
|
{([
|
||||||
['attention', 'Требует внимания'],
|
['all', 'Все'],
|
||||||
] as const).map(([value, label]) => <button
|
['recognized', 'Распознано'],
|
||||||
type="button"
|
['attention', 'Требует внимания'],
|
||||||
key={value}
|
] as const).map(([value, label]) => <button
|
||||||
aria-pressed={qualityFilter === value}
|
type="button"
|
||||||
onClick={() => setQualityFilter(value)}
|
key={value}
|
||||||
>{label}</button>)}
|
aria-pressed={qualityFilter === value}
|
||||||
|
onClick={() => setQualityFilter(value)}
|
||||||
|
>{label}</button>)}
|
||||||
|
</div>
|
||||||
</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>
|
<span>Сортировка</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Сортировка соединений">
|
<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
|
] as const).map(([value, label]) => <button
|
||||||
type="button"
|
type="button"
|
||||||
key={value}
|
key={value}
|
||||||
|
disabled={settingsPending}
|
||||||
aria-pressed={sortMode === value}
|
aria-pressed={sortMode === value}
|
||||||
onClick={() => setSortMode(value)}
|
onClick={() => void saveSettings({ ...feature.settings, sort: value })}
|
||||||
>{label}</button>)}
|
>{label}</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="client-traffic-retention">
|
<div className="client-traffic-option-row">
|
||||||
<span>Показывать завершённые</span>
|
<span>Завершённые</span>
|
||||||
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
<div className="client-traffic-filters" role="group" aria-label="Время показа завершённых соединений">
|
||||||
{RETENTION_OPTIONS.map((seconds) => <button
|
{TRAFFIC_RETENTION_OPTIONS.map((seconds) => <button
|
||||||
type="button"
|
type="button"
|
||||||
key={seconds}
|
key={seconds}
|
||||||
|
disabled={settingsPending}
|
||||||
aria-pressed={retentionSeconds === seconds}
|
aria-pressed={retentionSeconds === seconds}
|
||||||
onClick={() => selectRetention(seconds)}
|
onClick={() => void saveSettings({ ...feature.settings, retentionSeconds: seconds })}
|
||||||
>{seconds} с</button>)}
|
>{seconds} с</button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
{feature.paused && snapshot && <p className="client-traffic-notice" role="status">
|
||||||
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.
|
||||||
</p>}
|
</p>}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
import type { LiveTrafficConnection } from '../../../shared/liveTraffic.js';
|
||||||
|
import type { TrafficGrouping, TrafficSort } from '../../../shared/trafficSettings.js';
|
||||||
import { byteString } from '../../utils/format.js';
|
import { byteString } from '../../utils/format.js';
|
||||||
|
|
||||||
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
export type TrafficRouteFilter = 'all' | 'vpn' | 'direct' | 'other';
|
||||||
export type TrafficQualityFilter = 'all' | 'recognized' | 'attention';
|
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 {
|
export interface TrafficConnectionGroup {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,6 +26,7 @@ export interface TrafficConnectionGroup {
|
|||||||
origin: LiveTrafficConnection['origin'];
|
origin: LiveTrafficConnection['origin'];
|
||||||
destination: LiveTrafficConnection['destination'];
|
destination: LiveTrafficConnection['destination'];
|
||||||
destinationIps: string[];
|
destinationIps: string[];
|
||||||
|
origins: TrafficOriginSummary[];
|
||||||
traffic: LiveTrafficConnection['traffic'];
|
traffic: LiveTrafficConnection['traffic'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +41,11 @@ function normalizedDestination(connection: LiveTrafficConnection) {
|
|||||||
return { domain, ip };
|
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 { domain, ip } = normalizedDestination(connection);
|
||||||
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
const destination = domain ? ['domain', domain] : ip ? ['ip', ip] : ['unknown', connection.id];
|
||||||
return JSON.stringify([
|
return JSON.stringify([
|
||||||
@@ -38,7 +53,9 @@ function trafficGroupId(connection: LiveTrafficConnection) {
|
|||||||
connection.destination.port,
|
connection.destination.port,
|
||||||
connection.network,
|
connection.network,
|
||||||
connection.protocol?.trim().toLowerCase() || null,
|
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.kind,
|
||||||
connection.route.scope,
|
connection.route.scope,
|
||||||
@@ -57,6 +74,13 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
|||||||
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
const sum = (field: keyof LiveTrafficConnection['traffic'], values = connections) => values
|
||||||
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
.reduce((total, connection) => total + byteString(connection.traffic[field]), 0n)
|
||||||
.toString();
|
.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 {
|
return {
|
||||||
id,
|
id,
|
||||||
label: domain || ip || 'Назначение не определено',
|
label: domain || ip || 'Назначение не определено',
|
||||||
@@ -71,6 +95,19 @@ function buildTrafficGroup(id: string, connections: LiveTrafficConnection[]): Tr
|
|||||||
const address = destination.ip?.trim().toLowerCase();
|
const address = destination.ip?.trim().toLowerCase();
|
||||||
return address ? [address] : [];
|
return address ? [address] : [];
|
||||||
}))].sort((left, right) => left.localeCompare(right)),
|
}))].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: {
|
traffic: {
|
||||||
uploadBytes: sum('uploadBytes'),
|
uploadBytes: sum('uploadBytes'),
|
||||||
downloadBytes: sum('downloadBytes'),
|
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[]>();
|
const grouped = new Map<string, LiveTrafficConnection[]>();
|
||||||
for (const connection of connections) {
|
for (const connection of connections) {
|
||||||
const id = trafficGroupId(connection);
|
const id = trafficGroupId(connection, grouping);
|
||||||
const members = grouped.get(id);
|
const members = grouped.get(id);
|
||||||
if (members) members.push(connection);
|
if (members) members.push(connection);
|
||||||
else grouped.set(id, [connection]);
|
else grouped.set(id, [connection]);
|
||||||
@@ -91,6 +131,41 @@ export function groupTrafficConnections(connections: LiveTrafficConnection[]) {
|
|||||||
return [...grouped].map(([id, members]) => buildTrafficGroup(id, members));
|
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) {
|
function newestStartedAt(group: TrafficConnectionGroup) {
|
||||||
return group.connections.reduce((latest, connection) => (
|
return group.connections.reduce((latest, connection) => (
|
||||||
connection.startedAt > latest ? connection.startedAt : latest
|
connection.startedAt > latest ? connection.startedAt : latest
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
export type OperationKey = 'connection' | 'serverApply' | 'profileAdd' | 'profileRename'
|
||||||
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
| 'profileSelect' | 'profileActivate' | 'profileRefresh' | 'profileDelete'
|
||||||
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'failover';
|
| 'gatewayAuto' | 'routeRules' | 'diagnosticsSettings' | 'trafficSettings' | 'failover';
|
||||||
|
|
||||||
export interface OperationState {
|
export interface OperationState {
|
||||||
status: 'running';
|
status: 'running';
|
||||||
@@ -22,6 +22,7 @@ const OPERATION_KEYS: readonly OperationKey[] = [
|
|||||||
'gatewayAuto',
|
'gatewayAuto',
|
||||||
'routeRules',
|
'routeRules',
|
||||||
'diagnosticsSettings',
|
'diagnosticsSettings',
|
||||||
|
'trafficSettings',
|
||||||
'failover',
|
'failover',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -185,22 +185,132 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-retention {
|
.client-traffic-option-row {
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 112px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-traffic-retention > span {
|
.client-traffic-option-row > span {
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font: var(--type-control);
|
font: var(--type-control);
|
||||||
letter-spacing: var(--type-control-tracking);
|
letter-spacing: var(--type-control-tracking);
|
||||||
text-transform: var(--type-control-transform);
|
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-notice,
|
||||||
.client-traffic-state,
|
.client-traffic-state,
|
||||||
.client-traffic-truncated,
|
.client-traffic-truncated,
|
||||||
@@ -395,6 +505,15 @@
|
|||||||
color: var(--client-muted);
|
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 {
|
.client-traffic-details dd {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -459,11 +578,21 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 2px;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.client-traffic-connection,
|
.client-traffic-connection,
|
||||||
.client-traffic-details {
|
.client-traffic-details,
|
||||||
|
.client-traffic-device-list {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,3 +206,48 @@ test('route is unavailable when no traffic collector exists', async () => {
|
|||||||
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
(error) => error.code === 'ENDPOINT_NOT_FOUND',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('PUT persists validated traffic settings with revision protection and returns canonical state', async () => {
|
||||||
|
let state = { revision: 4, traffic: { grouping: 'site', sort: 'popular', retentionSeconds: 10 } };
|
||||||
|
let sent = false;
|
||||||
|
const route = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: {
|
||||||
|
read: () => state,
|
||||||
|
update: (mutator) => { state = { ...mutator(state), revision: state.revision + 1 }; },
|
||||||
|
},
|
||||||
|
readBody: async () => ({
|
||||||
|
settings: { grouping: 'device', sort: 'recent', retentionSeconds: 30 },
|
||||||
|
expectedRevision: 4,
|
||||||
|
}),
|
||||||
|
sendState: async (res) => { sent = true; res.end(JSON.stringify(state)); },
|
||||||
|
});
|
||||||
|
const res = response();
|
||||||
|
|
||||||
|
assert.equal(await route.handle({ method: 'PUT', url: '/api/traffic/settings' }, res), true);
|
||||||
|
assert.equal(sent, true);
|
||||||
|
assert.deepEqual(state.traffic, { grouping: 'device', sort: 'recent', retentionSeconds: 30 });
|
||||||
|
assert.equal(state.revision, 5);
|
||||||
|
|
||||||
|
const conflict = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||||
|
readBody: async () => ({ settings: state.traffic, expectedRevision: 4 }),
|
||||||
|
sendState: async () => {},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
conflict.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||||
|
(error) => error.code === 'STATE_CONFLICT',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalid = createLiveTrafficRoute({
|
||||||
|
traffic: null,
|
||||||
|
settingsState: { read: () => state, update: () => { throw new Error('must not update'); } },
|
||||||
|
readBody: async () => ({ settings: { grouping: 'guess' }, expectedRevision: 5 }),
|
||||||
|
sendState: async () => {},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
invalid.handle({ method: 'PUT', url: '/api/traffic/settings' }, response()),
|
||||||
|
(error) => error.code === 'REQUEST_INVALID',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ setInterval(() => {}, 60_000);
|
|||||||
'singboxStartedAt',
|
'singboxStartedAt',
|
||||||
'subscription',
|
'subscription',
|
||||||
'subscriptionHost',
|
'subscriptionHost',
|
||||||
|
'traffic',
|
||||||
'userInfo',
|
'userInfo',
|
||||||
]);
|
]);
|
||||||
let revision = initial.revision;
|
let revision = initial.revision;
|
||||||
|
|||||||
@@ -137,13 +137,14 @@ test('schema v7 migrates failover disabled without losing canonical state', (t)
|
|||||||
});
|
});
|
||||||
const migrated = store.read();
|
const migrated = store.read();
|
||||||
|
|
||||||
assert.equal(migrated.schemaVersion, 8);
|
assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION);
|
||||||
assert.equal(migrated.revision, 19);
|
assert.equal(migrated.revision, 19);
|
||||||
assert.equal(migrated.routeRulesRevision, 4);
|
assert.equal(migrated.routeRulesRevision, 4);
|
||||||
assert.equal(migrated.failoverPolicy.enabled, false);
|
assert.equal(migrated.failoverPolicy.enabled, false);
|
||||||
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null);
|
||||||
assert.equal(migrated.appliedFailoverPolicy, null);
|
assert.equal(migrated.appliedFailoverPolicy, null);
|
||||||
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']);
|
||||||
|
assert.deepEqual(migrated.traffic, { grouping: 'site', sort: 'popular', retentionSeconds: 10 });
|
||||||
assert.equal(store.migration.fromVersion, 7);
|
assert.equal(store.migration.fromVersion, 7);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
groupTrafficConnections,
|
groupTrafficConnections,
|
||||||
reconcileTrafficGroups,
|
reconcileTrafficGroups,
|
||||||
sortTrafficGroups,
|
sortTrafficGroups,
|
||||||
|
summarizeTrafficOrigins,
|
||||||
|
trafficConnectionMatchesFilters,
|
||||||
trafficGroupMatches,
|
trafficGroupMatches,
|
||||||
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
|
} from '../../.test-dist/src/web/features/traffic/trafficRows.js';
|
||||||
|
|
||||||
@@ -176,7 +178,8 @@ test('traffic drawer exposes the requested truthful states and accessible contro
|
|||||||
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
|
assert.match(feature, /\{feature\.isGateway \? 'GATEWAY' : 'MAC'\} · \{snapshot\?\.summary\.active \|\| 0\} АКТИВНЫХ/);
|
||||||
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
|
assert.match(feature, /feature\.isGateway[\s\S]*Инспектор трафика выключен в настройках Harbor Gateway\.[\s\S]*Инспектор трафика выключен в настройках Harbor Connect\./);
|
||||||
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
|
assert.match(feature, /feature\.isGateway[\s\S]*Только соединения, прошедшие через sing-box Gateway\. Трафик, обходящий sing-box напрямую, здесь не виден\.[\s\S]*Только трафик через Harbor Connect\. Приложения macOS недоступны внутри Docker\./);
|
||||||
assert.match(feature, /type="search"[\s\S]*aria-label="Найти домен, сервис или IP"/);
|
assert.match(feature, /aria-label="Найти устройство"/);
|
||||||
|
assert.match(feature, /aria-label="Найти сайт, IP или сервис"/);
|
||||||
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
|
assert.match(feature, /role="group" aria-label="Фильтр по маршруту"/);
|
||||||
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
|
assert.match(feature, /role="group" aria-label="Фильтр по качеству распознавания"/);
|
||||||
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
|
assert.match(feature, /aria-pressed=\{routeFilter === value\}/);
|
||||||
@@ -187,19 +190,19 @@ test('traffic drawer exposes the requested truthful states and accessible contro
|
|||||||
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
|
assert.doesNotMatch(feature, /closeConnection|reroute|history|sessionStorage/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => {
|
test('traffic retention and grouping use canonical server settings and the frozen server observation clock', () => {
|
||||||
assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/);
|
assert.doesNotMatch(feature, /localStorage|sessionStorage/);
|
||||||
assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/);
|
assert.match(feature, /TRAFFIC_RETENTION_OPTIONS\.map/);
|
||||||
assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/);
|
assert.match(feature, /feature\.updateSettings\(settings\)/);
|
||||||
assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/);
|
assert.match(api, /updateSettings:[\s\S]*\/api\/traffic\/settings[\s\S]*expectedRevision/);
|
||||||
assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/);
|
assert.match(app, /onUpdateTrafficSettings=[\s\S]*api\.traffic\.updateSettings/);
|
||||||
assert.match(feature, /Показывать завершённые/);
|
assert.match(feature, /Завершённые/);
|
||||||
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
|
assert.match(feature, /aria-label="Время показа завершённых соединений"/);
|
||||||
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
|
assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/);
|
||||||
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
|
assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/);
|
||||||
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
|
assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/);
|
||||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
|
||||||
assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/);
|
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||||||
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/);
|
||||||
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
|
assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./);
|
||||||
});
|
});
|
||||||
@@ -283,12 +286,47 @@ test('traffic groups sort by bounded frequency or latest start without changing
|
|||||||
'popular.test',
|
'popular.test',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(groups.map(({ label }) => label), original);
|
assert.deepEqual(groups.map(({ label }) => label), original);
|
||||||
assert.match(feature, /const \[sortMode, setSortMode\] = useState<TrafficSort>\('popular'\)/);
|
assert.match(feature, /const \{ grouping, sort: sortMode, retentionSeconds \} = feature\.settings/);
|
||||||
assert.match(feature, /role="group" aria-label="Сортировка соединений"/);
|
assert.match(feature, /role="group" aria-label="Сортировка соединений"/);
|
||||||
assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/);
|
assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/);
|
||||||
assert.match(feature, /aria-pressed=\{sortMode === value\}/);
|
assert.match(feature, /aria-pressed=\{sortMode === value\}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('site grouping combines devices with exact per-device totals while device grouping keeps them separate', () => {
|
||||||
|
const first = trafficConnection('phone', {
|
||||||
|
origin: { kind: 'device', id: 'dev_0000000000000001', label: 'Телефон', provenance: 'source-ip' },
|
||||||
|
source: { ip: '192.168.50.10' },
|
||||||
|
destination: { domain: 'youtube.com' },
|
||||||
|
traffic: { uploadBytes: '10', downloadBytes: '20' },
|
||||||
|
});
|
||||||
|
const second = trafficConnection('tv', {
|
||||||
|
origin: { kind: 'device', id: 'dev_0000000000000002', label: 'ТВ', provenance: 'source-ip' },
|
||||||
|
source: { ip: '192.168.50.11' },
|
||||||
|
destination: { domain: 'youtube.com' },
|
||||||
|
traffic: { uploadBytes: '30', downloadBytes: '40' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(groupTrafficConnections([first, second], 'device').length, 2);
|
||||||
|
const [site] = groupTrafficConnections([first, second], 'site');
|
||||||
|
assert.equal(site.connections.length, 2);
|
||||||
|
assert.equal(site.origins.length, 2);
|
||||||
|
assert.deepEqual(site.traffic, {
|
||||||
|
uploadBytes: '40',
|
||||||
|
downloadBytes: '60',
|
||||||
|
uploadBytesPerSecond: '2',
|
||||||
|
downloadBytesPerSecond: '4',
|
||||||
|
});
|
||||||
|
assert.deepEqual(site.origins.map(({ label, connections, traffic }) => ({ label, connections, traffic })), [
|
||||||
|
{ label: 'ТВ', connections: 1, traffic: { uploadBytes: '30', downloadBytes: '40' } },
|
||||||
|
{ label: 'Телефон', connections: 1, traffic: { uploadBytes: '10', downloadBytes: '20' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ranked = summarizeTrafficOrigins([first, first, second]);
|
||||||
|
assert.deepEqual(ranked.map(({ label, connections }) => [label, connections]), [['Телефон', 2], ['ТВ', 1]]);
|
||||||
|
assert.equal(trafficConnectionMatchesFilters(first, 'vpn', 'recognized'), true);
|
||||||
|
assert.equal(trafficConnectionMatchesFilters(first, 'direct', 'all'), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
|
test('traffic grouping keeps incompatible and unknown destinations separate', () => {
|
||||||
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
|
const base = trafficConnection('base', { destination: { domain: 'example.com', ip: '203.0.113.1' } });
|
||||||
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
|
const same = trafficConnection('same', { destination: { domain: 'Example.COM', ip: '203.0.113.2' } });
|
||||||
@@ -343,8 +381,8 @@ test('traffic groups stay mounted and inert through exit while the same group ca
|
|||||||
]);
|
]);
|
||||||
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
|
assert.deepEqual(reconcileTrafficGroups(rows, [], true), []);
|
||||||
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
|
assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/);
|
||||||
assert.match(feature, /groupTrafficConnections\(retainedConnections\)/);
|
assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/);
|
||||||
assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/);
|
assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/);
|
||||||
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
|
assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/);
|
||||||
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
|
assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/);
|
||||||
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
|
assert.match(feature, /inert=\{exiting \|\| undefined\}/);
|
||||||
@@ -379,5 +417,5 @@ test('traffic styling preserves the shared drawer geometry and minimal motion',
|
|||||||
assert.match(rowMotion, /translateY/);
|
assert.match(rowMotion, /translateY/);
|
||||||
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
|
assert.doesNotMatch(rowMotion, /height|width|margin|padding|scale|filter/);
|
||||||
}
|
}
|
||||||
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details \{[\s\S]*animation: none/);
|
assert.match(styles, /@media \(prefers-reduced-motion: reduce\) \{[\s\S]*\.client-traffic-connection,[\s\S]*\.client-traffic-details,[\s\S]*\.client-traffic-device-list \{[\s\S]*animation: none/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,26 +40,26 @@ const expectedImports = [
|
|||||||
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
||||||
const acceptedLedger = {
|
const acceptedLedger = {
|
||||||
counts: {
|
counts: {
|
||||||
cascadeEdges: 1153,
|
cascadeEdges: 1164,
|
||||||
customProperties: 115,
|
customProperties: 115,
|
||||||
declarations: 4698,
|
declarations: 4752,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 55,
|
keyframes: 55,
|
||||||
media: 22,
|
media: 22,
|
||||||
rules: 1267,
|
rules: 1288,
|
||||||
variableReferences: 1222,
|
variableReferences: 1240,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: 'b92a7365b40a48b7c1e9a026b08170c35519567132036a86049b37eebdcd5b76',
|
cascadeEdges: 'e71264f527a1a7ec77ce9c382cc8b99e7363a379d258b5feb418ec591901b0aa',
|
||||||
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097',
|
||||||
declarations: '0e994db1d4ac16eb91d1dd0d74027fa132bad1344cf114db3641d83262116d07',
|
declarations: 'bb96f5466d6717eb2e54ef620a275f510ac41a99c3dc282b221fabac5e96e2fb',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848',
|
||||||
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd',
|
||||||
ruleDeclarationSequences: '6226141f0ff66b775008149ccf7d7a55e952c3f5d75961e275f0509ee352a4d6',
|
ruleDeclarationSequences: 'f55ebc3544ddae34b1ed9aaba85fd4336d0e0722c7b986f6a42aafeb0f9abe3c',
|
||||||
selectors: '3a165769801a80e9bee5082728a58f38bb427a19ac2c764de8586a5831c9ec87',
|
selectors: '5be446ae824d5d9adfe912fdefe3220c0bc1b26ad6faa2306d399a9f416a03e4',
|
||||||
variableReferences: 'bdae48e482697d7cff6fdc434860cdacf19c8e3cee24b35574e461c3cff73a51',
|
variableReferences: 'f8e1ec8c81d9890880a177a283b04222c34132719e0d954d3e1944a50ebcdb2e',
|
||||||
witnesses: '94e1f95de681072ed27f7c576e682893faf55c7489e6a35abb78a5c0d1c62cfb',
|
witnesses: '783115d6bfe4955d195d2fa8fdc5dc602903a19fdf666f89fe4938bd47bb208a',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ test('client typography uses the shared semantic scale outside the token owner',
|
|||||||
|
|
||||||
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => {
|
||||||
const witnesses = readStyleWitnesses(root);
|
const witnesses = readStyleWitnesses(root);
|
||||||
assert.equal(witnesses.length, 1285);
|
assert.equal(witnesses.length, 1311);
|
||||||
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0);
|
||||||
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
const ledger = createStyleLedger(readStyleSource(root), { witnesses });
|
||||||
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
assert.deepEqual(ledger.counts, acceptedLedger.counts);
|
||||||
@@ -409,8 +409,8 @@ test('main owns one public stylesheet and the regrouped production CSS is determ
|
|||||||
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
assert.equal((main.match(/import ['"][^'"]+\.css['"]/g) || []).length, 1);
|
||||||
|
|
||||||
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
const assets = fs.readdirSync(path.join(root, 'dist/assets')).filter((file) => file.endsWith('.css'));
|
||||||
assert.deepEqual(assets, ['index-Djad1gX3.css']);
|
assert.deepEqual(assets, ['index--AY5M9TT.css']);
|
||||||
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
const built = fs.readFileSync(path.join(root, 'dist/assets', assets[0]));
|
||||||
assert.equal(built.byteLength, 178147);
|
assert.equal(built.byteLength, 180764);
|
||||||
assert.equal(sha256(built), 'bc1e317d3da0840d6f88a764c52385d70b97ff0bcb51d3911059a34444c14b09');
|
assert.equal(sha256(built), 'e36629a671854f92641bd0dfefd796336adc54e026604ef39d8389daa418beac');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user