Refine device inventory layout and last-seen details
Build and Deploy Gateway / build-and-push (push) Successful in 11s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-08-07 13:39:25 +03:00
parent 158aaadd23
commit 44367e0ef3
5 changed files with 291 additions and 119 deletions
+76 -56
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api.js';
import { formatLastSeen } from '../utils/format.js';
const STATUS_LABELS = {
online: 'В сети',
@@ -7,20 +8,8 @@ const STATUS_LABELS = {
offline: 'Не в сети',
};
const CONFIDENCE_LABELS = {
high: 'точная MAC',
medium: 'частная MAC',
low: 'приблизительно',
};
function seenAt(value) {
if (!value) return 'нет данных';
return new Date(value).toLocaleString('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
function Tooltip({ children }) {
return <span className="client-tooltip" role="tooltip">{children}</span>;
}
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
@@ -101,7 +90,7 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
{snapshot?.source?.error && (
<p className="client-devices-source" role="status">
Источник временно недоступен. Показаны последние сохранённые данные.
Список временно не обновляется. Показаны последние сохранённые данные.
</p>
)}
{error && (
@@ -117,53 +106,84 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
const title = device.alias || device.hostname || device.manufacturer || device.ip;
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">
<div>
<span className="client-device-status">{STATUS_LABELS[device.status]}</span>
<h3>{title}</h3>
{device.manufacturer && device.manufacturer !== title && <p>{device.manufacturer}</p>}
</div>
<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 })}
>{device.pinned ? '◆' : '◇'}</button>
<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>
<dl className="client-device-meta">
<div><dt>IP</dt><dd>{device.ip || '—'}</dd></div>
<div><dt>MAC</dt><dd>{device.mac || '—'}</dd></div>
<div><dt>Интерфейс</dt><dd>{device.interface || '—'}</dd></div>
<div><dt>Последний раз</dt><dd><time dateTime={device.lastSeenAt}>{seenAt(device.lastSeenAt)}</time></dd></div>
<div><dt>Источник</dt><dd>{device.source} · {CONFIDENCE_LABELS[device.confidence]}</dd></div>
</dl>
{editing ? (
<form className="client-device-alias" onSubmit={(event) => saveAlias(event, device)}>
<label>
<span>Название</span>
<input value={alias} maxLength="64" autoFocus onChange={(event) => setAlias(event.target.value)} />
</label>
<button type="submit" disabled={saving}>Сохранить</button>
<button type="button" disabled={saving} onClick={() => setEditingId('')}>Отмена</button>
</form>
) : (
<button
className="client-device-rename"
type="button"
onClick={() => {
setEditingId(device.id);
setAlias(device.alias || '');
}}
>Изменить название</button>
)}
<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 client-tooltip-anchor" tabIndex="0" aria-label={seen.tooltip}>
<time dateTime={device.lastSeenAt}>{seen.label}</time>
<Tooltip>{seen.tooltip}</Tooltip>
</span>
</div>
{device.manufacturer && <p className="client-device-manufacturer">{device.manufacturer}</p>}
</article>;
})}
</div>