400 lines
19 KiB
React
400 lines
19 KiB
React
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>
|
||
);
|
||
}
|