From 3157e9e8f7281a09851c1f0cc175ed6ba72f51fb Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 14:00:26 +0300 Subject: [PATCH] Add device inventory refresh endpoint and auto-refresh UI --- src/server/index.js | 5 + src/server/services/deviceInventoryService.js | 13 ++- src/shared/versions.js | 6 +- src/web/api.js | 1 + src/web/components/DevicesPanel.jsx | 82 ++++++++++++-- src/web/styles.css | 105 +++++++++++++++++- test/server/device-inventory.test.js | 11 +- test/web/device-inventory-contract.test.js | 9 ++ 8 files changed, 215 insertions(+), 17 deletions(-) diff --git a/src/server/index.js b/src/server/index.js index 982c387..1a9b56e 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -617,6 +617,11 @@ async function handleApi(req, res) { return sendJson(res, 200, deviceInventory.snapshot()); } + if (requestUrl.pathname === '/api/devices/refresh') { + if (!deviceInventory || req.method !== 'POST') throw new HarborError('ENDPOINT_NOT_FOUND'); + return sendJson(res, 200, await deviceInventory.refresh()); + } + const deviceMatch = requestUrl.pathname.match(/^\/api\/devices\/(dev_[a-f0-9]{16})$/); if (deviceMatch && req.method === 'PUT') { if (!deviceInventory) throw new HarborError('ENDPOINT_NOT_FOUND'); diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index dc6ce0d..dd6a769 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -66,6 +66,8 @@ function deviceStatus(lastSeenAt, now) { } export function createDeviceInventoryService({ store, observe, vendor = () => null, now = () => new Date() }) { + let refreshPromise = null; + function snapshot() { const state = migrate(store.read()); const current = now(); @@ -89,7 +91,7 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu }; } - async function refresh() { + async function performRefresh() { let result; try { result = await observe(); @@ -138,6 +140,15 @@ export function createDeviceInventoryService({ store, observe, vendor = () => nu return snapshot(); } + function refresh() { + if (!refreshPromise) { + refreshPromise = performRefresh().finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; + } + function update(id, patch, expectedRevision) { if (!patch || typeof patch !== 'object' || Array.isArray(patch)) { throw new HarborError('REQUEST_INVALID'); diff --git a/src/shared/versions.js b/src/shared/versions.js index 7a580ed..613385e 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.9.3', - gatewayClient: '0.9.3', - gatewayBackend: '0.9.0', + macClient: '0.9.4', + gatewayClient: '0.9.4', + gatewayBackend: '0.9.1', }); export function parseVersion(value) { diff --git a/src/web/api.js b/src/web/api.js index 5fa1521..95d71e2 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -81,6 +81,7 @@ export const api = { }, devices: { list: () => request('/api/devices'), + refresh: () => request('/api/devices/refresh', { method: 'POST' }), update: (id, patch, expectedRevision) => request(`/api/devices/${id}`, { method: 'PUT', body: JSON.stringify({ ...patch, expectedRevision }), diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index f4660ae..3862350 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { api } from '../api.js'; import { formatLastSeen } from '../utils/format.js'; @@ -7,6 +7,8 @@ const STATUS_LABELS = { recent: 'Недавно', offline: 'Не в сети', }; +const AUTO_REFRESH_MS = 15_000; +const DEVICE_MOVE_MS = 520; function Tooltip({ children }) { return {children}; @@ -28,27 +30,65 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { const [editingId, setEditingId] = useState(''); const [alias, setAlias] = useState(''); const [savingId, setSavingId] = useState(''); + const [refreshing, setRefreshing] = useState(false); + const [refreshCycle, setRefreshCycle] = useState(0); + const deviceNodes = useRef(new Map()); + const previousPositions = useRef(new Map()); + const devices = snapshot?.devices || []; - async function load(quiet = false) { + async function load(quiet = false, discover = false) { if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading'); + setRefreshing(true); try { - const next = await api.devices.list(); + 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(); - const timer = setInterval(() => load(true), 15_000); - return () => clearInterval(timer); + 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 { @@ -71,7 +111,6 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { setEditingId(''); } - const devices = snapshot?.devices || []; return (