import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, } from 'react'; import { Drawer } from '../../ui/Drawer.js'; import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js'; import { Tooltip } from '../../ui/Tooltip.js'; import { copyText } from '../../utils/clientControls.js'; import { byteString, formatByteString, formatLastSeen, positiveByteDelta, stabilizeDevicesByTraffic, } from '../../utils/format.js'; import { TrafficChart } from './TrafficChart.js'; import { type Device } from './deviceSnapshot.js'; import type { DevicesFeature } from './DevicesFeature.js'; const DEVICE_MOVE_MS = 520; const COPY_FEEDBACK_MS = 800; const TRAFFIC_DELTA_MS = 2_200; interface TrafficDelta { gateway?: string; proxy?: string; total?: string; vpn?: string; direct?: string; unknown?: string; outboundTotal?: string; } interface TrafficTotals { gateway: bigint; proxy: bigint; vpn: bigint; direct: bigint; unknown: bigint; } interface PinCollapse { animationDone: boolean; result: 'pending' | 'saved' | 'failed'; } type DeviceCopyField = 'IP' | 'MAC' | 'Hostname'; function requestMessage(value: unknown) { if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; const message: unknown = Reflect.get(value, 'message'); return typeof message === 'string' ? message : undefined; } function TextMorph({ from, to }: { from: string; to: string }) { const anchor = from.length >= to.length ? from : to; return ; } function TrafficValue({ value, delta }: { value: string; delta?: string }) { return {value} {delta ? `+${delta}` : ''} ; } export function DevicesPanel({ feature }: { feature: DevicesFeature }) { const { isOpen: open, panelRef, closeRef, snapshot, status, error, refreshing, refreshCycle, savingId, resetOpen, resetting, load: onLoad, updateDevice, updatePolicy, requestTrafficReset, cancelTrafficReset, confirmResetTraffic, close: onClose, } = feature; const [editingId, setEditingId] = useState(''); const [alias, setAlias] = useState(''); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear'); const [trafficView, setTrafficView] = useState<'outbound' | 'inbound'>('outbound'); const [copyFeedback, setCopyFeedback] = useState>({}); 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 previousScrollTop = useRef(0); const movementAnimations = useRef(new Map()); const previousTraffic = useRef(new Map()); const aliasBaseline = useRef({ id: '', value: '' }); const copyTimers = useRef(new Map>()); const copyAttempts = useRef(new Map()); const trafficDeltaTimer = useRef | null>(null); const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] }); const devices = useMemo( () => { const previousIds = trafficOrder.current.direction === sortDirection ? trafficOrder.current.ids : []; const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds) as { ids: string[]; devices: Device[]; }; trafficOrder.current = { direction: sortDirection, ids: result.ids }; return result.devices; }, [snapshot?.devices, sortDirection], ); useEffect(() => () => { for (const timer of copyTimers.current.values()) clearTimeout(timer); copyTimers.current.clear(); copyAttempts.current.clear(); if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current); }, []); useEffect(() => { if (!open) { previousTraffic.current.clear(); if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current); setTrafficDeltas({}); return; } const next = new Map(); const deltas: Record = {}; for (const device of snapshot?.devices || []) { const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes); const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes); const vpn = byteString(device.outboundTraffic?.vpnBytes); const direct = byteString(device.outboundTraffic?.directTrackedBytes) + byteString(device.outboundTraffic?.directIpv4Bytes); const unknown = byteString(device.outboundTraffic?.unknownBytes); const previous = previousTraffic.current.get(device.id); next.set(device.id, { gateway, proxy, vpn, direct, unknown }); if (!previous) continue; const gatewayDelta = positiveByteDelta(previous.gateway, gateway); const proxyDelta = positiveByteDelta(previous.proxy, proxy); const vpnDelta = positiveByteDelta(previous.vpn, vpn); const directDelta = positiveByteDelta(previous.direct, direct); const unknownDelta = positiveByteDelta(previous.unknown, unknown); if (!gatewayDelta && !proxyDelta && !vpnDelta && !directDelta && !unknownDelta) continue; const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy); const outboundTotalDelta = positiveByteDelta( previous.vpn + previous.direct + previous.unknown, vpn + direct + unknown, ); deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta, vpn: vpnDelta, direct: directDelta, unknown: unknownDelta, outboundTotal: outboundTotalDelta, }; } previousTraffic.current = next; if (!Object.keys(deltas).length) return; setTrafficDeltas(deltas); if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current); trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS); }, [snapshot?.devices, open]); useLayoutEffect(() => { if (!open) { previousPositions.current.clear(); 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 currentScrollTop = panelRef.current?.scrollTop || 0; 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 ? 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; previousScrollTop.current = currentScrollTop; }, [devices, open, panelRef, pinCollapses]); async function saveAlias(device: 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 copyDeviceValue(device: Device, field: DeviceCopyField, value: string) { const activeTimer = copyTimers.current.get(device.id); if (activeTimer) clearTimeout(activeTimer); const attempt = {}; copyAttempts.current.set(device.id, attempt); let feedback: { failed: boolean }; try { await copyText(value); feedback = { failed: false }; } catch { feedback = { failed: true }; } if (copyAttempts.current.get(device.id) !== attempt) return; const announcement = { id: device.id, message: feedback.failed ? `Не удалось скопировать ${field} ${value}` : `${field} ${value} скопирован`, }; const pendingTimer = copyTimers.current.get(device.id); if (pendingTimer) clearTimeout(pendingTimer); setCopyFeedback((current) => ({ ...current, [device.id]: { ...feedback, field } })); setCopyAnnouncement(announcement); copyTimers.current.set(device.id, setTimeout(() => { setCopyFeedback((current) => { const next = { ...current }; delete next[device.id]; return next; }); setCopyAnnouncement((current) => current?.id === device.id ? null : current); copyTimers.current.delete(device.id); copyAttempts.current.delete(device.id); }, COPY_FEEDBACK_MS)); } function startEditing(device: Device) { const value = device.alias || device.hostname || ''; aliasBaseline.current = { id: device.id, value }; setEditingId(device.id); setAlias(value); } async function collapsePinned(device: Device, patch: Record) { setPinCollapses((current) => ({ ...current, [device.id]: { animationDone: false, result: 'pending' }, })); const saved = await updateDevice(device, patch); 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; }); } async function togglePin(device: Device) { if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { await updateDevice(device, { pinned: !device.pinned }); return; } await collapsePinned(device, { pinned: false }); } async function toggleDeprioritized(device: Device) { const deprioritized = device.deprioritized === true; if (!device.pinned || deprioritized || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { await updateDevice(device, { deprioritized: !deprioritized }); return; } await collapsePinned(device, { deprioritized: true }); } 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 <>
Gateway · {devices.length} {refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'} Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика Обнулить трафик устройств

Устройства

Устройства, которые Gateway видит в локальной таблице соседей.

Вход — накопленные Gateway/Proxy. Выход — приблизительно через VPN или Direct с запуска текущего учёта.

{Boolean(snapshot?.source?.error) && (

Список временно не обновляется. Показаны последние сохранённые данные.

)} {Boolean(snapshot?.source?.traffic?.error) && (

Трафик временно не обновляется. Показаны последние сохранённые значения.

)} {Boolean(snapshot?.source?.traffic?.proxy?.error) && (

Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.

)} {Boolean(snapshot?.source?.policy?.error) && (

Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.

)} {Boolean(error) && (
{requestMessage(error)}
)} {status === 'loading' &&

Ищем устройства…

} {status !== 'loading' && !devices.length && !error && (

Gateway пока не видит устройств.

)}
{copyAnnouncement?.message || ''}
{devices.map((device, index) => { const collapsing = Boolean(pinCollapses[device.id]); const deprioritized = device.deprioritized === true; const compact = deprioritized && !collapsing; const expanded = device.pinned && !collapsing; const showDetails = !compact && (device.pinned || collapsing); const group = device.pinned ? 'pinned' : deprioritized ? 'deprioritized' : 'default'; const previous = devices[index - 1]; const previousGroup = previous ? previous.pinned ? 'pinned' : previous.deprioritized === true ? 'deprioritized' : 'default' : ''; const groupLabel = group === 'pinned' ? 'Закреплённые' : group === 'deprioritized' ? 'Фоновые' : 'Остальные'; const groupStart = group !== previousGroup; const hasName = Boolean(device.alias || device.hostname); const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство'; const editing = editingId === device.id; const saving = savingId === device.id; const seen = formatLastSeen(device.lastSeenAt); const online = device.status === 'online'; const gatewayTotal = byteString(device.downloadBytes) + byteString(device.uploadBytes); const proxyTotal = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes); const gatewayTraffic = formatByteString(gatewayTotal.toString()); const proxyTraffic = formatByteString(proxyTotal.toString()); const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString()); const hasProxyTraffic = proxyTotal > 0n; const outboundAvailable = Boolean( device.outboundTraffic?.singboxObservedAt || device.outboundTraffic?.directIpv4ObservedAt, ); const vpnTotal = byteString(device.outboundTraffic?.vpnBytes); const directTrackedTotal = byteString(device.outboundTraffic?.directTrackedBytes); const directIpv4Total = byteString(device.outboundTraffic?.directIpv4Bytes); const directTotal = directTrackedTotal + directIpv4Total; const unknownTotal = byteString(device.outboundTraffic?.unknownBytes); const outboundTotal = vpnTotal + directTotal + unknownTotal; const displayedTotal = trafficView === 'outbound' ? outboundAvailable ? `≈ ${formatByteString(outboundTotal.toString())}` : '—' : totalTraffic; const trafficLabel = trafficView === 'outbound' ? 'Выход' : 'Вход'; const trafficAriaLabel = trafficView === 'outbound' ? outboundAvailable ? `Выход с запуска текущего учёта: примерно ${formatByteString(outboundTotal.toString())}. VPN ${formatByteString(vpnTotal.toString())}, Direct примерно ${formatByteString(directTotal.toString())}${unknownTotal > 0n ? `, неизвестно ${formatByteString(unknownTotal.toString())}` : ''}` : 'Выход: ожидаем первые данные' : `Вход накоплен: ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`; const trafficDelta = trafficDeltas[device.id] || {}; const feedback = copyFeedback[device.id]; const policyBusy = device.policyStatus === 'applying'; const policyFailed = device.policyStatus === 'failed'; const policyPending = device.policyStatus === 'pending'; const displayPolicy = device.appliedPolicy; const policyTarget = device.policyStatus === 'applied' ? device.appliedPolicy === 'direct' ? 'vpn' : 'direct' : device.appliedPolicy; const cannotEnableDirect = device.policyStatus === 'applied' && device.appliedPolicy !== 'direct' && device.confidence === 'ambiguous'; const policyTooltip = policyBusy ? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}` : policyFailed ? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут` : policyPending ? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание' : device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct' ? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства' : displayPolicy === 'direct' ? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway' : 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую'; return
{ if (node) deviceNodes.current.set(device.id, node); else deviceNodes.current.delete(device.id); }} className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}${compact ? ' is-deprioritized' : ''}${groupStart ? ' is-group-start' : ''}`} > {groupStart ? groupLabel : ''} {!compact && {device.pinned ? 'Открепить' : 'Закрепить'} }

{editing ? ( setAlias(event.target.value)} onBlur={() => saveAlias(device)} onKeyDown={(event) => { if (event.key === 'Enter') event.currentTarget.blur(); }} /> ) : hasName && } {!hasName && !editing && {title}} {!editing && {device.ip && } {device.hostname && } }

{!hasName && !editing && Изменить название }
{!compact && } {!compact && {policyTooltip} } {deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'} {!compact && finishPinCollapse(device.id)} />}
; })}
; }