Files
harbor-net/src/web/components/DevicesPanel.jsx
T
dokril 53e6cf2146
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s
Track proxy traffic separately in device inventory
2026-08-07 17:08:24 +03:00

400 lines
19 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 { api } from '../api.js';
import {
byteString,
formatByteString,
formatLastSeen,
sortDevicesByTraffic,
} from '../utils/format.js';
const AUTO_REFRESH_MS = 15_000;
const DEVICE_MOVE_MS = 520;
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>;
}
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 deviceNodes = useRef(new Map());
const previousPositions = useRef(new Map());
const devices = useMemo(
() => sortDevicesByTraffic(snapshot?.devices, sortDirection),
[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]);
useLayoutEffect(() => {
if (!open) {
previousPositions.current.clear();
return;
}
const positions = new Map();
for (const [id, node] of deviceNodes.current) {
node.getAnimations().forEach((animation) => animation.cancel());
positions.set(id, node.getBoundingClientRect());
}
if (!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 : 0;
if (Math.abs(deltaY) < 1) continue;
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)' });
}
}
previousPositions.current = positions;
}, [devices, open]);
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('');
}
}
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 aria-hidden="true">{sortDirection === 'desc' ? '↓' : '↑'}</span>
</button>
<Tooltip>Сначала {sortDirection === 'desc' ? 'больше' : 'меньше'} трафика</Tooltip>
</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-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
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 download = formatByteString(device.downloadBytes);
const upload = formatByteString(device.uploadBytes);
const proxyDownload = formatByteString(device.proxyDownloadBytes);
const proxyUpload = formatByteString(device.proxyUploadBytes);
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 trafficLabel = [
gatewayTotal > 0n ? `Gateway: получено ${download}, отдано ${upload}` : '',
proxyTotal > 0n ? `Прокси: получено ${proxyDownload}, отдано ${proxyUpload}` : '',
].filter(Boolean).join('. ');
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.pinned || device.confidence === 'ambiguous');
const policyTooltip = policyBusy
? `Применяем: ${device.desiredPolicy === 'direct' ? 'полностью напрямую' : 'через правила Gateway'}`
: policyFailed
? `${device.policyError || 'Маршрут не применён'}. Сейчас: ${device.appliedPolicy === 'direct' ? 'напрямую' : 'через Gateway'}. Нажмите, чтобы оставить текущий маршрут`
: policyPending
? 'Gateway должен однозначно распознать устройство. Нажмите, чтобы отменить ожидание'
: !device.pinned
? 'Закрепите устройство, чтобы изменить маршрут'
: 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}`}
key={device.id}
>
<div className="client-device-heading">
{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>
) : (
<div className="client-device-title">
<h3>{title}</h3>
<span className="client-device-edit-wrap client-tooltip-anchor">
<button
className="client-device-edit"
type="button"
aria-label={`Изменить название ${title}`}
onClick={() => {
setEditingId(device.id);
setAlias(device.alias || '');
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<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>
</div>
)}
<span className="client-device-traffic-slot">
{(gatewayTotal > 0n || proxyTotal > 0n) && <span
className="client-device-traffic client-tooltip-anchor"
tabIndex="0"
aria-label={trafficLabel}
>
<span className="client-device-traffic-sources" aria-hidden="true">
{gatewayTotal > 0n && <span>Gateway {gatewayTraffic}</span>}
{proxyTotal > 0n && <span className="is-proxy">Прокси {proxyTraffic}</span>}
</span>
<Tooltip>{trafficLabel}</Tooltip>
</span>}
</span>
<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 || device.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'}
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.desiredPolicy === 'direct' || device.appliedPolicy === 'direct'
? 'Сначала верните маршрут через Gateway'
: device.pinned ? 'Открепить' : 'Закрепить'}</Tooltip>
</span>
</div>
<div className="client-device-meta">
<div className="client-device-addresses">
{title !== device.ip && device.ip && <span>{device.ip}</span>}
{device.manufacturer && <span className="client-device-manufacturer">{device.manufacturer}</span>}
</div>
<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' ? 'Напрямую' : 'VPN'}
</button>
<Tooltip>{policyTooltip}</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={seen.label} to={seen.relative} />}
</time>
</span>
</div>
</article>;
})}
</div>
</div>
</aside>
);
}