Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
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;
|
||||
}
|
||||
|
||||
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 Tooltip({ children }: { children: ReactNode }) {
|
||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||
}
|
||||
|
||||
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,
|
||||
load: onLoad,
|
||||
updateDevice,
|
||||
updatePolicy,
|
||||
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 [copyFeedback, setCopyFeedback] = useState<{ id: string; failed: boolean } | null>(null);
|
||||
const [pencilAnimationId, setPencilAnimationId] = useState('');
|
||||
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
|
||||
const deviceNodes = useRef(new Map<string, HTMLElement>());
|
||||
const previousPositions = useRef(new Map<string, DOMRect>());
|
||||
const previousOrder = useRef<string[]>([]);
|
||||
const previousScrollTop = useRef(0);
|
||||
const movementAnimations = useRef(new Map<string, Animation>());
|
||||
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
|
||||
const aliasBaseline = useRef({ id: '', value: '' });
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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(() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
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, { gateway: bigint; proxy: bigint }>();
|
||||
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 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);
|
||||
if (trafficDeltaTimer.current) 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<string, DOMRect>();
|
||||
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 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 copyDeviceIp(device: Device) {
|
||||
if (!device.ip) return;
|
||||
if (copyTimer.current) 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: Device) {
|
||||
const value = device.alias || device.hostname || '';
|
||||
aliasBaseline.current = { id: device.id, value };
|
||||
setEditingId(device.id);
|
||||
setAlias(value);
|
||||
}
|
||||
|
||||
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={() => 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={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>
|
||||
|
||||
{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">
|
||||
{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">
|
||||
<h3 className={`client-device-name-heading${hasName ? '' : ' is-address-only'}${editing ? ' is-editing' : ''}`}>
|
||||
{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"
|
||||
type="button"
|
||||
aria-label={`Изменить название ${title}`}
|
||||
onClick={() => startEditing(device)}
|
||||
>{title}</button>}
|
||||
{(hasName || (editing && Boolean(alias))) && 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 && !editing && <span>Неизвестное устройство</span>}
|
||||
</h3>
|
||||
{!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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user