Enhance device traffic feedback and totals
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 : [])
|
||||
|
||||
Reference in New Issue
Block a user