Add Gateway connectivity diagnostics and traffic chart improvements
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 20:48:43 +03:00
parent e6666558b7
commit 12375006d3
18 changed files with 1133 additions and 206 deletions
+90 -37
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { copyText } from '../utils/clientControls.js';
import {
@@ -6,9 +7,9 @@ import {
formatByteString,
formatLastSeen,
positiveByteDelta,
sortDevicesByTraffic,
stabilizeDevicesByTraffic,
trafficAxisMid,
trafficSampleMetrics,
trafficScaleRatio,
} from '../utils/format.js';
const AUTO_REFRESH_MS = 15_000;
@@ -42,52 +43,94 @@ function chartTime(value) {
});
}
function TrafficChart({ samples, scale, capacity, routeLabel }) {
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
const [hovered, setHovered] = useState(null);
const max = samples.reduce((largest, sample) => {
const total = byteString(sample.gatewayBytes) + byteString(sample.proxyBytes);
return total > largest ? total : largest;
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
}, 0n);
const mid = trafficAxisMid(max, scale);
const latest = samples.at(-1)?.observedAt || 'empty';
const firstSlot = capacity - samples.length;
const points = samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
return {
sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
gateway,
proxy,
gatewayY: 100 - trafficScaleRatio(gateway, max, scale) * 100,
proxyY: 100 - trafficScaleRatio(proxy, max, scale) * 100,
};
});
const previous = points.slice(0, -1);
const penultimate = points.at(-2);
const newest = points.at(-1);
const hasProxy = points.some(({ proxy }) => proxy > 0n);
function trackPointer(event) {
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>
<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,
);
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}>
<span className="client-device-traffic-track" key={latest} style={{ '--sample-count': Math.max(1, capacity) }}>
{samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
const total = gateway + proxy;
const time = chartTime(sample.observedAt);
const slot = capacity - samples.length + index;
const edge = slot < 28 ? ' is-edge-left' : slot >= capacity - 28 ? ' is-edge-right' : '';
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max, scale);
const newest = index === samples.length - 1;
return <i
className={`client-device-traffic-bar${edge}${newest ? ' is-new' : ''}`}
key={`${sample.observedAt}-${index}`}
style={{ height: `${height}%`, gridColumnStart: slot + 1 }}
>
<span className="client-device-traffic-bar-fill" aria-hidden="true">
<span className="is-gateway" style={{ height: `${gatewayShare}%` }} />
<span className="is-proxy" style={{ height: `${100 - gatewayShare}%` }} />
</span>
<span className="client-device-traffic-bar-tooltip" aria-hidden="true">
<time dateTime={sample.observedAt}>{time}</time>
<strong>Всего {formatByteString(total)}</strong>
<span>{routeLabel} {formatByteString(gateway)}</span>
{proxy > 0n && <span className="is-proxy">Proxy {formatByteString(proxy)}</span>}
</span>
</i>;
})}
</span>
{max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
{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-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="0" y2="0" />
<line x1="0" x2="100" y1="50" y2="50" />
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g key={latest} className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')} />}
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')} />}
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY} />}
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY} />}
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5" />}
</g>
{hovered && <g className="client-device-traffic-cursor">
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
<circle className="is-gateway" cx={hovered.x} cy={hovered.gatewayY} r="2" />
{hovered.proxy > 0n && <circle className="is-proxy" cx={hovered.x} cy={hovered.proxyY} r="2" />}
</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.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
</span>}
{tooltip}
</span>;
}
@@ -110,8 +153,16 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const previousTraffic = useRef(new Map());
const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null);
const trafficOrder = useRef({ direction: sortDirection, ids: [] });
const devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
() => {
const previousIds = trafficOrder.current.direction === sortDirection
? trafficOrder.current.ids
: [];
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds);
trafficOrder.current = { direction: sortDirection, ids: result.ids };
return result.devices;
},
[snapshot?.devices, sortDirection],
);
@@ -384,6 +435,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const editing = editingId === device.id;
const saving = savingId === device.id;
@@ -459,13 +511,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<>
{device.ip ? <h3 className="client-device-name-heading">
<button
className={`client-device-name${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
className={`client-device-name${hasName ? '' : ' is-address-only'}${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}
>
<span className="client-device-name-primary">{title}</span>
<span className="client-device-name-ip" aria-hidden="true">{device.ip}</span>
{hasName && <span className="client-device-name-ip" aria-hidden="true">{device.ip}</span>}
<span className="client-device-name-feedback" aria-hidden="true">
{copied && copyFeedback.failed ? 'Ошибка' : 'copied!'}
</span>
@@ -538,6 +590,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
scale={trafficScale}
capacity={snapshot.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
pinned={device.pinned}
/>
</article>;
})}