import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { api } from '../api.js'; import { byteString, formatByteString, formatLastSeen, sortDevicesByTraffic, } from '../utils/format.js'; const AUTO_REFRESH_MS = 15_000; const DEVICE_MOVE_MS = 520; function Tooltip({ children }) { return {children}; } function TextMorph({ from, to }) { const anchor = from.length >= to.length ? from : to; return ; } export function DevicesPanel({ open, panelRef, closeRef, onClose }) { const [snapshot, setSnapshot] = useState(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); const [editingId, setEditingId] = useState(''); const [alias, setAlias] = useState(''); const [savingId, setSavingId] = useState(''); const [refreshing, setRefreshing] = useState(false); const [refreshCycle, setRefreshCycle] = useState(0); const [sortDirection, setSortDirection] = useState('desc'); const deviceNodes = useRef(new Map()); const previousPositions = useRef(new Map()); const devices = useMemo( () => sortDevicesByTraffic(snapshot?.devices, sortDirection), [snapshot?.devices, sortDirection], ); async function load(quiet = false, discover = false) { if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading'); setRefreshing(true); try { const next = await (discover ? api.devices.refresh() : api.devices.list()); setSnapshot((current) => !current || next.revision >= current.revision ? next : current); setError(null); setStatus('ready'); } catch (requestError) { setError(requestError); setStatus('error'); } finally { setRefreshing(false); setRefreshCycle((cycle) => cycle + 1); } } useEffect(() => { if (!open) return undefined; load(); return undefined; }, [open]); useEffect(() => { if (!open || refreshing || status === 'loading') return undefined; const timer = setTimeout(() => load(true), AUTO_REFRESH_MS); return () => clearTimeout(timer); }, [open, refreshCycle, refreshing, status]); useLayoutEffect(() => { if (!open) { previousPositions.current.clear(); return; } const positions = new Map(); for (const [id, node] of deviceNodes.current) { node.getAnimations().forEach((animation) => animation.cancel()); positions.set(id, node.getBoundingClientRect()); } if (!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 : 0; if (Math.abs(deltaY) < 1) continue; 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)' }); } } previousPositions.current = positions; }, [devices, open]); 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(); setSnapshot((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); } setSnapshot((current) => !current || next.revision >= current.revision ? next : current); setError(null); return true; } catch (requestError) { setError(requestError); return false; } finally { setSavingId(''); } } async function saveAlias(event, device) { event.preventDefault(); if (!await updateDevice(device, { alias })) return; setEditingId(''); } 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(); setSnapshot((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); } setSnapshot((current) => !current || next.revision >= current.revision ? next : current); setError(null); } catch (requestError) { if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') { try { const latest = await api.devices.list(); setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current); } catch { // Keep the policy error as the actionable result. } } setError(requestError); } finally { setSavingId(''); } } return ( ); }