Track per-device traffic totals and recover inventory state
This commit is contained in:
@@ -361,12 +361,12 @@ function LocalRulesPanel({
|
||||
<aside
|
||||
ref={panelRef}
|
||||
id="client-local-rules"
|
||||
className={`client-local-rules${open ? ' is-open' : ''}`}
|
||||
className={`client-drawer client-local-rules${open ? ' is-open' : ''}`}
|
||||
aria-labelledby="local-rules-title"
|
||||
aria-hidden={!open}
|
||||
inert={!open ? true : undefined}
|
||||
>
|
||||
<div className="client-local-rules-sheet">
|
||||
<div className="client-drawer-sheet client-local-rules-sheet">
|
||||
<button
|
||||
ref={closeRef}
|
||||
className="client-drawer-close"
|
||||
@@ -1444,12 +1444,12 @@ export function ClientOverviewPage({
|
||||
{hasSubscription && subscriptionContentReady && <aside
|
||||
ref={instructionsPanelRef}
|
||||
id="client-instructions"
|
||||
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||
className={`client-drawer client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||
aria-labelledby="instructions-title"
|
||||
aria-hidden={!instructionsOpen}
|
||||
inert={!instructionsOpen ? true : undefined}
|
||||
>
|
||||
<div className="client-instructions-sheet">
|
||||
<div className="client-drawer-sheet client-instructions-sheet">
|
||||
<button
|
||||
ref={instructionsCloseRef}
|
||||
className="client-drawer-close"
|
||||
|
||||
@@ -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>
|
||||
|
||||
+59
-37
@@ -666,7 +666,7 @@ p {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: max(14px, env(safe-area-inset-right));
|
||||
z-index: 30;
|
||||
z-index: 60;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
transform: translateY(-50%);
|
||||
@@ -715,7 +715,7 @@ p {
|
||||
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.client-devices {
|
||||
.client-instructions.client-devices {
|
||||
width: min(560px, 100vw);
|
||||
}
|
||||
|
||||
@@ -737,6 +737,30 @@ p {
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.client-devices-sort-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.client-devices-sort {
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--client-muted);
|
||||
font: 700 9px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
cursor: pointer;
|
||||
transition: color 220ms ease, filter 300ms ease;
|
||||
}
|
||||
|
||||
.client-devices-sort:hover,
|
||||
.client-devices-sort:focus-visible {
|
||||
color: var(--client-accent);
|
||||
filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 44%, transparent));
|
||||
}
|
||||
|
||||
.client-devices-refresh {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
@@ -807,7 +831,8 @@ p {
|
||||
animation: client-spin 900ms linear infinite;
|
||||
}
|
||||
|
||||
.client-instructions-header .client-devices-refresh-wrap > .client-tooltip {
|
||||
.client-instructions-header .client-devices-refresh-wrap > .client-tooltip,
|
||||
.client-instructions-header .client-devices-sort-wrap > .client-tooltip {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
@@ -1075,15 +1100,32 @@ p {
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.client-device-manufacturer {
|
||||
overflow: hidden;
|
||||
margin: 0 0 0 76px;
|
||||
color: var(--client-muted);
|
||||
.client-device-details {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 4px 12px;
|
||||
margin-left: 76px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.client-device-manufacturer {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--client-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-traffic {
|
||||
margin-left: auto;
|
||||
color: var(--client-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.client-device-alias {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 28px 28px;
|
||||
@@ -1202,34 +1244,38 @@ p {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.client-local-rules {
|
||||
.client-drawer {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 20;
|
||||
width: min(480px, 100vw);
|
||||
z-index: 50;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||
color: var(--client-text);
|
||||
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(104%);
|
||||
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
||||
}
|
||||
|
||||
.client-local-rules.is-open {
|
||||
.client-drawer.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.client-local-rules-sheet {
|
||||
.client-drawer-sheet {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
padding: 54px 72px 72px 34px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.client-local-rules {
|
||||
width: min(480px, 100vw);
|
||||
}
|
||||
|
||||
.client-local-rules-header {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -1731,32 +1777,7 @@ p {
|
||||
}
|
||||
|
||||
.client-instructions {
|
||||
position: fixed;
|
||||
inset: 0 0 0 auto;
|
||||
z-index: 20;
|
||||
width: min(470px, 100vw);
|
||||
overflow-y: auto;
|
||||
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||
color: var(--client-text);
|
||||
box-shadow: -26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateX(104%);
|
||||
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
||||
}
|
||||
|
||||
.client-instructions.is-open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.client-instructions-sheet {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
padding: 54px 72px 72px 34px;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||
}
|
||||
|
||||
.client-instructions-header {
|
||||
@@ -4080,6 +4101,7 @@ p {
|
||||
.client-device-edit svg,
|
||||
.client-device-edit-wrap,
|
||||
.client-devices-refresh,
|
||||
.client-devices-sort,
|
||||
.client-devices-refresh-ring circle,
|
||||
.client-devices-refresh-icon,
|
||||
.client-text-morph-value,
|
||||
|
||||
@@ -10,6 +10,40 @@ export function formatBytes(value) {
|
||||
return `${size.toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
const BYTE_STRING_PATTERN = /^\d+$/;
|
||||
|
||||
export function byteString(value) {
|
||||
const normalized = String(value ?? '0');
|
||||
return BYTE_STRING_PATTERN.test(normalized) ? BigInt(normalized) : 0n;
|
||||
}
|
||||
|
||||
export function formatByteString(value) {
|
||||
const bytes = byteString(value);
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ', 'ПБ', 'ЭБ'];
|
||||
let unit = 0;
|
||||
let divisor = 1n;
|
||||
while (bytes >= divisor * 1024n && unit < units.length - 1) {
|
||||
divisor *= 1024n;
|
||||
unit += 1;
|
||||
}
|
||||
if (unit === 0) return `${bytes} ${units[unit]}`;
|
||||
const tenths = (bytes * 10n + divisor / 2n) / divisor;
|
||||
return `${tenths / 10n},${tenths % 10n} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function sortDevicesByTraffic(devices, direction = 'desc') {
|
||||
const factor = direction === 'asc' ? 1 : -1;
|
||||
return (Array.isArray(devices) ? devices : [])
|
||||
.map((device, index) => ({ device, index }))
|
||||
.sort((left, right) => {
|
||||
const leftTotal = byteString(left.device.uploadBytes) + byteString(left.device.downloadBytes);
|
||||
const rightTotal = byteString(right.device.uploadBytes) + byteString(right.device.downloadBytes);
|
||||
if (leftTotal === rightTotal) return left.index - right.index;
|
||||
return (leftTotal < rightTotal ? -1 : 1) * factor;
|
||||
})
|
||||
.map(({ device }) => device);
|
||||
}
|
||||
|
||||
export function formatRelative(iso) {
|
||||
if (!iso) return "";
|
||||
const ts = new Date(iso).getTime();
|
||||
|
||||
Reference in New Issue
Block a user