Enhance device traffic feedback and totals
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-07 18:10:19 +03:00
parent d0c5336b7e
commit 00a2e1606b
5 changed files with 162 additions and 16 deletions
+52 -6
View File
@@ -5,11 +5,14 @@ import {
byteString,
formatByteString,
formatLastSeen,
positiveByteDelta,
sortDevicesByTraffic,
} 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;
function Tooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
@@ -24,6 +27,13 @@ function TextMorph({ from, to }) {
</span>;
}
function TrafficValue({ value, delta }) {
return <strong className={`client-device-traffic-value${delta ? ' has-delta' : ''}`}>
<span className="is-total">{value}</span>
<span className="is-delta">{delta ? `+${delta}` : ''}</span>
</strong>;
}
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [snapshot, setSnapshot] = useState(null);
const [status, setStatus] = useState('idle');
@@ -35,9 +45,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [refreshCycle, setRefreshCycle] = useState(0);
const [sortDirection, setSortDirection] = useState('desc');
const [copyFeedback, setCopyFeedback] = useState(null);
const [trafficDeltas, setTrafficDeltas] = 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 devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
[snapshot?.devices, sortDirection],
@@ -72,7 +85,37 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
return () => clearTimeout(timer);
}, [open, refreshCycle, refreshing, status]);
useEffect(() => () => clearTimeout(copyTimer.current), []);
useEffect(() => () => {
clearTimeout(copyTimer.current);
clearTimeout(trafficDeltaTimer.current);
}, []);
useEffect(() => {
if (!open) {
previousTraffic.current.clear();
clearTimeout(trafficDeltaTimer.current);
setTrafficDeltas({});
return;
}
const next = new Map();
const deltas = {};
for (const device of snapshot?.devices || []) {
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const previous = previousTraffic.current.get(device.id);
next.set(device.id, { gateway, proxy });
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 };
}
previousTraffic.current = next;
if (!Object.keys(deltas).length) return;
setTrafficDeltas(deltas);
clearTimeout(trafficDeltaTimer.current);
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
}, [snapshot?.devices, open]);
useLayoutEffect(() => {
if (!open) {
@@ -171,7 +214,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
} catch {
setCopyFeedback({ id: device.id, failed: true });
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), 800);
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
}
return (
@@ -266,7 +309,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
)}
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'copied!' : ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
@@ -279,6 +322,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const proxyTotal = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const gatewayTraffic = formatByteString(gatewayTotal.toString());
const proxyTraffic = formatByteString(proxyTotal.toString());
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
const trafficDelta = trafficDeltas[device.id] || {};
const copied = copyFeedback?.id === device.id;
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
@@ -350,7 +395,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<span className="client-device-name-primary">{title}</span>
<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 && copyFeedback.failed ? 'Ошибка' : 'copied!'}
</span>
</button>
</h3> : <h3>{title}</h3>}
@@ -384,8 +429,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<span className="client-device-traffic" role="group" aria-label={`Gateway ${gatewayTraffic}, Прокси ${proxyTraffic}`}>
<span className="client-device-traffic-sources" aria-hidden="true">
<span><b>Gateway</b><strong>{gatewayTraffic}</strong></span>
<span className="is-proxy"><b>Прокси</b><strong>{proxyTraffic}</strong></span>
<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>
+82 -1
View File
@@ -909,6 +909,7 @@ p {
}
.client-device-main {
height: 34px;
min-width: 0;
display: flex;
align-items: center;
@@ -916,8 +917,11 @@ p {
}
.client-device-main > h3 {
height: 32px;
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
margin: 0;
font-size: 15px;
@@ -927,9 +931,11 @@ p {
}
.client-device-name {
height: 32px;
width: 100%;
min-width: 0;
display: grid;
align-items: center;
padding: 0;
overflow: hidden;
border: 0;
@@ -945,22 +951,32 @@ p {
grid-area: 1 / 1;
overflow: hidden;
opacity: 0;
filter: blur(8px);
text-overflow: ellipsis;
white-space: nowrap;
transform: translateY(0.18em) scale(0.96);
transform-origin: left center;
transition: opacity 360ms ease, filter 480ms cubic-bezier(0.16, 1, 0.3, 1), transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-device-name > .client-device-name-primary {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
}
.client-device-name:hover .client-device-name-primary,
.client-device-name:focus-visible .client-device-name-primary {
opacity: 0;
filter: blur(8px);
transform: translateY(-0.18em) scale(1.04);
}
.client-device-name:hover .client-device-name-ip,
.client-device-name:focus-visible .client-device-name-ip {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
}
.client-device-name.is-copied .client-device-name-primary,
@@ -968,11 +984,21 @@ p {
.client-device-name.is-copy-error .client-device-name-primary,
.client-device-name.is-copy-error .client-device-name-ip {
opacity: 0;
filter: blur(8px);
transform: translateY(-0.18em) scale(1.04);
}
.client-device-name.is-copied .client-device-name-feedback,
.client-device-name.is-copy-error .client-device-name-feedback {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
}
.client-device-name-feedback {
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-device-name.is-copied .client-device-name-feedback {
@@ -1066,8 +1092,11 @@ p {
}
.client-device-last-seen {
height: 32px;
flex: 0 1 auto;
min-width: 0;
display: flex;
align-items: center;
max-width: 116px;
overflow: hidden;
font-size: 10px;
@@ -1077,6 +1106,12 @@ p {
white-space: nowrap;
}
.client-device-last-seen time {
height: 100%;
display: flex;
align-items: center;
}
.client-device-last-seen.is-online {
color: var(--client-accent);
font-weight: 700;
@@ -1155,7 +1190,7 @@ p {
gap: 2px;
}
.client-device-traffic-sources span {
.client-device-traffic-sources > span {
display: grid;
grid-template-columns: 46px minmax(0, 1fr);
align-items: baseline;
@@ -1178,6 +1213,41 @@ p {
white-space: nowrap;
}
.client-device-traffic-value {
min-width: 0;
display: inline-grid;
}
.client-device-traffic-value > span {
grid-area: 1 / 1;
overflow: hidden;
justify-self: end;
opacity: 0;
filter: blur(6px);
text-overflow: ellipsis;
transform: translateY(0.18em) scale(0.96);
transform-origin: right center;
transition: opacity 260ms ease, filter 360ms cubic-bezier(0.16, 1, 0.3, 1), transform 360ms cubic-bezier(0.16, 1, 0.3, 1);
white-space: nowrap;
}
.client-device-traffic-value > .is-total,
.client-device-traffic-value.has-delta > .is-delta {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
}
.client-device-traffic-value.has-delta > .is-total {
opacity: 0;
filter: blur(6px);
transform: translateY(-0.18em) scale(1.04);
}
.client-device-traffic-value > .is-delta {
color: var(--client-accent);
}
.client-device-traffic-sources .is-proxy {
color: var(--client-accent);
}
@@ -1186,6 +1256,15 @@ p {
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-sources > .is-total b {
color: var(--client-muted);
}
.client-device-policy-wrap {
width: 34px;
position: relative;
@@ -4243,6 +4322,8 @@ p {
.client-device-pin svg,
.client-device-policy,
.client-device-policy svg,
.client-device-name > span,
.client-device-traffic-value > span,
.client-device-edit,
.client-device-edit svg,
.client-device-edit-wrap,
+6
View File
@@ -31,6 +31,12 @@ export function formatByteString(value) {
return `${tenths / 10n},${tenths % 10n} ${units[unit]}`;
}
export function positiveByteDelta(previous, current) {
const before = byteString(previous);
const after = byteString(current);
return after > before ? formatByteString((after - before).toString()) : '';
}
export function sortDevicesByTraffic(devices, direction = 'desc') {
const factor = direction === 'asc' ? 1 : -1;
return (Array.isArray(devices) ? devices : [])