Add Gateway connectivity diagnostics and traffic chart improvements
This commit is contained in:
@@ -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>;
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user