import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { api } from '../api.js'; import { copyText } from '../utils/clientControls.js'; import { byteString, formatByteString, formatLastSeen, positiveByteDelta, stabilizeDevicesByTraffic, trafficAxisMid, trafficScaleRatio, } from '../utils/format.js'; const AUTO_REFRESH_MS = 15_000; const DEVICE_MOVE_MS = 520; const COPY_FEEDBACK_MS = 800; const TRAFFIC_DELTA_MS = 2_200; const TRAFFIC_CHART_HEADROOM = 10; const trafficChartY = (ratio) => 100 - ratio * (100 - TRAFFIC_CHART_HEADROOM); 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}` : ''} ; } function chartTime(value) { return new Date(value).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); } function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) { const [hovered, setHovered] = useState(null); const previousPoints = useRef([]); const previousScale = useRef(scale); const max = samples.reduce((largest, sample) => { const gateway = byteString(sample.gatewayBytes); const proxy = byteString(sample.proxyBytes); return gateway > largest ? gateway : proxy > largest ? proxy : largest; }, 0n); const mid = trafficAxisMid(max, scale); const firstSlot = capacity - samples.length; const points = samples.map((sample, index) => { const gateway = byteString(sample.gatewayBytes); const proxy = byteString(sample.proxyBytes); return { sample, x: (firstSlot + index) * 100 / Math.max(1, capacity - 1), gateway, proxy, gatewayY: trafficChartY(trafficScaleRatio(gateway, max, scale)), proxyY: trafficChartY(trafficScaleRatio(proxy, max, scale)), }; }); const previous = points.slice(0, -1); const penultimate = points.at(-2); const newest = points.at(-1); const hasProxy = points.some(({ proxy }) => proxy > 0n); const scaleFrom = previousPoints.current; const animateScale = previousScale.current !== scale && scaleFrom.length === points.length && !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches); useLayoutEffect(() => { previousPoints.current = points; previousScale.current = scale; }, [points, scale]); function trackPointer(event) { const bounds = event.currentTarget.getBoundingClientRect(); const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1)); const index = slot - firstSlot; if (index < 0 || index >= points.length) { setHovered(null); return; } setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY }); } const tooltip = hovered && typeof document !== 'undefined' && createPortal( Всего {formatByteString(hovered.gateway + hovered.proxy)} {routeLabel} {formatByteString(hovered.gateway)} {hovered.proxy > 0n && Proxy {formatByteString(hovered.proxy)}} , document.body, ); return {pinned && max > 0n && } setHovered(null)}> {samples.length > 0 && } {tooltip} ; } 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 [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 aliasWidth = useRef('1px'); 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], ); 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]); 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(); 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(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(); 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(''); } } 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, width = 1) { const value = device.alias || device.hostname || ''; aliasBaseline.current = { id: device.id, value }; aliasWidth.current = `${width}px`; setEditingId(device.id); setAlias(value); } return ( ); }