Add outbound traffic breakdown to device charts
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-12 22:18:08 +03:00
parent 9e52ccc24d
commit 9d43e74d97
10 changed files with 403 additions and 69 deletions
@@ -141,6 +141,24 @@ interface TrafficCursor {
download: bigint; download: bigint;
} }
interface OutboundTrafficSample {
observedAt: string;
vpnBytes: string;
directTrackedBytes: string;
directIpv4Bytes: string;
unknownBytes: string;
}
interface OutboundTrafficCursor {
signature: string;
routeEpoch: string;
directEpoch: string;
vpn: bigint;
directTracked: bigint;
directIpv4: bigint;
unknown: bigint;
}
interface DeviceObservation { interface DeviceObservation {
mac: string; mac: string;
ip: string; ip: string;
@@ -689,6 +707,8 @@ export function createDeviceInventoryService({
let policyQueue: Promise<unknown> = Promise.resolve(); let policyQueue: Promise<unknown> = Promise.resolve();
const trafficHistoryByMac = new Map<string, TrafficSample[]>(); const trafficHistoryByMac = new Map<string, TrafficSample[]>();
const trafficCursorByMac = new Map<string, TrafficCursor>(); const trafficCursorByMac = new Map<string, TrafficCursor>();
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
let globalTrafficHistory: TrafficSample[] = []; let globalTrafficHistory: TrafficSample[] = [];
let globalTrafficCursor: TrafficCursor | null = null; let globalTrafficCursor: TrafficCursor | null = null;
const hostnameAttempts = new Map<string, number>(); const hostnameAttempts = new Map<string, number>();
@@ -763,6 +783,79 @@ export function createDeviceInventoryService({
} }
} }
function captureOutboundTrafficHistory(state: InventoryState) {
const routeEpoch = typeof domainTrafficSnapshot.epoch === 'string' ? domainTrafficSnapshot.epoch : '';
const directEpoch = typeof directTrafficSnapshot.epoch === 'string' ? directTrafficSnapshot.epoch : '';
const routeObservedAt = validTimestamp(domainTrafficSnapshot.observedAt)
? String(domainTrafficSnapshot.observedAt)
: '';
const directObservedAt = validTimestamp(directTrafficSnapshot.observedAt)
? String(directTrafficSnapshot.observedAt)
: '';
const signature = `${routeEpoch}|${routeObservedAt}|${directEpoch}|${directObservedAt}`;
if (signature === '|||') return;
const totals = new Map<string, Omit<OutboundTrafficCursor, 'signature' | 'routeEpoch' | 'directEpoch'>>();
const totalFor = (deviceId: string) => {
const existing = totals.get(deviceId) || { vpn: 0n, directTracked: 0n, directIpv4: 0n, unknown: 0n };
totals.set(deviceId, existing);
return existing;
};
for (const value of Array.isArray(domainTrafficSnapshot.routes) ? domainTrafficSnapshot.routes : []) {
const row = record(value);
const deviceId = String(row.deviceId || '');
const outbound = String(row.outbound || '');
const uploadBytes = String(row.uploadBytes || '');
const downloadBytes = String(row.downloadBytes || '');
if (!DEVICE_ID_PATTERN.test(deviceId) || !['vpn', 'direct', 'unknown'].includes(outbound)
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
const amount = BigInt(uploadBytes) + BigInt(downloadBytes);
const total = totalFor(deviceId);
if (outbound === 'vpn') total.vpn += amount;
else if (outbound === 'direct') total.directTracked += amount;
else total.unknown += amount;
}
for (const value of Array.isArray(directTrafficSnapshot.series) ? directTrafficSnapshot.series : []) {
const row = record(value);
const deviceId = String(row.deviceId || '');
const uploadBytes = String(row.uploadBytes || '');
const downloadBytes = String(row.downloadBytes || '');
if (!DEVICE_ID_PATTERN.test(deviceId)
|| !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) continue;
totalFor(deviceId).directIpv4 += BigInt(uploadBytes) + BigInt(downloadBytes);
}
const knownIds = new Set(state.devices.map(({ id }) => id));
const observedAt = [routeObservedAt, directObservedAt].filter(Boolean).sort().at(-1) || '';
for (const device of state.devices) {
const total = totalFor(device.id);
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
const previous = outboundTrafficCursorByDeviceId.get(device.id);
outboundTrafficCursorByDeviceId.set(device.id, current);
if (!previous || previous.signature === signature || !observedAt) continue;
const routeDelta = (value: bigint, before: bigint) => (
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
);
const directDelta = directEpoch && directEpoch === previous.directEpoch && total.directIpv4 > previous.directIpv4
? total.directIpv4 - previous.directIpv4
: 0n;
const samples = outboundTrafficHistoryByDeviceId.get(device.id) || [];
outboundTrafficHistoryByDeviceId.set(device.id, [...samples, {
observedAt,
vpnBytes: routeDelta(total.vpn, previous.vpn).toString(),
directTrackedBytes: routeDelta(total.directTracked, previous.directTracked).toString(),
directIpv4Bytes: directDelta.toString(),
unknownBytes: routeDelta(total.unknown, previous.unknown).toString(),
}].slice(-TRAFFIC_HISTORY_LIMIT));
}
for (const deviceId of outboundTrafficCursorByDeviceId.keys()) {
if (!knownIds.has(deviceId)) {
outboundTrafficCursorByDeviceId.delete(deviceId);
outboundTrafficHistoryByDeviceId.delete(deviceId);
}
}
}
function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> { function serializePolicy<T>(action: () => Promise<T> | T): Promise<T> {
const result = policyQueue.then(() => action(), () => action()); const result = policyQueue.then(() => action(), () => action());
policyQueue = result.catch(() => {}); policyQueue = result.catch(() => {});
@@ -826,6 +919,7 @@ export function createDeviceInventoryService({
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0', proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
proxyTrafficObservedAt: proxyTraffic?.observedAt || null, proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
trafficHistory: trafficHistoryByMac.get(device.mac) || [], trafficHistory: trafficHistoryByMac.get(device.mac) || [],
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
desiredPolicy: policy.desired, desiredPolicy: policy.desired,
appliedPolicy: policy.applied, appliedPolicy: policy.applied,
policyStatus: policy.status, policyStatus: policy.status,
@@ -1375,6 +1469,7 @@ export function createDeviceInventoryService({
}; };
}); });
captureTrafficHistory(nextState); captureTrafficHistory(nextState);
captureOutboundTrafficHistory(nextState);
if (typeof policyResult?.transportError === 'string') { if (typeof policyResult?.transportError === 'string') {
commitPolicyFailure(new Error(policyResult.transportError)); commitPolicyFailure(new Error(policyResult.transportError));
} }
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.2', macClient: '0.25.3',
gatewayClient: '0.26.1', gatewayClient: '0.26.2',
gatewayBackend: '0.26.2', gatewayBackend: '0.26.3',
}); });
export interface ParsedVersion { export interface ParsedVersion {
+7 -1
View File
@@ -79,6 +79,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear'); const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [trafficView, setTrafficView] = useState<'outbound' | 'inbound'>('outbound');
const [copyFeedback, setCopyFeedback] = useState<Record<string, { field: DeviceCopyField; failed: boolean }>>({}); const [copyFeedback, setCopyFeedback] = useState<Record<string, { field: DeviceCopyField; failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null); const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState(''); const [pencilAnimationId, setPencilAnimationId] = useState('');
@@ -324,6 +325,10 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</button> </button>
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip> <Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
</span> </span>
<span className="client-devices-scale" role="group" aria-label="Разрез графика трафика">
<button type="button" aria-pressed={trafficView === 'outbound'} onClick={() => setTrafficView('outbound')}>Выход</button>
<button type="button" aria-pressed={trafficView === 'inbound'} onClick={() => setTrafficView('inbound')}>Вход</button>
</span>
<span className="client-devices-scale" role="group" aria-label="Масштаб графика трафика"> <span className="client-devices-scale" role="group" aria-label="Масштаб графика трафика">
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button> <button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button> <button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
@@ -581,10 +586,11 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
<Tooltip>{deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'}</Tooltip> <Tooltip>{deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'}</Tooltip>
</span> </span>
{!compact && <TrafficChart {!compact && <TrafficChart
samples={device.trafficHistory || []} samples={trafficView === 'outbound' ? device.outboundTrafficHistory || [] : device.trafficHistory || []}
scale={trafficScale} scale={trafficScale}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1} capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'} routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
series={trafficView}
pinned={showDetails} pinned={showDetails}
collapsing={collapsing} collapsing={collapsing}
onCollapseEnd={() => finishPinCollapse(device.id)} onCollapseEnd={() => finishPinCollapse(device.id)}
+94 -43
View File
@@ -14,7 +14,7 @@ import {
trafficBytesPerSecond, trafficBytesPerSecond,
trafficScaleRatio, trafficScaleRatio,
} from '../../utils/format.js'; } from '../../utils/format.js';
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js'; import type { OutboundTrafficSample, TrafficSample, TrafficScale } from './deviceSnapshot.js';
const TRAFFIC_CHART_HEADROOM = 10; const TRAFFIC_CHART_HEADROOM = 10;
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM); const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
@@ -25,13 +25,21 @@ function chartTime(value: string) {
}); });
} }
type ChartSample = TrafficSample | OutboundTrafficSample;
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY';
interface ChartPoint { interface ChartPoint {
sample: TrafficSample; sample: ChartSample;
x: number; x: number;
gateway: bigint; gateway: bigint;
proxy: bigint; proxy: bigint;
directIpv4: bigint;
unknown: bigint;
gatewayY: number; gatewayY: number;
proxyY: number; proxyY: number;
directIpv4Y: number;
unknownY: number;
} }
interface HoveredPoint extends ChartPoint { interface HoveredPoint extends ChartPoint {
@@ -39,7 +47,7 @@ interface HoveredPoint extends ChartPoint {
clientY: number; clientY: number;
} }
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') { function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) {
if (!points.length) return ''; if (!points.length) return '';
return points.slice(1).reduce((path, point, index) => { return points.slice(1).reduce((path, point, index) => {
const previous = points[index]; const previous = points[index];
@@ -60,20 +68,22 @@ function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartP
x: source.x, x: source.x,
gatewayY: source.gatewayY, gatewayY: source.gatewayY,
proxyY: source.proxyY, proxyY: source.proxyY,
directIpv4Y: source.directIpv4Y,
unknownY: source.unknownY,
} : { } : {
...point, ...point,
gatewayY: 100, gatewayY: 100,
proxyY: 100, proxyY: 100,
directIpv4Y: 100,
unknownY: 100,
}; };
}); });
} }
function trafficSeriesMax(values: Array<{ gateway: bigint; proxy: bigint }>) { function trafficSeriesMax(values: Array<Record<ChartValueKey, bigint>>) {
return values.reduce((largest, { gateway, proxy }) => { return values.reduce((largest, value) => (
return gateway > largest Object.values(value).reduce((current, item) => item > current ? item : current, largest)
? (proxy > gateway ? proxy : gateway) ), 0n);
: (proxy > largest ? proxy : largest);
}, 0n);
} }
function formatRate(value: bigint) { function formatRate(value: bigint) {
@@ -88,64 +98,101 @@ export function TrafficChart({
pinned = true, pinned = true,
collapsing = false, collapsing = false,
onCollapseEnd, onCollapseEnd,
series = 'routes', series = 'inbound',
}: { }: {
samples: TrafficSample[]; samples: ChartSample[];
scale?: TrafficScale; scale?: TrafficScale;
capacity: number; capacity: number;
routeLabel: string; routeLabel: string;
pinned?: boolean; pinned?: boolean;
collapsing?: boolean; collapsing?: boolean;
onCollapseEnd?: () => void; onCollapseEnd?: () => void;
series?: 'routes' | 'speed'; series?: 'inbound' | 'outbound' | 'speed';
}) { }) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null); const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]); const previousPoints = useRef<ChartPoint[]>([]);
const previousSeries = useRef(series); const previousSeries = useRef(series);
const speedAvailable = samples.length > 1 && samples.every((sample) => ( const speedAvailable = samples.length > 1 && samples.every((sample) => (
typeof sample.downloadBytes === 'string' && typeof sample.uploadBytes === 'string' 'downloadBytes' in sample && typeof sample.downloadBytes === 'string'
&& 'uploadBytes' in sample && typeof sample.uploadBytes === 'string'
)); ));
const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples; const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples;
const values = visibleSamples.map((sample, index) => { const values = visibleSamples.map((sample, index) => {
if (series === 'routes') { if (series === 'inbound') {
const traffic = sample as TrafficSample;
return { return {
sample, sample,
gateway: byteString(sample.gatewayBytes), gateway: byteString(traffic.gatewayBytes),
proxy: byteString(sample.proxyBytes), proxy: byteString(traffic.proxyBytes),
directIpv4: 0n,
unknown: 0n,
}; };
} }
const previous = samples[index]; if (series === 'outbound') {
const outbound = sample as OutboundTrafficSample;
return { return {
sample, sample,
gateway: trafficBytesPerSecond(sample.downloadBytes, previous.observedAt, sample.observedAt), gateway: byteString(outbound.vpnBytes),
proxy: trafficBytesPerSecond(sample.uploadBytes, previous.observedAt, sample.observedAt), proxy: byteString(outbound.directTrackedBytes),
directIpv4: byteString(outbound.directIpv4Bytes),
unknown: byteString(outbound.unknownBytes),
};
}
const traffic = sample as TrafficSample;
const previous = samples[index] as TrafficSample;
return {
sample,
gateway: trafficBytesPerSecond(traffic.downloadBytes, previous.observedAt, traffic.observedAt),
proxy: trafficBytesPerSecond(traffic.uploadBytes, previous.observedAt, traffic.observedAt),
directIpv4: 0n,
unknown: 0n,
}; };
}); });
const max = trafficSeriesMax(values); const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({
gateway, proxy, directIpv4, unknown,
})));
const mid = trafficAxisMid(max, scale); const mid = trafficAxisMid(max, scale);
const firstSlot = capacity - visibleSamples.length; const firstSlot = capacity - visibleSamples.length;
const points = values.map(({ sample, gateway, proxy }, index) => { const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => {
return { return {
sample, sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1), x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
gateway, gateway,
proxy, proxy,
directIpv4,
unknown,
gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)), gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)),
proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)), proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)),
directIpv4Y: trafficChartY(trafficScaleRatio(directIpv4, max, scale)),
unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)),
}; };
}); });
const previous = points.slice(0, -1); const previous = points.slice(0, -1);
const penultimate = points.at(-2); const penultimate = points.at(-2);
const newest = points.at(-1); const newest = points.at(-1);
const hasSecondary = points.some(({ proxy }) => proxy > 0n); const lineDefinitions: Array<{
valueKey: ChartValueKey;
yKey: ChartYKey;
}> = series === 'outbound'
? [
{ valueKey: 'gateway', yKey: 'gatewayY' },
{ valueKey: 'proxy', yKey: 'proxyY' },
{ valueKey: 'directIpv4', yKey: 'directIpv4Y' },
{ valueKey: 'unknown', yKey: 'unknownY' },
]
: [
{ valueKey: 'gateway', yKey: 'gatewayY' },
{ valueKey: 'proxy', yKey: 'proxyY' },
];
const visibleLines = lineDefinitions.filter(({ valueKey }) => points.some((point) => point[valueKey] > 0n));
const motionFrom = previousSeries.current === series ? previousPoints.current : []; const motionFrom = previousSeries.current === series ? previousPoints.current : [];
const previousMotionFrom = trafficPathAnimationSource(previous, motionFrom); const previousMotionFrom = trafficPathAnimationSource(previous, motionFrom);
const newestMotionFrom = trafficPathAnimationSource( const newestMotionFrom = trafficPathAnimationSource(
penultimate && newest ? [penultimate, newest] : [], penultimate && newest ? [penultimate, newest] : [],
motionFrom, motionFrom,
); );
const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy }) => ( const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy, directIpv4, unknown }) => (
`${sample.observedAt}:${gateway}:${proxy}` `${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}`
)).join('|')}`; )).join('|')}`;
const animatePaths = points.length > 0 const animatePaths = points.length > 0
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); && !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
@@ -182,6 +229,12 @@ export function TrafficChart({
<span className="is-interval">За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span> <span className="is-interval">За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span>
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span> <span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span>
{byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>} {byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
</> : series === 'outbound' ? <>
{hovered.gateway > 0n && <strong className="is-vpn">VPN · sing-box {formatByteString(hovered.gateway)}</strong>}
{hovered.proxy > 0n && <span className="is-direct-tracked">Direct · sing-box {formatByteString(hovered.proxy)}</span>}
{hovered.directIpv4 > 0n && <span className="is-direct-ipv4">Direct · IPv4 {formatByteString(hovered.directIpv4)}</span>}
{hovered.unknown > 0n && <span className="is-unknown">Неизвестно · sing-box {formatByteString(hovered.unknown)}</span>}
<span className="is-interval">Разные уровни учёта не складываются</span>
</> : <> </> : <>
<strong className="is-total">Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong> <strong className="is-total">Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.gateway)}</span> <span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.gateway)}</span>
@@ -194,7 +247,7 @@ export function TrafficChart({
return <span return <span
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`} className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
role="img" role="img"
aria-label={`${series === 'speed' ? 'История скорости' : 'История трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`} aria-label={`${series === 'speed' ? 'История скорости' : series === 'outbound' ? 'Фактический выход трафика' : 'Источник входящего трафика'}, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${series === 'speed' ? formatRate(max) : formatByteString(max)}`}
style={{ style={{
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`, '--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`, '--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
@@ -216,32 +269,30 @@ export function TrafficChart({
<line x1="0" x2="100" y1="100" y2="100" /> <line x1="0" x2="100" y1="100" y2="100" />
</g>} </g>}
<g className="client-device-traffic-lines"> <g className="client-device-traffic-lines">
{previous.length > 0 && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath(previous, 'gatewayY')}> {visibleLines.map((line) => previous.length > 0 && <path key={`old-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} d={smoothTrafficPath(previous, line.yKey)}>
{animatePaths && <animate key={`gateway-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />} {animatePaths && <animate key={`${line.valueKey}-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, line.yKey)} to={smoothTrafficPath(previous, line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>} </path>)}
{hasSecondary && previous.length > 0 && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath(previous, 'proxyY')}> {visibleLines.map((line) => penultimate && newest && <path key={`new-${line.valueKey}`} className={line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} d={smoothTrafficPath([penultimate, newest], line.yKey)}>
{animatePaths && <animate key={`proxy-${motionKey}`} attributeName="d" from={smoothTrafficPath(previousMotionFrom, 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />} {animatePaths && <animate key={`${line.valueKey}-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, line.yKey)} to={smoothTrafficPath([penultimate, newest], line.yKey)} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>} </path>)}
{penultimate && newest && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath([penultimate, newest], 'gatewayY')}> {visibleLines.map((line) => !penultimate && newest && <line key={`point-${line.valueKey}`} className={`${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'} is-point`} x1={newest.x} x2={newest.x} y1={newest[line.yKey]} y2={newest[line.yKey]}>
{animatePaths && <animate key={`gateway-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />} {animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
</path>} </line>)}
{hasSecondary && penultimate && newest && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
{animatePaths && <animate key={`proxy-new-${motionKey}`} attributeName="d" from={smoothTrafficPath(newestMotionFrom, 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>}
{!penultimate && newest && <line className={`${series === 'speed' ? 'is-download' : 'is-gateway'} is-point`} x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY}>
{animatePaths && <animate key={`point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
</line>}
</g> </g>
{hovered && <g className="client-device-traffic-cursor"> {hovered && <g className="client-device-traffic-cursor">
<line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" /> <line className="is-guide" x1={hovered.x} x2={hovered.x} y1={TRAFFIC_CHART_HEADROOM} y2="100" />
<line className={`is-point ${series === 'speed' ? 'is-download' : 'is-gateway'}`} x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} /> {visibleLines.map((line) => hovered[line.valueKey] > 0n && <line key={line.valueKey} className={`is-point ${line.valueKey === 'gateway' ? series === 'outbound' ? 'is-vpn' : series === 'speed' ? 'is-download' : 'is-gateway' : line.valueKey === 'proxy' ? series === 'outbound' ? 'is-direct-tracked' : series === 'speed' ? 'is-upload' : 'is-proxy' : line.valueKey === 'directIpv4' ? 'is-direct-ipv4' : 'is-unknown'}`} x1={hovered.x} x2={hovered.x} y1={hovered[line.yKey]} y2={hovered[line.yKey]} />)}
{hovered.proxy > 0n && <line className={`is-point ${series === 'speed' ? 'is-upload' : 'is-proxy'}`} x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
</g>} </g>}
</svg> </svg>
</span> </span>
{visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true"> {visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time> <time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
<span>{series === 'speed' ? '↓ / ↑' : '15 с'}</span> {series === 'outbound' ? <span className="client-device-traffic-legend">
<span className="is-vpn">VPN</span>
<span className="is-direct-tracked">Direct · sing-box</span>
<span className="is-direct-ipv4">Direct · IPv4</span>
<span className="is-unknown">?</span>
</span> : <span>{series === 'speed' ? '↓ / ↑' : 'Вход'}</span>}
<time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time> <time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time>
</span>} </span>}
{tooltip} {tooltip}
+24 -1
View File
@@ -13,6 +13,14 @@ export interface TrafficSample extends Record<string, unknown> {
downloadBytes?: ByteValue; downloadBytes?: ByteValue;
} }
export interface OutboundTrafficSample extends Record<string, unknown> {
observedAt: string;
vpnBytes: ByteValue;
directTrackedBytes: ByteValue;
directIpv4Bytes: ByteValue;
unknownBytes: ByteValue;
}
export interface Device extends Record<string, unknown> { export interface Device extends Record<string, unknown> {
id: string; id: string;
alias: string | null; alias: string | null;
@@ -33,6 +41,7 @@ export interface Device extends Record<string, unknown> {
appliedPolicy: DevicePolicy; appliedPolicy: DevicePolicy;
confidence: DeviceConfidence; confidence: DeviceConfidence;
trafficHistory: TrafficSample[]; trafficHistory: TrafficSample[];
outboundTrafficHistory?: OutboundTrafficSample[];
} }
interface SnapshotSource extends Record<string, unknown> { interface SnapshotSource extends Record<string, unknown> {
@@ -108,6 +117,19 @@ function validHistory(value: unknown): value is TrafficSample[] {
return Array.isArray(value) && value.every(validTrafficSample); return Array.isArray(value) && value.every(validTrafficSample);
} }
function validOutboundTrafficSample(value: unknown): value is OutboundTrafficSample {
return record(value)
&& timestamp(value.observedAt)
&& bytes(value.vpnBytes)
&& bytes(value.directTrackedBytes)
&& bytes(value.directIpv4Bytes)
&& bytes(value.unknownBytes);
}
function validOutboundHistory(value: unknown): value is OutboundTrafficSample[] {
return Array.isArray(value) && value.every(validOutboundTrafficSample);
}
function validDevice(value: unknown): value is Device { function validDevice(value: unknown): value is Device {
return record(value) return record(value)
&& typeof value.id === 'string' && typeof value.id === 'string'
@@ -132,7 +154,8 @@ function validDevice(value: unknown): value is Device {
&& (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct') && (value.desiredPolicy === 'vpn' || value.desiredPolicy === 'direct')
&& (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct') && (value.appliedPolicy === 'vpn' || value.appliedPolicy === 'direct')
&& (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous') && (value.confidence === 'high' || value.confidence === 'medium' || value.confidence === 'ambiguous')
&& validHistory(value.trafficHistory); && validHistory(value.trafficHistory)
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
} }
function validSource(value: unknown): value is SnapshotSource { function validSource(value: unknown): value is SnapshotSource {
+72
View File
@@ -909,6 +909,25 @@
stroke-dasharray: 5 4; stroke-dasharray: 5 4;
} }
.client-device-traffic-lines .is-vpn {
stroke: var(--harbor-connect);
}
.client-device-traffic-lines .is-direct-tracked {
stroke: var(--harbor-gateway);
stroke-dasharray: 5 4;
}
.client-device-traffic-lines .is-direct-ipv4 {
stroke: var(--harbor-word);
stroke-dasharray: 2 3;
}
.client-device-traffic-lines .is-unknown {
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
stroke-dasharray: 1 4;
}
.client-device-traffic-lines .is-point { .client-device-traffic-lines .is-point {
stroke-width: 3; stroke-width: 3;
stroke-linecap: round; stroke-linecap: round;
@@ -944,6 +963,22 @@
stroke: var(--harbor-gateway); stroke: var(--harbor-gateway);
} }
.client-device-traffic-cursor .is-point.is-vpn {
stroke: var(--harbor-connect);
}
.client-device-traffic-cursor .is-point.is-direct-tracked {
stroke: var(--harbor-gateway);
}
.client-device-traffic-cursor .is-point.is-direct-ipv4 {
stroke: var(--harbor-word);
}
.client-device-traffic-cursor .is-point.is-unknown {
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
}
.client-device-traffic-time { .client-device-traffic-time {
grid-column: 2; grid-column: 2;
grid-row: 2; grid-row: 2;
@@ -956,6 +991,27 @@
text-transform: var(--type-micro-transform); text-transform: var(--type-micro-transform);
} }
.client-device-traffic-legend {
display: flex;
gap: 7px;
}
.client-device-traffic-legend .is-vpn {
color: var(--harbor-connect);
}
.client-device-traffic-legend .is-direct-tracked {
color: var(--harbor-gateway);
}
.client-device-traffic-legend .is-direct-ipv4 {
color: var(--harbor-word);
}
.client-device-traffic-legend .is-unknown {
color: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
}
.client-device-traffic-point-tooltip { .client-device-traffic-point-tooltip {
position: fixed; position: fixed;
z-index: 1002; z-index: 1002;
@@ -1012,6 +1068,22 @@
color: oklch(0.79 0.11 72); color: oklch(0.79 0.11 72);
} }
.client-device-traffic-point-tooltip .is-vpn {
color: oklch(0.75 0.1 185);
}
.client-device-traffic-point-tooltip .is-direct-tracked {
color: oklch(0.79 0.11 72);
}
.client-device-traffic-point-tooltip .is-direct-ipv4 {
color: oklch(0.78 0.07 232);
}
.client-device-traffic-point-tooltip .is-unknown {
color: oklch(0.68 0.012 145);
}
.client-device-traffic-point-tooltip .is-interval, .client-device-traffic-point-tooltip .is-interval,
.client-device-traffic-point-tooltip .is-direct { .client-device-traffic-point-tooltip .is-direct {
color: oklch(0.72 0.012 145); color: oklch(0.72 0.012 145);
+76
View File
@@ -504,6 +504,82 @@ test('device traffic history stays in bounded service memory and survives client
assert.deepEqual(createService().snapshot().traffic.history, []); assert.deepEqual(createService().snapshot().traffic.history, []);
}); });
test('device outbound history keeps sing-box routes and kernel Direct on separate reset-safe series', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-outbound-history-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const filePath = path.join(directory, 'devices.json');
const store = createJsonStore({ filePath, defaultValue: {}, migrate: migrateDeviceInventoryState });
const mac = '00:11:22:33:44:55';
const id = deviceId(mac);
let observedAt = '2026-08-12T12:00:00.000Z';
let epoch = 'epoch-a';
let vpn = ['100', '200'];
let trackedDirect = ['10', '20'];
let unknown = ['1', '2'];
let directIpv4 = ['40', '60'];
const service = createDeviceInventoryService({
store,
observe: () => ({
observedAt,
error: null,
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
}),
observeTraffic: () => ({
epoch,
generation: 'rules-a',
observedAt,
source: { error: null },
direct: { uploadBytes: directIpv4[0], downloadBytes: directIpv4[1] },
devices: [{
ip: '192.168.50.7', mac, interface: 'eth0', uploadBytes: '100', downloadBytes: '200',
directUploadBytes: directIpv4[0], directDownloadBytes: directIpv4[1],
}],
}),
observeDomainTraffic: () => ({
epoch,
observedAt,
source: { error: null },
routes: [
{ deviceId: id, source: 'gateway', outbound: 'vpn', uploadBytes: vpn[0], downloadBytes: vpn[1] },
{ deviceId: id, source: 'proxy', outbound: 'direct', uploadBytes: trackedDirect[0], downloadBytes: trackedDirect[1] },
{ deviceId: id, source: 'gateway', outbound: 'unknown', uploadBytes: unknown[0], downloadBytes: unknown[1] },
],
series: [],
}),
});
assert.deepEqual((await service.refresh()).devices[0].outboundTrafficHistory, []);
observedAt = '2026-08-12T12:00:15.000Z';
vpn = ['150', '250'];
trackedDirect = ['30', '50'];
unknown = ['4', '8'];
directIpv4 = ['70', '90'];
let snapshot = await service.refresh();
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, [{
observedAt,
vpnBytes: '100',
directTrackedBytes: '50',
directIpv4Bytes: '60',
unknownBytes: '9',
}]);
observedAt = '2026-08-12T12:00:30.000Z';
epoch = 'epoch-b';
vpn = ['3', '4'];
trackedDirect = ['1', '2'];
unknown = ['0', '1'];
directIpv4 = ['5', '6'];
snapshot = await service.refresh();
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory.at(-1), {
observedAt,
vpnBytes: '0',
directTrackedBytes: '0',
directIpv4Bytes: '0',
unknownBytes: '0',
});
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTrafficHistory/);
});
test('global traffic stays monotonic when a device expires and returns in the same epoch', async (t) => { test('global traffic stays monotonic when a device expires and returns in the same epoch', async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-global-traffic-retention-')); const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-global-traffic-retention-'));
t.after(() => fs.rmSync(directory, { recursive: true, force: true })); t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+10 -7
View File
@@ -87,24 +87,26 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/); assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
assert.match(panel, /const hasProxyTraffic = proxyTotal > 0n/); assert.match(panel, /const hasProxyTraffic = proxyTotal > 0n/);
assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && <span className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/); assert.match(panel, /client-device-traffic-breakdown[\s\S]*\{hasProxyTraffic && <span className="is-proxy"><b>Прокси<\/b><TrafficValue value=\{proxyTraffic\} delta=\{trafficDelta\.proxy\}/);
assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity/); assert.match(panel, /TrafficChart[\s\S]*samples=\{trafficView === 'outbound' \? device\.outboundTrafficHistory \|\| \[\] : device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity[\s\S]*series=\{trafficView\}/);
assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/); assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/);
assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/); assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/);
assert.match(panel, /useState<'outbound' \| 'inbound'>\('outbound'\)[\s\S]*aria-pressed=\{trafficView === 'outbound'\}[\s\S]*>Выход<\/button>[\s\S]*aria-pressed=\{trafficView === 'inbound'\}[\s\S]*>Вход<\/button>/);
assert.match(chart, /function trafficPathAnimationSource[\s\S]*previousByTime[\s\S]*gatewayY: 100[\s\S]*attributeName="d"[\s\S]*dur="520ms"/); assert.match(chart, /function trafficPathAnimationSource[\s\S]*previousByTime[\s\S]*gatewayY: 100[\s\S]*attributeName="d"[\s\S]*dur="520ms"/);
assert.match(chart, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /); assert.match(chart, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /);
assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio: number\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/); assert.match(chart, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio: number\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/);
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/); assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)[\s\S]*directIpv4Y: trafficChartY\(trafficScaleRatio\(directIpv4, max, scale\)\)[\s\S]*unknownY: trafficChartY\(trafficScaleRatio\(unknown, max, scale\)\)/);
assert.match(chart, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/); assert.match(chart, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/);
assert.match(chart, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/); assert.match(chart, /client-device-traffic-cursor[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\} y2="100"/);
assert.doesNotMatch(panel, /key=\{latest\}/); assert.doesNotMatch(panel, /key=\{latest\}/);
assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/); assert.match(chart, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/);
assert.match(chart, /onPointerMove=\{trackPointer\}/); assert.match(chart, /onPointerMove=\{trackPointer\}/);
assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/); assert.match(chart, /pinned && max > 0n && <span className="client-device-traffic-axis"[\s\S]*is-max[\s\S]*is-mid[\s\S]*is-zero/);
assert.match(chart, /client-device-traffic-lines[\s\S]*series === 'speed' \? 'is-download' : 'is-gateway'[\s\S]*series === 'speed' \? 'is-upload' : 'is-proxy'/); assert.match(chart, /series === 'outbound'[\s\S]*'is-vpn'[\s\S]*'is-direct-tracked'[\s\S]*'is-direct-ipv4'[\s\S]*'is-unknown'/);
assert.match(chart, /client-device-traffic-cursor[\s\S]*`is-point \$\{series === 'speed' \? 'is-download' : 'is-gateway'\}`[\s\S]*`is-point \$\{series === 'speed' \? 'is-upload' : 'is-proxy'\}`/); assert.match(chart, /client-device-traffic-lines[\s\S]*visibleLines\.map[\s\S]*smoothTrafficPath\(previous, line\.yKey\)[\s\S]*client-device-traffic-cursor[\s\S]*visibleLines\.map/);
assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/); assert.match(panel, /routeLabel=\{device\.appliedPolicy === 'direct' \? 'Напрямую' : 'Gateway'\}/);
assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/); assert.match(chart, /\{hovered\.proxy > 0n && <span className="is-proxy">Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/);
assert.match(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*series === 'speed' \? '↓ \/ ↑' : '15 с'/); assert.match(chart, /VPN · sing-box[\s\S]*Direct · sing-box[\s\S]*Direct · IPv4[\s\S]*Неизвестно · sing-box[\s\S]*Разные уровни учёта не складываются/);
assert.match(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*client-device-traffic-legend[\s\S]*VPN[\s\S]*Direct · sing-box[\s\S]*Direct · IPv4[\s\S]*series === 'speed' \? '↓ \/ ↑' : 'Вход'/);
assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/); assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/);
assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/); assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/);
assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/); assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|<Tooltip>\{trafficLabel\}<\/Tooltip>/);
@@ -155,6 +157,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/); assert.match(styles, /\.client-device-traffic-point-tooltip \{[\s\S]*position: fixed;[\s\S]*z-index: 1002/);
assert.doesNotMatch(styles, /client-device-traffic-shift|client-device-traffic-line-draw/); assert.doesNotMatch(styles, /client-device-traffic-shift|client-device-traffic-line-draw/);
assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/); assert.match(styles, /\.client-device-traffic-lines \.is-upload \{[\s\S]*stroke-dasharray: 5 4/);
assert.match(styles, /\.client-device-traffic-lines \.is-vpn[\s\S]*\.is-direct-tracked[\s\S]*\.is-direct-ipv4[\s\S]*\.is-unknown/);
assert.match(styles, /@keyframes client-device-traffic-plot-expand[\s\S]*scaleY\(0\.3448275862\)[\s\S]*scaleY\(1\)/); assert.match(styles, /@keyframes client-device-traffic-plot-expand[\s\S]*scaleY\(0\.3448275862\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /@keyframes client-device-traffic-plot-collapse[\s\S]*scaleY\(2\.9\)[\s\S]*scaleY\(1\)/); assert.match(styles, /@keyframes client-device-traffic-plot-collapse[\s\S]*scaleY\(2\.9\)[\s\S]*scaleY\(1\)/);
assert.match(styles, /@keyframes client-device-traffic-detail-in[\s\S]*opacity: 0[\s\S]*opacity: 1/); assert.match(styles, /@keyframes client-device-traffic-detail-in[\s\S]*opacity: 0[\s\S]*opacity: 1/);
@@ -230,8 +233,8 @@ test('Gateway Home reuses the canonical device snapshot for applied route and gl
assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/); assert.doesNotMatch(panel, /const \[snapshot, setSnapshot\]|setTimeout\(\(\) => load\(true\),/);
}); });
test('traffic chart scale compares Gateway and Proxy before choosing the maximum', () => { test('traffic chart scale compares every visible route before choosing the maximum', () => {
assert.match(chart, /function trafficSeriesMax[\s\S]*proxy > gateway \? proxy : gateway[\s\S]*proxy > largest \? proxy : largest/); assert.match(chart, /function trafficSeriesMax[\s\S]*Object\.values\(value\)\.reduce[\s\S]*item > current \? item : current/);
}); });
test('device last-seen copy is compact with precise accessible and relative forms', () => { test('device last-seen copy is compact with precise accessible and relative forms', () => {
@@ -62,6 +62,13 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
appliedPolicy: 'vpn', appliedPolicy: 'vpn',
confidence: 'high', confidence: 'high',
trafficHistory: [{ observedAt, gatewayBytes: '42', proxyBytes: '0' }], trafficHistory: [{ observedAt, gatewayBytes: '42', proxyBytes: '0' }],
outboundTrafficHistory: [{
observedAt,
vpnBytes: '30',
directTrackedBytes: '8',
directIpv4Bytes: '4',
unknownBytes: '0',
}],
}], }],
trafficHistoryCapacity: 120, trafficHistoryCapacity: 120,
traffic: { traffic: {
@@ -109,6 +116,7 @@ test('all unknown inventory results pass one identity-preserving runtime parser'
{ ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] }, { ...valid, devices: [{ ...valid.devices[0], confidence: 'unknown' }] },
{ ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] }, { ...valid, devices: [{ ...valid.devices[0], lastSeenAt: 'not-a-date' }] },
{ ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] }, { ...valid, devices: [{ ...valid.devices[0], trafficHistory: [{ observedAt, gatewayBytes: '1' }] }] },
{ ...valid, devices: [{ ...valid.devices[0], outboundTrafficHistory: [{ observedAt, vpnBytes: '1' }] }] },
{ ...valid, traffic: { ...valid.traffic, totalBytes: -4 } }, { ...valid, traffic: { ...valid.traffic, totalBytes: -4 } },
{ ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } }, { ...valid, traffic: { ...valid.traffic, observedAt: 'not-a-date' } },
{ ...valid, source: { ...valid.source, kind: 'arp' } }, { ...valid, source: { ...valid.source, kind: 'arp' } },
+14 -14
View File
@@ -37,26 +37,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: 771, cascadeEdges: 829,
customProperties: 103, customProperties: 103,
declarations: 3247, declarations: 3268,
important: 0, important: 0,
keyframes: 56, keyframes: 56,
media: 13, media: 13,
rules: 922, rules: 939,
variableReferences: 797, variableReferences: 812,
}, },
hashes: { hashes: {
cascadeEdges: 'e1b9f75210ef7258d9cb5b9e332de529b2ccceaf92b9b86eabcd0b62fefb5c98', cascadeEdges: '31e13ff6817e157b5ab4bb6a7caeb9f869da114e7ddb72a44d6972f3594f027a',
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb', customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
declarations: '6e30102fba87a4c28ea429159a13239a313ea8d18928423763bb166e0456f8cc', declarations: '9eee80b8494eab2d9015b797ad0ea7487bcc5b01c84ec90f00884ddf39396cd2',
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945', duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036', duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce', keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
ruleDeclarationSequences: '9dacb5d7407717ae041cabf6e8e75af7cfccfb7b5e4c387880468db409e93fbd', ruleDeclarationSequences: '7fcacaa4386895d6eeb43678d151ec882959bbbd6815843a626f7cea7886a5f6',
selectors: '8456f64c608e0411bbc56c0c955d25383391f019fa5af0648a72d40564cfee1a', selectors: 'edf95ecfdb232b500674687f577a03884c399ec33a590ac73fc5145ec280ca38',
variableReferences: '42661286e6150115a367068908f79c79951e24fb3fcf8775c2b083bb2d520e38', variableReferences: '2035aa280c6096e2581bb9e06ef687fd80e4ce4ee5d8844ed16c96b3fa735ff9',
witnesses: '7f44af307fd67fc88e274c1dcc6a21a5e064a8be3895d80fd640715e1af42419', witnesses: 'cf5d27b5fac96afae3c8f5c68f5787815679ef00c825d06d76ea3b27e4d6ead0',
}, },
}; };
@@ -209,7 +209,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, 757); assert.equal(witnesses.length, 770);
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);
@@ -405,8 +405,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-CGpprjFm.css']); assert.deepEqual(assets, ['index-DxMz3Vnp.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, 125113); assert.equal(built.byteLength, 126501);
assert.equal(sha256(built), 'f9ddcf2a43d81f42abe53303087966b5ec4ecfd3c032bd98cf1dc2a805b47364'); assert.equal(sha256(built), '99f66c86e8a2bb8d0351b46cad8d16393ae92c1246b9027312e7b79756355a7d');
}); });