fix(ui): обновлять детали и пояснять модель карты сети
This commit is contained in:
1 parent
6cb27b2aab
commit
0cdb43a29d
6 files changed
+133
-63
No files matched your search
@@ -436,7 +436,7 @@ function pingColor(ms: number | null) {
|
||||
return "#fb923c"
|
||||
}
|
||||
function latencyColor(ms: number | null) {
|
||||
if (ms == null) return "#f87171"
|
||||
if (ms == null) return "var(--muted-foreground)"
|
||||
if (ms > 60) return "#fbbf24"
|
||||
return "#4ade80"
|
||||
}
|
||||
@@ -506,7 +506,7 @@ function PingBadge({ mx, my, ping, dl, ul, color }: {
|
||||
</text>
|
||||
{hasSpeed && (
|
||||
<text textAnchor="middle" y="10" fontSize="7" fill="#64748b" fontFamily="ui-monospace,monospace">
|
||||
{`↓${dl} ↑${ul}`}
|
||||
{`↓${dl??"—"} ↑${ul??"—"}`}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
@@ -524,6 +524,7 @@ function GreEdgeMetricBadge({
|
||||
rttFromMonitor,
|
||||
throughputFromMonitor,
|
||||
outerSummary,
|
||||
probe,
|
||||
}: {
|
||||
mx: number
|
||||
my: number
|
||||
@@ -536,11 +537,9 @@ function GreEdgeMetricBadge({
|
||||
throughputFromMonitor?: boolean
|
||||
/** Внешние IP GRE и WAN — только в подсказке при наведении */
|
||||
outerSummary?: string
|
||||
probe?: GreSpeedProbeSnapshot
|
||||
}) {
|
||||
const hasSpeed =
|
||||
dl != null &&
|
||||
ul != null &&
|
||||
(throughputFromMonitor || dl > 0 || ul > 0)
|
||||
const hasSpeed = dl != null || ul != null
|
||||
const bw = 62
|
||||
const bh = 24 + (hasSpeed ? 14 : 0)
|
||||
const v1 = pingMs == null ? "—" : `${pingMs} мс`
|
||||
@@ -548,8 +547,9 @@ function GreEdgeMetricBadge({
|
||||
(outerSummary ? `${outerSummary}. ` : "") +
|
||||
(rttFromMonitor
|
||||
? "RTT — ping/BT из «Мониторинг → скорость». "
|
||||
: "RTT — модель по данным туннеля. ") +
|
||||
: pingMs==null?"RTT неизвестен. ":"RTT — демонстрационная модель. ") +
|
||||
(throughputFromMonitor ? "Строка ниже — TX/RX с пробы. " : "") +
|
||||
(probe?"Проба "+probe.id+"; время Ping: "+(probe.lastPingAt?new Date(probe.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+"; время TX/RX неизвестно. ":"") +
|
||||
"Клик — панель справа."
|
||||
return (
|
||||
<g
|
||||
@@ -679,7 +679,9 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
||||
const ts = TYPE_STYLE[n.type]
|
||||
|
||||
return (
|
||||
<g transform={`translate(${n.x},${n.y})`}
|
||||
<g role="button" aria-label={"Устройство "+n.name} aria-pressed={isSel} tabIndex={isVis?0:-1}
|
||||
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onClick()}}}
|
||||
transform={`translate(${n.x},${n.y})`}
|
||||
style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }}
|
||||
opacity={isVis ? 1 : 0.07}
|
||||
onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }}
|
||||
@@ -691,8 +693,6 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
||||
>
|
||||
{n.status === "online" && isVis && (
|
||||
<circle r={ts.r + 14} fill={ss.glow} opacity="0.7">
|
||||
<animate attributeName="r" values={`${ts.r+10};${ts.r+18};${ts.r+10}`} dur="3s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.8;0.15;0.8" dur="3s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
)}
|
||||
{n.status === "degraded" && isVis && (
|
||||
@@ -753,13 +753,13 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
||||
)}
|
||||
{!hideCatalogLatency && (
|
||||
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
|
||||
{n.latency == null ? "офлайн" : `${n.latency} мс`}
|
||||
{n.latency == null ? "—" : `${n.latency} мс`}
|
||||
</text>
|
||||
)}
|
||||
{n.type === "home-router" && (
|
||||
{(n.name && (
|
||||
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="#94a3b8"
|
||||
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
|
||||
)}
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -1147,6 +1147,7 @@ function NetworkMapContent() {
|
||||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||||
|
||||
const [mapServers, setMapServers] = useState<Server[]>([])
|
||||
const [sessionCounts,setSessionCounts]=useState<Record<string,number|null>>({})
|
||||
const [mapGreTunnels, setMapGreTunnels] = useState<GreTunnel[]>([])
|
||||
const [speedProbes, setSpeedProbes] = useState<GreSpeedProbeSnapshot[]>([])
|
||||
const [mapHops, setMapHops] = useState<FlowMapHop[]>([])
|
||||
@@ -1206,6 +1207,7 @@ function NetworkMapContent() {
|
||||
const mappedTunnels=snapshot.tunnels.map(apiGreToGreTunnel)
|
||||
if(!current())return
|
||||
setMapServers(rows.map(mapBackendRowToServer));setMapGreTunnels(mappedTunnels)
|
||||
setSessionCounts(Object.fromEntries(rows.map(row=>[String(row.id),row.sessions??null])))
|
||||
setSpeedProbes(snapshot.probes.map(probe=>({...probe,srcInterface:probe.srcInterface||undefined,dstInterface:probe.dstInterface||undefined})))
|
||||
setHasSnapshot(true);setReadAt(new Date().toISOString());setDataError(null)
|
||||
const hostSet = new Set<string>(collectGreEndpointHostnames(mappedTunnels))
|
||||
@@ -1283,7 +1285,7 @@ function NetworkMapContent() {
|
||||
() => computeNetworkMapLayout(mapServers, mapGreTunnels, greResolvedMap),
|
||||
[mapServers, mapGreTunnels, greResolvedMap],
|
||||
)
|
||||
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers), [mapServers])
|
||||
const wanJhEdges = useMemo(() => buildWanJhEdges(mapServers).map(edge=>({...edge,active:!useLiveData&&edge.active})), [mapServers,useLiveData])
|
||||
const srvResMap = useMemo(() => buildServerResourceMap(mapServers, !useLiveData), [mapServers,useLiveData])
|
||||
const homeRouters = useMemo(
|
||||
() => mapServers.filter((s) => s.type === "home-router"),
|
||||
@@ -1296,7 +1298,8 @@ function NetworkMapContent() {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
const [selected, setSelected] = useState<Server | null>(null)
|
||||
const [selectedStored, setSelected] = useState<Server | null>(null)
|
||||
const selected=selectedStored?mapServers.find(server=>server.id===selectedStored.id)??null:null
|
||||
const [selectedService, setSelectedService] = useState<FlowMapService | null>(null)
|
||||
/** Раскрытая страна (режим «Страны»): справа столбец её сервисов. Не персистится. */
|
||||
const [expandedCountryId, setExpandedCountryId] = useState<string | null>(null)
|
||||
@@ -1350,9 +1353,7 @@ function NetworkMapContent() {
|
||||
if (sessionStorage.getItem(k) !== String(NETWORK_MAP_LAYOUT_REVISION)) {
|
||||
// Важно: без явного сброса старые drag-координаты остаются в state
|
||||
// и перекрывают новый авто-лейаут на live-данных.
|
||||
setNodePositions({})
|
||||
setSatPositions({})
|
||||
setServicePositions({})
|
||||
queueMicrotask(()=>{setNodePositions({});setSatPositions({});setServicePositions({})})
|
||||
sessionStorage.setItem(k, String(NETWORK_MAP_LAYOUT_REVISION))
|
||||
}
|
||||
} catch {
|
||||
@@ -1363,7 +1364,7 @@ function NetworkMapContent() {
|
||||
// ── Context menu ────────────────────────────────────────────────────────────
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
|
||||
|
||||
const [selectedGreEdge, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
|
||||
const [selectedGreStored, setSelectedGreEdge] = useState<GreMapEdge | null>(null)
|
||||
|
||||
// ── Toggles ─────────────────────────────────────────────────────────────────
|
||||
const [filter, setFilter] = useState<FilterKey>("all")
|
||||
@@ -1524,6 +1525,8 @@ function NetworkMapContent() {
|
||||
[greEdges, speedProbes, greResolvedMap],
|
||||
)
|
||||
|
||||
const selectedGreEdge=selectedGreStored?greEdges.find(edge=>greEdgeKey(edge)===greEdgeKey(selectedGreStored))??null:null
|
||||
|
||||
/** WAN→JH: не показывать GreEdgeMetricBadge со speed, если те же метрики уже на GRE (один бейдж на сегмент). */
|
||||
const suppressWanJhSpeedBadgeDuplicate = useMemo(() => {
|
||||
const s = new Set<string>()
|
||||
@@ -1697,8 +1700,7 @@ function NetworkMapContent() {
|
||||
|
||||
const zoomRef = useRef(zoom)
|
||||
const panRef = useRef(pan)
|
||||
zoomRef.current = zoom
|
||||
panRef.current = pan
|
||||
useEffect(()=>{zoomRef.current=zoom;panRef.current=pan},[zoom,pan])
|
||||
|
||||
// ── SVG export ───────────────────────────────────────────────────────────
|
||||
function exportSvg() {
|
||||
@@ -1746,6 +1748,27 @@ function NetworkMapContent() {
|
||||
return () => svg.removeEventListener("wheel", handler)
|
||||
}, [])
|
||||
|
||||
// ── Zoom helpers ──────────────────────────────────────────────────────────
|
||||
const applyZoomCenter=useCallback((factor: number) => {
|
||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||||
setPan(p => ({
|
||||
x: p.x + (W / zoom - W / newZoom) / 2,
|
||||
y: p.y + (H / zoom - H / newZoom) / 2,
|
||||
}))
|
||||
setZoom(newZoom)
|
||||
},[zoom])
|
||||
|
||||
const fitView=useCallback(() => {
|
||||
setPan({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
},[])
|
||||
|
||||
function focusNode(x: number, y: number) {
|
||||
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
|
||||
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
|
||||
setZoom(newZoom)
|
||||
}
|
||||
|
||||
// ── Keyboard shortcuts ────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -1766,28 +1789,7 @@ function NetworkMapContent() {
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [zoom, pan]) // eslint-disable-line react-hooks/exhaustive-deps -- applyZoomCenter/fitView ниже; handler обновляется через [zoom, pan]
|
||||
|
||||
// ── Zoom helpers ──────────────────────────────────────────────────────────
|
||||
function applyZoomCenter(factor: number) {
|
||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||||
setPan(p => ({
|
||||
x: p.x + (W / zoom - W / newZoom) / 2,
|
||||
y: p.y + (H / zoom - H / newZoom) / 2,
|
||||
}))
|
||||
setZoom(newZoom)
|
||||
}
|
||||
|
||||
function fitView() {
|
||||
setPan({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
}
|
||||
|
||||
function focusNode(x: number, y: number) {
|
||||
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
|
||||
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
|
||||
setZoom(newZoom)
|
||||
}
|
||||
}, [applyZoomCenter,fitView])
|
||||
|
||||
// ── Drag ─────────────────────────────────────────────────────────────────
|
||||
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
||||
@@ -1966,7 +1968,7 @@ function NetworkMapContent() {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
const connectedTunnels = selected
|
||||
const connectedTunnels = useMemo(()=>selected
|
||||
? mapGreTunnels.filter((t) => {
|
||||
if (t.serverId === selected.id) return true
|
||||
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
|
||||
@@ -1983,7 +1985,7 @@ function NetworkMapContent() {
|
||||
selectedWanIps.includes(local)
|
||||
)
|
||||
})
|
||||
: []
|
||||
: [],[selected,mapGreTunnels,mapServers,greResolvedMap])
|
||||
const grePrimaryByGroupKey = useMemo(() => {
|
||||
if (!selected) return new Map<string, string>()
|
||||
const group = new Map<string, GreTunnel[]>()
|
||||
@@ -2182,6 +2184,7 @@ function NetworkMapContent() {
|
||||
{useLiveData&&flowLoading&&(showNetflow||showServices)&&<span role="status">Обновление NetFlow…</span>}
|
||||
{useLiveData&&flowError&&(showNetflow||showServices)&&<span role="alert" className="text-destructive break-all">NetFlow: {flowError}. {flowReadAt?"Сохранён предыдущий снимок NetFlow; актуальность не подтверждена.":"Первоначальная загрузка NetFlow не подтверждена."}</span>}
|
||||
{useLiveData&&dnsError&&<span role="alert" className="text-destructive break-all">DNS: {dnsError}. Связи по неизвестному адресу не подтверждены.</span>}
|
||||
<span>Пунктир WAN→JH — модель связности; маршрут не подтверждён.</span>
|
||||
{dataLoading && <span role="status">Обновление снимка карты…</span>}
|
||||
{dataError && (
|
||||
<span role="alert" className="text-[10px] text-destructive break-all">
|
||||
@@ -2327,6 +2330,7 @@ function NetworkMapContent() {
|
||||
dl={merged.dlMbps}
|
||||
ul={merged.ulMbps}
|
||||
onOpen={openGreDetail}
|
||||
probe={spGre}
|
||||
rttFromMonitor={rttFromMonitor}
|
||||
throughputFromMonitor={throughputFromMonitor}
|
||||
outerSummary={greOuterSummaryLine(e.tunnel, fromN, toN, greResolvedMap)}
|
||||
@@ -2395,7 +2399,7 @@ function NetworkMapContent() {
|
||||
const kj = wanJhEdgeMapKey(edge)
|
||||
const sp = speedProbeByWanJhKey.get(kj)
|
||||
const merged = mergeGreMetricsWithSpeedProbe(sp, {
|
||||
pingMs: edge.pingMs,
|
||||
pingMs: useLiveData?null:edge.pingMs,
|
||||
dlMbps: null,
|
||||
ulMbps: null,
|
||||
})
|
||||
@@ -2411,6 +2415,7 @@ function NetworkMapContent() {
|
||||
dl={merged.dlMbps}
|
||||
ul={merged.ulMbps}
|
||||
onOpen={(ev) => openWanJhSpeedDetail(ev, edge)}
|
||||
probe={sp}
|
||||
rttFromMonitor={sp?.lastPingRttMs != null}
|
||||
throughputFromMonitor={
|
||||
sp?.lastTxAvgMbps != null || sp?.lastRxAvgMbps != null
|
||||
@@ -2418,7 +2423,7 @@ function NetworkMapContent() {
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <PingBadge mx={mx} my={my} ping={edge.pingMs} color={pingColor(edge.pingMs)} />
|
||||
return <PingBadge mx={mx} my={my} ping={useLiveData?null:edge.pingMs} color={pingColor(useLiveData?null:edge.pingMs)} />
|
||||
})()}
|
||||
{showNetflow && hopHasRate(wanFlow) && (
|
||||
<NetflowRateBadge
|
||||
@@ -2744,7 +2749,7 @@ function NetworkMapContent() {
|
||||
<line x1="38" y1="4" x2="60" y2="4" stroke={c}
|
||||
strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
|
||||
<text x="66" y="8" fontSize="7.5" fill="#475569" fontFamily="system-ui">
|
||||
{i === 0 ? "primary" : "backup"}
|
||||
{useLiveData?"модель":i === 0 ? "primary (демо)" : "backup (демо)"}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
@@ -2901,7 +2906,7 @@ function NetworkMapContent() {
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{rttMon ? "Мониторинг (ping/BT)" : "Модель RTT"}
|
||||
{rttMon ? "Мониторинг (ping/BT)" : useLiveData?"Ping неизвестен":"Модель RTT (демо)"}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-mono font-semibold"
|
||||
@@ -2912,11 +2917,11 @@ function NetworkMapContent() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 border-b border-border/50">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{bwMon ? "TX / RX (BT)" : "Скорость канала (модель)"}
|
||||
{bwMon ? "TX / RX (BT)" : useLiveData?"TX / RX неизвестны":"Скорость канала (демо)"}
|
||||
</span>
|
||||
<span className="text-xs font-mono font-semibold text-sky-400">
|
||||
{merged.dlMbps != null && merged.ulMbps != null
|
||||
? `↓${merged.dlMbps} ↑${merged.ulMbps}`
|
||||
{(merged.dlMbps != null || merged.ulMbps != null)
|
||||
? `↓${merged.dlMbps??"—"} ↑${merged.ulMbps??"—"}`
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -2935,8 +2940,9 @@ function NetworkMapContent() {
|
||||
<p className="text-[10px] text-muted-foreground pt-2 leading-snug">
|
||||
{merged.hasSpeedMonitor
|
||||
? "Ping и/или TX/RX — с последнего прогона speed-пробы; проба сопоставляется с этим GRE по WAN и интерфейсам. "
|
||||
: "«Модель RTT» и «скорость канала» — демо до появления подходящей speed-пробы в «Мониторинг → скорость». "}
|
||||
Поток — живой NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
|
||||
: useLiveData?"Измерения GRE неизвестны: нет подходящей пробы. ":"Измерения демонстрационные. "}
|
||||
{spGr?"Проба "+spGr.id+" · время Ping: "+(spGr.lastPingAt?new Date(spGr.lastPingAt).toLocaleString("ru-RU"):"неизвестно")+" · время TX/RX неизвестно. ":""}
|
||||
Поток — снимок NetFlow за 5 мин (как в «Трафик»: без overlay/mesh), не ёмкость канала.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3267,7 +3273,7 @@ function NetworkMapContent() {
|
||||
["Модель", selected.model],
|
||||
["RouterOS", selected.os],
|
||||
...(selected.type !== "home-router"
|
||||
? [["ASN", selected.asn], ["Сессий", String(selected.sessions)]]
|
||||
? [["ASN", selected.asn], ["Сессий", useLiveData?(sessionCounts[selected.id]==null?"—":String(sessionCounts[selected.id])):String(selected.sessions)]]
|
||||
: [["LAN", selected.lanSubnet ?? "—"]]),
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex items-center justify-between py-2 border-b border-border/50 last:border-0">
|
||||
@@ -3278,7 +3284,7 @@ function NetworkMapContent() {
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-xs text-muted-foreground">Задержка</span>
|
||||
<span className="text-xs font-mono font-semibold" style={{ color: latencyColor(selected.latency) }}>
|
||||
{selected.latency == null ? "— офлайн" : `${selected.latency} мс`}
|
||||
{selected.latency == null ? "—" : `${selected.latency} мс`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3310,7 +3316,7 @@ function NetworkMapContent() {
|
||||
"ml-auto text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||
isActive ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{isActive ? "primary" : "backup"}
|
||||
{useLiveData?"Аплинк каталога":isActive ? "primary (демо)" : "backup (демо)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -3339,7 +3345,7 @@ function NetworkMapContent() {
|
||||
{myEdges.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border/40">
|
||||
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
|
||||
Подключения к JH
|
||||
Модель связей с JH · Ping модели
|
||||
</p>
|
||||
{myEdges.map((e) => {
|
||||
const jh = mapServers.find(s => s.id === e.jhId)
|
||||
@@ -3405,7 +3411,7 @@ function NetworkMapContent() {
|
||||
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
|
||||
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{isPrimary ? "primary" : "backup"}
|
||||
{isPrimary ? "Основная на схеме" : "Дополнительная на схеме"}
|
||||
</span>
|
||||
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
|
||||
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
|
||||
@@ -3418,7 +3424,7 @@ function NetworkMapContent() {
|
||||
</span>
|
||||
{t.ipsec
|
||||
? <span className="text-[10px] text-emerald-400 flex items-center gap-0.5"><LockIcon className="size-2.5" />IPsec</span>
|
||||
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />Plain</span>}
|
||||
: <span className="text-[10px] text-muted-foreground flex items-center gap-0.5"><LockOpenIcon className="size-2.5" />{useLiveData?"IPsec неизвестен":"Plain (демо)"}</span>}
|
||||
</div>
|
||||
{showMetrics && (
|
||||
<div className="flex items-center justify-between pt-1 border-t border-border/30">
|
||||
|
||||
Reference in new issue
Block a user