import { useEffect, useRef, useState } from 'react'; import { RailAction } from '../../ui/RailAction.js'; import { formatByteString, formatLastSeen, trafficBytesPerSecond, } from '../../utils/format.js'; import { TrafficChart } from './TrafficChart.js'; import { parseDeviceSnapshot, type Device, type DevicePolicy, type DeviceSnapshot, } from './deviceSnapshot.js'; const DEVICE_AUTO_REFRESH_MS = 15_000; interface DevicesFeatureOptions { isGateway: boolean; listDevices: () => Promise; refreshDevices: () => Promise; resetDeviceTraffic: (expectedRevision: number) => Promise; updateDevice: (id: string, patch: Record, expectedRevision: number) => Promise; setDevicePolicy: (id: string, mode: DevicePolicy, expectedRevision: number) => Promise; } interface RequestError { code?: string; } function record(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function requestError(value: unknown): RequestError { if (!record(value)) return {}; return { code: typeof value.code === 'string' ? value.code : undefined }; } export function useDevicesFeature({ isGateway, listDevices, refreshDevices, resetDeviceTraffic, updateDevice: requestDeviceUpdate, setDevicePolicy, }: DevicesFeatureOptions) { const [isOpen, setIsOpen] = useState(false); const [snapshot, setSnapshot] = useState(null); const [status, setStatus] = useState<'idle' | 'loading' | 'refreshing' | 'ready' | 'error'>('idle'); const [error, setError] = useState(null); const [refreshing, setRefreshing] = useState(false); const [refreshCycle, setRefreshCycle] = useState(0); const [savingId, setSavingId] = useState(''); const [resetOpen, setResetOpen] = useState(false); const [resetting, setResetting] = useState(false); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(null); function publish(value: unknown) { const next = parseDeviceSnapshot(value); setSnapshot((current) => !current || next.revision > current.revision ? next : current); return next; } async function load(quiet = false, discover = false) { if (!isGateway) return; if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading'); setRefreshing(true); try { publish(await (discover ? refreshDevices() : listDevices())); setError(null); setStatus('ready'); } catch (requestError) { setError(requestError); setStatus('error'); } finally { setRefreshing(false); setRefreshCycle((cycle) => cycle + 1); } } async function updateDevice(device: Device, patch: Record) { if (!snapshot) return false; setSavingId(device.id); try { let next: DeviceSnapshot; try { next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, snapshot.revision)); } catch (caught) { if (requestError(caught).code !== 'STATE_CONFLICT') throw caught; const latest = parseDeviceSnapshot(await listDevices()); publish(latest); const latestDevice = latest.devices.find((candidate) => candidate.id === device.id); if (!latestDevice || Object.keys(patch).some((key) => latestDevice[key] !== device[key])) { throw caught; } next = parseDeviceSnapshot(await requestDeviceUpdate(device.id, patch, latest.revision)); } publish(next); setError(null); return true; } catch (caught) { setError(caught); return false; } finally { setSavingId(''); } } async function updatePolicy(device: Device, mode: DevicePolicy) { if (!snapshot) return; setSavingId(device.id); try { let next: DeviceSnapshot; try { next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, snapshot.revision)); } catch (caught) { if (requestError(caught).code !== 'STATE_CONFLICT') throw caught; const latest = parseDeviceSnapshot(await listDevices()); publish(latest); const latestDevice = latest.devices.find((candidate) => candidate.id === device.id); if (!latestDevice || latestDevice.desiredPolicy !== device.desiredPolicy) throw caught; next = parseDeviceSnapshot(await setDevicePolicy(device.id, mode, latest.revision)); } publish(next); setError(null); } catch (caught) { if (requestError(caught).code === 'DEVICE_POLICY_APPLY_FAILED') { try { publish(parseDeviceSnapshot(await listDevices())); } catch { // Keep the policy error as the actionable result. } } setError(caught); } finally { setSavingId(''); } } async function confirmResetTraffic() { if (!snapshot) return; setResetting(true); try { let next: DeviceSnapshot; try { next = parseDeviceSnapshot(await resetDeviceTraffic(snapshot.revision)); } catch (caught) { if (requestError(caught).code !== 'STATE_CONFLICT') throw caught; const latest = parseDeviceSnapshot(await listDevices()); publish(latest); next = parseDeviceSnapshot(await resetDeviceTraffic(latest.revision)); } publish(next); setError(null); setResetOpen(false); } catch (caught) { setError(caught); } finally { setResetting(false); } } useEffect(() => { if (!isGateway) return undefined; load(); return undefined; }, [isGateway]); useEffect(() => { if (!isGateway || refreshing || status === 'loading') return undefined; const timer = setTimeout(() => load(true), DEVICE_AUTO_REFRESH_MS); return () => clearTimeout(timer); }, [isGateway, refreshCycle, refreshing, status]); useEffect(() => { if (!isOpen) return undefined; const frame = requestAnimationFrame(() => closeRef.current?.focus()); const closeDevices = (event: PointerEvent | KeyboardEvent) => { if (resetOpen) return; if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return; if (event.type !== 'keydown' && ( panelRef.current?.contains(event.target as Node) || toggleRef.current?.contains(event.target as Node) )) return; setIsOpen(false); }; document.addEventListener('pointerdown', closeDevices); document.addEventListener('keydown', closeDevices); return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', closeDevices); document.removeEventListener('keydown', closeDevices); requestAnimationFrame(() => { if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus(); }); }; }, [isOpen, resetOpen]); return { isOpen, snapshot, status, error, refreshing, refreshCycle, savingId, resetOpen, resetting, panelRef, toggleRef, closeRef, load, updateDevice, updatePolicy, requestTrafficReset: () => setResetOpen(true), cancelTrafficReset: () => setResetOpen(false), confirmResetTraffic, close: () => setIsOpen(false), toggle: () => setIsOpen((open) => !open), }; } export type DevicesFeature = ReturnType; export function DevicesToggle({ feature, open, onToggle, }: { feature: DevicesFeature; open: boolean; onToggle: () => void; }) { return ; } export function GatewayTrafficSummary({ feature, now }: { feature: DevicesFeature; now: number }) { const globalTraffic = feature.snapshot?.traffic; const history = globalTraffic?.history || []; const latest = history.at(-1); const previous = history.at(-2); const hasDirectionalRate = latest && previous && typeof latest.downloadBytes === 'string' && typeof latest.uploadBytes === 'string'; const downloadRate = hasDirectionalRate ? trafficBytesPerSecond(latest.downloadBytes, previous.observedAt, latest.observedAt) : null; const uploadRate = hasDirectionalRate ? trafficBytesPerSecond(latest.uploadBytes, previous.observedAt, latest.observedAt) : null; const trafficSourceError = feature.snapshot?.source?.traffic?.error || feature.snapshot?.source?.traffic?.proxy?.error || (feature.status === 'error' ? feature.error : null); const trafficFreshness = globalTraffic?.observedAt ? formatLastSeen(globalTraffic.observedAt, new Date(now)).relative : 'Нет данных'; return
Учтено Harbor {formatByteString(globalTraffic?.totalBytes || '0')} ↓ {downloadRate === null ? '—' : `${formatByteString(downloadRate)}/с`} ↑ {uploadRate === null ? '—' : `${formatByteString(uploadRate)}/с`}
{trafficSourceError ? `Трафик не обновляется · последние данные ${trafficFreshness}` : globalTraffic?.observedAt ? `Обновлено ${trafficFreshness}` : 'Ожидаем первые данные трафика'}
; }