diff --git a/src/server/http/routes/liveTrafficRoute.ts b/src/server/http/routes/liveTrafficRoute.ts index 3407e79..2798f88 100644 --- a/src/server/http/routes/liveTrafficRoute.ts +++ b/src/server/http/routes/liveTrafficRoute.ts @@ -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): unknown; +} + function record(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record @@ -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>) | null; + sendState?: ((res: ServerResponse) => Promise) | 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; }, }; } diff --git a/src/server/index.ts b/src/server/index.ts index 51025a5..4651259 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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), diff --git a/src/server/services/stateStore.ts b/src/server/services/stateStore.ts index 621d893..eed6f31 100644 --- a/src/server/services/stateStore.ts +++ b/src/server/services/stateStore.ts @@ -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; diff --git a/src/shared/contracts/state.ts b/src/shared/contracts/state.ts index 704726d..9941078 100644 --- a/src/shared/contracts/state.ts +++ b/src/shared/contracts/state.ts @@ -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 { 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) || diff --git a/src/shared/trafficSettings.ts b/src/shared/trafficSettings.ts new file mode 100644 index 0000000..5e803dc --- /dev/null +++ b/src/shared/trafficSettings.ts @@ -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 + : {}; + 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, + }; +} diff --git a/src/shared/versions.ts b/src/shared/versions.ts index dc8a83d..67876ea 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -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 { diff --git a/src/web/App.tsx b/src/web/App.tsx index 0353c06..9386691 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -56,6 +56,7 @@ const operationErrorContext: Record = { '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), diff --git a/src/web/api/harborClient.ts b/src/web/api/harborClient.ts index dab8b2e..11500f3 100644 --- a/src/web/api/harborClient.ts +++ b/src/web/api/harborClient.ts @@ -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, diff --git a/src/web/components/ClientOverviewPage.tsx b/src/web/components/ClientOverviewPage.tsx index 5ba68b4..489265c 100644 --- a/src/web/components/ClientOverviewPage.tsx +++ b/src/web/components/ClientOverviewPage.tsx @@ -160,6 +160,7 @@ interface ClientOverviewPageProps { onSetGatewayAuto: (enabled: boolean) => Promise; onSaveRouteRules: (rules: RouteRule[], expectedRevision: number) => Promise; onUpdateDiagnosticsSettings: (settings: unknown) => Promise; + onUpdateTrafficSettings: (settings: unknown) => Promise; onSaveFailover: (policy: FailoverPolicy) => Promise; onPauseFailover: (paused: boolean) => Promise; onSwitchFailover: (role: 'primary' | 'reserve') => Promise; @@ -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 = { diff --git a/src/web/features/traffic/TrafficFeature.tsx b/src/web/features/traffic/TrafficFeature.tsx index 434a115..c3793c2 100644 --- a/src/web/features/traffic/TrafficFeature.tsx +++ b/src/web/features/traffic/TrafficFeature.tsx @@ -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; + settings: TrafficSettings; + updateSettings: (settings: TrafficSettings) => Promise; } const routeLabels: Record = { @@ -39,15 +44,6 @@ const routeLabels: Record = { 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(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({ {expanded &&
+ {group.origins.length > 1 && group.origins.map((origin) =>
+
{origin.label}
+
+ {origin.connections} соединений · ↓ {formatByteString(origin.traffic.downloadBytes)} · ↑ {formatByteString(origin.traffic.uploadBytes)} +
+
)}
Источник
{source}
Назначение
{groupDestination(group)}
Правило
{group.route.rule || '—'}
@@ -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('all'); const [qualityFilter, setQualityFilter] = useState('all'); - const [sortMode, setSortMode] = useState('popular'); - const [retentionSeconds, setRetentionSeconds] = useState(storedRetentionSeconds); const [displayedGroups, setDisplayedGroups] = useState([]); 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 }) { }
- -
- {([ - ['all', 'Все'], - ['vpn', 'VPN'], - ['direct', 'Direct'], - ['other', 'Другое'], - ] as const).map(([value, label]) => )} +
+ Маршрут +
+ {([ + ['all', 'Все'], + ['vpn', 'VPN'], + ['direct', 'Direct'], + ['other', 'Другое'], + ] as const).map(([value, label]) => )} +
-
- {([ - ['all', 'Все'], - ['recognized', 'Распознано'], - ['attention', 'Требует внимания'], - ] as const).map(([value, label]) => )} +
+ Распознано +
+ {([ + ['all', 'Все'], + ['recognized', 'Распознано'], + ['attention', 'Требует внимания'], + ] as const).map(([value, label]) => )} +
-
+
+ Группировка +
+ {([ + ['site', 'По сайтам'], + ['device', 'По устройствам'], + ] as const).map(([value, label]) => )} +
+
+
Сортировка
{([ @@ -450,24 +502,84 @@ export function TrafficPanel({ feature }: { feature: TrafficFeature }) { ] as const).map(([value, label]) => )}
-
- Показывать завершённые +
+ Завершённые
- {RETENTION_OPTIONS.map((seconds) => )}
+ {feature.isGateway && canShowList &&
+
+

Устройства

+ +
+ +
+ {visibleOrigins.map((origin) => )} +
+ {hiddenOriginCount > 0 && } +
} + + {canShowList && } + {feature.paused && snapshot &&

Пауза · показаны данные на {updatedAt(snapshot.observedAt).replace('обновлено ', '')}.

} diff --git a/src/web/features/traffic/trafficRows.ts b/src/web/features/traffic/trafficRows.ts index e2bcfae..7b082c0 100644 --- a/src/web/features/traffic/trafficRows.ts +++ b/src/web/features/traffic/trafficRows.ts @@ -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; +} 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(); + 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(); 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(); + 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 diff --git a/src/web/state/operations.ts b/src/web/state/operations.ts index 17ace3b..307fa80 100644 --- a/src/web/state/operations.ts +++ b/src/web/state/operations.ts @@ -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', ]; diff --git a/src/web/styles/features/traffic.css b/src/web/styles/features/traffic.css index 95edd25..affba6d 100644 --- a/src/web/styles/features/traffic.css +++ b/src/web/styles/features/traffic.css @@ -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; } } diff --git a/test/server/live-traffic-route.test.js b/test/server/live-traffic-route.test.js index b41e360..e6b5c21 100644 --- a/test/server/live-traffic-route.test.js +++ b/test/server/live-traffic-route.test.js @@ -206,3 +206,48 @@ test('route is unavailable when no traffic collector exists', async () => { (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', + ); +}); diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 572cd93..e048816 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -339,6 +339,7 @@ setInterval(() => {}, 60_000); 'singboxStartedAt', 'subscription', 'subscriptionHost', + 'traffic', 'userInfo', ]); let revision = initial.revision; diff --git a/test/server/state-store.test.js b/test/server/state-store.test.js index defef48..dfeabef 100644 --- a/test/server/state-store.test.js +++ b/test/server/state-store.test.js @@ -137,13 +137,14 @@ test('schema v7 migrates failover disabled without losing canonical state', (t) }); const migrated = store.read(); - assert.equal(migrated.schemaVersion, 8); + assert.equal(migrated.schemaVersion, STATE_SCHEMA_VERSION); assert.equal(migrated.revision, 19); assert.equal(migrated.routeRulesRevision, 4); assert.equal(migrated.failoverPolicy.enabled, false); assert.equal(migrated.failoverRuntimeState.lastSwitchAt, null); assert.equal(migrated.appliedFailoverPolicy, null); assert.deepEqual(migrated.diagnostics.hiddenServiceIds, ['google']); + assert.deepEqual(migrated.traffic, { grouping: 'site', sort: 'popular', retentionSeconds: 10 }); assert.equal(store.migration.fromVersion, 7); assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy); }); diff --git a/test/web/live-traffic-contract.test.js b/test/web/live-traffic-contract.test.js index 016b6d1..68b496d 100644 --- a/test/web/live-traffic-contract.test.js +++ b/test/web/live-traffic-contract.test.js @@ -8,6 +8,8 @@ import { groupTrafficConnections, reconcileTrafficGroups, sortTrafficGroups, + summarizeTrafficOrigins, + trafficConnectionMatchesFilters, trafficGroupMatches, } 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[\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, /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, /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/); }); -test('traffic retention is local, bounded to approved choices and uses the frozen server observation clock', () => { - assert.match(feature, /const RETENTION_STORAGE_KEY = 'harbor:traffic-retention-seconds'/); - assert.match(feature, /const RETENTION_OPTIONS = \[5, 10, 30\] as const/); - assert.match(feature, /Number\(localStorage\.getItem\(RETENTION_STORAGE_KEY\)\)/); - assert.match(feature, /RETENTION_OPTIONS\.includes\(value as RetentionSeconds\)[\s\S]*: 10/); - assert.match(feature, /localStorage\.setItem\(RETENTION_STORAGE_KEY, String\(seconds\)\)/); - assert.match(feature, /Показывать завершённые/); +test('traffic retention and grouping use canonical server settings and the frozen server observation clock', () => { + assert.doesNotMatch(feature, /localStorage|sessionStorage/); + assert.match(feature, /TRAFFIC_RETENTION_OPTIONS\.map/); + assert.match(feature, /feature\.updateSettings\(settings\)/); + assert.match(api, /updateSettings:[\s\S]*\/api\/traffic\/settings[\s\S]*expectedRevision/); + assert.match(app, /onUpdateTrafficSettings=[\s\S]*api\.traffic\.updateSettings/); + assert.match(feature, /Завершённые/); assert.match(feature, /aria-label="Время показа завершённых соединений"/); assert.match(feature, /snapshot\?\.observedAt \? Date\.parse\(snapshot\.observedAt\) : Number\.NaN/); assert.match(feature, /connection\.closedAt === null \|\| !Number\.isFinite\(snapshotTime\)/); assert.match(feature, /snapshotTime - Date\.parse\(connection\.closedAt\) < retentionSeconds \* 1_000/); - assert.match(feature, /groupTrafficConnections\(retainedConnections\)/); - assert.match(feature, /trafficGroups\.filter\([\s\S]*trafficGroupMatches/); + assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/); + assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/); assert.match(feature, /group\.connections\.length === 1[\s\S]*`Завершено · \$\{group\.protocol\}`/); assert.match(feature, /Соединения сгруппированы по назначению, протоколу и маршруту\./); }); @@ -283,12 +286,47 @@ test('traffic groups sort by bounded frequency or latest start without changing 'popular.test', ]); assert.deepEqual(groups.map(({ label }) => label), original); - assert.match(feature, /const \[sortMode, setSortMode\] = useState\('popular'\)/); + assert.match(feature, /const \{ grouping, sort: sortMode, retentionSeconds \} = feature\.settings/); assert.match(feature, /role="group" aria-label="Сортировка соединений"/); assert.match(feature, /\['popular', 'Популярные'\][\s\S]*\['recent', 'Последние'\]/); 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', () => { 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' } }); @@ -343,8 +381,8 @@ test('traffic groups stay mounted and inert through exit while the same group ca ]); assert.deepEqual(reconcileTrafficGroups(rows, [], true), []); assert.match(rowModel, /const desiredIds = new Set\(desired\.map\(\(group\) => group\.id\)\)/); - assert.match(feature, /groupTrafficConnections\(retainedConnections\)/); - assert.match(feature, /trafficGroupMatches\(group, query, routeFilter, qualityFilter\)/); + assert.match(feature, /groupTrafficConnections\(selectedConnections, grouping\)/); + assert.match(feature, /trafficGroupMatches\(group, query, 'all', 'all'\)/); assert.match(feature, /reconcileTrafficGroups\(current, groups, immediate\)/); assert.match(feature, /setExpandedId\(\(current\) => desiredIds\.has\(current\) \? current : ''\)/); 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.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/); }); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index c9ba333..f1cecf5 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -40,26 +40,26 @@ const expectedImports = [ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'); const acceptedLedger = { counts: { - cascadeEdges: 1153, + cascadeEdges: 1164, customProperties: 115, - declarations: 4698, + declarations: 4752, important: 0, keyframes: 55, media: 22, - rules: 1267, - variableReferences: 1222, + rules: 1288, + variableReferences: 1240, }, hashes: { - cascadeEdges: 'b92a7365b40a48b7c1e9a026b08170c35519567132036a86049b37eebdcd5b76', + cascadeEdges: 'e71264f527a1a7ec77ce9c382cc8b99e7363a379d258b5feb418ec591901b0aa', customProperties: '81d1e70737ae5a8e4b20d4c1be1b24685975a404863444da4a8821be1f0d5097', - declarations: '0e994db1d4ac16eb91d1dd0d74027fa132bad1344cf114db3641d83262116d07', + declarations: 'bb96f5466d6717eb2e54ef620a275f510ac41a99c3dc282b221fabac5e96e2fb', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateSelectors: '8982145dba05b33bf1c93b2d54cfbe76305b276ff3c657aa020144016ada5848', keyframes: 'b9b0bf3fad92e69b93ec0c24f01da15e78bbe89a095597d64ce4f7ef5e272fdd', - ruleDeclarationSequences: '6226141f0ff66b775008149ccf7d7a55e952c3f5d75961e275f0509ee352a4d6', - selectors: '3a165769801a80e9bee5082728a58f38bb427a19ac2c764de8586a5831c9ec87', - variableReferences: 'bdae48e482697d7cff6fdc434860cdacf19c8e3cee24b35574e461c3cff73a51', - witnesses: '94e1f95de681072ed27f7c576e682893faf55c7489e6a35abb78a5c0d1c62cfb', + ruleDeclarationSequences: 'f55ebc3544ddae34b1ed9aaba85fd4336d0e0722c7b986f6a42aafeb0f9abe3c', + selectors: '5be446ae824d5d9adfe912fdefe3220c0bc1b26ad6faa2306d399a9f416a03e4', + variableReferences: 'f8e1ec8c81d9890880a177a283b04222c34132719e0d954d3e1944a50ebcdb2e', + 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', () => { 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); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); 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); 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])); - assert.equal(built.byteLength, 178147); - assert.equal(sha256(built), 'bc1e317d3da0840d6f88a764c52385d70b97ff0bcb51d3911059a34444c14b09'); + assert.equal(built.byteLength, 180764); + assert.equal(sha256(built), 'e36629a671854f92641bd0dfefd796336adc54e026604ef39d8389daa418beac'); });