501 lines
24 KiB
React
501 lines
24 KiB
React
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||
import { api } from '../api.js';
|
||
import { copyText } from '../utils/clientControls.js';
|
||
import {
|
||
byteString,
|
||
formatByteString,
|
||
formatLastSeen,
|
||
positiveByteDelta,
|
||
stabilizeDevicesByTraffic,
|
||
} from '../utils/format.js';
|
||
import { TrafficChart } from './TrafficChart.jsx';
|
||
|
||
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>;
|
||
}
|
||
|
||
export function DevicesPanel({
|
||
open, panelRef, closeRef, onClose, snapshot, status, error, refreshing, refreshCycle,
|
||
onLoad, onSnapshot, onError,
|
||
}) {
|
||
const [editingId, setEditingId] = useState('');
|
||
const [alias, setAlias] = useState('');
|
||
const [savingId, setSavingId] = useState('');
|
||
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 aliasBaseline = useRef({ id: '', value: '' });
|
||
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],
|
||
);
|
||
|
||
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();
|
||
onSnapshot((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);
|
||
}
|
||
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||
onError(null);
|
||
return true;
|
||
} catch (requestError) {
|
||
onError(requestError);
|
||
return false;
|
||
} finally {
|
||
setSavingId('');
|
||
}
|
||
}
|
||
|
||
async function saveAlias(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 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();
|
||
onSnapshot((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);
|
||
}
|
||
onSnapshot((current) => !current || next.revision > current.revision ? next : current);
|
||
onError(null);
|
||
} catch (requestError) {
|
||
if (requestError.code === 'DEVICE_POLICY_APPLY_FAILED') {
|
||
try {
|
||
const latest = await api.devices.list();
|
||
onSnapshot((current) => !current || latest.revision > current.revision ? latest : current);
|
||
} catch {
|
||
// Keep the policy error as the actionable result.
|
||
}
|
||
}
|
||
onError(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) {
|
||
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>
|
||
|
||
{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={() => 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` }}
|
||
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}
|
||
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>
|
||
);
|
||
}
|