From 39f3467f9b0eaccac08d406746a16767af9223d5 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 23:02:18 +0300 Subject: [PATCH] Smooth traffic chart paths and update version --- src/shared/versions.js | 4 +- src/web/components/DevicesPanel.jsx | 57 +++++++++++----------- src/web/styles.css | 20 ++++---- test/web/device-inventory-contract.test.js | 9 ++-- 4 files changed, 47 insertions(+), 43 deletions(-) diff --git a/src/shared/versions.js b/src/shared/versions.js index 15edc3c..a978a8a 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.17.15', - gatewayClient: '0.18.15', + macClient: '0.17.16', + gatewayClient: '0.18.16', gatewayBackend: '0.18.1', }); diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 96044be..702258b 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -45,6 +45,15 @@ function chartTime(value) { }); } +function smoothTrafficPath(points, valueKey) { + if (!points.length) return ''; + return points.slice(1).reduce((path, point, index) => { + const previous = points[index]; + const midX = (previous.x + point.x) / 2; + return `${path} C ${midX},${previous[valueKey]} ${midX},${point[valueKey]} ${point.x},${point[valueKey]}`; + }, `M ${points[0].x},${points[0][valueKey]}`); +} + function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { const [hovered, setHovered] = useState(null); const previousPoints = useRef([]); @@ -132,52 +141,44 @@ function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { } - {previous.length > 0 && `${x},${gatewayY}`).join(' ')}> + {previous.length > 0 && {animateScale && `${x},${gatewayY}`).join(' ')} - to={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')} + attributeName="d" + from={smoothTrafficPath(scaleFrom.slice(0, -1), 'gatewayY')} + to={smoothTrafficPath(previous, 'gatewayY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />} - } - {hasProxy && previous.length > 0 && `${x},${proxyY}`).join(' ')}> + } + {hasProxy && previous.length > 0 && {animateScale && `${x},${proxyY}`).join(' ')} - to={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')} + attributeName="d" + from={smoothTrafficPath(scaleFrom.slice(0, -1), 'proxyY')} + to={smoothTrafficPath(previous, 'proxyY')} dur="520ms" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze" />} - } - {penultimate && newest && - {animateScale && <> - - - } - } - {hasProxy && penultimate && newest && - {animateScale && <> - - - } - } - {!penultimate && newest && - {animateScale && } - } + } + {penultimate && newest && + {animateScale && } + } + {hasProxy && penultimate && newest && + {animateScale && } + } + {!penultimate && newest && } {hovered && - - - {hovered.proxy > 0n && } + + + {hovered.proxy > 0n && } } diff --git a/src/web/styles.css b/src/web/styles.css index 9d815e4..11c4e68 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -1451,7 +1451,7 @@ p { animation: client-device-traffic-shift 560ms cubic-bezier(0.16, 1, 0.3, 1); } -.client-device-traffic-lines polyline, +.client-device-traffic-lines path, .client-device-traffic-lines line { fill: none; stroke: color-mix(in oklch, var(--client-text) 72%, var(--client-muted)); @@ -1465,32 +1465,32 @@ p { stroke: var(--client-accent); } -.client-device-traffic-lines line.is-new { +.client-device-traffic-lines path.is-new { stroke-dasharray: 1; stroke-dashoffset: 1; animation: client-device-traffic-line-draw 620ms cubic-bezier(0.16, 1, 0.3, 1) forwards; } -.client-device-traffic-lines circle { - fill: color-mix(in oklch, var(--client-text) 72%, var(--client-muted)); - vector-effect: non-scaling-stroke; +.client-device-traffic-lines .is-point { + stroke-width: 3; + stroke-linecap: round; } -.client-device-traffic-cursor line { +.client-device-traffic-cursor .is-guide { stroke: color-mix(in oklch, var(--client-text) 38%, transparent); stroke-width: 1; stroke-dasharray: 2 3; vector-effect: non-scaling-stroke; } -.client-device-traffic-cursor circle { - fill: var(--client-bg); +.client-device-traffic-cursor .is-point { stroke: color-mix(in oklch, var(--client-text) 82%, var(--client-muted)); - stroke-width: 1.5; + stroke-width: 4; + stroke-linecap: round; vector-effect: non-scaling-stroke; } -.client-device-traffic-cursor circle.is-proxy { +.client-device-traffic-cursor .is-point.is-proxy { stroke: var(--client-accent); } diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index 7cbf9de..900d88a 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -75,7 +75,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /TrafficChart[\s\S]*samples=\{device\.trafficHistory \|\| \[\]\}[\s\S]*scale=\{trafficScale\}[\s\S]*capacity=\{snapshot\.trafficHistoryCapacity/); assert.doesNotMatch(panel, /setTrafficHistory|TRAFFIC_HISTORY_LIMIT/); assert.match(panel, /aria-pressed=\{trafficScale === 'linear'\}[\s\S]*aria-pressed=\{trafficScale === 'log'\}/); - assert.match(panel, /previousScale\.current !== scale[\s\S]*attributeName="points"[\s\S]*dur="520ms"/); + assert.match(panel, /previousScale\.current !== scale[\s\S]*attributeName="d"[\s\S]*dur="520ms"/); + assert.match(panel, /function smoothTrafficPath[\s\S]*const midX = \(previous\.x \+ point\.x\) \/ 2[\s\S]* C /); assert.match(panel, /TRAFFIC_CHART_HEADROOM = 10[\s\S]*trafficChartY = \(ratio\) => 100 - ratio \* \(100 - TRAFFIC_CHART_HEADROOM\)/); assert.match(panel, /gatewayY: trafficChartY\(trafficScaleRatio\(gateway, max, scale\)\)[\s\S]*proxyY: trafficChartY\(trafficScaleRatio\(proxy, max, scale\)\)/); assert.match(panel, /client-device-traffic-grid[\s\S]*y1=\{TRAFFIC_CHART_HEADROOM\}[\s\S]*y1=\{\(100 \+ TRAFFIC_CHART_HEADROOM\) \/ 2\}/); @@ -84,7 +85,8 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /createPortal\([\s\S]*client-device-traffic-point-tooltip[\s\S]*document\.body/); assert.match(panel, /onPointerMove=\{trackPointer\}/); assert.match(panel, /pinned && max > 0n && 0n && Proxy \{formatByteString\(hovered\.proxy\)\}<\/span>\}/); assert.match(panel, /