From 3a4173db43723991aa565bca437835fcef62dccd Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sun, 9 Aug 2026 23:58:02 +0300 Subject: [PATCH] Animate device pin collapse and traffic chart updates --- src/shared/versions.ts | 4 +- src/web/features/devices/DevicesPanel.tsx | 61 +++++++++++--- src/web/features/devices/TrafficChart.tsx | 84 ++++++++++++++----- src/web/styles/features/devices.css | 95 +++++++++++++--------- src/web/styles/themes.css | 5 -- test/web/device-inventory-contract.test.js | 15 ++-- test/web/style-boundaries.test.js | 30 +++---- 7 files changed, 192 insertions(+), 102 deletions(-) diff --git a/src/shared/versions.ts b/src/shared/versions.ts index 10ecf0f..1818129 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.21.6', - gatewayClient: '0.22.1', + macClient: '0.21.7', + gatewayClient: '0.22.2', gatewayBackend: '0.22.5', }); diff --git a/src/web/features/devices/DevicesPanel.tsx b/src/web/features/devices/DevicesPanel.tsx index d41830b..e99e04e 100644 --- a/src/web/features/devices/DevicesPanel.tsx +++ b/src/web/features/devices/DevicesPanel.tsx @@ -29,6 +29,11 @@ interface TrafficDelta { total?: string; } +interface PinCollapse { + animationDone: boolean; + result: 'pending' | 'saved' | 'failed'; +} + function requestMessage(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const message: unknown = Reflect.get(value, 'message'); @@ -79,9 +84,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null); const [pencilAnimationId, setPencilAnimationId] = useState(''); const [trafficDeltas, setTrafficDeltas] = useState>({}); + const [pinCollapses, setPinCollapses] = useState>({}); const deviceNodes = useRef(new Map()); const previousPositions = useRef(new Map()); - const previousOrder = useRef([]); const previousScrollTop = useRef(0); const movementAnimations = useRef(new Map()); const previousTraffic = useRef(new Map()); @@ -144,7 +149,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { useLayoutEffect(() => { if (!open) { previousPositions.current.clear(); - previousOrder.current = []; previousScrollTop.current = 0; for (const animation of movementAnimations.current.values()) animation.cancel(); movementAnimations.current.clear(); @@ -155,12 +159,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { movementAnimations.current.get(id)?.cancel(); positions.set(id, node.getBoundingClientRect()); } - const order = devices.map(({ id }) => id); - const orderChanged = previousOrder.current.length > 0 - && (order.length !== previousOrder.current.length - || order.some((id, index) => id !== previousOrder.current[index])); const currentScrollTop = panelRef.current?.scrollTop || 0; - if (orderChanged && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + if (previousPositions.current.size > 0 + && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { for (const [id, after] of positions) { const before = previousPositions.current.get(id); const deltaY = before @@ -178,9 +179,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { } } previousPositions.current = positions; - previousOrder.current = order; previousScrollTop.current = currentScrollTop; - }, [devices, open, panelRef]); + }, [devices, open, panelRef, pinCollapses]); async function saveAlias(device: Device) { const nextAlias = alias.trim(); @@ -233,6 +233,39 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) { setAlias(value); } + async function togglePin(device: Device) { + if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + await updateDevice(device, { pinned: !device.pinned }); + return; + } + setPinCollapses((current) => ({ + ...current, + [device.id]: { animationDone: false, result: 'pending' }, + })); + const saved = await updateDevice(device, { pinned: false }); + setPinCollapses((current) => { + const phase = current[device.id]; + if (!phase) return current; + if (!phase.animationDone) { + return { ...current, [device.id]: { ...phase, result: saved ? 'saved' : 'failed' } }; + } + const next = { ...current }; + delete next[device.id]; + return next; + }); + } + + function finishPinCollapse(deviceId: string) { + setPinCollapses((current) => { + const phase = current[deviceId]; + if (!phase) return current; + if (phase.result === 'pending') return { ...current, [deviceId]: { ...phase, animationDone: true } }; + const next = { ...current }; + delete next[deviceId]; + return next; + }); + } + return (