671 lines
32 KiB
React
671 lines
32 KiB
React
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>
|
||
);
|
||
}
|