Files
harbor-net/src/web/components/DevicesPanel.jsx
T
dokril d32bc14108
Build and Deploy Gateway / build-and-push (push) Successful in 13s
Build and Deploy Gateway / deploy (push) Successful in 13s
Enable connectivity diagnostics in Connect mode
2026-08-07 21:43:09 +03:00

671 lines
32 KiB
React
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 React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { api } from '../api.js';
import { copyText } from '../utils/clientControls.js';
import {
byteString,
formatByteString,
formatLastSeen,
positiveByteDelta,
stabilizeDevicesByTraffic,
trafficAxisMid,
trafficScaleRatio,
} from '../utils/format.js';
const AUTO_REFRESH_MS = 15_000;
const DEVICE_MOVE_MS = 520;
const COPY_FEEDBACK_MS = 800;
const TRAFFIC_DELTA_MS = 2_200;
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>;
}
function TrafficValue({ value, delta }) {
return <strong className={`client-device-traffic-value${delta ? ' has-delta' : ''}`}>
<span className="is-total">{value}</span>
<span className="is-delta">{delta ? `+${delta}` : ''}</span>
</strong>;
}
function chartTime(value) {
return new Date(value).toLocaleTimeString('ru-RU', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
});
}
function TrafficChart({ samples, scale, capacity, routeLabel, pinned }) {
const [hovered, setHovered] = useState(null);
const previousPoints = useRef([]);
const previousScale = useRef(scale);
const max = samples.reduce((largest, sample) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
return gateway > largest ? gateway : proxy > largest ? proxy : largest;
}, 0n);
const mid = trafficAxisMid(max, scale);
const firstSlot = capacity - samples.length;
const points = samples.map((sample, index) => {
const gateway = byteString(sample.gatewayBytes);
const proxy = byteString(sample.proxyBytes);
return {
sample,
x: (firstSlot + index) * 100 / Math.max(1, capacity - 1),
gateway,
proxy,
gatewayY: 100 - trafficScaleRatio(gateway, max, scale) * 100,
proxyY: 100 - trafficScaleRatio(proxy, max, scale) * 100,
};
});
const previous = points.slice(0, -1);
const penultimate = points.at(-2);
const newest = points.at(-1);
const hasProxy = points.some(({ proxy }) => proxy > 0n);
const scaleFrom = previousPoints.current;
const animateScale = previousScale.current !== scale
&& scaleFrom.length === points.length
&& !(typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
useLayoutEffect(() => {
previousPoints.current = points;
previousScale.current = scale;
}, [points, scale]);
function trackPointer(event) {
const bounds = event.currentTarget.getBoundingClientRect();
const slot = Math.round(Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (capacity - 1));
const index = slot - firstSlot;
if (index < 0 || index >= points.length) {
setHovered(null);
return;
}
setHovered({ ...points[index], clientX: event.clientX, clientY: event.clientY });
}
const tooltip = hovered && typeof document !== 'undefined' && createPortal(
<span
className="client-device-traffic-point-tooltip"
style={{
left: `${Math.max(8, Math.min(hovered.clientX + 12, globalThis.innerWidth - 190))}px`,
top: `${hovered.clientY < 150 ? hovered.clientY + 14 : hovered.clientY - 12}px`,
transform: hovered.clientY < 150 ? 'none' : 'translateY(-100%)',
}}
>
<time dateTime={hovered.sample.observedAt}>{chartTime(hovered.sample.observedAt)}</time>
<strong>Всего {formatByteString(hovered.gateway + hovered.proxy)}</strong>
<span>{routeLabel} {formatByteString(hovered.gateway)}</span>
{hovered.proxy > 0n && <span className="is-proxy">Proxy {formatByteString(hovered.proxy)}</span>}
</span>,
document.body,
);
return <span className="client-device-traffic-chart" role="img" aria-label={`История трафика, шкала ${scale === 'log' ? 'логарифмическая' : 'линейная'}, максимум ${formatByteString(max)}`}>
{pinned && max > 0n && <span className="client-device-traffic-axis" aria-hidden="true">
<span className="is-max">{formatByteString(max)}</span>
<span className="is-mid">{formatByteString(mid)}</span>
<span className="is-zero">0</span>
</span>}
<span className="client-device-traffic-plot" onPointerMove={trackPointer} onPointerLeave={() => setHovered(null)}>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{pinned && <g className="client-device-traffic-grid">
<line x1="0" x2="100" y1="0" y2="0" />
<line x1="0" x2="100" y1="50" y2="50" />
<line x1="0" x2="100" y1="100" y2="100" />
</g>}
<g className="client-device-traffic-lines" style={{ '--sample-count': Math.max(1, capacity) }}>
{previous.length > 0 && <polyline className="is-gateway" points={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}>
{animateScale && <animate
key={`gateway-${scale}`}
attributeName="points"
from={scaleFrom.slice(0, -1).map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
to={previous.map(({ x, gatewayY }) => `${x},${gatewayY}`).join(' ')}
dur="520ms"
calcMode="spline"
keyTimes="0;1"
keySplines="0.16 1 0.3 1"
fill="freeze"
/>}
</polyline>}
{hasProxy && previous.length > 0 && <polyline className="is-proxy" points={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}>
{animateScale && <animate
key={`proxy-${scale}`}
attributeName="points"
from={scaleFrom.slice(0, -1).map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
to={previous.map(({ x, proxyY }) => `${x},${proxyY}`).join(' ')}
dur="520ms"
calcMode="spline"
keyTimes="0;1"
keySplines="0.16 1 0.3 1"
fill="freeze"
/>}
</polyline>}
{penultimate && newest && <line className="is-gateway is-new" pathLength="1" x1={penultimate.x} y1={penultimate.gatewayY} x2={newest.x} y2={newest.gatewayY}>
{animateScale && <>
<animate key={`gateway-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).gatewayY} to={penultimate.gatewayY} dur="520ms" fill="freeze" />
<animate key={`gateway-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />
</>}
</line>}
{hasProxy && penultimate && newest && <line className="is-proxy is-new" pathLength="1" x1={penultimate.x} y1={penultimate.proxyY} x2={newest.x} y2={newest.proxyY}>
{animateScale && <>
<animate key={`proxy-y1-${scale}`} attributeName="y1" from={scaleFrom.at(-2).proxyY} to={penultimate.proxyY} dur="520ms" fill="freeze" />
<animate key={`proxy-y2-${scale}`} attributeName="y2" from={scaleFrom.at(-1).proxyY} to={newest.proxyY} dur="520ms" fill="freeze" />
</>}
</line>}
{!penultimate && newest && <circle className="is-gateway is-new" cx={newest.x} cy={newest.gatewayY} r="1.5">
{animateScale && <animate key={`gateway-cy-${scale}`} attributeName="cy" from={scaleFrom[0].gatewayY} to={newest.gatewayY} dur="520ms" fill="freeze" />}
</circle>}
</g>
{hovered && <g className="client-device-traffic-cursor">
<line x1={hovered.x} x2={hovered.x} y1="0" y2="100" />
<circle className="is-gateway" cx={hovered.x} cy={hovered.gatewayY} r="2" />
{hovered.proxy > 0n && <circle className="is-proxy" cx={hovered.x} cy={hovered.proxyY} r="2" />}
</g>}
</svg>
</span>
{samples.length > 0 && <span className="client-device-traffic-time" aria-hidden="true">
<time dateTime={samples[0].observedAt}>{chartTime(samples[0].observedAt)}</time>
<span>15 с</span>
<time dateTime={samples.at(-1).observedAt}>{chartTime(samples.at(-1).observedAt)}</time>
</span>}
{tooltip}
</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('');
const [refreshing, setRefreshing] = useState(false);
const [refreshCycle, setRefreshCycle] = useState(0);
const [sortDirection, setSortDirection] = useState('desc');
const [trafficScale, setTrafficScale] = useState('linear');
const [copyFeedback, setCopyFeedback] = useState(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState({});
const deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
const previousOrder = useRef([]);
const previousScrollTop = useRef(0);
const movementAnimations = useRef(new Map());
const previousTraffic = useRef(new Map());
const copyTimer = useRef(null);
const trafficDeltaTimer = useRef(null);
const trafficOrder = useRef({ direction: sortDirection, ids: [] });
const devices = useMemo(
() => {
const previousIds = trafficOrder.current.direction === sortDirection
? trafficOrder.current.ids
: [];
const result = stabilizeDevicesByTraffic(snapshot?.devices, sortDirection, previousIds);
trafficOrder.current = { direction: sortDirection, ids: result.ids };
return result.devices;
},
[snapshot?.devices, sortDirection],
);
async function load(quiet = false, discover = false) {
if (!quiet) setStatus(snapshot ? 'refreshing' : 'loading');
setRefreshing(true);
try {
const next = await (discover ? api.devices.refresh() : api.devices.list());
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
setStatus('ready');
} catch (requestError) {
setError(requestError);
setStatus('error');
} finally {
setRefreshing(false);
setRefreshCycle((cycle) => cycle + 1);
}
}
useEffect(() => {
if (!open) return undefined;
load();
return undefined;
}, [open]);
useEffect(() => {
if (!open || refreshing || status === 'loading') return undefined;
const timer = setTimeout(() => load(true), AUTO_REFRESH_MS);
return () => clearTimeout(timer);
}, [open, refreshCycle, refreshing, status]);
useEffect(() => () => {
clearTimeout(copyTimer.current);
clearTimeout(trafficDeltaTimer.current);
}, []);
useEffect(() => {
if (!open) {
previousTraffic.current.clear();
clearTimeout(trafficDeltaTimer.current);
setTrafficDeltas({});
return;
}
const next = new Map();
const deltas = {};
for (const device of snapshot?.devices || []) {
const gateway = byteString(device.downloadBytes) + byteString(device.uploadBytes);
const proxy = byteString(device.proxyDownloadBytes) + byteString(device.proxyUploadBytes);
const previous = previousTraffic.current.get(device.id);
next.set(device.id, { gateway, proxy });
if (!previous) continue;
const gatewayDelta = positiveByteDelta(previous.gateway, gateway);
const proxyDelta = positiveByteDelta(previous.proxy, proxy);
if (!gatewayDelta && !proxyDelta) continue;
const totalDelta = positiveByteDelta(previous.gateway + previous.proxy, gateway + proxy);
deltas[device.id] = { gateway: gatewayDelta, proxy: proxyDelta, total: totalDelta };
}
previousTraffic.current = next;
if (!Object.keys(deltas).length) return;
setTrafficDeltas(deltas);
clearTimeout(trafficDeltaTimer.current);
trafficDeltaTimer.current = setTimeout(() => setTrafficDeltas({}), TRAFFIC_DELTA_MS);
}, [snapshot?.devices, open]);
useLayoutEffect(() => {
if (!open) {
previousPositions.current.clear();
previousOrder.current = [];
previousScrollTop.current = 0;
for (const animation of movementAnimations.current.values()) animation.cancel();
movementAnimations.current.clear();
return;
}
const positions = new Map();
for (const [id, node] of deviceNodes.current) {
movementAnimations.current.get(id)?.cancel();
positions.set(id, node.getBoundingClientRect());
}
const order = devices.map(({ id }) => id);
const orderChanged = previousOrder.current.length > 0
&& (order.length !== previousOrder.current.length
|| order.some((id, index) => id !== previousOrder.current[index]));
const currentScrollTop = panelRef.current?.scrollTop || 0;
if (orderChanged && !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;
previousOrder.current = order;
previousScrollTop.current = currentScrollTop;
}, [devices, open, panelRef]);
async function updateDevice(device, patch) {
setSavingId(device.id);
try {
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) {
setError(requestError);
return false;
} finally {
setSavingId('');
}
}
async function saveAlias(event, device) {
event.preventDefault();
if (!await updateDevice(device, { alias })) return;
setEditingId('');
}
async function updatePolicy(device, mode) {
setSavingId(device.id);
try {
let next;
try {
next = await api.devices.setPolicy(device.id, mode, 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 || latestDevice.desiredPolicy !== device.desiredPolicy) throw requestError;
next = await api.devices.setPolicy(device.id, mode, latest.revision);
}
setSnapshot((current) => !current || next.revision > current.revision ? next : current);
setError(null);
} catch (requestError) {
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
try {
const latest = await api.devices.list();
setSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
} catch {
// Keep the policy error as the actionable result.
}
}
setError(requestError);
} finally {
setSavingId('');
}
}
async function copyDeviceIp(device) {
if (!device.ip) return;
clearTimeout(copyTimer.current);
try {
await copyText(device.ip);
setCopyFeedback({ id: device.id, failed: false });
} catch {
setCopyFeedback({ id: device.id, failed: true });
}
copyTimer.current = setTimeout(() => setCopyFeedback(null), COPY_FEEDBACK_MS);
}
function startEditing(device) {
setEditingId(device.id);
setAlias(device.alias || device.hostname || '');
}
return (
<aside
ref={panelRef}
id="client-devices"
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-drawer-sheet 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">
<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={() => load(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={trafficScale === 'linear'} onClick={() => setTrafficScale('linear')}>Лин</button>
<button type="button" aria-pressed={trafficScale === 'log'} onClick={() => setTrafficScale('log')}>Лог</button>
</span>
</div>
<h2 id="client-devices-title">Устройства</h2>
<div className="client-instructions-intro">
<p>Устройства, которые Gateway видит в локальной таблице соседей.</p>
<p>Учитывается только трафик, который прошёл через Harbor.</p>
</div>
</header>
{snapshot?.source?.error && (
<p className="client-devices-source" role="status">
Список временно не обновляется. Показаны последние сохранённые данные.
</p>
)}
{snapshot?.source?.traffic?.error && (
<p className="client-devices-source" role="status">
Трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{snapshot?.source?.traffic?.proxy?.error && (
<p className="client-devices-source" role="status">
Прокси-трафик временно не обновляется. Показаны последние сохранённые значения.
</p>
)}
{snapshot?.source?.policy?.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-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать IP' : 'IP скопирован' : ''}
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
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 trafficDelta = trafficDeltas[device.id] || {};
const copied = copyFeedback?.id === 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
ref={(node) => {
if (node) deviceNodes.current.set(device.id, node);
else deviceNodes.current.delete(device.id);
}}
className={`client-device is-${device.status}${device.pinned ? ' is-pinned' : ''}`}
key={device.id}
>
<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 className="client-device-main">
{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>
) : (
<>
<h3 className="client-device-name-heading">
{hasName && <button
className="client-device-alias-trigger"
type="button"
aria-label={`Изменить название ${title}`}
onClick={() => startEditing(device)}
>{title}</button>}
{hasName && device.ip && <span className="client-device-name-separator" aria-hidden="true">·</span>}
{device.ip ? <button
className={`client-device-ip${copied ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
type="button"
aria-label={`Скопировать IP ${device.ip} устройства ${title}`}
onClick={() => copyDeviceIp(device)}
>{device.ip}</button> : !hasName && <span>Неизвестное устройство</span>}
</h3>
{!hasName && <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}
aria-label={`${online ? 'В сети' : 'Не в сети'}. Последний контакт: ${seen.tooltip}`}
>
{online ? 'В сети' : <TextMorph from="Не в сети" to={seen.relative} />}
</time>
</span>
</div>
<span
className="client-device-traffic"
role="group"
tabIndex="0"
aria-label={`Всего ${totalTraffic}. Gateway ${gatewayTraffic}${hasProxyTraffic ? `, Прокси ${proxyTraffic}` : ''}`}
>
<span className="client-device-traffic-total" aria-hidden="true">
<b>Всего</b><TrafficValue value={totalTraffic} delta={trafficDelta.total} />
</span>
<span className="client-device-traffic-breakdown" aria-hidden="true">
<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>
<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>
<TrafficChart
samples={device.trafficHistory || []}
scale={trafficScale}
capacity={snapshot.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
pinned={device.pinned}
/>
</article>;
})}
</div>
</div>
</aside>
);
}