Files
harbor-net/src/web/features/devices/DevicesPanel.tsx
T
dokril 0ea2f9d548
Build and Deploy Gateway / build-and-push (push) Successful in 22s
Build and Deploy Gateway / deploy (push) Successful in 7s
Track outbound device traffic deltas
2026-08-13 14:12:12 +03:00

691 lines
34 KiB
TypeScript
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 {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from 'react';
import { Drawer } from '../../ui/Drawer.js';
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
import { Tooltip } from '../../ui/Tooltip.js';
import { copyText } from '../../utils/clientControls.js';
import {
byteString,
formatByteString,
formatLastSeen,
positiveByteDelta,
stabilizeDevicesByTraffic,
} from '../../utils/format.js';
import { TrafficChart } from './TrafficChart.js';
import { type Device } from './deviceSnapshot.js';
import type { DevicesFeature } from './DevicesFeature.js';
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 800;
const TRAFFIC_DELTA_MS = 2_200;
interface TrafficDelta {
gateway?: string;
proxy?: string;
total?: string;
vpn?: string;
direct?: string;
unknown?: string;
outboundTotal?: string;
}
interface TrafficTotals {
gateway: bigint;
proxy: bigint;
vpn: bigint;
direct: bigint;
unknown: bigint;
}
interface PinCollapse {
animationDone: boolean;
result: 'pending' | 'saved' | 'failed';
}
type DeviceCopyField = 'IP' | 'MAC' | 'Hostname';
function requestMessage(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const message: unknown = Reflect.get(value, 'message');
return typeof message === 'string' ? message : undefined;
}
function TextMorph({ from, to }: { from: string; to: string }) {
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>;
}
function TrafficValue({ value, delta }: { value: string; delta?: string }) {
return <strong className={`client-device-traffic-value${delta ? ' has-delta' : ''}`}>
<span className="is-total">{value}</span>
<span className="is-delta">{delta ? `+${delta}` : ''}</span>
</strong>;
}
export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const {
isOpen: open,
panelRef,
closeRef,
snapshot,
status,
error,
refreshing,
refreshCycle,
savingId,
resetOpen,
resetting,
load: onLoad,
updateDevice,
updatePolicy,
requestTrafficReset,
cancelTrafficReset,
confirmResetTraffic,
close: onClose,
} = feature;
const [editingId, setEditingId] = useState('');
const [alias, setAlias] = useState('');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [trafficScale, setTrafficScale] = useState<'linear' | 'log'>('linear');
const [trafficView, setTrafficView] = useState<'outbound' | 'inbound'>('outbound');
const [copyFeedback, setCopyFeedback] = useState<Record<string, { field: DeviceCopyField; failed: boolean }>>({});
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
const [pinCollapses, setPinCollapses] = useState<Record<string, PinCollapse>>({});
const deviceNodes = useRef(new Map<string, HTMLElement>());
const previousPositions = useRef(new Map<string, DOMRect>());
const previousScrollTop = useRef(0);
const movementAnimations = useRef(new Map<string, Animation>());
const previousTraffic = useRef(new Map<string, TrafficTotals>());
const aliasBaseline = useRef({ id: '', value: '' });
const copyTimers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const copyAttempts = useRef(new Map<string, object>());
const trafficDeltaTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const trafficOrder = useRef<{ direction: 'asc' | 'desc'; ids: string[] }>({ direction: sortDirection, ids: [] });
const devices = useMemo(
() => {
const previousIds = trafficOrder.current.direction === sortDirection
? trafficOrder.current.ids
: [];
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds) as {
ids: string[];
devices: Device[];
};
trafficOrder.current = { direction: sortDirection, ids: result.ids };
return result.devices;
},
[snapshot?.devices, sortDirection],
);
useEffect(() => () => {
for (const timer of copyTimers.current.values()) clearTimeout(timer);
copyTimers.current.clear();
copyAttempts.current.clear();
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
}, []);
useEffect(() => {
if (!open) {
previousTraffic.current.clear();
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
setTrafficDeltas({});
return;
}
const next = new Map<string, TrafficTotals>();
const deltas: Record<string, TrafficDelta> = {};
for (const device of snapshot?.devices || []) {
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const vpn = byteString(device.outboundTraffic?.vpnBytes);
const direct = byteString(device.outboundTraffic?.directTrackedBytes)
+ byteString(device.outboundTraffic?.directIpv4Bytes);
const unknown = byteString(device.outboundTraffic?.unknownBytes);
const previous = previousTraffic.current.get(device.id);
next.set(device.id, { gateway, proxy, vpn, direct, unknown });
if (!previous) continue;
const gatewayDelta = positiveByteDelta(previous.gateway, gateway);
const proxyDelta = positiveByteDelta(previous.proxy, proxy);
const vpnDelta = positiveByteDelta(previous.vpn, vpn);
const directDelta = positiveByteDelta(previous.direct, direct);
const unknownDelta = positiveByteDelta(previous.unknown, unknown);
if (!gatewayDelta && !proxyDelta && !vpnDelta && !directDelta && !unknownDelta) continue;
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
const outboundTotalDelta = positiveByteDelta(
previous.vpn + previous.direct + previous.unknown,
vpn + direct + unknown,
);
deltas[device.id] = {
gateway: gatewayDelta,
proxy: proxyDelta,
total: totalDelta,
vpn: vpnDelta,
direct: directDelta,
unknown: unknownDelta,
outboundTotal: outboundTotalDelta,
};
}
previousTraffic.current = next;
if (!Object.keys(deltas).length) return;
setTrafficDeltas(deltas);
if (trafficDeltaTimer.current) clearTimeout(trafficDeltaTimer.current);
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
}, [snapshot?.devices, open]);
useLayoutEffect(() => {
if (!open) {
previousPositions.current.clear();
previousScrollTop.current = 0;
for (const animation of movementAnimations.current.values()) animation.cancel();
movementAnimations.current.clear();
return;
}
const positions = new Map<string, DOMRect>();
for (const [id, node] of deviceNodes.current) {
movementAnimations.current.get(id)?.cancel();
positions.set(id, node.getBoundingClientRect());
}
const currentScrollTop = panelRef.current?.scrollTop || 0;
if (previousPositions.current.size > 0
&& !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 + previousScrollTop.current - currentScrollTop
: 0;
if (Math.abs(deltaY) < 1) continue;
const animation = 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)' });
if (animation) {
movementAnimations.current.set(id, animation);
animation.onfinish = () => movementAnimations.current.delete(id);
}
}
}
previousPositions.current = positions;
previousScrollTop.current = currentScrollTop;
}, [devices, open, panelRef, pinCollapses]);
async function saveAlias(device: Device) {
const nextAlias = alias.trim();
if (aliasBaseline.current.id === device.id && nextAlias === aliasBaseline.current.value.trim()) {
setEditingId((current) => current === device.id ? '' : current);
return;
}
if (!await updateDevice(device, { alias: nextAlias })) return;
setEditingId((current) => current === device.id ? '' : current);
}
async function copyDeviceValue(device: Device, field: DeviceCopyField, value: string) {
const activeTimer = copyTimers.current.get(device.id);
if (activeTimer) clearTimeout(activeTimer);
const attempt = {};
copyAttempts.current.set(device.id, attempt);
let feedback: { failed: boolean };
try {
await copyText(value);
feedback = { failed: false };
} catch {
feedback = { failed: true };
}
if (copyAttempts.current.get(device.id) !== attempt) return;
const announcement = {
id: device.id,
message: feedback.failed ? `Не удалось скопировать ${field} ${value}` : `${field} ${value} скопирован`,
};
const pendingTimer = copyTimers.current.get(device.id);
if (pendingTimer) clearTimeout(pendingTimer);
setCopyFeedback((current) => ({ ...current, [device.id]: { ...feedback, field } }));
setCopyAnnouncement(announcement);
copyTimers.current.set(device.id, setTimeout(() => {
setCopyFeedback((current) => {
const next = { ...current };
delete next[device.id];
return next;
});
setCopyAnnouncement((current) => current?.id === device.id ? null : current);
copyTimers.current.delete(device.id);
copyAttempts.current.delete(device.id);
}, COPY_FEEDBACK_MS));
}
function startEditing(device: Device) {
const value = device.alias || device.hostname || '';
aliasBaseline.current = { id: device.id, value };
setEditingId(device.id);
setAlias(value);
}
async function collapsePinned(device: Device, patch: Record<string, unknown>) {
setPinCollapses((current) => ({
...current,
[device.id]: { animationDone: false, result: 'pending' },
}));
const saved = await updateDevice(device, patch);
setPinCollapses((current) => {
const phase = current[device.id];
if (!phase) return current;
if (!phase.animationDone) {
return { ...current, [device.id]: { ...phase, result: saved ? 'saved' : 'failed' } };
}
const next = { ...current };
delete next[device.id];
return next;
});
}
async function togglePin(device: Device) {
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await updateDevice(device, { pinned: !device.pinned });
return;
}
await collapsePinned(device, { pinned: false });
}
async function toggleDeprioritized(device: Device) {
const deprioritized = device.deprioritized === true;
if (!device.pinned || deprioritized || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await updateDevice(device, { deprioritized: !deprioritized });
return;
}
await collapsePinned(device, { deprioritized: true });
}
function finishPinCollapse(deviceId: string) {
setPinCollapses((current) => {
const phase = current[deviceId];
if (!phase) return current;
if (phase.result === 'pending') return { ...current, [deviceId]: { ...phase, animationDone: true } };
const next = { ...current };
delete next[deviceId];
return next;
});
}
return <>
<Drawer
panelRef={panelRef}
closeRef={closeRef}
id="client-devices"
className="client-instructions client-devices"
sheetClassName="client-instructions-sheet client-devices-sheet"
open={open}
labelledBy="client-devices-title"
closeLabel="Закрыть устройства"
onClose={onClose}
>
<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={() => onLoad(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>
<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 className="client-devices-sort-icon" aria-hidden="true">
{sortDirection === 'desc' ? '↓' : '↑'}
</span>
</button>
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
</span>
<span className="client-devices-scale" role="group" aria-label="Разрез графика трафика">
<button type="button" aria-pressed={trafficView === 'outbound'} onClick={() => setTrafficView('outbound')}>Выход</button>
<button type="button" aria-pressed={trafficView === 'inbound'} onClick={() => setTrafficView('inbound')}>Вход</button>
</span>
<span className="client-devices-scale" role="group" aria-label="Масштаб графика трафика">
<button type="button" aria-pressed={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
</span>
<span className="client-devices-reset-wrap client-tooltip-anchor">
<button
className="client-devices-reset"
type="button"
aria-label="Обнулить трафик устройств"
disabled={!snapshot || resetting}
onClick={requestTrafficReset}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12a8 8 0 1 0 2.3-5.7M4 5v7h7" />
</svg>
</button>
<Tooltip>Обнулить трафик устройств</Tooltip>
</span>
</div>
<h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro">
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
<p>Вход накопленные Gateway/Proxy. Выход приблизительно через VPN или Direct с запуска текущего учёта.</p>
</div>
</header>
{Boolean(snapshot?.source?.error) && (
<p className="client-devices-source" role="status">
Список временно не обновляется. Показаны последние сохранённые данные.
</p>
)}
{Boolean(snapshot?.source?.traffic?.error) && (
<p className="client-devices-source" role="status">
Трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{Boolean(snapshot?.source?.traffic?.proxy?.error) && (
<p className="client-devices-source" role="status">
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{Boolean(snapshot?.source?.policy?.error) && (
<p className="client-devices-source" role="status">
Маршруты устройств временно не обновляются. Показано последнее подтверждённое состояние.
</p>
)}
{Boolean(error) && (
<div className="client-devices-error" role="alert">
<span>{requestMessage(error)}</span>
<button type="button" onClick={() => onLoad()}>Повторить</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-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyAnnouncement?.message || ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device, index) => {
const collapsing = Boolean(pinCollapses[device.id]);
const deprioritized = device.deprioritized === true;
const compact = deprioritized && !collapsing;
const expanded = device.pinned && !collapsing;
const showDetails = !compact && (device.pinned || collapsing);
const group = device.pinned ? 'pinned' : deprioritized ? 'deprioritized' : 'default';
const previous = devices[index - 1];
const previousGroup = previous
? previous.pinned ? 'pinned' : previous.deprioritized === true ? 'deprioritized' : 'default'
: '';
const groupLabel = group === 'pinned'
? 'Закреплённые'
: group === 'deprioritized' ? 'Фоновые' : 'Остальные';
const groupStart = group !== previousGroup;
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const editing = editingId === device.id;
const saving = savingId === device.id;
const seen = formatLastSeen(device.lastSeenAt);
const online = device.status === 'online';
const gatewayTotal = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxyTotal = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const gatewayTraffic = formatByteString(gatewayTotal.toString());
const proxyTraffic = formatByteString(proxyTotal.toString());
const totalTraffic = formatByteString((gatewayTotal + proxyTotal).toString());
const hasProxyTraffic = proxyTotal > 0n;
const outboundAvailable = Boolean(
device.outboundTraffic?.singboxObservedAt || device.outboundTraffic?.directIpv4ObservedAt,
);
const vpnTotal = byteString(device.outboundTraffic?.vpnBytes);
const directTrackedTotal = byteString(device.outboundTraffic?.directTrackedBytes);
const directIpv4Total = byteString(device.outboundTraffic?.directIpv4Bytes);
const directTotal = directTrackedTotal + directIpv4Total;
const unknownTotal = byteString(device.outboundTraffic?.unknownBytes);
const outboundTotal = vpnTotal + directTotal + unknownTotal;
const displayedTotal = trafficView === 'outbound'
? outboundAvailable ? `≈ ${formatByteString(outboundTotal.toString())}` : '—'
: totalTraffic;
const trafficLabel = trafficView === 'outbound' ? 'Выход' : 'Вход';
const trafficAriaLabel = trafficView === 'outbound'
? outboundAvailable
? `Выход с запуска текущего учёта: примерно ${formatByteString(outboundTotal.toString())}. VPN ${formatByteString(vpnTotal.toString())}, Direct примерно ${formatByteString(directTotal.toString())}${unknownTotal > 0n ? `, неизвестно ${formatByteString(unknownTotal.toString())}` : ''}`
: 'Выход: ожидаем первые данные'
: `Вход накоплен: ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`;
const trafficDelta = trafficDeltas[device.id] || {};
const feedback = copyFeedback[device.id];
const policyBusy = device.policyStatus === 'applying';
const policyFailed = device.policyStatus === 'failed';
const policyPending = device.policyStatus === 'pending';
const displayPolicy = device.appliedPolicy;
const policyTarget = device.policyStatus === 'applied'
? device.appliedPolicy === 'direct' ? 'vpn' : 'direct'
: device.appliedPolicy;
const cannotEnableDirect = device.policyStatus === 'applied'
&& device.appliedPolicy !== 'direct'
&& device.confidence === 'ambiguous';
const policyTooltip = policyBusy
? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}`
: policyFailed
? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут`
: policyPending
? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание'
: device.confidence === 'ambiguous' && device.desiredPolicy !== 'direct'
? 'Маршрут недоступен, пока Gateway видит несколько сетевых адресов одного устройства'
: displayPolicy === 'direct'
? 'Полностью обходит sing-box. Нажмите, чтобы вернуть обработку Gateway'
: 'Проходит через sing-box и правила Gateway. Нажмите, чтобы пустить полностью напрямую';
return <article
key={device.id}
ref={(node) => {
if (node) deviceNodes.current.set(device.id, node);
else deviceNodes.current.delete(device.id);
}}
className={`client-device is-${device.status}${expanded ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}${compact ? ' is-deprioritized' : ''}${groupStart ? ' is-group-start' : ''}`}
>
<span
className="client-device-group-heading"
role={groupStart ? 'heading' : undefined}
aria-level={groupStart ? 3 : undefined}
aria-hidden={!groupStart}
>{groupStart ? groupLabel : ''}</span>
{!compact && <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={() => togglePin(device)}
>
<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 className="client-device-main">
<h4
className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}
tabIndex={!hasName && !editing ? 0 : undefined}
>
{editing ? (
<input
className="client-device-alias-input"
value={alias}
style={{ '--alias-width': `${Math.max(1, alias.length)}ch` } as CSSProperties}
maxLength={64}
autoFocus
aria-label="Название устройства"
aria-busy={saving}
disabled={saving}
onChange={(event) => setAlias(event.target.value)}
onBlur={() => saveAlias(device)}
onKeyDown={(event) => {
if (event.key === 'Enter') event.currentTarget.blur();
}}
/>
) : hasName && <button
className={`client-device-alias-trigger${device.alias ? ' is-custom-name' : ''}`}
type="button"
aria-label={`Изменить название ${title}`}
onClick={() => startEditing(device)}
>{title}</button>}
{!hasName && !editing && <span className="client-device-fallback-name">{title}</span>}
{!editing && <span className="client-device-identity-details" role="group" aria-label={`Технические данные устройства ${title}`}>
{device.ip && <button
className={`client-device-identity-copy${feedback?.field === 'IP' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip}`}
onClick={() => copyDeviceValue(device, 'IP', device.ip!)}
><b>IP</b><span>{device.ip}</span></button>}
<button
className={`client-device-identity-copy${feedback?.field === 'MAC' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать MAC ${device.mac}`}
onClick={() => copyDeviceValue(device, 'MAC', device.mac)}
><b>MAC</b><span>{device.mac}</span></button>
{device.hostname && <button
className={`client-device-identity-copy${feedback?.field === 'Hostname' ? feedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать Hostname ${device.hostname}`}
onClick={() => copyDeviceValue(device, 'Hostname', device.hostname!)}
><b>Host</b><span>{device.hostname}</span></button>}
</span>}
</h4>
{!hasName && !editing && <span className="client-device-edit-wrap client-tooltip-anchor">
<button
className={`client-device-edit${pencilAnimationId === device.id ? ' is-writing' : ''}`}
type="button"
aria-label={`Изменить название ${title}`}
onPointerEnter={() => setPencilAnimationId(device.id)}
onFocus={() => setPencilAnimationId(device.id)}
onClick={() => startEditing(device)}
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
onAnimationEnd={() => setPencilAnimationId((id) => id === device.id ? '' : id)}
>
<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>}
<span className={`client-device-last-seen${online ? ' is-online' : ''}`} tabIndex={0}>
<time
dateTime={device.lastSeenAt || undefined}
aria-label={`${online ? 'В сети' : 'Не в сети'}. Последний контакт: ${seen.tooltip}`}
>
{online ? 'В сети' : <TextMorph from="Не в сети" to={seen.relative} />}
</time>
</span>
</div>
{!compact && <span
className="client-device-traffic"
role="group"
tabIndex={0}
aria-label={trafficAriaLabel}
>
<span className="client-device-traffic-total" aria-hidden="true">
<b>{trafficLabel}</b><TrafficValue value={displayedTotal} delta={trafficView === 'outbound' ? trafficDelta.outboundTotal : trafficDelta.total} />
</span>
<span className="client-device-traffic-breakdown" aria-hidden="true">
{trafficView === 'outbound' ? <>
<span className="is-vpn"><b>VPN</b><TrafficValue value={outboundAvailable ? formatByteString(vpnTotal.toString()) : '—'} delta={trafficDelta.vpn} /></span>
<span className="is-direct-total"><b>Direct</b><TrafficValue value={outboundAvailable ? formatByteString(directTotal.toString()) : '—'} delta={trafficDelta.direct} /></span>
{unknownTotal > 0n && <span className="is-unknown"><b>Другое</b><TrafficValue value={formatByteString(unknownTotal.toString())} delta={trafficDelta.unknown} /></span>}
</> : <>
<span><b>Gateway</b><TrafficValue value={gatewayTraffic} delta={trafficDelta.gateway} /></span>
{hasProxyTraffic && <span className="is-proxy"><b>Прокси</b><TrafficValue value={proxyTraffic} delta={trafficDelta.proxy} /></span>}
</>}
</span>
</span>}
{!compact && <span className="client-device-policy-wrap client-tooltip-anchor">
<button
className={`client-device-policy is-${displayPolicy}${policyFailed ? ' is-failed' : ''}${policyPending ? ' is-pending' : ''}`}
type="button"
aria-label={`Маршрут устройства: ${displayPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}. ${policyTooltip}`}
aria-pressed={displayPolicy === 'direct'}
aria-busy={policyBusy}
disabled={saving || policyBusy || cannotEnableDirect}
onClick={() => updatePolicy(device, policyTarget)}
>
{displayPolicy === 'direct' ? <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 12h15M14 7l5 5-5 5" />
</svg> : <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 3 19 6v5c0 4.4-2.8 8-7 10-4.2-2-7-5.6-7-10V6l7-3Z" />
<path d="m9 12 2 2 4-4" />
</svg>}
</button>
<Tooltip>{policyTooltip}</Tooltip>
</span>}
<span className="client-device-deprioritize-wrap client-tooltip-anchor">
<button
className="client-device-deprioritize"
type="button"
aria-pressed={deprioritized}
aria-label={deprioritized ? `Вернуть ${title} в основной список` : `Убрать ${title} в фон`}
disabled={saving}
onClick={() => toggleDeprioritized(device)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
{deprioritized
? <path d="M12 20V8M7 13l5-5 5 5M5 4h14" />
: <path d="M12 4v12M7 11l5 5 5-5M5 20h14" />}
</svg>
</button>
<Tooltip>{deprioritized ? 'Вернуть в основной список' : 'Убрать в фон'}</Tooltip>
</span>
{!compact && <TrafficChart
samples={trafficView === 'outbound' ? device.outboundTrafficHistory || [] : device.trafficHistory || []}
scale={trafficScale}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
series={trafficView}
pinned={showDetails}
collapsing={collapsing}
onCollapseEnd={() => finishPinCollapse(device.id)}
/>}
</article>;
})}
</div>
</Drawer>
<ConfirmationDialog
open={resetOpen}
id="client-devices-reset"
kicker="Данные устройств"
title="Сбросить статистику?"
description="Вход и выход для всех устройств начнут считаться заново. Общая скорость и история Prometheus/Grafana останутся без изменений."
cancelLabel="Оставить данные"
confirmLabel="Сбросить"
busy={resetting}
onCancel={cancelTrafficReset}
onConfirm={confirmResetTraffic}
/>
</>;
}