Refine device inventory animations and diagnostics layout
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 21:35:46 +03:00
parent 0a4a6d9443
commit 70cc221f34
6 changed files with 67 additions and 88 deletions
+33 -13
View File
@@ -192,9 +192,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
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 copyTimer = useRef(null);
@@ -217,7 +220,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
setRefreshing(true);
try {
const next = await (discover ? api.devices.refresh() : api.devices.list());
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
setStatus('ready');
} catch (requestError) {
@@ -278,6 +281,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
useLayoutEffect(() => {
if (!open) {
previousPositions.current.clear();
previousOrder.current = [];
previousScrollTop.current = 0;
for (const animation of movementAnimations.current.values()) animation.cancel();
movementAnimations.current.clear();
return;
@@ -287,10 +292,17 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
movementAnimations.current.get(id)?.cancel();
positions.set(id, node.getBoundingClientRect());
}
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
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 : 0;
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)` },
@@ -303,7 +315,9 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
}
}
previousPositions.current = positions;
}, [devices, open]);
previousOrder.current = order;
previousScrollTop.current = currentScrollTop;
}, [devices, open, panelRef]);
async function updateDevice(device, patch) {
setSavingId(device.id);
@@ -314,14 +328,14 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
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);
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
return true;
} catch (requestError) {
@@ -347,18 +361,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
setSnapshot((current) => !current || latest.revision >= current.revision ? latest : current);
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);
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);
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
} catch {
// Keep the policy error as the actionable result.
}
@@ -575,19 +589,25 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
</h3>
{!hasName && <span className="client-device-edit-wrap client-tooltip-anchor">
<span className="client-device-edit-wrap client-tooltip-anchor">
<button
className="client-device-edit"
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
type="button"
aria-label={`Изменить название ${title}`}
onPointerEnter={() => setPencilAnimationId(device.id)}
onFocus={() => setPencilAnimationId(device.id)}
onClick={() => startEditing(device)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
onAnimationEnd={() => setPencilAnimationId((id) => id === device.id ? '' : id)}
>
<path d="m4 20 4.2-1 10.3-10.3a2 2 0 0 0-2.8-2.8L5.4 16.2 4 20ZM14.5 7.1l2.8 2.8" />
</svg>
</button>
<Tooltip>Изменить название</Tooltip>
</span>}
</span>
</>
)}
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex="0">