Files
harbor-net/src/web/components/DevicesPanel.jsx
T
dokril 3157e9e8f7
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 7s
Add device inventory refresh endpoint and auto-refresh UI
2026-08-07 14:00:26 +03:00

270 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { api } from '../api.js';
import { formatLastSeen } from '../utils/format.js';
const STATUS_LABELS = {
online: 'В сети',
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>;
}
function TextMorph({ from, to }) {
const anchor = from.length >= to.length ? from : to;
return <span className="client-text-morph" aria-hidden="true">
<span className="client-text-morph-anchor">{anchor}</span>
<span className="client-text-morph-value is-date">{from}</span>
<span className="client-text-morph-value is-relative">{to}</span>
</span>;
}
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [snapshot, setSnapshot] = useState(null);
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
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, discover = false) {
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
setRefreshing(true);
try {
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();
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 {
const next = await api.devices.update(device.id, patch, snapshot.revision);
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
setError(null);
return true;
} catch (requestError) {
if (requestError.code === 'STATE_CONFLICT') await load(true);
setError(requestError);
return false;
} finally {
setSavingId('');
}
}
async function saveAlias(event, device) {
event.preventDefault();
if (!await updateDevice(device, { alias })) return;
setEditingId('');
}
return (
<aside
ref={panelRef}
id="client-devices"
className={`client-instructions client-devices${open ? ' is-open' : ''}`}
aria-labelledby="client-devices-title"
aria-hidden={!open}
inert={!open ? true : undefined}
>
<div className="client-instructions-sheet client-devices-sheet">
<button
ref={closeRef}
className="client-drawer-close"
type="button"
aria-label="Закрыть устройства"
onClick={onClose}
>×</button>
<header className="client-instructions-header client-devices-header">
<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>
</div>
</header>
{snapshot?.source?.error && (
<p className="client-devices-source" role="status">
Список временно не обновляется. Показаны последние сохранённые данные.
</p>
)}
{error && (
<div className="client-devices-error" role="alert">
<span>{error.message}</span>
<button type="button" onClick={() => load()}>Повторить</button>
</div>
)}
{status === 'loading' && <p className="client-devices-empty" role="status">Ищем устройства</p>}
{status !== 'loading' && !devices.length && !error && (
<p className="client-devices-empty">Gateway пока не видит устройств.</p>
)}
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const editing = editingId === device.id;
const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt);
const uncertainIdentity = device.confidence !== 'high';
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 ? (
<form className="client-device-alias" onSubmit={(event) => saveAlias(event, device)}>
<input
value={alias}
maxLength="64"
autoFocus
aria-label="Название устройства"
onChange={(event) => setAlias(event.target.value)}
/>
<button type="submit" aria-label="Сохранить название" disabled={saving}></button>
<button type="button" aria-label="Отменить изменение" disabled={saving} onClick={() => setEditingId('')}>×</button>
</form>
) : (
<div className="client-device-title">
<h3>{title}</h3>
<span className="client-device-edit-wrap client-tooltip-anchor">
<button
className="client-device-edit"
type="button"
aria-label={`Изменить название ${title}`}
onClick={() => {
setEditingId(device.id);
setAlias(device.alias || '');
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<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>
</div>
)}
<span className="client-device-pin-wrap client-tooltip-anchor">
<button
className="client-device-pin"
type="button"
aria-pressed={device.pinned}
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
disabled={saving}
onClick={() => updateDevice(device, { pinned: !device.pinned })}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
</svg>
</button>
<Tooltip>{device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
</div>
<div className="client-device-meta">
<div className="client-device-addresses">
{title !== device.ip && device.ip && <span>{device.ip}</span>}
{device.mac && <span className="client-device-mac">
{device.mac}
{uncertainIdentity && <span className="client-device-identity client-tooltip-anchor" tabIndex="0" aria-label="Пояснение идентификации устройства">
<Tooltip>{device.confidence === 'medium'
? 'Устройство использует приватный MAC, производитель может не определиться'
: 'Устройство определено приблизительно'}</Tooltip>
</span>}
</span>}
{device.interface && <span>{device.interface}</span>}
</div>
<span className="client-device-last-seen" tabIndex="0">
<time dateTime={device.lastSeenAt} aria-label={seen.tooltip}>
<TextMorph from={seen.label} to={seen.relative} />
</time>
</span>
</div>
{device.manufacturer && <p className="client-device-manufacturer">{device.manufacturer}</p>}
</article>;
})}
</div>
</div>
</aside>
);
}