Track per-device traffic totals and recover inventory state
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 15:24:54 +03:00
parent e774486b99
commit 307ad02cd7
13 changed files with 842 additions and 96 deletions
+54 -8
View File
@@ -1,6 +1,10 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { formatLastSeen } from '../utils/format.js';
import {
formatByteString,
formatLastSeen,
sortDevicesByTraffic,
} from '../utils/format.js';
const STATUS_LABELS = {
online: 'В сети',
@@ -32,9 +36,13 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const [savingId, setSavingId] = useState('');
const [refreshing, setRefreshing] = useState(false);
const [refreshCycle, setRefreshCycle] = useState(0);
const [sortDirection, setSortDirection] = useState('desc');
const deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
const devices = snapshot?.devices || [];
const devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
[snapshot?.devices, sortDirection],
);
async function load(quiet = false, discover = false) {
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
@@ -92,12 +100,23 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
async function updateDevice(device, patch) {
setSavingId(device.id);
try {
const next = await api.devices.update(device.id, patch, snapshot.revision);
let next;
try {
next = await api.devices.update(device.id, patch, snapshot.revision);
} catch (requestError) {
if (requestError.code !== 'STATE_CONFLICT') throw requestError;
const latest = await api.devices.list();
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);
setError(null);
return true;
} catch (requestError) {
if (requestError.code === 'STATE_CONFLICT') await load(true);
setError(requestError);
return false;
} finally {
@@ -115,12 +134,12 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
<aside
ref={panelRef}
id="client-devices"
className={`client-instructions client-devices${open ? ' is-open' : ''}`}
className={`client-drawer 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">
<div className="client-drawer-sheet client-instructions-sheet client-devices-sheet">
<button
ref={closeRef}
className="client-drawer-close"
@@ -149,6 +168,18 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</button>
<Tooltip>{refreshing ? 'Обновляем устройства…' : 'Обновить сейчас · автоматически каждые 15 с'}</Tooltip>
</span>
<span className="client-devices-sort-wrap client-tooltip-anchor">
<button
className="client-devices-sort"
type="button"
aria-label={`Сортировка по трафику: сначала ${sortDirection === 'desc' ? 'больше' : 'меньше'}. Изменить направление`}
onClick={() => setSortDirection((direction) => direction === 'desc' ? 'asc' : 'desc')}
>
<span>Трафик</span>
<span aria-hidden="true">{sortDirection === 'desc' ? '↓' : '↑'}</span>
</button>
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
</span>
</div>
<h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro">
@@ -161,6 +192,11 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
Список временно не обновляется. Показаны последние сохранённые данные.
</p>
)}
{snapshot?.source?.traffic?.error && (
<p className="client-devices-source" role="status">
Трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{error && (
<div className="client-devices-error" role="alert">
<span>{error.message}</span>
@@ -179,6 +215,8 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt);
const uncertainIdentity = device.confidence !== 'high';
const download = formatByteString(device.downloadBytes);
const upload = formatByteString(device.uploadBytes);
return <article
ref={(node) => {
if (node) deviceNodes.current.set(device.id, node);
@@ -260,7 +298,15 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
</time>
</span>
</div>
{device.manufacturer && <p className="client-device-manufacturer">{device.manufacturer}</p>}
{(device.manufacturer || device.trafficObservedAt) && <div className="client-device-details">
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
{device.trafficObservedAt && <span
className="client-device-traffic"
aria-label={`Получено ${download}, отдано ${upload}`}
>
<span aria-hidden="true"> {download} · {upload}</span>
</span>}
</div>}
</article>;
})}
</div>