Expose outbound traffic totals and simplify Direct chart breakdown
This commit is contained in:
@@ -149,6 +149,11 @@ interface OutboundTrafficSample {
|
|||||||
unknownBytes: string;
|
unknownBytes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface OutboundTrafficTotal extends OutboundTrafficSample {
|
||||||
|
singboxObservedAt: string | null;
|
||||||
|
directIpv4ObservedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface OutboundTrafficCursor {
|
interface OutboundTrafficCursor {
|
||||||
signature: string;
|
signature: string;
|
||||||
routeEpoch: string;
|
routeEpoch: string;
|
||||||
@@ -709,6 +714,7 @@ export function createDeviceInventoryService({
|
|||||||
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
const trafficCursorByMac = new Map<string, TrafficCursor>();
|
||||||
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
|
const outboundTrafficHistoryByDeviceId = new Map<string, OutboundTrafficSample[]>();
|
||||||
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
|
const outboundTrafficCursorByDeviceId = new Map<string, OutboundTrafficCursor>();
|
||||||
|
const outboundTrafficByDeviceId = new Map<string, OutboundTrafficTotal>();
|
||||||
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>();
|
||||||
@@ -832,6 +838,15 @@ export function createDeviceInventoryService({
|
|||||||
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
const current: OutboundTrafficCursor = { signature, routeEpoch, directEpoch, ...total };
|
||||||
const previous = outboundTrafficCursorByDeviceId.get(device.id);
|
const previous = outboundTrafficCursorByDeviceId.get(device.id);
|
||||||
outboundTrafficCursorByDeviceId.set(device.id, current);
|
outboundTrafficCursorByDeviceId.set(device.id, current);
|
||||||
|
if (observedAt) outboundTrafficByDeviceId.set(device.id, {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: routeObservedAt || null,
|
||||||
|
directIpv4ObservedAt: directObservedAt || null,
|
||||||
|
vpnBytes: total.vpn.toString(),
|
||||||
|
directTrackedBytes: total.directTracked.toString(),
|
||||||
|
directIpv4Bytes: total.directIpv4.toString(),
|
||||||
|
unknownBytes: total.unknown.toString(),
|
||||||
|
});
|
||||||
if (!previous || previous.signature === signature || !observedAt) continue;
|
if (!previous || previous.signature === signature || !observedAt) continue;
|
||||||
const routeDelta = (value: bigint, before: bigint) => (
|
const routeDelta = (value: bigint, before: bigint) => (
|
||||||
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
|
routeEpoch && routeEpoch === previous.routeEpoch && value > before ? value - before : 0n
|
||||||
@@ -852,6 +867,7 @@ export function createDeviceInventoryService({
|
|||||||
if (!knownIds.has(deviceId)) {
|
if (!knownIds.has(deviceId)) {
|
||||||
outboundTrafficCursorByDeviceId.delete(deviceId);
|
outboundTrafficCursorByDeviceId.delete(deviceId);
|
||||||
outboundTrafficHistoryByDeviceId.delete(deviceId);
|
outboundTrafficHistoryByDeviceId.delete(deviceId);
|
||||||
|
outboundTrafficByDeviceId.delete(deviceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -919,6 +935,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) || [],
|
||||||
|
outboundTraffic: outboundTrafficByDeviceId.get(device.id) || null,
|
||||||
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
|
outboundTrafficHistory: outboundTrafficHistoryByDeviceId.get(device.id) || [],
|
||||||
desiredPolicy: policy.desired,
|
desiredPolicy: policy.desired,
|
||||||
appliedPolicy: policy.applied,
|
appliedPolicy: policy.applied,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.25.3',
|
macClient: '0.25.4',
|
||||||
gatewayClient: '0.26.2',
|
gatewayClient: '0.26.3',
|
||||||
gatewayBackend: '0.26.3',
|
gatewayBackend: '0.26.4',
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface ParsedVersion {
|
export interface ParsedVersion {
|
||||||
|
|||||||
@@ -337,7 +337,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
<h2 id="client-devices-title">Устройства</h2>
|
<h2 id="client-devices-title">Устройства</h2>
|
||||||
<div className="client-instructions-intro">
|
<div className="client-instructions-intro">
|
||||||
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
|
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
|
||||||
<p>Учитывается только трафик, который прошёл через Harbor.</p>
|
<p>Вход — накопленные Gateway/Proxy. Выход — оценка VPN/Direct с запуска текущего учёта.</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -403,6 +403,24 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
const proxyTraffic = formatByteString(proxyTotal.toString());
|
const proxyTraffic = formatByteString(proxyTotal.toString());
|
||||||
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
|
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
|
||||||
const hasProxyTraffic = proxyTotal > 0n;
|
const hasProxyTraffic = proxyTotal > 0n;
|
||||||
|
const outboundAvailable = Boolean(
|
||||||
|
device.outboundTraffic?.singboxObservedAt || device.outboundTraffic?.directIpv4ObservedAt,
|
||||||
|
);
|
||||||
|
const vpnTotal = byteString(device.outboundTraffic?.vpnBytes);
|
||||||
|
const directTrackedTotal = byteString(device.outboundTraffic?.directTrackedBytes);
|
||||||
|
const directIpv4Total = byteString(device.outboundTraffic?.directIpv4Bytes);
|
||||||
|
const directTotal = directTrackedTotal + directIpv4Total;
|
||||||
|
const unknownTotal = byteString(device.outboundTraffic?.unknownBytes);
|
||||||
|
const outboundTotal = vpnTotal + directTotal + unknownTotal;
|
||||||
|
const displayedTotal = trafficView === 'outbound'
|
||||||
|
? outboundAvailable ? `≈ ${formatByteString(outboundTotal.toString())}` : '—'
|
||||||
|
: totalTraffic;
|
||||||
|
const trafficLabel = trafficView === 'outbound' ? 'Выход' : 'Вход';
|
||||||
|
const trafficAriaLabel = trafficView === 'outbound'
|
||||||
|
? outboundAvailable
|
||||||
|
? `Выход с запуска текущего учёта: примерно ${formatByteString(outboundTotal.toString())}. VPN ${formatByteString(vpnTotal.toString())}, Direct примерно ${formatByteString(directTotal.toString())}${unknownTotal > 0n ? `, неизвестно ${formatByteString(unknownTotal.toString())}` : ''}`
|
||||||
|
: 'Выход: ожидаем первые данные'
|
||||||
|
: `Вход накоплен: ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`;
|
||||||
const trafficDelta = trafficDeltas[device.id] || {};
|
const trafficDelta = trafficDeltas[device.id] || {};
|
||||||
const feedback = copyFeedback[device.id];
|
const feedback = copyFeedback[device.id];
|
||||||
const policyBusy = device.policyStatus === 'applying';
|
const policyBusy = device.policyStatus === 'applying';
|
||||||
@@ -538,14 +556,20 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
|
|||||||
className="client-device-traffic"
|
className="client-device-traffic"
|
||||||
role="group"
|
role="group"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
|
aria-label={trafficAriaLabel}
|
||||||
>
|
>
|
||||||
<span className="client-device-traffic-total" aria-hidden="true">
|
<span className="client-device-traffic-total" aria-hidden="true">
|
||||||
<b>Всего</b><TrafficValue value={totalTraffic} delta={trafficDelta.total} />
|
<b>{trafficLabel}</b><TrafficValue value={displayedTotal} delta={trafficView === 'inbound' ? trafficDelta.total : undefined} />
|
||||||
</span>
|
</span>
|
||||||
<span className="client-device-traffic-breakdown" aria-hidden="true">
|
<span className="client-device-traffic-breakdown" aria-hidden="true">
|
||||||
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
|
{trafficView === 'outbound' ? <>
|
||||||
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
|
<span className="is-vpn"><b>VPN</b><TrafficValue value={outboundAvailable ? formatByteString(vpnTotal.toString()) : '—'} /></span>
|
||||||
|
<span className="is-direct-total"><b>Direct ≈</b><TrafficValue value={outboundAvailable ? formatByteString(directTotal.toString()) : '—'} /></span>
|
||||||
|
{unknownTotal > 0n && <span className="is-unknown"><b>Неизв.</b><TrafficValue value={formatByteString(unknownTotal.toString())} /></span>}
|
||||||
|
</> : <>
|
||||||
|
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
|
||||||
|
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
|
||||||
|
</>}
|
||||||
</span>
|
</span>
|
||||||
</span>}
|
</span>}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function chartTime(value: string) {
|
|||||||
|
|
||||||
type ChartSample = TrafficSample | OutboundTrafficSample;
|
type ChartSample = TrafficSample | OutboundTrafficSample;
|
||||||
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
|
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
|
||||||
type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY';
|
type ChartYKey = 'gatewayY' | 'proxyY' | 'unknownY';
|
||||||
|
|
||||||
interface ChartPoint {
|
interface ChartPoint {
|
||||||
sample: ChartSample;
|
sample: ChartSample;
|
||||||
@@ -38,7 +38,6 @@ interface ChartPoint {
|
|||||||
unknown: bigint;
|
unknown: bigint;
|
||||||
gatewayY: number;
|
gatewayY: number;
|
||||||
proxyY: number;
|
proxyY: number;
|
||||||
directIpv4Y: number;
|
|
||||||
unknownY: number;
|
unknownY: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +67,11 @@ 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,
|
unknownY: source.unknownY,
|
||||||
} : {
|
} : {
|
||||||
...point,
|
...point,
|
||||||
gatewayY: 100,
|
gatewayY: 100,
|
||||||
proxyY: 100,
|
proxyY: 100,
|
||||||
directIpv4Y: 100,
|
|
||||||
unknownY: 100,
|
unknownY: 100,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -133,7 +130,7 @@ export function TrafficChart({
|
|||||||
return {
|
return {
|
||||||
sample,
|
sample,
|
||||||
gateway: byteString(outbound.vpnBytes),
|
gateway: byteString(outbound.vpnBytes),
|
||||||
proxy: byteString(outbound.directTrackedBytes),
|
proxy: byteString(outbound.directTrackedBytes) + byteString(outbound.directIpv4Bytes),
|
||||||
directIpv4: byteString(outbound.directIpv4Bytes),
|
directIpv4: byteString(outbound.directIpv4Bytes),
|
||||||
unknown: byteString(outbound.unknownBytes),
|
unknown: byteString(outbound.unknownBytes),
|
||||||
};
|
};
|
||||||
@@ -148,8 +145,8 @@ export function TrafficChart({
|
|||||||
unknown: 0n,
|
unknown: 0n,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const max = trafficSeriesMax(values.map(({ gateway, proxy, directIpv4, unknown }) => ({
|
const max = trafficSeriesMax(values.map(({ gateway, proxy, unknown }) => ({
|
||||||
gateway, proxy, directIpv4, unknown,
|
gateway, proxy, directIpv4: 0n, unknown,
|
||||||
})));
|
})));
|
||||||
const mid = trafficAxisMid(max, scale);
|
const mid = trafficAxisMid(max, scale);
|
||||||
const firstSlot = capacity - visibleSamples.length;
|
const firstSlot = capacity - visibleSamples.length;
|
||||||
@@ -163,7 +160,6 @@ export function TrafficChart({
|
|||||||
unknown,
|
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)),
|
unknownY: trafficChartY(trafficScaleRatio(unknown, max, scale)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -177,7 +173,6 @@ export function TrafficChart({
|
|||||||
? [
|
? [
|
||||||
{ valueKey: 'gateway', yKey: 'gatewayY' },
|
{ valueKey: 'gateway', yKey: 'gatewayY' },
|
||||||
{ valueKey: 'proxy', yKey: 'proxyY' },
|
{ valueKey: 'proxy', yKey: 'proxyY' },
|
||||||
{ valueKey: 'directIpv4', yKey: 'directIpv4Y' },
|
|
||||||
{ valueKey: 'unknown', yKey: 'unknownY' },
|
{ valueKey: 'unknown', yKey: 'unknownY' },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
@@ -230,11 +225,13 @@ export function TrafficChart({
|
|||||||
<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' ? <>
|
</> : series === 'outbound' ? <>
|
||||||
{hovered.gateway > 0n && <strong className="is-vpn">VPN · sing-box {formatByteString(hovered.gateway)}</strong>}
|
<strong className="is-total">Учтено ≈ {formatByteString(hovered.gateway + hovered.proxy + hovered.unknown)}</strong>
|
||||||
{hovered.proxy > 0n && <span className="is-direct-tracked">Direct · sing-box {formatByteString(hovered.proxy)}</span>}
|
{hovered.gateway > 0n && <span className="is-vpn">VPN {formatByteString(hovered.gateway)}</span>}
|
||||||
{hovered.directIpv4 > 0n && <span className="is-direct-ipv4">Direct · IPv4 {formatByteString(hovered.directIpv4)}</span>}
|
{hovered.proxy > 0n && <span className="is-direct-total">Direct ≈ {formatByteString(hovered.proxy)}</span>}
|
||||||
|
{hovered.proxy - hovered.directIpv4 > 0n && <span className="is-direct-detail">через sing-box {formatByteString(hovered.proxy - hovered.directIpv4)}</span>}
|
||||||
|
{hovered.directIpv4 > 0n && <span className="is-direct-detail">мимо sing-box · IPv4 {formatByteString(hovered.directIpv4)}</span>}
|
||||||
{hovered.unknown > 0n && <span className="is-unknown">Неизвестно · sing-box {formatByteString(hovered.unknown)}</span>}
|
{hovered.unknown > 0n && <span className="is-unknown">Неизвестно · sing-box {formatByteString(hovered.unknown)}</span>}
|
||||||
<span className="is-interval">Разные уровни учёта не складываются</span>
|
<span className="is-interval">Оценка за 15-секундный интервал</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>
|
||||||
@@ -269,19 +266,19 @@ 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">
|
||||||
{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)}>
|
{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-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} d={smoothTrafficPath(previous, line.yKey)}>
|
||||||
{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" />}
|
{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>)}
|
||||||
{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)}>
|
{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-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} d={smoothTrafficPath([penultimate, newest], line.yKey)}>
|
||||||
{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" />}
|
{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>)}
|
||||||
{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]}>
|
{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-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'} is-point`} x1={newest.x} x2={newest.x} y1={newest[line.yKey]} y2={newest[line.yKey]}>
|
||||||
{animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
|
{animatePaths && <animate key={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
|
||||||
</line>)}
|
</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" />
|
||||||
{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]} />)}
|
{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-total' : series === 'speed' ? 'is-upload' : 'is-proxy' : 'is-unknown'}`} x1={hovered.x} x2={hovered.x} y1={hovered[line.yKey]} y2={hovered[line.yKey]} />)}
|
||||||
</g>}
|
</g>}
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
@@ -289,8 +286,7 @@ export function TrafficChart({
|
|||||||
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
|
<time dateTime={visibleSamples[0].observedAt}>{chartTime(visibleSamples[0].observedAt)}</time>
|
||||||
{series === 'outbound' ? <span className="client-device-traffic-legend">
|
{series === 'outbound' ? <span className="client-device-traffic-legend">
|
||||||
<span className="is-vpn">VPN</span>
|
<span className="is-vpn">VPN</span>
|
||||||
<span className="is-direct-tracked">Direct · sing-box</span>
|
<span className="is-direct-total">Direct ≈</span>
|
||||||
<span className="is-direct-ipv4">Direct · IPv4</span>
|
|
||||||
<span className="is-unknown">?</span>
|
<span className="is-unknown">?</span>
|
||||||
</span> : <span>{series === 'speed' ? '↓ / ↑' : 'Вход'}</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>
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export interface OutboundTrafficSample extends Record<string, unknown> {
|
|||||||
unknownBytes: ByteValue;
|
unknownBytes: ByteValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OutboundTrafficTotal extends OutboundTrafficSample {
|
||||||
|
singboxObservedAt: string | null;
|
||||||
|
directIpv4ObservedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Device extends Record<string, unknown> {
|
export interface Device extends Record<string, unknown> {
|
||||||
id: string;
|
id: string;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
@@ -41,6 +46,7 @@ export interface Device extends Record<string, unknown> {
|
|||||||
appliedPolicy: DevicePolicy;
|
appliedPolicy: DevicePolicy;
|
||||||
confidence: DeviceConfidence;
|
confidence: DeviceConfidence;
|
||||||
trafficHistory: TrafficSample[];
|
trafficHistory: TrafficSample[];
|
||||||
|
outboundTraffic?: OutboundTrafficTotal | null;
|
||||||
outboundTrafficHistory?: OutboundTrafficSample[];
|
outboundTrafficHistory?: OutboundTrafficSample[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +136,12 @@ function validOutboundHistory(value: unknown): value is OutboundTrafficSample[]
|
|||||||
return Array.isArray(value) && value.every(validOutboundTrafficSample);
|
return Array.isArray(value) && value.every(validOutboundTrafficSample);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validOutboundTraffic(value: unknown): value is OutboundTrafficTotal | null {
|
||||||
|
return value === null || (validOutboundTrafficSample(value)
|
||||||
|
&& nullableTimestamp(value.singboxObservedAt)
|
||||||
|
&& nullableTimestamp(value.directIpv4ObservedAt));
|
||||||
|
}
|
||||||
|
|
||||||
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'
|
||||||
@@ -155,6 +167,7 @@ function validDevice(value: unknown): value is Device {
|
|||||||
&& (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.outboundTraffic === undefined || validOutboundTraffic(value.outboundTraffic))
|
||||||
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
|
&& (value.outboundTrafficHistory === undefined || validOutboundHistory(value.outboundTrafficHistory));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -694,6 +694,10 @@
|
|||||||
transition-delay: 45ms;
|
transition-delay: 45ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-traffic-breakdown > span:nth-child(3) {
|
||||||
|
transition-delay: 90ms;
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-traffic:hover .client-device-traffic-breakdown,
|
.client-device-traffic:hover .client-device-traffic-breakdown,
|
||||||
.client-device-traffic:focus .client-device-traffic-breakdown {
|
.client-device-traffic:focus .client-device-traffic-breakdown {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
@@ -764,6 +768,18 @@
|
|||||||
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
|
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-traffic-breakdown .is-vpn {
|
||||||
|
color: var(--harbor-connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-traffic-breakdown .is-direct-total {
|
||||||
|
color: var(--harbor-gateway);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-device-traffic-breakdown .is-unknown {
|
||||||
|
color: var(--client-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-traffic-breakdown b {
|
.client-device-traffic-breakdown b {
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
text-shadow: 0 0 3px var(--client-bg), 0 0 8px var(--client-bg);
|
text-shadow: 0 0 3px var(--client-bg), 0 0 8px var(--client-bg);
|
||||||
@@ -773,6 +789,12 @@
|
|||||||
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
|
color: color-mix(in oklch, var(--client-accent) 76%, var(--client-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-device-traffic-breakdown .is-vpn b,
|
||||||
|
.client-device-traffic-breakdown .is-direct-total b,
|
||||||
|
.client-device-traffic-breakdown .is-unknown b {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.client-device-traffic:focus-visible {
|
.client-device-traffic:focus-visible {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
outline: 2px solid var(--client-accent);
|
outline: 2px solid var(--client-accent);
|
||||||
@@ -913,16 +935,11 @@
|
|||||||
stroke: var(--harbor-connect);
|
stroke: var(--harbor-connect);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-lines .is-direct-tracked {
|
.client-device-traffic-lines .is-direct-total {
|
||||||
stroke: var(--harbor-gateway);
|
stroke: var(--harbor-gateway);
|
||||||
stroke-dasharray: 5 4;
|
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 {
|
.client-device-traffic-lines .is-unknown {
|
||||||
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
||||||
stroke-dasharray: 1 4;
|
stroke-dasharray: 1 4;
|
||||||
@@ -967,14 +984,10 @@
|
|||||||
stroke: var(--harbor-connect);
|
stroke: var(--harbor-connect);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-cursor .is-point.is-direct-tracked {
|
.client-device-traffic-cursor .is-point.is-direct-total {
|
||||||
stroke: var(--harbor-gateway);
|
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 {
|
.client-device-traffic-cursor .is-point.is-unknown {
|
||||||
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
stroke: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
||||||
}
|
}
|
||||||
@@ -1000,14 +1013,10 @@
|
|||||||
color: var(--harbor-connect);
|
color: var(--harbor-connect);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-legend .is-direct-tracked {
|
.client-device-traffic-legend .is-direct-total {
|
||||||
color: var(--harbor-gateway);
|
color: var(--harbor-gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-legend .is-direct-ipv4 {
|
|
||||||
color: var(--harbor-word);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-device-traffic-legend .is-unknown {
|
.client-device-traffic-legend .is-unknown {
|
||||||
color: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
color: color-mix(in oklch, var(--client-text) 54%, var(--client-muted));
|
||||||
}
|
}
|
||||||
@@ -1072,12 +1081,13 @@
|
|||||||
color: oklch(0.75 0.1 185);
|
color: oklch(0.75 0.1 185);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-point-tooltip .is-direct-tracked {
|
.client-device-traffic-point-tooltip .is-direct-total {
|
||||||
color: oklch(0.79 0.11 72);
|
color: oklch(0.79 0.11 72);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-point-tooltip .is-direct-ipv4 {
|
.client-device-traffic-point-tooltip .is-direct-detail {
|
||||||
color: oklch(0.78 0.07 232);
|
padding-left: 8px;
|
||||||
|
color: oklch(0.68 0.012 145);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-traffic-point-tooltip .is-unknown {
|
.client-device-traffic-point-tooltip .is-unknown {
|
||||||
|
|||||||
@@ -548,13 +548,32 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual((await service.refresh()).devices[0].outboundTrafficHistory, []);
|
let snapshot = await service.refresh();
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: observedAt,
|
||||||
|
directIpv4ObservedAt: observedAt,
|
||||||
|
vpnBytes: '300',
|
||||||
|
directTrackedBytes: '30',
|
||||||
|
directIpv4Bytes: '100',
|
||||||
|
unknownBytes: '3',
|
||||||
|
});
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, []);
|
||||||
observedAt = '2026-08-12T12:00:15.000Z';
|
observedAt = '2026-08-12T12:00:15.000Z';
|
||||||
vpn = ['150', '250'];
|
vpn = ['150', '250'];
|
||||||
trackedDirect = ['30', '50'];
|
trackedDirect = ['30', '50'];
|
||||||
unknown = ['4', '8'];
|
unknown = ['4', '8'];
|
||||||
directIpv4 = ['70', '90'];
|
directIpv4 = ['70', '90'];
|
||||||
let snapshot = await service.refresh();
|
snapshot = await service.refresh();
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: observedAt,
|
||||||
|
directIpv4ObservedAt: observedAt,
|
||||||
|
vpnBytes: '400',
|
||||||
|
directTrackedBytes: '80',
|
||||||
|
directIpv4Bytes: '160',
|
||||||
|
unknownBytes: '12',
|
||||||
|
});
|
||||||
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, [{
|
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory, [{
|
||||||
observedAt,
|
observedAt,
|
||||||
vpnBytes: '100',
|
vpnBytes: '100',
|
||||||
@@ -570,6 +589,15 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
unknown = ['0', '1'];
|
unknown = ['0', '1'];
|
||||||
directIpv4 = ['5', '6'];
|
directIpv4 = ['5', '6'];
|
||||||
snapshot = await service.refresh();
|
snapshot = await service.refresh();
|
||||||
|
assert.deepEqual(snapshot.devices[0].outboundTraffic, {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: observedAt,
|
||||||
|
directIpv4ObservedAt: observedAt,
|
||||||
|
vpnBytes: '7',
|
||||||
|
directTrackedBytes: '3',
|
||||||
|
directIpv4Bytes: '11',
|
||||||
|
unknownBytes: '1',
|
||||||
|
});
|
||||||
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory.at(-1), {
|
assert.deepEqual(snapshot.devices[0].outboundTrafficHistory.at(-1), {
|
||||||
observedAt,
|
observedAt,
|
||||||
vpnBytes: '0',
|
vpnBytes: '0',
|
||||||
@@ -578,6 +606,7 @@ test('device outbound history keeps sing-box routes and kernel Direct on separat
|
|||||||
unknownBytes: '0',
|
unknownBytes: '0',
|
||||||
});
|
});
|
||||||
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTrafficHistory/);
|
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTrafficHistory/);
|
||||||
|
assert.doesNotMatch(fs.readFileSync(filePath, 'utf8'), /outboundTraffic/);
|
||||||
});
|
});
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -82,9 +82,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
assert.match(panel, /device\.confidence === 'ambiguous'/);
|
||||||
assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
|
assert.match(panel, /stabilizeDevicesByTraffic\(snapshot\?\.devices, sortDirection, previousIds\)/);
|
||||||
assert.match(panel, /Трафик временно не обновляется/);
|
assert.match(panel, /Трафик временно не обновляется/);
|
||||||
assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/);
|
assert.match(panel, /Вход — накопленные Gateway\/Proxy\. Выход — оценка VPN\/Direct с запуска текущего учёта\./);
|
||||||
assert.match(panel, /client-device-traffic-total[\s\S]*<b>Всего<\/b><TrafficValue value=\{totalTraffic\} delta=\{trafficDelta\.total\}/);
|
assert.match(panel, /const displayedTotal = trafficView === 'outbound'[\s\S]*`≈ \$\{formatByteString\(outboundTotal\.toString\(\)\)\}`[\s\S]*const trafficLabel = trafficView === 'outbound' \? 'Выход' : 'Вход'/);
|
||||||
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>Gateway<\/b><TrafficValue value=\{gatewayTraffic\} delta=\{trafficDelta\.gateway\}/);
|
assert.match(panel, /client-device-traffic-total[\s\S]*<b>\{trafficLabel\}<\/b><TrafficValue value=\{displayedTotal\}/);
|
||||||
|
assert.match(panel, /client-device-traffic-breakdown[\s\S]*<b>VPN<\/b>[\s\S]*<b>Direct ≈<\/b>[\s\S]*<b>Неизв\.<\/b>/);
|
||||||
|
assert.match(panel, /<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=\{trafficView === 'outbound' \? device\.outboundTrafficHistory \|\| \[\] : device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\?\.trafficHistoryCapacity[\s\S]*series=\{trafficView\}/);
|
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\}/);
|
||||||
@@ -94,19 +96,20 @@ test('Gateway device inventory uses the existing accessible responsive drawer',
|
|||||||
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\)\)[\s\S]*directIpv4Y: trafficChartY\(trafficScaleRatio\(directIpv4, max, scale\)\)[\s\S]*unknownY: trafficChartY\(trafficScaleRatio\(unknown, max, scale\)\)/);
|
assert.match(chart, /proxy: byteString\(outbound\.directTrackedBytes\) \+ byteString\(outbound\.directIpv4Bytes\)/);
|
||||||
|
assert.match(chart, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, 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, /series === 'outbound'[\s\S]*'is-vpn'[\s\S]*'is-direct-tracked'[\s\S]*'is-direct-ipv4'[\s\S]*'is-unknown'/);
|
assert.match(chart, /series === 'outbound'[\s\S]*'is-vpn'[\s\S]*'is-direct-total'[\s\S]*'is-unknown'/);
|
||||||
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(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, /VPN · sing-box[\s\S]*Direct · sing-box[\s\S]*Direct · IPv4[\s\S]*Неизвестно · sing-box[\s\S]*Разные уровни учёта не складываются/);
|
assert.match(chart, /Учтено ≈[\s\S]*VPN[\s\S]*Direct ≈[\s\S]*через sing-box[\s\S]*мимо sing-box · IPv4[\s\S]*Неизвестно · sing-box[\s\S]*Оценка за 15-секундный интервал/);
|
||||||
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(chart, /<time dateTime=\{visibleSamples\[0\]\.observedAt\}>[\s\S]*client-device-traffic-legend[\s\S]*VPN[\s\S]*Direct ≈[\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>/);
|
||||||
@@ -157,7 +160,8 @@ 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, /\.client-device-traffic-lines \.is-vpn[\s\S]*\.is-direct-total[\s\S]*\.is-unknown/);
|
||||||
|
assert.match(styles, /\.client-device-traffic-breakdown \.is-vpn[\s\S]*\.is-direct-total[\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/);
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ 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' }],
|
||||||
|
outboundTraffic: {
|
||||||
|
observedAt,
|
||||||
|
singboxObservedAt: observedAt,
|
||||||
|
directIpv4ObservedAt: observedAt,
|
||||||
|
vpnBytes: '30',
|
||||||
|
directTrackedBytes: '8',
|
||||||
|
directIpv4Bytes: '4',
|
||||||
|
unknownBytes: '0',
|
||||||
|
},
|
||||||
outboundTrafficHistory: [{
|
outboundTrafficHistory: [{
|
||||||
observedAt,
|
observedAt,
|
||||||
vpnBytes: '30',
|
vpnBytes: '30',
|
||||||
@@ -116,6 +125,8 @@ 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], outboundTraffic: { observedAt, vpnBytes: '1' } }] },
|
||||||
|
{ ...valid, devices: [{ ...valid.devices[0], outboundTraffic: { ...valid.devices[0].outboundTraffic, directIpv4ObservedAt: 'not-a-date' } }] },
|
||||||
{ ...valid, devices: [{ ...valid.devices[0], outboundTrafficHistory: [{ observedAt, vpnBytes: '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' } },
|
||||||
|
|||||||
@@ -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: 829,
|
cascadeEdges: 813,
|
||||||
customProperties: 103,
|
customProperties: 103,
|
||||||
declarations: 3268,
|
declarations: 3270,
|
||||||
important: 0,
|
important: 0,
|
||||||
keyframes: 56,
|
keyframes: 56,
|
||||||
media: 13,
|
media: 13,
|
||||||
rules: 939,
|
rules: 941,
|
||||||
variableReferences: 812,
|
variableReferences: 812,
|
||||||
},
|
},
|
||||||
hashes: {
|
hashes: {
|
||||||
cascadeEdges: '31e13ff6817e157b5ab4bb6a7caeb9f869da114e7ddb72a44d6972f3594f027a',
|
cascadeEdges: '38c9f42cdc5839efbfc06fd12e21d943742fd71a7170b239059c9041e675697d',
|
||||||
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
|
customProperties: 'ef70fb1d9b61b177f1939f811babe1116bee631de26f5eac0aa0d0009ca5a1fb',
|
||||||
declarations: '9eee80b8494eab2d9015b797ad0ea7487bcc5b01c84ec90f00884ddf39396cd2',
|
declarations: '8ed706131420b5e15efcaaffa64282d4d44e6bcf8473938c16ec55f375013661',
|
||||||
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
duplicateKeyframes: '4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945',
|
||||||
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
duplicateSelectors: '257eff4727dab9ce25f4e1f9320e89bf2270eb29feae921b96601f103ad7f036',
|
||||||
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
|
keyframes: 'd4378751f36eccc87923c12540315462055032a5d34978a0f45ecada0a5cc1ce',
|
||||||
ruleDeclarationSequences: '7fcacaa4386895d6eeb43678d151ec882959bbbd6815843a626f7cea7886a5f6',
|
ruleDeclarationSequences: 'f054d14a153e93f07aebc28ada717e004ac8618b9f766d4701cc748a271fad9a',
|
||||||
selectors: 'edf95ecfdb232b500674687f577a03884c399ec33a590ac73fc5145ec280ca38',
|
selectors: '4e8f1d8af4830eb6ca40a5b94d3b1b234817f22eb7073c643b4773ee36f8249c',
|
||||||
variableReferences: '2035aa280c6096e2581bb9e06ef687fd80e4ce4ee5d8844ed16c96b3fa735ff9',
|
variableReferences: 'd3a740a583156df0b42ce0f52687617a5b5507b394251070c63d355560921f03',
|
||||||
witnesses: 'cf5d27b5fac96afae3c8f5c68f5787815679ef00c825d06d76ea3b27e4d6ead0',
|
witnesses: '834f4fcda87e58c8cd49011d6d8f3e88393c09a88b1d255f3e8d43ccb00ea3e6',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -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, 770);
|
assert.equal(witnesses.length, 787);
|
||||||
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-DxMz3Vnp.css']);
|
assert.deepEqual(assets, ['index-BT8lRf1r.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, 126501);
|
assert.equal(built.byteLength, 126715);
|
||||||
assert.equal(sha256(built), '99f66c86e8a2bb8d0351b46cad8d16393ae92c1246b9027312e7b79756355a7d');
|
assert.equal(sha256(built), '0d1d0c03b2d89265a373ebd8e80aa5f5b2a37bb1dd4d5cb350802dcb7ddf673a');
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user