Add device traffic history chart and total breakdown
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-07 18:52:46 +03:00
parent 00a2e1606b
commit cf90fd5b4e
5 changed files with 191 additions and 34 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.13.5',
gatewayClient: '0.14.5',
macClient: '0.13.6',
gatewayClient: '0.14.6',
gatewayBackend: '0.14.0',
});
+47 -4
View File
@@ -7,12 +7,14 @@ import {
formatLastSeen,
positiveByteDelta,
sortDevicesByTraffic,
trafficSampleMetrics,
} from '../utils/format.js';
const AUTO_REFRESH_MS = 15_000;
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 5_000;
const TRAFFIC_DELTA_MS = 2_200;
const TRAFFIC_HISTORY_LIMIT = 18;
function Tooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
@@ -34,6 +36,27 @@ function TrafficValue({ value, delta }) {
</strong>;
}
function TrafficChart({ samples }) {
const max = samples.reduce((largest, sample) => {
const total = byteString(sample.gateway) + byteString(sample.proxy);
return total > largest ? total : largest;
}, 0n);
const latest = samples.at(-1)?.id || 'empty';
return <span className="client-device-traffic-chart" aria-hidden="true">
<span className="client-device-traffic-track" key={latest}>
{samples.map((sample) => {
const gateway = byteString(sample.gateway);
const proxy = byteString(sample.proxy);
const { height, gatewayShare } = trafficSampleMetrics(gateway, proxy, max);
return <i className="client-device-traffic-bar" key={sample.id} style={{ height: `${height}%` }}>
<span className="is-gateway" style={{ height: `${gatewayShare}%` }} />
<span className="is-proxy" style={{ height: `${100 - gatewayShare}%` }} />
</i>;
})}
</span>
</span>;
}
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [snapshot, setSnapshot] = useState(null);
const [status, setStatus] = useState('idle');
@@ -46,11 +69,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [sortDirection, setSortDirection] = useState('desc');
const [copyFeedback, setCopyFeedback] = useState(null);
const [trafficDeltas, setTrafficDeltas] = useState({});
const [trafficHistory, setTrafficHistory] = useState({});
const deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
const previousTraffic = useRef(new Map());
const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null);
const trafficSequence = useRef(0);
const devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
[snapshot?.devices, sortDirection],
@@ -100,6 +125,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const next = new Map();
const deltas = {};
const historyChanges = {};
for (const device of snapshot?.devices || []) {
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
@@ -108,11 +134,25 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
if (!previous) continue;
const gatewayDelta = positiveByteDelta(previous.gateway, gateway);
const proxyDelta = positiveByteDelta(previous.proxy, proxy);
if (gatewayDelta || proxyDelta) deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta };
if (!gatewayDelta && !proxyDelta) continue;
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta };
historyChanges[device.id] = {
id: ++trafficSequence.current,
gateway: gateway > previous.gateway ? (gateway - previous.gateway).toString() : '0',
proxy: proxy > previous.proxy ? (proxy - previous.proxy).toString() : '0',
};
}
previousTraffic.current = next;
if (!Object.keys(deltas).length) return;
setTrafficDeltas(deltas);
setTrafficHistory((current) => {
const updated = { ...current };
for (const [id, change] of Object.entries(historyChanges)) {
updated[id] = [...(current[id] || []), change].slice(-TRAFFIC_HISTORY_LIMIT);
}
return updated;
});
clearTimeout(trafficDeltaTimer.current);
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
}, [snapshot?.devices, open]);
@@ -427,11 +467,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</span>
</div>
<span className="client-device-traffic" role="group" aria-label={`Gateway ${gatewayTraffic}, Прокси ${proxyTraffic}`}>
<span className="client-device-traffic-sources" aria-hidden="true">
<span className="client-device-traffic" role="group" tabIndex="0" aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}, Прокси ${proxyTraffic}`}>
<span className="client-device-traffic-total" aria-hidden="true">
<b>Всего</b><TrafficValue value={totalTraffic} delta={trafficDelta.total} />
</span>
<span className="client-device-traffic-breakdown" aria-hidden="true">
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
<span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>
<span className="is-total"><b>Всего</b><strong>{totalTraffic}</strong></span>
</span>
</span>
@@ -454,6 +496,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</button>
<Tooltip>{policyTooltip}</Tooltip>
</span>
<TrafficChart samples={trafficHistory[device.id] || []} />
</article>;
})}
</div>
+114 -20
View File
@@ -902,13 +902,17 @@ p {
.client-device {
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 112px 34px;
align-items: center;
gap: 8px;
grid-template-rows: 34px 28px;
align-items: start;
column-gap: 8px;
row-gap: 6px;
padding: 10px 8px;
border-top: 1px solid color-mix(in oklch, var(--client-border) 72%, transparent);
}
.client-device-main {
grid-column: 2;
grid-row: 1;
height: 34px;
min-width: 0;
display: flex;
@@ -1035,6 +1039,8 @@ p {
}
.client-device-pin-wrap {
grid-column: 1;
grid-row: 1;
width: 34px;
height: 34px;
}
@@ -1178,33 +1184,64 @@ p {
}
.client-device-traffic {
grid-column: 3;
grid-row: 1;
width: 112px;
display: grid;
align-items: center;
height: 34px;
position: relative;
z-index: 3;
color: var(--client-text);
font-variant-numeric: tabular-nums;
}
.client-device-traffic-sources {
display: grid;
gap: 2px;
}
.client-device-traffic-sources > span {
.client-device-traffic-total,
.client-device-traffic-breakdown > span {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
align-items: baseline;
align-items: center;
gap: 4px;
}
.client-device-traffic-sources b {
.client-device-traffic-total {
height: 34px;
}
.client-device-traffic-breakdown {
position: absolute;
top: 30px;
right: 0;
width: 112px;
display: grid;
gap: 2px;
padding: 7px 0 6px;
background: color-mix(in oklch, var(--client-bg) 98%, var(--client-panel));
box-shadow: 0 10px 22px oklch(0.08 0.015 145 / 0.12);
opacity: 0;
visibility: hidden;
filter: blur(5px);
pointer-events: none;
transform: translateY(-7px);
transform-origin: top center;
transition: opacity 220ms ease, filter 300ms ease, transform 360ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 360ms;
}
.client-device-traffic:hover .client-device-traffic-breakdown,
.client-device-traffic:focus .client-device-traffic-breakdown {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0);
transition-delay: 0s;
}
.client-device-traffic b {
color: var(--client-muted);
font-size: 8px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.client-device-traffic-sources strong {
.client-device-traffic strong {
overflow: hidden;
font-size: 10px;
font-weight: 600;
@@ -1248,24 +1285,79 @@ p {
color: var(--client-accent);
}
.client-device-traffic-sources .is-proxy {
.client-device-traffic-breakdown .is-proxy {
color: var(--client-accent);
}
.client-device-traffic-sources .is-proxy b {
.client-device-traffic-breakdown .is-proxy b {
color: color-mix(in oklch, var(--client-accent) 72%, var(--client-muted));
}
.client-device-traffic-sources > .is-total {
margin-top: 2px;
color: var(--client-text);
.client-device-traffic:focus-visible {
border-radius: 3px;
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-device-traffic-sources > .is-total b {
color: var(--client-muted);
.client-device-traffic-chart {
grid-column: 2 / 4;
grid-row: 2;
height: 28px;
position: relative;
overflow: hidden;
}
.client-device-traffic-chart::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 1px;
background: color-mix(in oklch, var(--client-border) 58%, transparent);
content: '';
}
.client-device-traffic-track {
position: absolute;
inset: 0 0 1px;
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 3px;
animation: client-device-traffic-shift 420ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-device-traffic-bar {
width: 5px;
min-height: 2px;
flex: 0 0 5px;
display: flex;
overflow: hidden;
border-radius: 2px 2px 0 0;
flex-direction: column-reverse;
}
.client-device-traffic-bar > span {
width: 100%;
display: block;
}
.client-device-traffic-bar > .is-gateway {
background: color-mix(in oklch, var(--client-text) 58%, var(--client-muted));
}
.client-device-traffic-bar > .is-proxy {
background: var(--client-accent);
}
@keyframes client-device-traffic-shift {
from { opacity: 0.52; transform: translateX(8px); }
to { opacity: 1; transform: translateX(0); }
}
.client-device-policy-wrap {
grid-column: 4;
grid-row: 1;
width: 34px;
position: relative;
}
@@ -4324,6 +4416,8 @@ p {
.client-device-policy svg,
.client-device-name > span,
.client-device-traffic-value > span,
.client-device-traffic-breakdown,
.client-device-traffic-track,
.client-device-edit,
.client-device-edit svg,
.client-device-edit-wrap,
+11
View File
@@ -37,6 +37,17 @@ export function positiveByteDelta(previous, current) {
return after > before ? formatByteString((after - before).toString()) : '';
}
export function trafficSampleMetrics(gatewayValue, proxyValue, maxValue) {
const gateway = byteString(gatewayValue);
const proxy = byteString(proxyValue);
const total = gateway + proxy;
const max = byteString(maxValue);
return {
height: max && total ? Math.max(10, Math.min(100, Number(total * 100n / max))) : 0,
gatewayShare: total ? Number(gateway * 100n / total) : 0,
};
}
export function sortDevicesByTraffic(devices, direction = 'desc') {
const factor = direction === 'asc' ? 1 : -1;
return (Array.isArray(devices) ? devices : [])