Add device inventory refresh endpoint and auto-refresh UI
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-07 14:00:26 +03:00
parent 36c8438b7f
commit 3157e9e8f7
8 changed files with 215 additions and 17 deletions
+74 -8
View File
@@ -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 <span className="client-tooltip" role="tooltip">{children}</span>;
@@ -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 (
<aside
ref={panelRef}
@@ -90,7 +129,27 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
onClick={onClose}
>×</button>
<header className="client-instructions-header client-devices-header">
<span>Gateway · {devices.length}</span>
<div className="client-devices-kicker">
<span>Gateway · {devices.length}</span>
<span className="client-devices-refresh-wrap client-tooltip-anchor">
<button
className={`client-devices-refresh${refreshing ? ' is-refreshing' : ''}`}
type="button"
aria-label={refreshing ? 'Обновляем устройства' : 'Обновить устройства сейчас'}
aria-busy={refreshing}
disabled={refreshing}
onClick={() => load(true, true)}
>
<svg key={refreshCycle} className="client-devices-refresh-ring" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="10" pathLength="1" />
</svg>
<svg className="client-devices-refresh-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 11a8 8 0 1 0-2.3 6.7M20 5v6h-6" />
</svg>
</button>
<Tooltip>{refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'}</Tooltip>
</span>
</div>
<h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro">
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
@@ -120,7 +179,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt);
const uncertainIdentity = device.confidence !== 'high';
return <article className={`client-device is-${device.status}`} key={device.id}>
return <article
ref={(node) => {
if (node) deviceNodes.current.set(device.id, node);
else deviceNodes.current.delete(device.id);
}}
className={`client-device is-${device.status}`}
key={device.id}
>
<div className="client-device-heading">
<span className="client-device-status">{STATUS_LABELS[device.status]}</span>
{editing ? (