691 lines
34 KiB
TypeScript
691 lines
34 KiB
TypeScript
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}
|
||
/>
|
||
</>;
|
||
}
|