Files
harbor-net/src/web/features/devices/TrafficChart.tsx
T
dokril 9d43e74d97
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 14s
Add outbound traffic breakdown to device charts
2026-08-12 22:18:08 +03:00

301 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, {
useLayoutEffect,
useRef,
useState,
type AnimationEvent,
type CSSProperties,
type PointerEvent,
} from 'react';
import { createPortal } from 'react-dom';
import {
byteString,
formatByteString,
trafficAxisMid,
trafficBytesPerSecond,
trafficScaleRatio,
} from '../../utils/format.js';
import type { OutboundTrafficSample, TrafficSample, TrafficScale } from './deviceSnapshot.js';
const TRAFFIC_CHART_HEADROOM = 10;
const trafficChartY = (ratio: number) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM);
function chartTime(value: string) {
return new Date(value).toLocaleTimeString('ru-RU', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
}
type ChartSample = TrafficSample | OutboundTrafficSample;
type ChartValueKey = 'gateway' | 'proxy' | 'directIpv4' | 'unknown';
type ChartYKey = 'gatewayY' | 'proxyY' | 'directIpv4Y' | 'unknownY';
interface ChartPoint {
sample: ChartSample;
x: number;
gateway: bigint;
proxy: bigint;
directIpv4: bigint;
unknown: bigint;
gatewayY: number;
proxyY: number;
directIpv4Y: number;
unknownY: number;
}
interface HoveredPoint extends ChartPoint {
clientX: number;
clientY: number;
}
function smoothTrafficPath(points: ChartPoint[], valueKey: ChartYKey) {
if (!points.length) return '';
return points.slice(1).reduce((path, point, index) => {
const previous = points[index];
const midX = (previous.x + point.x) / 2;
return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`;
}, `M ${points[0].x},${points[0][valueKey]}`);
}
function trafficPathAnimationSource(points: ChartPoint[], previousPoints: ChartPoint[]) {
const previousByTime = new Map(previousPoints.map((point) => [point.sample.observedAt, point]));
let anchor: ChartPoint | undefined;
return points.map((point) => {
const exact = previousByTime.get(point.sample.observedAt);
if (exact) anchor = exact;
const source = exact || anchor;
return source ? {
...point,
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<Record<ChartValueKey, bigint>>) {
return values.reduce((largest, value) => (
Object.values(value).reduce((current, item) => item > current ? item : current, largest)
), 0n);
}
function formatRate(value: bigint) {
return `${formatByteString(value)}/с`;
}
export function TrafficChart({
samples,
scale = 'linear',
capacity,
routeLabel,
pinned = true,
collapsing = false,
onCollapseEnd,
series = 'inbound',
}: {
samples: ChartSample[];
scale?: TrafficScale;
capacity: number;
routeLabel: string;
pinned?: boolean;
collapsing?: boolean;
onCollapseEnd?: () => void;
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) => (
'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 === 'inbound') {
const traffic = sample as TrafficSample;
return {
sample,
gateway: byteString(traffic.gatewayBytes),
proxy: byteString(traffic.proxyBytes),
directIpv4: 0n,
unknown: 0n,
};
}
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(traffic.downloadBytes, previous.observedAt, traffic.observedAt),
proxy: trafficBytesPerSecond(traffic.uploadBytes, previous.observedAt, traffic.observedAt),
directIpv4: 0n,
unknown: 0n,
};
});
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, 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 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, directIpv4, unknown }) => (
`${sample.observedAt}:${gateway}:${proxy}:${directIpv4}:${unknown}`
)).join('|')}`;
const animatePaths = points.length > 0
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => {
previousPoints.current = points;
previousSeries.current = series;
}, [points, series]);
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
const index = slot - firstSlot;
if (index < 0 || index >= points.length) {
setHovered(null);
return;
}
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
}
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
<span
className="client-device-traffic-point-tooltip"
style={{
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
}}
>
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
{series === 'speed' ? <>
<strong className="is-download"> Download {formatRate(hovered.gateway)}</strong>
<span className="is-upload"> Upload {formatRate(hovered.proxy)}</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>
{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>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
</>}
</span>,
document.body,
);
return <span
className={`client-device-traffic-chart${collapsing ? ' is-collapsing' : ''}`}
role="img"
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}%`,
} as CSSProperties}
onAnimationEnd={(event: AnimationEvent<HTMLSpanElement>) => {
if (collapsing && event.animationName === 'client-device-traffic-plot-collapse') onCollapseEnd?.();
}}
>
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
<span className="is-max">{series === 'speed' ? formatRate(max) : formatByteString(max)}</span>
<span className="is-mid">{series === 'speed' ? formatRate(mid) : formatByteString(mid)}</span>
<span className="is-zero">0</span>
</span>}
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{pinned && <g className="client-device-traffic-grid">
<line x1="0" x2="100" y1={TRAFFIC_CHART_HEADROOM} y2={TRAFFIC_CHART_HEADROOM} />
<line x1="0" x2="100" y1={(100 + TRAFFIC_CHART_HEADROOM) / 2} y2={(100 + TRAFFIC_CHART_HEADROOM) / 2} />
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<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)}>
{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" />
{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>
{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}
</span>;
}