Update VPN proxy client behavior
Build and Deploy Gateway / build-and-push (push) Successful in 18s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-09 11:55:02 +03:00
parent 71ede44be0
commit 90433d7cd8
27 changed files with 770 additions and 254 deletions
+65 -32
View File
@@ -4,6 +4,7 @@ import {
byteString,
formatByteString,
trafficAxisMid,
trafficBytesPerSecond,
trafficScaleRatio,
} from '../../utils/format.js';
import type { TrafficSample, TrafficScale } from './deviceSnapshot.js';
@@ -40,38 +41,60 @@ function smoothTrafficPath(points: ChartPoint[], valueKey: 'gatewayY' | 'proxyY'
}, `M ${points[0].x},${points[0][valueKey]}`);
}
function trafficSeriesMax(samples: TrafficSample[]) {
return samples.reduce((largest, sample) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
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 formatRate(value: bigint) {
return `${formatByteString(value)}/с`;
}
export function TrafficChart({
samples,
scale = 'linear',
capacity,
routeLabel,
pinned = true,
series = 'routes',
}: {
samples: TrafficSample[];
scale?: TrafficScale;
capacity: number;
routeLabel: string;
pinned?: boolean;
series?: 'routes' | 'speed';
}) {
const [hovered, setHovered] = useState<HoveredPoint | null>(null);
const previousPoints = useRef<ChartPoint[]>([]);
const previousScale = useRef<TrafficScale>(scale);
const max = trafficSeriesMax(samples);
const previousSeries = useRef(series);
const speedAvailable = samples.length > 1 && samples.every((sample) => (
typeof sample.downloadBytes === 'string' && typeof sample.uploadBytes === 'string'
));
const visibleSamples = series === 'speed' ? speedAvailable ? samples.slice(1) : [] : samples;
const values = visibleSamples.map((sample, index) => {
if (series === 'routes') {
return {
sample,
gateway: byteString(sample.gatewayBytes),
proxy: byteString(sample.proxyBytes),
};
}
const previous = samples[index];
return {
sample,
gateway: trafficBytesPerSecond(sample.downloadBytes, previous.observedAt, sample.observedAt),
proxy: trafficBytesPerSecond(sample.uploadBytes, previous.observedAt, sample.observedAt),
};
});
const max = trafficSeriesMax(values);
const mid = trafficAxisMid(max, scale);
const firstSlot = capacity - samples.length;
const points = samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
const firstSlot = capacity - visibleSamples.length;
const points = values.map(({ sample, gateway, proxy }, index) => {
return {
sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
@@ -84,16 +107,18 @@ export function TrafficChart({
const previous = points.slice(0, -1);
const penultimate = points.at(-2);
const newest = points.at(-1);
const hasProxy = points.some(({ proxy }) => proxy > 0n);
const hasSecondary = points.some(({ proxy }) => proxy > 0n);
const scaleFrom = previousPoints.current;
const animateScale = previousScale.current !== scale
&& previousSeries.current === series
&& scaleFrom.length === points.length
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => {
previousPoints.current = points;
previousScale.current = scale;
}, [points, scale]);
previousSeries.current = series;
}, [points, scale, series]);
function trackPointer(event: PointerEvent<HTMLSpanElement>) {
const bounds = event.currentTarget.getBoundingClientRect();
@@ -116,9 +141,17 @@ export function TrafficChart({
}}
>
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
{series === 'speed' ? <>
<strong> Download {formatRate(hovered.gateway)}</strong>
<span className="is-upload"> Upload {formatRate(hovered.proxy)}</span>
<span>За интервал {formatByteString(byteString(hovered.sample.gatewayBytes) + byteString(hovered.sample.proxyBytes))}</span>
<span>{routeLabel} {formatByteString(hovered.sample.gatewayBytes)}</span>
{byteString(hovered.sample.proxyBytes) > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.sample.proxyBytes)}</span>}
</> : <>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
</>}
</span>,
document.body,
);
@@ -126,15 +159,15 @@ export function TrafficChart({
return <span
className="client-device-traffic-chart"
role="img"
aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}
aria-label={`${series === 'speed' ? 'История скорости' : 'История трафика'}, шкала ${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}
>
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
<span className="is-max">{formatByteString(max)}</span>
<span className="is-mid">{formatByteString(mid)}</span>
<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)}>
@@ -145,31 +178,31 @@ export function TrafficChart({
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) } as CSSProperties}>
{previous.length > 0 && <path className="is-gateway" d={smoothTrafficPath(previous, 'gatewayY')}>
{animateScale && <animate key={`gateway-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
{previous.length > 0 && <path className={series === 'speed' ? 'is-download' : 'is-gateway'} d={smoothTrafficPath(previous, 'gatewayY')}>
{animateScale && <animate key={`gateway-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
</path>}
{hasProxy && previous.length > 0 && <path className="is-proxy" d={smoothTrafficPath(previous, 'proxyY')}>
{animateScale && <animate key={`proxy-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />}
{hasSecondary && previous.length > 0 && <path className={series === 'speed' ? 'is-upload' : 'is-proxy'} d={smoothTrafficPath(previous, 'proxyY')}>
{animateScale && <animate key={`proxy-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(0, -1), '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="is-gateway is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
{animateScale && <animate key={`gateway-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
{penultimate && newest && <path className={`${series === 'speed' ? 'is-download' : 'is-gateway'} is-new`} pathLength="1" d={smoothTrafficPath([penultimate, newest], 'gatewayY')}>
{animateScale && <animate key={`gateway-new-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'gatewayY')} to={smoothTrafficPath([penultimate, newest], 'gatewayY')} dur="520ms" fill="freeze" />}
</path>}
{hasProxy && penultimate && newest && <path className="is-proxy is-new" pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
{animateScale && <animate key={`proxy-new-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
{hasSecondary && penultimate && newest && <path className={`${series === 'speed' ? 'is-upload' : 'is-proxy'} is-new`} pathLength="1" d={smoothTrafficPath([penultimate, newest], 'proxyY')}>
{animateScale && <animate key={`proxy-new-${series}-${scale}`} attributeName="d" from={smoothTrafficPath(scaleFrom.slice(-2), 'proxyY')} to={smoothTrafficPath([penultimate, newest], 'proxyY')} dur="520ms" fill="freeze" />}
</path>}
{!penultimate && newest && <line className="is-gateway is-point" x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
{!penultimate && newest && <line className={`${series === 'speed' ? 'is-download' : 'is-gateway'} is-point`} x1={newest.x} x2={newest.x} y1={newest.gatewayY} y2={newest.gatewayY} />}
</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 is-gateway" x1={hovered.x} x2={hovered.x} y1={hovered.gatewayY} y2={hovered.gatewayY} />
{hovered.proxy > 0n && <line className="is-point is-proxy" x1={hovered.x} x2={hovered.x} y1={hovered.proxyY} y2={hovered.proxyY} />}
<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} />}
</g>}
</svg>
</span>
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
<span>15 с</span>
<time dateTime={samples[samples.length - 1].observedAt}>{chartTime(samples[samples.length - 1].observedAt)}</time>
{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>
<time dateTime={visibleSamples[visibleSamples.length - 1].observedAt}>{chartTime(visibleSamples[visibleSamples.length - 1].observedAt)}</time>
</span>}
{tooltip}
</span>;