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
+94 -43
View File
@@ -14,7 +14,7 @@ import {
trafficBytesPerSecond,
trafficScaleRatio,
} 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 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 {
sample: TrafficSample;
sample: ChartSample;
x: number;
gateway: bigint;
proxy: bigint;
directIpv4: bigint;
unknown: bigint;
gatewayY: number;
proxyY: number;
directIpv4Y: number;
unknownY: number;
}
interface HoveredPoint extends ChartPoint {
@@ -39,7 +47,7 @@ interface HoveredPoint extends ChartPoint {
clientY: number;
}
function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY') {
function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) {
if (!points.length) return '';
return points.slice(1).reduce((path, point, index) => {
const previous = points[index];
@@ -60,20 +68,22 @@ function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartP
x: source.x,
gatewayY: source.gatewayY,
proxyY: source.proxyY,
directIpv4Y: source.directIpv4Y,
unknownY: source.unknownY,
} : {
...point,
gatewayY: 100,
proxyY: 100,
directIpv4Y: 100,
unknownY: 100,
};
});
}
function trafficSeriesMax(values: Array<{ gateway: bigint; proxy: bigint }>) {
return values.reduce((largest, { gateway, proxy }) => {
return gateway > largest
? (proxy > gateway ? proxy : gateway)
: (proxy > largest ? proxy : largest);
}, 0n);
function trafficSeriesMax(values: Array<Record<ChartValueKey, bigint>>) {
return values.reduce((largest, value) => (
Object.values(value).reduce((current, item) => item > current ? item : current, largest)
), 0n);
}
function formatRate(value: bigint) {
@@ -88,64 +98,101 @@ export function TrafficChart({
pinned = true,
collapsing = false,
onCollapseEnd,
series = 'routes',
series = 'inbound',
}: {
samples: TrafficSample[];
samples: ChartSample[];
scale?: TrafficScale;
capacity: number;
routeLabel: string;
pinned?: boolean;
collapsing?: boolean;
onCollapseEnd?: () => void;
series?: 'routes' | 'speed';
series?: 'inbound' | 'outbound' | 'speed';
}) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]);
const previousSeries = useRef(series);
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 values = visibleSamples.map((sample, index) => {
if (series === 'routes') {
if (series === 'inbound') {
const traffic = sample as TrafficSample;
return {
sample,
gateway: byteString(sample.gatewayBytes),
proxy: byteString(sample.proxyBytes),
gateway: byteString(traffic.gatewayBytes),
proxy: byteString(traffic.proxyBytes),
directIpv4: 0n,
unknown: 0n,
};
}
const previous = samples[index];
if (series === 'outbound') {
const outbound = sample as OutboundTrafficSample;
return {
sample,
gateway: byteString(outbound.vpnBytes),
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(sample.downloadBytes, previous.observedAt, sample.observedAt),
proxy: trafficBytesPerSecond(sample.uploadBytes, previous.observedAt, sample.observedAt),
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 firstSlot = capacity - visibleSamples.length;
const points = values.map(({ sample, gateway, proxy }, index) => {
const points = values.map(({ sample, gateway, proxy, directIpv4, unknown }, index) => {
return {
sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
gateway,
proxy,
directIpv4,
unknown,
gatewayY: trafficChartY(trafficScaleRatio(gateway, 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 penultimate = points.at(-2);
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 previousMotionFrom = trafficPathAnimationSource(previous, motionFrom);
const newestMotionFrom = trafficPathAnimationSource(
penultimate && newest ? [penultimate, newest] : [],
motionFrom,
);
const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy }) => (
`${sample.observedAt}:${gateway}:${proxy}`
const motionKey = `${series}-${scale}-${points.map(({ sample, gateway, proxy, directIpv4, unknown }) => (
`${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}`
)).join('|')}`;
const animatePaths = points.length > 0
&& !(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={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>}
</> : 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>
<span className={routeLabel === 'Gateway' ? 'is-gateway' : 'is-direct'}>{routeLabel} {formatByteString(hovered.gateway)}</span>
@@ -194,7 +247,7 @@ export function TrafficChart({
return <span
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
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={{
'--traffic-chart-top': `${TRAFFIC_CHART_HEADROOM}%`,
'--traffic-chart-mid': `${(100 + TRAFFIC_CHART_HEADROOM) / 2}%`,
@@ -216,32 +269,30 @@ export function TrafficChart({
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g className="client-device-traffic-lines">
{previous.length > 0 && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath(previous, 'gatewayY')}>
{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" />}
</path>}
{hasSecondary && previous.length > 0 && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath(previous, 'proxyY')}>
{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" />}
</path>}
{penultimate && newest && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
{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" />}
</path>}
{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>}
{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={`${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>)}
{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={`${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>)}
{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={`${line.valueKey}-point-${motionKey}`} attributeName="opacity" from="0" to="1" dur="220ms" fill="freeze" />}
</line>)}
</g>
{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-point ${series === 'speed' ? 'is-download' : 'is-gateway'}`} x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
{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} />}
{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]} />)}
</g>}
</svg>
</span>
{visibleSamples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<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>
</span>}
{tooltip}