diff --git a/src/shared/versions.js b/src/shared/versions.js index ec13bd6..c3391f2 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -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', }); diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 8c5155c..53bb530 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -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 {children}; @@ -34,6 +36,27 @@ function TrafficValue({ value, delta }) { ; } +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 ; +} + 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 }) { - - + ; })} diff --git a/src/web/styles.css b/src/web/styles.css index ab8b2c1..46b3a87 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -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, diff --git a/src/web/utils/format.js b/src/web/utils/format.js index 022dd41..6d0712d 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -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 : []) diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index e28dda9..037ae42 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -8,6 +8,7 @@ import { formatLastSeen, positiveByteDelta, sortDevicesByTraffic, + trafficSampleMetrics, } from '../../src/web/utils/format.js'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -48,9 +49,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>Прокси<\/b>Всего<\/b>\{totalTraffic\}<\/strong>/); + assert.match(panel, /client-device-traffic-total[\s\S]*Всего<\/b>Gateway<\/b>Прокси<\/b> setTrafficDeltas\(\{\}\), TRAFFIC_DELTA_MS\)/); assert.doesNotMatch(panel, /client-device-traffic client-tooltip-anchor|\{trafficLabel\}<\/Tooltip>/); @@ -68,14 +71,17 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.doesNotMatch(panel, /Закрепите устройство, чтобы изменить маршрут|Сначала верните маршрут через Gateway/); assert.match(panel, /maxLength="64"[\s\S]*autoFocus/); assert.match(styles, /\.client-devices \{\s*width: min\(580px, 100vw\)/); - 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 \{[\s\S]*grid-template-columns: 34px minmax\(0, 1fr\) 112px 34px;[\s\S]*grid-template-rows: 34px 28px;[\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-total,[\s\S]*\.client-device-traffic-breakdown > span \{[\s\S]*grid-template-columns: 46px minmax\(0, 1fr\)/); + assert.match(styles, /\.client-device-traffic:hover \.client-device-traffic-breakdown,[\s\S]*opacity: 1[\s\S]*translateY\(0\)/); + assert.match(styles, /\.client-device-traffic-chart \{[\s\S]*grid-column: 2 \/ 4;[\s\S]*grid-row: 2/); + assert.match(styles, /\.client-device-traffic-track \{[\s\S]*client-device-traffic-shift 420ms/); + assert.match(styles, /@keyframes client-device-traffic-shift[\s\S]*translateX\(8px\)[\s\S]*translateX\(0\)/); 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-traffic 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 > span \{[^}]*transition: opacity 360ms[^}]*transform 480ms/); @@ -95,7 +101,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-device-name > span[\s\S]*\.client-device-traffic-value > span[\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-device-traffic-breakdown[\s\S]*\.client-device-traffic-track[\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/); }); @@ -120,6 +126,9 @@ test('device traffic formatting and sorting preserve uint64 precision and canoni assert.equal(positiveByteDelta('1048576', '3145728'), '2,0 МБ'); assert.equal(positiveByteDelta('3145728', '3145728'), ''); assert.equal(positiveByteDelta('3145728', '1048576'), ''); + assert.deepEqual(trafficSampleMetrics('75', '25', '200'), { height: 50, gatewayShare: 75 }); + assert.deepEqual(trafficSampleMetrics('1', '0', '1000'), { height: 10, gatewayShare: 100 }); + assert.deepEqual(trafficSampleMetrics('0', '0', '0'), { height: 0, gatewayShare: 0 }); const devices = [ { id: 'a', uploadBytes: '9007199254740993', downloadBytes: '0', proxyUploadBytes: '0' },