204 lines
8.7 KiB
React
204 lines
8.7 KiB
React
import React, { useEffect, useState } from 'react';
|
||
import { api } from '../api.js';
|
||
import { formatLastSeen } from '../utils/format.js';
|
||
|
||
const STATUS_LABELS = {
|
||
online: 'В сети',
|
||
recent: 'Недавно',
|
||
offline: 'Не в сети',
|
||
};
|
||
|
||
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('');
|
||
|
||
async function load(quiet = false) {
|
||
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
|
||
try {
|
||
const next = await api.devices.list();
|
||
setSnapshot((current) => !current || next.revision >= current.revision ? next : current);
|
||
setError(null);
|
||
setStatus('ready');
|
||
} catch (requestError) {
|
||
setError(requestError);
|
||
setStatus('error');
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
load();
|
||
const timer = setInterval(() => load(true), 15_000);
|
||
return () => clearInterval(timer);
|
||
}, [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('');
|
||
}
|
||
|
||
const devices = snapshot?.devices || [];
|
||
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">
|
||
<span>Gateway · {devices.length}</span>
|
||
<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 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>
|
||
);
|
||
}
|