import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import { copyText } from '../utils/clientControls.js'; import { byteString, formatByteString, formatLastSeen, positiveByteDelta, stabilizeDevicesByTraffic, } from '../utils/format.js'; import { TrafficChart } from './TrafficChart.jsx'; const DEVICE_MOVE_MS = 520; const COPY_FEEDBACK_MS = 800; const TRAFFIC_DELTA_MS = 2_200; function Tooltip({ children }) { return {children}; } function TextMorph({ from, to }) { const anchor = from.length >= to.length ? from : to; return ; } function TrafficValue({ value, delta }) { return {value} {delta ? `+${delta}` : ''} ; } export function DevicesPanel({ open, panelRef, closeRef, onClose, snapshot, status, error, refreshing, refreshCycle, onLoad, onSnapshot, onError, }) { const [editingId, setEditingId] = useState(''); const [alias, setAlias] = useState(''); const [savingId, setSavingId] = useState(''); const [sortDirection, setSortDirection] = useState('desc'); const [trafficScale, setTrafficScale] = useState('linear'); const [copyFeedback, setCopyFeedback] = useState(null); const [pencilAnimationId, setPencilAnimationId] = useState(''); const [trafficDeltas, setTrafficDeltas] = 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()); const aliasBaseline = useRef({ id: '', value: '' }); const copyTimer = useRef(null); const trafficDeltaTimer = useRef(null); const trafficOrder = useRef({ direction: sortDirection, ids: [] }); const devices = useMemo( () => { const previousIds = trafficOrder.current.direction === sortDirection ? trafficOrder.current.ids : []; const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds); trafficOrder.current = { direction: sortDirection, ids: result.ids }; return result.devices; }, [snapshot?.devices, sortDirection], ); 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) continue; const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy); deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta }; } 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) { previousPositions.current.clear(); previousOrder.current = []; previousScrollTop.current = 0; for (const animation of movementAnimations.current.values()) animation.cancel(); movementAnimations.current.clear(); return; } const positions = new Map(); for (const [id, node] of deviceNodes.current) { 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) { for (const [id, after] of positions) { const before = previousPositions.current.get(id); const deltaY = before ? before.top - after.top + previousScrollTop.current - currentScrollTop : 0; if (Math.abs(deltaY) < 1) continue; const animation = deviceNodes.current.get(id)?.animate([ { transform: `translateY(${deltaY}px)` }, { transform: 'translateY(0)' }, ], { duration: DEVICE_MOVE_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }); if (animation) { movementAnimations.current.set(id, animation); animation.onfinish = () => movementAnimations.current.delete(id); } } } previousPositions.current = positions; previousOrder.current = order; previousScrollTop.current = currentScrollTop; }, [devices, open, panelRef]); async function updateDevice(device, patch) { setSavingId(device.id); try { let next; try { next = await api.devices.update(device.id, patch, snapshot.revision); } catch (requestError) { if (requestError.code !== 'STATE_CONFLICT') throw requestError; const latest = await api.devices.list(); onSnapshot((current) => !current || latest.revision > current.revision ? latest : current); const latestDevice = latest.devices.find((candidate) => candidate.id === device.id); if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) { throw requestError; } next = await api.devices.update(device.id, patch, latest.revision); } onSnapshot((current) => !current || next.revision > current.revision ? next : current); onError(null); return true; } catch (requestError) { onError(requestError); return false; } finally { setSavingId(''); } } async function saveAlias(device) { const nextAlias = alias.trim(); if (aliasBaseline.current.id === device.id && nextAlias === aliasBaseline.current.value.trim()) { setEditingId((current) => current === device.id ? '' : current); return; } if (!await updateDevice(device, { alias: nextAlias })) return; setEditingId((current) => current === device.id ? '' : current); } async function updatePolicy(device, mode) { setSavingId(device.id); try { let next; try { next = await api.devices.setPolicy(device.id, mode, snapshot.revision); } catch (requestError) { if (requestError.code !== 'STATE_CONFLICT') throw requestError; const latest = await api.devices.list(); onSnapshot((current) => !current || latest.revision > current.revision ? latest : current); const latestDevice = latest.devices.find((candidate) => candidate.id === device.id); if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError; next = await api.devices.setPolicy(device.id, mode, latest.revision); } onSnapshot((current) => !current || next.revision > current.revision ? next : current); onError(null); } catch (requestError) { if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') { try { const latest = await api.devices.list(); onSnapshot((current) => !current || latest.revision > current.revision ? latest : current); } catch { // Keep the policy error as the actionable result. } } onError(requestError); } finally { setSavingId(''); } } async function copyDeviceIp(device) { if (!device.ip) return; clearTimeout(copyTimer.current); try { await copyText(device.ip); setCopyFeedback({ id: device.id, failed: false }); } catch { setCopyFeedback({ id: device.id, failed: true }); } copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS); } function startEditing(device) { const value = device.alias || device.hostname || ''; aliasBaseline.current = { id: device.id, value }; setEditingId(device.id); setAlias(value); } return ( ); }