From 00a2e1606bb02084f1f06eb8e11a60ca0db9a3ac Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 18:10:19 +0300 Subject: [PATCH] Enhance device traffic feedback and totals --- src/shared/versions.js | 4 +- src/web/components/DevicesPanel.jsx | 58 +++++++++++++-- src/web/styles.css | 83 +++++++++++++++++++++- src/web/utils/format.js | 6 ++ test/web/device-inventory-contract.test.js | 27 +++++-- 5 files changed, 162 insertions(+), 16 deletions(-) diff --git a/src/shared/versions.js b/src/shared/versions.js index 558bf59..ec13bd6 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.13.4', - gatewayClient: '0.14.4', + macClient: '0.13.5', + gatewayClient: '0.14.5', gatewayBackend: '0.14.0', }); diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index cefb72d..8c5155c 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -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 {children}; @@ -24,6 +27,13 @@ function TextMorph({ from, to }) { ; } +function TrafficValue({ value, delta }) { + return + {value} + {delta ? `+${delta}` : ''} + ; +} + 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 }) { )}
- {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''} + {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'copied!' : ''}
{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 }) { {title} :

{title}

} @@ -384,8 +429,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { diff --git a/src/web/styles.css b/src/web/styles.css index 2647dc3..ab8b2c1 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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, diff --git a/src/web/utils/format.js b/src/web/utils/format.js index 8a916ef..022dd41 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -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 : []) diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index e257147..e28dda9 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { formatByteString, formatLastSeen, + positiveByteDelta, sortDevicesByTraffic, } from '../../src/web/utils/format.js'; @@ -31,7 +32,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /Изменить название<\/Tooltip>/); assert.match(panel, /copyText\(device\.ip\)/); assert.match(panel, /client-device-name-primary[\s\S]*client-device-name-ip[\s\S]*client-device-name-feedback/); - assert.match(panel, /client-device-name-feedback[\s\S]*'Скопировано'/); + assert.match(panel, /client-device-name-feedback[\s\S]*'copied!'/); + assert.match(panel, /COPY_FEEDBACK_MS = 5_000/); assert.match(panel, /client-device-name-feedback[\s\S]*client-device-edit-wrap[\s\S]*client-device-last-seen/); assert.doesNotMatch(panel, /client-device-title/); assert.match(panel, /online \? 'В сети' : /); @@ -46,8 +48,11 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /sortDevicesByTraffic\(snapshot\?\.devices, sortDirection\)/); assert.match(panel, /Трафик временно не обновляется/); assert.match(panel, /Учитывается только трафик, который прошёл через Harbor/); - assert.match(panel, /Gateway<\/b>\{gatewayTraffic\}<\/strong>/); - assert.match(panel, /className="is-proxy">Прокси<\/b>\{proxyTraffic\}<\/strong>/); + assert.match(panel, /Gateway<\/b>Прокси<\/b>Всего<\/b>\{totalTraffic\}<\/strong>/); + assert.match(panel, /positiveByteDelta\(previous\.gateway, gateway\)[\s\S]*positiveByteDelta\(previous\.proxy, proxy\)/); + assert.match(panel, /setTimeout\(\(\) => setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/); assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|\{trafficLabel\}<\/Tooltip>/); assert.match(panel, /source\?\.traffic\?\.proxy\?\.error/); assert.match(panel, /client-device-pin-wrap[\s\S]*client-device-main[\s\S]*client-device-traffic[\s\S]*client-device-policy-wrap/); @@ -66,16 +71,21 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(styles, /\.client-device \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*padding: 10px 8px/); assert.match(styles, /\.client-device-main \{[\s\S]*display: flex;[\s\S]*align-items: center/); assert.match(styles, /\.client-device-traffic-sources \{[\s\S]*display: grid/); - assert.match(styles, /\.client-device-traffic-sources span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/); + assert.match(styles, /\.client-device-traffic-sources > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/); assert.match(styles, /\.client-device-policy \{[\s\S]*width: 34px;[\s\S]*border-radius: 50%/); assert.match(styles, /\.client-device-name \{[\s\S]*font: 700 15px\/1\.2/); assert.match(styles, /\.client-device-last-seen \{[\s\S]*font-size: 10px/); assert.match(styles, /\.client-device-traffic-sources strong \{[\s\S]*font-size: 10px/); assert.match(styles, /\.client-device-name > span \{[^}]*opacity: 0/); assert.match(styles, /\.client-device-name > \.client-device-name-primary \{[^}]*opacity: 1/); - assert.match(styles, /\.client-device-name:hover \.client-device-name-ip[\s\S]*opacity: 1/); + assert.match(styles, /\.client-device-name > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/); + assert.match(styles, /\.client-device-name:hover \.client-device-name-primary[\s\S]*translateY\(-0\.18em\)/); + assert.match(styles, /\.client-device-name:hover \.client-device-name-ip[\s\S]*opacity: 1[\s\S]*translateY\(0\) scale\(1\)/); assert.match(styles, /\.client-device-name\.is-copied \.client-device-name-feedback[\s\S]*opacity: 1/); - assert.doesNotMatch(styles, /\.client-device-name > span \{[^}]*transition:/); + assert.match(styles, /\.client-device-name-feedback \{[^}]*font-size: 9px/); + assert.match(styles, /\.client-device-main \{[^}]*height: 34px[\s\S]*align-items: center/); + assert.match(styles, /\.client-device-last-seen \{[^}]*height: 32px[\s\S]*align-items: center/); + assert.match(styles, /\.client-device-traffic-value\.has-delta > \.is-total[\s\S]*translateY\(-0\.18em\)/); assert.match(styles, /\.client-device-last-seen\.is-online \{[\s\S]*color: var\(--client-accent\)/); assert.match(styles, /\.client-text-morph-value \{[\s\S]*transition: opacity 360ms[\s\S]*filter 480ms/); assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/); @@ -85,7 +95,7 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(styles, /\.client-device-edit:hover svg[\s\S]*translate\(1px, -1px\) rotate\(-4deg\)/); assert.match(styles, /\.client-devices-sort:hover \.client-devices-sort-icon[\s\S]*rotate\(360deg\)/); assert.match(styles, /\.client-devices-refresh-ring circle[\s\S]*client-devices-refresh-progress 15s/); - assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-text-morph-value/); + assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-policy svg[\s\S]*\.client-device-name > span[\s\S]*\.client-device-traffic-value > span[\s\S]*\.client-text-morph-value/); assert.match(styles, /\.client-drawer \{[\s\S]*z-index: 50;[\s\S]*box-shadow:/); assert.match(styles, /\.harbor-versions \{[\s\S]*z-index: 40/); }); @@ -107,6 +117,9 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni assert.equal(formatByteString('9007199254740993'), '8,0 ПБ'); assert.equal(formatByteString('1536'), '1,5 КБ'); assert.equal(formatByteString('invalid'), '0 Б'); + assert.equal(positiveByteDelta('1048576', '3145728'), '2,0 МБ'); + assert.equal(positiveByteDelta('3145728', '3145728'), ''); + assert.equal(positiveByteDelta('3145728', '1048576'), ''); const devices = [ { id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },