Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m9s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
Коллектор снова отдаёт назначения в живом потоке. Ошибки записи видны в статусе, лишние перезаписи минутных агрегатов убраны. Co-authored-by: Cursor <[email protected]>
1482 lines
63 KiB
TypeScript
1482 lines
63 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useMemo, useEffect, useCallback, type ReactNode } from "react"
|
||
import { PageHeader } from "@/components/page-header"
|
||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Sparkline } from "@/components/sparkline"
|
||
import { StatusDot } from "@/components/status-dot"
|
||
import { Flag } from "@/components/flag"
|
||
import {
|
||
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
|
||
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon, GitBranchIcon, PlusIcon,
|
||
} from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import { fmtGB, fmtRate } from "@/lib/fmt-rate"
|
||
import { useDataSource } from "@/lib/data-source"
|
||
import { useTrafficLive } from "@/hooks/use-traffic-live"
|
||
import { useFlowLive } from "@/hooks/use-flow-live"
|
||
import { requestJson } from "@/shared/api/http-client"
|
||
import { getFlowAnalytics, getFlowClients, getFlowExporters, getFlowMonthly, getTrafficFlows } from "@/shared/api/traffic-flow"
|
||
import { listServers } from "@/shared/api/servers"
|
||
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
|
||
import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel"
|
||
import type { FlowAnalyticsDto, FlowEntityCard, FlowMonthlyDto, FlowStatsDto } from "@mmapp/contracts/traffic-flow"
|
||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||
import { Badge } from "@/components/reui/badge"
|
||
import {
|
||
IFACE_TYPE_LABEL,
|
||
INIT_USERS,
|
||
ROLE_LABEL,
|
||
type InterfaceType,
|
||
} from "@/lib/users"
|
||
import { TYPE_VARIANT } from "@/components/data-grids/users-expanded-detail"
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function seededRand(seed: number) {
|
||
let s = seed
|
||
return () => { s = (s * 9301 + 49297) % 233280; return s / 233280 }
|
||
}
|
||
|
||
function buildSeries(seed: number, base: number, range: number, n = 60): number[] {
|
||
const rand = seededRand(seed)
|
||
return Array.from({ length: n }, () => Math.max(0, Math.round(base + (rand() - 0.5) * range * 2)))
|
||
}
|
||
|
||
function addSeries(a: number[], b: number[]): number[] {
|
||
return a.map((v, i) => v + (b[i] ?? 0))
|
||
}
|
||
|
||
function flowIngestLine(stats: FlowStatsDto | null): string | null {
|
||
if (!stats) return null
|
||
const listener = stats.listenerBound
|
||
? (stats.listenerAddress ?? "слушает")
|
||
: "не слушает"
|
||
const last = stats.lastDatagramAt
|
||
? new Date(stats.lastDatagramAt).toLocaleString("ru-RU")
|
||
: "—"
|
||
const exporter = stats.lastExporterIp ? ` · ${stats.lastExporterIp}` : ""
|
||
const err = stats.lastError ? ` · ${stats.lastError}` : ""
|
||
return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}`
|
||
}
|
||
|
||
function flowEmptyHint(stats: FlowStatsDto | null, collectorAlive?: boolean): string | undefined {
|
||
if (!stats) return undefined
|
||
if (stats.lastError) return stats.lastError
|
||
if (stats.packetsReceived) {
|
||
return `IPFIX приходит (${stats.lastExporterIp ?? "экспортёр"}), но сессии ещё не записаны.`
|
||
}
|
||
if (stats.listenerBound === false && !collectorAlive) {
|
||
return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически."
|
||
}
|
||
if (stats.listenerBound || collectorAlive) {
|
||
return "Коллектор жив, IPFIX ещё не доходит. На jump-host у target Src должен быть 0.0.0.0 (авто)."
|
||
}
|
||
return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow."
|
||
}
|
||
|
||
function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto {
|
||
const emptySeries = Array(60).fill(0) as number[]
|
||
return {
|
||
bpsNow: 0,
|
||
bytes: m.bytes,
|
||
packets: 0,
|
||
conversations: 0,
|
||
conversationsRaw: 0,
|
||
uniqueSrc: 0,
|
||
uniqueDst: 0,
|
||
topProto: "—",
|
||
topCategory: "—",
|
||
rxSeries: emptySeries,
|
||
txSeries: emptySeries,
|
||
applications: [],
|
||
protocols: [],
|
||
sources: [],
|
||
destinations: [],
|
||
interfaces: [],
|
||
asns: m.asns,
|
||
countries: m.countries,
|
||
categories: [],
|
||
services: m.services,
|
||
mapEdges: [],
|
||
conversationsList: [],
|
||
paths: [],
|
||
ifaces: [],
|
||
live: false,
|
||
degraded: false,
|
||
bytesPayload: m.bytes,
|
||
bytesOverlay: 0,
|
||
bytesMesh: 0,
|
||
bytesWire: 0,
|
||
}
|
||
}
|
||
|
||
// ─── data model ───────────────────────────────────────────────────────────────
|
||
|
||
interface BoundIfaceTraffic {
|
||
id: string
|
||
bindingId: string
|
||
userId: string
|
||
userLogin: string
|
||
userName: string
|
||
interfaceName: string
|
||
interfaceType: InterfaceType
|
||
peerPublicKey?: string
|
||
peerName?: string
|
||
comment: string
|
||
serverId: string
|
||
serverName: string
|
||
serverSite: string
|
||
serverCountry: string
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
status: "online" | "offline"
|
||
}
|
||
|
||
interface ServerTraffic {
|
||
id: string
|
||
name: string
|
||
site: string
|
||
country: string
|
||
status: "online" | "offline" | "degraded"
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
sessions: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
boundIfaces: BoundIfaceTraffic[]
|
||
}
|
||
|
||
interface UserTraffic {
|
||
id: string
|
||
login: string
|
||
displayName: string
|
||
role: string
|
||
active?: boolean
|
||
interfaces: BoundIfaceTraffic[]
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
}
|
||
|
||
interface LiveTrafficServer {
|
||
id: string
|
||
name: string
|
||
site: string
|
||
country: string
|
||
status: "online" | "offline" | "degraded"
|
||
rxNow: number
|
||
txNow: number
|
||
rxPeak: number
|
||
txPeak: number
|
||
rxTotal: number
|
||
txTotal: number
|
||
sessions: number
|
||
rxSeries: number[]
|
||
txSeries: number[]
|
||
}
|
||
|
||
interface LiveTrafficInterface {
|
||
name: string
|
||
running: boolean
|
||
disabled: boolean
|
||
rxNow: number
|
||
txNow: number
|
||
}
|
||
|
||
function makeApiFetch(backendUrl: string) {
|
||
return async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||
return requestJson<T>(backendUrl, path, init)
|
||
}
|
||
}
|
||
|
||
function hashSeed(s: string): number {
|
||
let h = 0
|
||
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
|
||
return Math.abs(h)
|
||
}
|
||
|
||
function boundIfaceLabel(c: Pick<BoundIfaceTraffic, "interfaceName" | "interfaceType" | "peerName">): string {
|
||
if (c.interfaceType === "wg" && c.peerName) return `${c.peerName} · ${c.interfaceName}`
|
||
return c.interfaceName
|
||
}
|
||
|
||
function mockBoundFromUsers(): BoundIfaceTraffic[] {
|
||
return INIT_USERS.flatMap((u) =>
|
||
u.bindings.map((b) => {
|
||
const seed = hashSeed(b.id)
|
||
const offline = !u.active || b.interfaceName.includes("retail") || b.interfaceName.includes("warehouse")
|
||
const rxNow = offline ? 0 : 12 + (seed % 140)
|
||
const txNow = offline ? 0 : 8 + (seed % 110)
|
||
return {
|
||
id: `${b.userId}:${b.serverId}:${b.interfaceName}:${b.peerPublicKey ?? "_iface"}`,
|
||
bindingId: b.id,
|
||
userId: u.id,
|
||
userLogin: u.login,
|
||
userName: u.name,
|
||
interfaceName: b.interfaceName,
|
||
interfaceType: b.interfaceType,
|
||
peerPublicKey: b.peerPublicKey,
|
||
peerName: b.peerName,
|
||
comment: b.comment,
|
||
serverId: b.serverId,
|
||
serverName: b.serverName,
|
||
serverSite: b.serverSite,
|
||
serverCountry: b.serverCountry,
|
||
rxNow,
|
||
txNow,
|
||
rxPeak: rxNow + 40,
|
||
txPeak: txNow + 28,
|
||
rxTotal: offline ? 0 : +(rxNow / 8).toFixed(1),
|
||
txTotal: offline ? 0 : +(txNow / 10).toFixed(1),
|
||
rxSeries: offline ? Array(60).fill(0) : buildSeries(seed, rxNow, Math.max(4, rxNow / 4)),
|
||
txSeries: offline ? Array(60).fill(0) : buildSeries(seed + 17, txNow, Math.max(3, txNow / 4)),
|
||
status: (offline ? "offline" : "online") as "online" | "offline",
|
||
}
|
||
}),
|
||
)
|
||
}
|
||
|
||
const boundIfaces: BoundIfaceTraffic[] = mockBoundFromUsers()
|
||
|
||
// ─── mock server data (with bound interfaces attached) ────────────────────────
|
||
|
||
const serverTraffic: ServerTraffic[] = [
|
||
{
|
||
id: "srv1", name: "mt-msk-core-01", site: "MSK", country: "RU", status: "online",
|
||
rxNow: 342, txNow: 287, rxPeak: 614, txPeak: 521, rxTotal: 124.8, txTotal: 98.2, sessions: 4,
|
||
rxSeries: buildSeries(101, 342, 80), txSeries: buildSeries(201, 287, 70),
|
||
boundIfaces: boundIfaces.filter((c) => c.serverId === "srv1"),
|
||
},
|
||
{
|
||
id: "srv2", name: "mt-spb-edge-01", site: "SPB", country: "RU", status: "online",
|
||
rxNow: 218, txNow: 164, rxPeak: 412, txPeak: 310, rxTotal: 78.4, txTotal: 61.1, sessions: 6,
|
||
rxSeries: buildSeries(102, 218, 60), txSeries: buildSeries(202, 164, 50),
|
||
boundIfaces: [],
|
||
},
|
||
{
|
||
id: "srv3", name: "mt-fra-edge-01", site: "FRA", country: "DE", status: "online",
|
||
rxNow: 184, txNow: 141, rxPeak: 388, txPeak: 282, rxTotal: 66.2, txTotal: 51.4, sessions: 3,
|
||
rxSeries: buildSeries(103, 184, 55), txSeries: buildSeries(203, 141, 45),
|
||
boundIfaces: [],
|
||
},
|
||
{
|
||
id: "srv4", name: "mt-ams-edge-01", site: "AMS", country: "NL", status: "degraded",
|
||
rxNow: 88, txNow: 61, rxPeak: 244, txPeak: 188, rxTotal: 32.1, txTotal: 24.8, sessions: 2,
|
||
rxSeries: buildSeries(104, 88, 40), txSeries: buildSeries(204, 61, 30),
|
||
boundIfaces: [],
|
||
},
|
||
{
|
||
id: "srv5", name: "mt-sgp-edge-01", site: "SGP", country: "SG", status: "offline",
|
||
rxNow: 0, txNow: 0, rxPeak: 0, txPeak: 0, rxTotal: 0, txTotal: 0, sessions: 0,
|
||
rxSeries: Array(60).fill(0), txSeries: Array(60).fill(0),
|
||
boundIfaces: [],
|
||
},
|
||
{
|
||
id: "srv6", name: "mt-ams-test-01", site: "AMS", country: "NL", status: "online",
|
||
rxNow: 42, txNow: 28, rxPeak: 118, txPeak: 84, rxTotal: 14.2, txTotal: 9.8, sessions: 1,
|
||
rxSeries: buildSeries(105, 42, 20), txSeries: buildSeries(205, 28, 14),
|
||
boundIfaces: [],
|
||
},
|
||
{
|
||
id: "srv7", name: "mt-msk-lab-01", site: "MSK", country: "RU", status: "online",
|
||
rxNow: 12, txNow: 8, rxPeak: 44, txPeak: 31, rxTotal: 4.2, txTotal: 2.9, sessions: 2,
|
||
rxSeries: buildSeries(106, 12, 6), txSeries: buildSeries(206, 8, 4),
|
||
boundIfaces: boundIfaces.filter(c => c.serverId === "srv7"),
|
||
},
|
||
]
|
||
|
||
// ─── user traffic (aggregated from bound interfaces) ──────────────────────────
|
||
|
||
function buildUserTraffic(
|
||
id: string, login: string, displayName: string, role: string,
|
||
clients: BoundIfaceTraffic[],
|
||
active = true,
|
||
): UserTraffic {
|
||
const rxNow = clients.reduce((a, c) => a + c.rxNow, 0)
|
||
const txNow = clients.reduce((a, c) => a + c.txNow, 0)
|
||
const rxPeak = clients.reduce((a, c) => a + c.rxPeak, 0)
|
||
const txPeak = clients.reduce((a, c) => a + c.txPeak, 0)
|
||
const rxTotal = clients.reduce((a, c) => a + c.rxTotal, 0)
|
||
const txTotal = clients.reduce((a, c) => a + c.txTotal, 0)
|
||
const rxSeries = clients.reduce((a, c) => addSeries(a, c.rxSeries), Array(60).fill(0) as number[])
|
||
const txSeries = clients.reduce((a, c) => addSeries(a, c.txSeries), Array(60).fill(0) as number[])
|
||
return { id, login, displayName, role, active, interfaces: clients, rxNow, txNow, rxPeak, txPeak, rxTotal, txTotal, rxSeries, txSeries }
|
||
}
|
||
|
||
const userTraffic: UserTraffic[] = INIT_USERS.map((u) =>
|
||
buildUserTraffic(
|
||
u.id,
|
||
u.login,
|
||
u.name,
|
||
ROLE_LABEL[u.role],
|
||
boundIfaces.filter((c) => c.userId === u.id),
|
||
u.active,
|
||
),
|
||
)
|
||
|
||
// ─── types ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
|
||
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
|
||
type Range = (typeof TRAFFIC_RANGE_KEYS)[number] | "30d"
|
||
|
||
const TRAFFIC_RANGE_LABELS: Record<(typeof TRAFFIC_RANGE_KEYS)[number], string> = {
|
||
"5m": "5м",
|
||
"15m": "15м",
|
||
"1h": "1ч",
|
||
"4h": "4ч",
|
||
"24h": "24ч",
|
||
}
|
||
|
||
type GroupMode = "servers" | "users" | "ifaces" | "flows"
|
||
type FlowScope = "servers" | "users"
|
||
type SortField = "rx" | "tx" | "name" | "sessions"
|
||
type SortDir = "asc" | "desc"
|
||
|
||
// ─── shared sub-components ────────────────────────────────────────────────────
|
||
|
||
function StatChip({ icon, label, value }: { icon: ReactNode; label: string; value: string }) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center justify-center size-7 rounded-md bg-muted shrink-0">{icon}</div>
|
||
<div className="leading-tight">
|
||
<p className="text-xs text-muted-foreground">{label}</p>
|
||
<p className="text-sm font-semibold tabular-nums">{value}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function MiniAreaChart({ rx, tx, height = 44 }: { rx: number[]; tx: number[]; height?: number }) {
|
||
const W = 300, H = height
|
||
const maxVal = Math.max(...rx, ...tx, 1) * 1.1
|
||
const xAt = (i: number) => (i / (rx.length - 1)) * W
|
||
const yAt = (v: number) => H - (v / maxVal) * H
|
||
const area = (arr: number[]) => {
|
||
const pts = arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" L ")
|
||
return `M 0,${H} L ${pts} L ${W},${H} Z`
|
||
}
|
||
const line = (arr: number[]) => arr.map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(" ")
|
||
return (
|
||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: "100%", height, display: "block" }} preserveAspectRatio="none">
|
||
<path d={area(rx)} fill="var(--chart-rx)" fillOpacity={0.12} />
|
||
<polyline points={line(rx)} fill="none" stroke="var(--chart-rx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||
<path d={area(tx)} fill="var(--chart-tx)" fillOpacity={0.10} />
|
||
<polyline points={line(tx)} fill="none" stroke="var(--chart-tx)" strokeWidth="1.4" strokeLinejoin="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// ─── left-panel entity cards ──────────────────────────────────────────────────
|
||
|
||
function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<button onClick={onClick} className={cn(
|
||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||
s.status === "offline" && "opacity-60",
|
||
)}>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<StatusDot status={s.status} />
|
||
<span className="text-xs font-medium truncate">{s.name}</span>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
|
||
<Flag code={s.country} />{s.site}
|
||
</span>
|
||
</div>
|
||
<MiniAreaChart rx={s.rxSeries} tx={s.txSeries} />
|
||
<div className="flex justify-between mt-2 gap-2">
|
||
<div className="flex items-center gap-1 text-[11px]">
|
||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||
<span className="font-mono font-medium text-emerald-500">{fmtRate(s.rxNow)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 text-[11px]">
|
||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||
<span className="font-mono font-medium text-blue-500">{fmtRate(s.txNow)}</span>
|
||
</div>
|
||
{s.boundIfaces.length > 0 && (
|
||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||
<CableIcon className="size-3" />{s.boundIfaces.length}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function UserCard({ u, selected, onClick }: { u: UserTraffic; selected: boolean; onClick: () => void }) {
|
||
const hasClients = u.interfaces.length > 0
|
||
return (
|
||
<button onClick={onClick} className={cn(
|
||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||
!hasClients && "opacity-60",
|
||
)}>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<div className="size-5 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||
<UsersIcon className="size-3 text-muted-foreground" />
|
||
</div>
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-medium truncate">{u.login}</p>
|
||
<p className="text-[10px] text-muted-foreground truncate">{u.displayName}</p>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground shrink-0">
|
||
<CableIcon className="size-3" />{u.interfaces.length}
|
||
</div>
|
||
</div>
|
||
{hasClients ? (
|
||
<>
|
||
<MiniAreaChart rx={u.rxSeries} tx={u.txSeries} />
|
||
<div className="flex justify-between mt-2 gap-2">
|
||
<div className="flex items-center gap-1 text-[11px]">
|
||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||
<span className="font-mono font-medium text-emerald-500">{fmtRate(u.rxNow)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 text-[11px]">
|
||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||
<span className="font-mono font-medium text-blue-500">{fmtRate(u.txNow)}</span>
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="text-[10px] text-muted-foreground">Нет привязанных интерфейсов</p>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function IfaceCard({ c, selected, onClick }: { c: BoundIfaceTraffic; selected: boolean; onClick: () => void }) {
|
||
return (
|
||
<button onClick={onClick} className={cn(
|
||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||
c.status === "offline" && "opacity-60",
|
||
)}>
|
||
<div className="flex items-center justify-between gap-2 mb-2">
|
||
<div className="flex items-center gap-1.5 min-w-0">
|
||
<StatusDot status={c.status} />
|
||
<div className="min-w-0">
|
||
<p className="text-xs font-mono font-medium truncate">{boundIfaceLabel(c)}</p>
|
||
<p className="text-[10px] text-muted-foreground truncate">{c.userLogin}</p>
|
||
</div>
|
||
</div>
|
||
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
|
||
<Flag code={c.serverCountry} />{c.serverSite}
|
||
</span>
|
||
</div>
|
||
<MiniAreaChart rx={c.rxSeries} tx={c.txSeries} />
|
||
<div className="flex justify-between mt-2 gap-2">
|
||
<div className="flex items-center gap-1 text-[11px]">
|
||
<ArrowDownIcon className="size-3 text-emerald-500" />
|
||
<span className="font-mono font-medium text-emerald-500">{fmtRate(c.rxNow)}</span>
|
||
</div>
|
||
<div className="flex items-center gap-1 text-[11px]">
|
||
<ArrowUpIcon className="size-3 text-blue-500" />
|
||
<span className="font-mono font-medium text-blue-500">{fmtRate(c.txNow)}</span>
|
||
</div>
|
||
</div>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// ─── GRE client row (replaces bulky grid tables) ─────────────────────────────
|
||
|
||
/**
|
||
* Compact single-row card for a GRE client.
|
||
* showServer=true adds a server chip (used in user-detail where server is not implied).
|
||
*/
|
||
function IfaceRow({ c, showServer = false }: { c: BoundIfaceTraffic; showServer?: boolean }) {
|
||
return (
|
||
<div className={cn(
|
||
"flex items-center gap-3 px-3 py-2.5 rounded-lg border bg-card hover:bg-muted/30 transition-colors",
|
||
c.status === "offline" && "opacity-55",
|
||
)}>
|
||
<StatusDot status={c.status} />
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-mono font-semibold">{boundIfaceLabel(c)}</span>
|
||
<Badge variant={TYPE_VARIANT[c.interfaceType]} size="sm">{IFACE_TYPE_LABEL[c.interfaceType]}</Badge>
|
||
{showServer && (
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||
<Flag code={c.serverCountry} />{c.serverSite}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground truncate mt-0.5">{c.comment || c.userLogin}</p>
|
||
</div>
|
||
<div className="shrink-0 text-right leading-tight">
|
||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-emerald-600 dark:text-emerald-400">
|
||
<ArrowDownIcon className="size-3" />{fmtRate(c.rxNow)}
|
||
</div>
|
||
<div className="flex items-center gap-1 justify-end text-xs font-mono font-medium text-blue-600 dark:text-blue-400">
|
||
<ArrowUpIcon className="size-3" />{fmtRate(c.txNow)}
|
||
</div>
|
||
</div>
|
||
<div className="shrink-0 text-right leading-tight text-[10px] text-muted-foreground w-[72px]">
|
||
<p className="tabular-nums">{fmtGB(c.rxTotal)}</p>
|
||
<p className="tabular-nums">{fmtGB(c.txTotal)}</p>
|
||
</div>
|
||
<span className={cn(
|
||
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full font-medium tabular-nums",
|
||
c.status === "online" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground",
|
||
)}>
|
||
{c.status === "online" ? "online" : "offline"}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BoundIfacesList({ clients, showServer = false }: { clients: BoundIfaceTraffic[]; showServer?: boolean }) {
|
||
if (clients.length === 0)
|
||
return <p className="text-xs text-muted-foreground py-1">Нет привязанных интерфейсов</p>
|
||
return (
|
||
<div className="flex flex-col gap-1.5">
|
||
{clients.map((c) => <IfaceRow key={c.id} c={c} showServer={showServer} />)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── detail panel helpers ─────────────────────────────────────────────────────
|
||
|
||
function DetailHeader({ range, setRange, children }: {
|
||
range: Range; setRange: (r: Range) => void; children: ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex items-start justify-between mb-3 gap-3">
|
||
<div className="flex items-center gap-2 flex-wrap min-w-0">{children}</div>
|
||
<div className="flex gap-1 shrink-0">
|
||
{TRAFFIC_RANGE_KEYS.map((r) => (
|
||
<Button key={r} size="sm" variant={r === range ? "default" : "ghost"}
|
||
className="h-7 px-2 text-xs" onClick={() => setRange(r)}>
|
||
{TRAFFIC_RANGE_LABELS[r]}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function OfflinePlaceholder({ text = "Нет данных — объект недоступен" }: { text?: string }) {
|
||
return (
|
||
<div className="flex items-center justify-center h-[220px] text-muted-foreground/40">
|
||
<div className="text-center">
|
||
<ActivityIcon className="size-10 mx-auto mb-2 opacity-40" />
|
||
<p className="text-sm">{text}</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TotalsRow({ rxTotal, txTotal, rxSeries, txSeries }: {
|
||
rxTotal: number; txTotal: number; rxSeries: number[]; txSeries: number[]
|
||
}) {
|
||
return (
|
||
<div className="mt-4 pt-4 border-t grid grid-cols-2 gap-4">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-1">Получено за период</p>
|
||
<p className="text-lg font-semibold tabular-nums">
|
||
{rxTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||
</p>
|
||
<Sparkline data={rxSeries} width={180} height={28} color="var(--chart-rx)" filled />
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-1">Отправлено за период</p>
|
||
<p className="text-lg font-semibold tabular-nums">
|
||
{txTotal.toFixed(1)} <span className="text-sm font-normal text-muted-foreground">ГБ</span>
|
||
</p>
|
||
<Sparkline data={txSeries} width={180} height={28} color="var(--chart-tx)" filled />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── detail panels ────────────────────────────────────────────────────────────
|
||
|
||
function ServerDetail({
|
||
sel, range, setRange, liveRx, liveTx, liveHint,
|
||
}: {
|
||
sel: ServerTraffic
|
||
range: Range
|
||
setRange: (r: Range) => void
|
||
liveRx?: number
|
||
liveTx?: number
|
||
liveHint?: string
|
||
}) {
|
||
const rxNow = liveRx ?? sel.rxNow
|
||
const txNow = liveTx ?? sel.txNow
|
||
return (
|
||
<>
|
||
<DetailHeader range={range} setRange={setRange}>
|
||
<StatusDot status={sel.status} />
|
||
<h2 className="text-base font-semibold">{sel.name}</h2>
|
||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
|
||
<Flag code={sel.country} />{sel.site}
|
||
</span>
|
||
</DetailHeader>
|
||
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||
<div className="mt-4 pt-4 border-t">
|
||
<KpiStatGrid
|
||
aria-label="Скорость выбранного сервера"
|
||
items={[
|
||
{
|
||
id: "rx-now",
|
||
label: "RX сейчас",
|
||
value: fmtRate(rxNow),
|
||
hint: liveHint,
|
||
icon: <ArrowDownIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "tx-now",
|
||
label: "TX сейчас",
|
||
value: fmtRate(txNow),
|
||
hint: liveHint,
|
||
icon: <ArrowUpIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "rx-peak",
|
||
label: "Пик RX",
|
||
value: fmtRate(sel.rxPeak),
|
||
icon: <TrendingUpIcon className="size-4" />,
|
||
iconClassName: "text-warning",
|
||
},
|
||
{
|
||
id: "tx-peak",
|
||
label: "Пик TX",
|
||
value: fmtRate(sel.txPeak),
|
||
icon: <TrendingDownIcon className="size-4" />,
|
||
iconClassName: "text-warning",
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||
{sel.boundIfaces.length > 0 && (
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||
<CableIcon className="size-4 text-muted-foreground" />
|
||
Привязанные интерфейсы
|
||
<span className="text-xs text-muted-foreground font-normal">({sel.boundIfaces.length})</span>
|
||
</h3>
|
||
<BoundIfacesList clients={sel.boundIfaces} />
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function UserDetail({ sel, range, setRange }: { sel: UserTraffic; range: Range; setRange: (r: Range) => void }) {
|
||
const hasClients = sel.interfaces.length > 0
|
||
return (
|
||
<>
|
||
<DetailHeader range={range} setRange={setRange}>
|
||
<div className="size-7 rounded-full bg-muted flex items-center justify-center shrink-0">
|
||
<UsersIcon className="size-4 text-muted-foreground" />
|
||
</div>
|
||
<div className="leading-tight">
|
||
<h2 className="text-base font-semibold">{sel.login}</h2>
|
||
<p className="text-xs text-muted-foreground">{sel.displayName} · {sel.role}</p>
|
||
</div>
|
||
</DetailHeader>
|
||
{!hasClients ? (
|
||
<OfflinePlaceholder text="Нет привязанных интерфейсов у этого пользователя" />
|
||
) : (
|
||
<>
|
||
<TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
|
||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
|
||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
|
||
<StatChip icon={<CableIcon className="size-3.5 text-purple-500" />} label="Интерфейсы" value={`${sel.interfaces.length}`} />
|
||
</div>
|
||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2">
|
||
<CableIcon className="size-4 text-muted-foreground" />
|
||
Привязанные интерфейсы
|
||
<span className="text-xs text-muted-foreground font-normal">({sel.interfaces.length})</span>
|
||
</h3>
|
||
<BoundIfacesList clients={sel.interfaces} showServer />
|
||
</div>
|
||
</>
|
||
)}
|
||
</>
|
||
)
|
||
}
|
||
|
||
function IfaceDetail({ sel, range, setRange }: { sel: BoundIfaceTraffic; range: Range; setRange: (r: Range) => void }) {
|
||
return (
|
||
<>
|
||
<DetailHeader range={range} setRange={setRange}>
|
||
<StatusDot status={sel.status} />
|
||
<div className="leading-tight min-w-0">
|
||
<h2 className="text-base font-mono font-semibold">{boundIfaceLabel(sel)}</h2>
|
||
<p className="text-xs text-muted-foreground truncate">{sel.comment || sel.userLogin}</p>
|
||
</div>
|
||
<Badge variant={TYPE_VARIANT[sel.interfaceType]} size="sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</Badge>
|
||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1 shrink-0">
|
||
<Flag code={sel.serverCountry} />{sel.serverSite}
|
||
</span>
|
||
</DetailHeader>
|
||
{sel.status === "offline" ? <OfflinePlaceholder /> : <TrafficRxTxChart rx={sel.rxSeries} tx={sel.txSeries} range={range} />}
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-4 pt-4 border-t">
|
||
<StatChip icon={<ArrowDownIcon className="size-3.5 text-emerald-500" />} label="RX сейчас" value={fmtRate(sel.rxNow)} />
|
||
<StatChip icon={<ArrowUpIcon className="size-3.5 text-blue-500" />} label="TX сейчас" value={fmtRate(sel.txNow)} />
|
||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик RX" value={fmtRate(sel.rxPeak)} />
|
||
<StatChip icon={<TrendingUpIcon className="size-3.5 text-amber-500" />} label="Пик TX" value={fmtRate(sel.txPeak)} />
|
||
</div>
|
||
<TotalsRow rxTotal={sel.rxTotal} txTotal={sel.txTotal} rxSeries={sel.rxSeries} txSeries={sel.txSeries} />
|
||
<div className="mt-4 pt-4 border-t grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Сервер</p>
|
||
<p className="text-sm font-mono">{sel.serverName}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Тип</p>
|
||
<p className="text-sm">{IFACE_TYPE_LABEL[sel.interfaceType]}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs text-muted-foreground mb-0.5">Пользователь</p>
|
||
<p className="text-sm font-mono">{sel.userLogin}</p>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
// ─── page ─────────────────────────────────────────────────────────────────────
|
||
|
||
const GROUP_MODES: Array<{ mode: GroupMode; icon: ReactNode; label: string }> = [
|
||
{ mode: "servers", icon: <ServerIcon className="size-3" />, label: "Серверы" },
|
||
{ mode: "users", icon: <UsersIcon className="size-3" />, label: "Клиенты" },
|
||
{ mode: "ifaces", icon: <CableIcon className="size-3" />, label: "Интерфейсы" },
|
||
{ mode: "flows", icon: <GitBranchIcon className="size-3" />, label: "Потоки" },
|
||
]
|
||
|
||
const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMode[] }> = [
|
||
{ field: "rx", label: "RX" },
|
||
{ field: "tx", label: "TX" },
|
||
{ field: "name", label: "Имя" },
|
||
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] },
|
||
]
|
||
|
||
export default function TrafficPage() {
|
||
const { mode, backendUrl } = useDataSource()
|
||
const isLive = mode === "live"
|
||
const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl])
|
||
|
||
const [groupMode, setGroupMode] = useState<GroupMode>("servers")
|
||
const [sortField, setSortField] = useState<SortField>("rx")
|
||
const [sortDir, setSortDir] = useState<SortDir>("desc")
|
||
const [selectedId, setSelectedId] = useState("srv1")
|
||
const [range, setRange] = useState<Range>("1h")
|
||
const [search, setSearch] = useState("")
|
||
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
|
||
const [liveBusy, setLiveBusy] = useState(false)
|
||
const [liveError, setLiveError] = useState<string | null>(null)
|
||
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
|
||
const [selectedIface, setSelectedIface] = useState("__all__")
|
||
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
|
||
const [detailBusy, setDetailBusy] = useState(false)
|
||
const [liveDetailServer, setLiveDetailServer] = useState<ServerTraffic | null>(null)
|
||
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
|
||
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
|
||
const [flowStats, setFlowStats] = useState<FlowStatsDto | null>(null)
|
||
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
|
||
const [flowExporters, setFlowExporters] = useState<FlowEntityCard[]>([])
|
||
const [flowClients, setFlowClients] = useState<FlowEntityCard[]>([])
|
||
const [flowAnalytics, setFlowAnalytics] = useState<FlowAnalyticsDto | null>(null)
|
||
const [flowIface, setFlowIface] = useState("__all__")
|
||
const [flowDedup, setFlowDedup] = useState(true)
|
||
const [flowExcludeMesh, setFlowExcludeMesh] = useState(true)
|
||
const [flowExcludeOverlay, setFlowExcludeOverlay] = useState(true)
|
||
const [overlayOpen, setOverlayOpen] = useState(false)
|
||
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
|
||
const effectiveMode: GroupMode = groupMode
|
||
const { sample: liveSample, error: liveStreamError } = useTrafficLive({
|
||
enabled: isLive && effectiveMode === "servers" && liveServers.some((s) => s.id === selectedId),
|
||
backendUrl,
|
||
serverId: selectedId,
|
||
iface: selectedIface,
|
||
})
|
||
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m"
|
||
const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({
|
||
enabled: flowLiveEnabled,
|
||
backendUrl,
|
||
range,
|
||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||
userId: flowScope === "users" ? selectedId : undefined,
|
||
iface: flowIface,
|
||
dedup: flowDedup,
|
||
excludeMesh: flowExcludeMesh,
|
||
excludeOverlay: flowExcludeOverlay,
|
||
})
|
||
|
||
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
|
||
return {
|
||
id: String(s.id),
|
||
name: s.name,
|
||
site: s.site || "—",
|
||
country: s.country || "UN",
|
||
status: s.status,
|
||
rxNow: s.rxNow,
|
||
txNow: s.txNow,
|
||
rxPeak: s.rxPeak,
|
||
txPeak: s.txPeak,
|
||
rxTotal: s.rxTotal,
|
||
txTotal: s.txTotal,
|
||
sessions: s.sessions,
|
||
rxSeries: s.rxSeries.length ? s.rxSeries : Array(60).fill(0),
|
||
txSeries: s.txSeries.length ? s.txSeries : Array(60).fill(0),
|
||
boundIfaces: [],
|
||
}
|
||
}
|
||
|
||
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
|
||
...c,
|
||
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
|
||
rxSeries: c.rxSeries.length ? c.rxSeries : Array(60).fill(0),
|
||
txSeries: c.txSeries.length ? c.txSeries : Array(60).fill(0),
|
||
})
|
||
|
||
const toLiveUser = (u: UserTraffic & { interfaces?: BoundIfaceTraffic[] }): UserTraffic => {
|
||
const ifaces = (u.interfaces ?? []).map(toLiveBound)
|
||
return {
|
||
...u,
|
||
role: ROLE_LABEL[u.role as keyof typeof ROLE_LABEL] ?? u.role,
|
||
interfaces: ifaces,
|
||
rxSeries: u.rxSeries.length ? u.rxSeries : Array(60).fill(0),
|
||
txSeries: u.txSeries.length ? u.txSeries : Array(60).fill(0),
|
||
}
|
||
}
|
||
|
||
const loadLiveTraffic = useCallback(async (targetRange: Range) => {
|
||
if (!isLive) return
|
||
setLiveBusy(true)
|
||
setLiveError(null)
|
||
try {
|
||
const q = encodeURIComponent(targetRange === "30d" ? "24h" : targetRange)
|
||
const [srvRes, usersRes, ifacesRes] = await Promise.all([
|
||
apiFetch<{ servers: LiveTrafficServer[] }>(`/api/traffic/servers?range=${q}`),
|
||
apiFetch<{ users: UserTraffic[] }>(`/api/traffic/users?range=${q}`),
|
||
apiFetch<{ interfaces: BoundIfaceTraffic[] }>(`/api/traffic/bound-interfaces?range=${q}`),
|
||
])
|
||
const liveIfaces = ifacesRes.interfaces.map(toLiveBound)
|
||
const mapped: ServerTraffic[] = srvRes.servers.map((s) => {
|
||
const base = toLiveServer(s)
|
||
return { ...base, boundIfaces: liveIfaces.filter((i) => i.serverId === String(s.id)) }
|
||
})
|
||
const mappedUsers = usersRes.users.map(toLiveUser)
|
||
setLiveServers(mapped)
|
||
setLiveUsers(mappedUsers)
|
||
setLiveBoundIfaces(liveIfaces)
|
||
setSelectedId((prev) => {
|
||
if (groupMode === "users") return mappedUsers.some((x) => x.id === prev) ? prev : (mappedUsers[0]?.id ?? "")
|
||
if (groupMode === "ifaces") return liveIfaces.some((x) => x.id === prev) ? prev : (liveIfaces[0]?.id ?? "")
|
||
return mapped.some((x) => x.id === prev) ? prev : (mapped[0]?.id ?? "")
|
||
})
|
||
} catch (e) {
|
||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить live трафик")
|
||
} finally {
|
||
setLiveBusy(false)
|
||
}
|
||
}, [apiFetch, isLive, groupMode])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setLiveServers([])
|
||
setLiveUsers([])
|
||
setLiveBoundIfaces([])
|
||
setLiveError(null)
|
||
return
|
||
}
|
||
void loadLiveTraffic(range)
|
||
}, [isLive, range, loadLiveTraffic])
|
||
|
||
const loadFlows = useCallback(async () => {
|
||
if (!isLive) return
|
||
setLiveBusy(true)
|
||
setLiveError(null)
|
||
try {
|
||
const [stats, exporters, clients] = await Promise.all([
|
||
getTrafficFlows(backendUrl, range),
|
||
getFlowExporters(backendUrl, range),
|
||
getFlowClients(backendUrl, range),
|
||
])
|
||
setFlowStats(stats)
|
||
setFlowExporters(exporters.exporters)
|
||
setFlowClients(clients.clients)
|
||
setSelectedId((prev) => {
|
||
const list = flowScope === "users" ? clients.clients : exporters.exporters
|
||
if (list.some((x) => x.id === prev)) return prev
|
||
return list[0]?.id ?? ""
|
||
})
|
||
} catch (e) {
|
||
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
|
||
} finally {
|
||
setLiveBusy(false)
|
||
}
|
||
}, [isLive, backendUrl, range, flowScope])
|
||
|
||
useEffect(() => {
|
||
if (!isLive || effectiveMode !== "flows") return
|
||
void loadFlows()
|
||
}, [isLive, effectiveMode, loadFlows])
|
||
|
||
useEffect(() => {
|
||
if (!isLive || effectiveMode !== "flows" || !selectedId) {
|
||
setFlowAnalytics(null)
|
||
return
|
||
}
|
||
if (range === "30d") {
|
||
const month = new Date().toISOString().slice(0, 7)
|
||
void getFlowMonthly(backendUrl, {
|
||
month,
|
||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null))
|
||
return
|
||
}
|
||
void getFlowAnalytics(backendUrl, {
|
||
range,
|
||
serverId: flowScope === "servers" ? selectedId : undefined,
|
||
userId: flowScope === "users" ? selectedId : undefined,
|
||
iface: flowIface,
|
||
dedup: flowDedup,
|
||
excludeMesh: flowExcludeMesh,
|
||
excludeOverlay: flowExcludeOverlay,
|
||
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
|
||
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl])
|
||
|
||
useEffect(() => {
|
||
setFlowIface("__all__")
|
||
}, [selectedId, flowScope])
|
||
|
||
useEffect(() => {
|
||
if (!isLive) return
|
||
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
|
||
}, [isLive, backendUrl])
|
||
|
||
const activeServerTraffic = isLive ? liveServers : serverTraffic
|
||
const activeUserTraffic = isLive ? liveUsers : userTraffic
|
||
const activeBoundIfaces = isLive ? liveBoundIfaces : boundIfaces
|
||
const visibleIfaces = useMemo(
|
||
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
|
||
[serverIfaces, hideDisabledIfaces],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!isLive || effectiveMode !== "servers" || !selectedId) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setServerIfaces([])
|
||
setSelectedIface("__all__")
|
||
setLiveDetailServer(null)
|
||
return
|
||
}
|
||
setSelectedIface("__all__")
|
||
apiFetch<{ interfaces: LiveTrafficInterface[] }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}/interfaces`)
|
||
.then((res) => setServerIfaces(res.interfaces))
|
||
.catch(() => setServerIfaces([]))
|
||
}, [isLive, selectedId, effectiveMode, apiFetch])
|
||
|
||
useEffect(() => {
|
||
if (!isLive || effectiveMode !== "servers" || !selectedId) {
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
setLiveDetailServer(null)
|
||
return
|
||
}
|
||
setDetailBusy(true)
|
||
apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`)
|
||
.then((res) => setLiveDetailServer({
|
||
...toLiveServer(res.server),
|
||
boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId),
|
||
}))
|
||
.catch(() => setLiveDetailServer(null))
|
||
.finally(() => setDetailBusy(false))
|
||
}, [isLive, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces])
|
||
|
||
const handleModeChange = (next: GroupMode) => {
|
||
setGroupMode(next)
|
||
if (next === "servers") {
|
||
setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
|
||
if (range === "30d") setRange("1h")
|
||
} else if (next === "users") {
|
||
setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
|
||
if (range === "30d") setRange("1h")
|
||
} else if (next === "ifaces") {
|
||
setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
|
||
if (range === "30d") setRange("1h")
|
||
} else if (next === "flows") {
|
||
setFlowScope("servers")
|
||
setFlowIface("__all__")
|
||
setRange("5m")
|
||
setSelectedId(flowExporters[0]?.id ?? "")
|
||
}
|
||
setSortField("rx")
|
||
setSortDir("desc")
|
||
setSearch("")
|
||
}
|
||
|
||
const toggleSort = (field: SortField) => {
|
||
if (sortField === field) setSortDir(d => d === "desc" ? "asc" : "desc")
|
||
else { setSortField(field); setSortDir("desc") }
|
||
}
|
||
|
||
const q = search.toLowerCase()
|
||
|
||
const sortedServers = useMemo(() => {
|
||
return [...activeServerTraffic]
|
||
.filter(s => !q || s.name.toLowerCase().includes(q) || s.site.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.name.localeCompare(b.name)
|
||
else if (sortField === "sessions") v = a.sessions - b.sessions
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [sortField, sortDir, q, activeServerTraffic])
|
||
|
||
const sortedUsers = useMemo(() => {
|
||
return [...activeUserTraffic]
|
||
.filter(u => !q || u.login.toLowerCase().includes(q) || u.displayName.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.login.localeCompare(b.login)
|
||
else if (sortField === "sessions") v = a.interfaces.length - b.interfaces.length
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [sortField, sortDir, q, activeUserTraffic])
|
||
|
||
const sortedIfaces = useMemo(() => {
|
||
return [...activeBoundIfaces]
|
||
.filter(c => !q
|
||
|| c.interfaceName.toLowerCase().includes(q)
|
||
|| (c.peerName ?? "").toLowerCase().includes(q)
|
||
|| c.comment.toLowerCase().includes(q)
|
||
|| c.userLogin.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.interfaceName.localeCompare(b.interfaceName)
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [sortField, sortDir, q, activeBoundIfaces])
|
||
|
||
const flowCards = flowScope === "users" ? flowClients : flowExporters
|
||
const sortedFlowCards = useMemo(() => {
|
||
return [...flowCards]
|
||
.filter((c) => !q || c.name.toLowerCase().includes(q) || c.subtitle.toLowerCase().includes(q) || c.site.toLowerCase().includes(q))
|
||
.sort((a, b) => {
|
||
let v = 0
|
||
if (sortField === "rx") v = a.rxNow - b.rxNow
|
||
else if (sortField === "tx") v = a.txNow - b.txNow
|
||
else if (sortField === "name") v = a.name.localeCompare(b.name)
|
||
else if (sortField === "sessions") v = a.sessions - b.sessions
|
||
return sortDir === "desc" ? -v : v
|
||
})
|
||
}, [flowCards, q, sortField, sortDir])
|
||
const selFlowCard = sortedFlowCards.find((c) => c.id === selectedId) ?? sortedFlowCards[0] ?? null
|
||
const displayedFlow = flowLiveSample ?? flowAnalytics
|
||
|
||
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
|
||
const detailServer = liveDetailServer?.id === selectedId ? liveDetailServer : selServer
|
||
const selUser = useMemo(() => activeUserTraffic.find(u => u.id === selectedId) ?? activeUserTraffic[0], [selectedId, activeUserTraffic])
|
||
const selIface = useMemo(() => activeBoundIfaces.find(c => c.id === selectedId) ?? activeBoundIfaces[0], [selectedId, activeBoundIfaces])
|
||
|
||
const kpiSource = effectiveMode === "users"
|
||
? activeUserTraffic
|
||
: effectiveMode === "ifaces"
|
||
? activeBoundIfaces
|
||
: activeServerTraffic
|
||
const totalRx = kpiSource.reduce((a, s) => a + s.rxNow, 0)
|
||
const totalTx = kpiSource.reduce((a, s) => a + s.txNow, 0)
|
||
const peakRx = kpiSource.reduce((a, s) => Math.max(a, s.rxPeak), 0)
|
||
const peakTx = kpiSource.reduce((a, s) => Math.max(a, s.txPeak), 0)
|
||
|
||
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
|
||
const ingestLine = flowIngestLine(flowStats)
|
||
const collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
|
||
const flowError = liveError
|
||
|| flowLiveError
|
||
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
|
||
|
||
const flowKpiItems = [
|
||
{
|
||
id: "exporters",
|
||
label: "Экспортёры",
|
||
value: String(flowExporters.length),
|
||
icon: <ServerIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "bps",
|
||
label: "Скорость",
|
||
value: displayedFlow ? fmtRate(displayedFlow.bpsNow / 1_000_000) : (flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—"),
|
||
hint: displayedFlow?.live ? "live" : undefined,
|
||
icon: <ActivityIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "src",
|
||
label: "Уник. src",
|
||
value: String(displayedFlow?.uniqueSrc ?? flowStats?.uniqueSrc ?? 0),
|
||
icon: <ArrowUpIcon className="size-4" />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "proto",
|
||
label: "Топ протокол",
|
||
value: displayedFlow?.topProto ?? flowStats?.topProto ?? "—",
|
||
icon: <GitBranchIcon className="size-4" />,
|
||
iconClassName: "text-warning",
|
||
},
|
||
]
|
||
|
||
const counterKpiItems = [
|
||
{
|
||
id: "rx",
|
||
label: "RX сейчас",
|
||
value: fmtRate(totalRx),
|
||
icon: <ArrowDownIcon className="size-4" />,
|
||
iconClassName: "text-success",
|
||
},
|
||
{
|
||
id: "tx",
|
||
label: "TX сейчас",
|
||
value: fmtRate(totalTx),
|
||
icon: <ArrowUpIcon className="size-4" />,
|
||
iconClassName: "text-info",
|
||
},
|
||
{
|
||
id: "peak-rx",
|
||
label: "Пик RX",
|
||
value: fmtRate(peakRx),
|
||
icon: <TrendingUpIcon className="size-4" />,
|
||
iconClassName: "text-warning",
|
||
},
|
||
{
|
||
id: "peak-tx",
|
||
label: "Пик TX",
|
||
value: fmtRate(peakTx),
|
||
icon: <TrendingUpIcon className="size-4" />,
|
||
iconClassName: "text-warning",
|
||
},
|
||
]
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<PageHeader
|
||
crumbs={[{ label: "Обзор" }, { label: "Трафик" }]}
|
||
actions={
|
||
<>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
if (effectiveMode === "flows") void loadFlows()
|
||
else void loadLiveTraffic(range)
|
||
}}
|
||
disabled={isLive && liveBusy}
|
||
>
|
||
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
|
||
</Button>
|
||
<Button variant="outline" size="sm"><DownloadIcon className="size-4" />Экспорт</Button>
|
||
</>
|
||
}
|
||
/>
|
||
|
||
{/* ── mode tab bar ── */}
|
||
<div className="border-b bg-background shrink-0">
|
||
<div className="flex items-center px-6">
|
||
{GROUP_MODES.map(({ mode, icon, label }) => (
|
||
<button key={mode} onClick={() => handleModeChange(mode)}
|
||
className={cn(
|
||
"flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors -mb-px",
|
||
effectiveMode === mode
|
||
? "border-primary text-foreground"
|
||
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border",
|
||
)}>
|
||
{icon}{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto p-6">
|
||
<div className="flex flex-col gap-5">
|
||
<KpiStatGrid
|
||
aria-label="Сводка трафика"
|
||
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
|
||
/>
|
||
|
||
{effectiveMode === "flows" ? (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<p className="text-xs text-muted-foreground font-mono truncate min-w-0">
|
||
{ingestLine ?? "IPFIX коллектор"}
|
||
</p>
|
||
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
|
||
<PlusIcon className="size-4" />
|
||
Подключить JH
|
||
</Button>
|
||
</div>
|
||
{flowError && (
|
||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||
{flowError}
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex gap-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => { setFlowScope("servers"); setFlowIface("__all__") }}
|
||
className={cn(
|
||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||
flowScope === "servers"
|
||
? "border-primary bg-primary/10 text-primary font-medium"
|
||
: "border-border text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
Серверы
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => { setFlowScope("users"); setFlowIface("__all__") }}
|
||
className={cn(
|
||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||
flowScope === "users"
|
||
? "border-primary bg-primary/10 text-primary font-medium"
|
||
: "border-border text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
Клиенты
|
||
</button>
|
||
</div>
|
||
<div className="relative">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Поиск…"
|
||
className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||
/>
|
||
</div>
|
||
<div className="flex gap-1 flex-wrap">
|
||
<span className="text-[10px] text-muted-foreground self-center mr-0.5">Сортировка:</span>
|
||
{visibleSortFields.map(({ field, label }) => (
|
||
<button
|
||
key={field}
|
||
type="button"
|
||
onClick={() => toggleSort(field)}
|
||
className={cn(
|
||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||
sortField === field
|
||
? "border-primary bg-primary/10 text-primary font-medium"
|
||
: "border-border text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
{label}{sortField === field ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="flex flex-col gap-2">
|
||
{sortedFlowCards.map((card) => (
|
||
<FlowEntityCardView
|
||
key={card.id}
|
||
card={card}
|
||
selected={card.id === (selFlowCard?.id ?? selectedId)}
|
||
onClick={() => setSelectedId(card.id)}
|
||
/>
|
||
))}
|
||
{sortedFlowCards.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground">
|
||
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<Frame dense className="w-full flex flex-col">
|
||
<FramePanel className="flex-1 px-5 pb-5 pt-5">
|
||
<FlowAnalyticsDetail
|
||
card={selFlowCard}
|
||
analytics={displayedFlow}
|
||
range={range}
|
||
onRange={(r) => setRange(r as Range)}
|
||
selectedIface={flowIface}
|
||
onIface={setFlowIface}
|
||
dedup={flowDedup}
|
||
onDedup={setFlowDedup}
|
||
excludeMesh={flowExcludeMesh}
|
||
onExcludeMesh={setFlowExcludeMesh}
|
||
excludeOverlay={flowExcludeOverlay}
|
||
onExcludeOverlay={setFlowExcludeOverlay}
|
||
liveHint={displayedFlow?.live ? "live" : undefined}
|
||
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
|
||
/>
|
||
</FramePanel>
|
||
</Frame>
|
||
</div>
|
||
<FlowOverlaySheet
|
||
open={overlayOpen}
|
||
onOpenChange={setOverlayOpen}
|
||
servers={catalogServers}
|
||
backendUrl={backendUrl}
|
||
onDone={() => { void loadFlows() }}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
|
||
{/* ── left panel ── */}
|
||
<div className="flex flex-col gap-3">
|
||
|
||
{/* search */}
|
||
<div className="relative">
|
||
<SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground pointer-events-none" />
|
||
<input
|
||
type="text"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
placeholder="Поиск…"
|
||
className="w-full pl-8 pr-3 h-8 text-xs bg-muted/50 border border-border rounded-md outline-none focus:border-primary transition-colors"
|
||
/>
|
||
</div>
|
||
|
||
{/* sort controls */}
|
||
<div className="flex gap-1 flex-wrap">
|
||
<span className="text-[10px] text-muted-foreground self-center mr-0.5">Сортировка:</span>
|
||
{visibleSortFields.map(({ field, label }) => (
|
||
<button key={field} onClick={() => toggleSort(field)}
|
||
className={cn(
|
||
"text-[10px] px-2 py-0.5 rounded border transition-colors",
|
||
sortField === field
|
||
? "border-primary bg-primary/10 text-primary font-medium"
|
||
: "border-border text-muted-foreground hover:text-foreground",
|
||
)}>
|
||
{label}{sortField === field ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{liveError && (
|
||
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
|
||
{liveError}
|
||
</div>
|
||
)}
|
||
|
||
{/* entity list */}
|
||
<div className="flex flex-col gap-2">
|
||
{effectiveMode === "servers" && sortedServers.map(s => (
|
||
<ServerCard key={s.id} s={s} selected={s.id === selectedId} onClick={() => setSelectedId(s.id)} />
|
||
))}
|
||
{effectiveMode === "users" && sortedUsers.map(u => (
|
||
<UserCard key={u.id} u={u} selected={u.id === selectedId} onClick={() => setSelectedId(u.id)} />
|
||
))}
|
||
{effectiveMode === "ifaces" && sortedIfaces.map(c => (
|
||
<IfaceCard key={c.id} c={c} selected={c.id === selectedId} onClick={() => setSelectedId(c.id)} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── detail panel ── */}
|
||
<Frame dense className="w-full flex flex-col">
|
||
<FramePanel className="flex-1 px-5 pb-5 pt-5">
|
||
{isLive && effectiveMode === "servers" && (
|
||
<div className="mb-3 pb-3 border-b">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-[11px] text-muted-foreground mr-1">Интерфейс:</span>
|
||
<button
|
||
onClick={() => setHideDisabledIfaces((v) => !v)}
|
||
className={cn(
|
||
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium transition-all",
|
||
hideDisabledIfaces
|
||
? "bg-muted text-foreground border-border"
|
||
: "text-muted-foreground border-border hover:text-foreground hover:border-foreground/40",
|
||
)}
|
||
>
|
||
{hideDisabledIfaces ? "Показать отключенные" : "Скрыть отключенные"}
|
||
</button>
|
||
<button
|
||
onClick={() => setSelectedIface("__all__")}
|
||
className={cn(
|
||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||
selectedIface === "__all__"
|
||
? "bg-foreground text-background border-foreground"
|
||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||
)}
|
||
>
|
||
Все
|
||
</button>
|
||
{visibleIfaces.map((iface, index) => {
|
||
const active = selectedIface === iface.name
|
||
return (
|
||
<button
|
||
key={`${iface.name}:${index}`}
|
||
onClick={() => setSelectedIface(iface.name)}
|
||
className={cn(
|
||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||
active
|
||
? "bg-foreground text-background border-foreground"
|
||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||
iface.disabled && !active && "opacity-40",
|
||
)}
|
||
>
|
||
<StatusDot status={iface.running && !iface.disabled ? "online" : "offline"} />
|
||
<span className="font-mono">{iface.name}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
{detailBusy && <span className="text-[10px] text-muted-foreground ml-2">Загрузка...</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{effectiveMode === "servers" && detailServer && (
|
||
<ServerDetail
|
||
sel={detailServer}
|
||
range={range}
|
||
setRange={setRange}
|
||
liveRx={liveSample?.rxMbps}
|
||
liveTx={liveSample?.txMbps}
|
||
liveHint={liveSample ? "live" : (liveStreamError ? "история" : undefined)}
|
||
/>
|
||
)}
|
||
{effectiveMode === "users" && selUser && <UserDetail sel={selUser} range={range} setRange={setRange} />}
|
||
{effectiveMode === "ifaces" && selIface && <IfaceDetail sel={selIface} range={range} setRange={setRange} />}
|
||
</FramePanel>
|
||
</Frame>
|
||
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|