Files
MikrotikManager/app/(main)/traffic/page.tsx
T

1482 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useMemo, useEffect, type ReactNode } from "react"
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
import { TrafficDataTime } from "@/components/traffic/traffic-data-time"
import { PageHeader } from "@/components/page-header"
import { Frame, FramePanel } from "@/components/reui/frame"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart"
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, 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."
}
// ─── 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 min-w-0 break-words">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-semibold tabular-nums">{value}</p>
</div>
</div>
)
}
// ─── left-panel entity cards ──────────────────────────────────────────────────
function ServerCard({ s, selected, onClick }: { s: ServerTraffic; selected: boolean; onClick: () => void }) {
return (
<button type="button" aria-pressed={selected} 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>
<TrafficMiniChart 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 type="button" aria-pressed={selected} 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 ? (
<>
<TrafficMiniChart 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 type="button" aria-pressed={selected} 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>
<TrafficMiniChart 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 flex-wrap 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="min-w-0 basis-full sm:basis-0 sm:flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono font-semibold break-all">{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 flex-wrap 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 flex-wrap gap-1">
{TRAFFIC_RANGE_KEYS.map((r) => (
<Button key={r} type="button" aria-pressed={range === 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]:min-w-0 [&_svg]:max-w-full">
<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 break-all">{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 break-all">{sel.login}</h2>
<p className="text-xs text-muted-foreground break-all">{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 break-all">{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 break-all">{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 break-all">{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"] },
]
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,
txSeries: s.txSeries,
boundIfaces: [],
}
}
const toLiveBound = (c: BoundIfaceTraffic): BoundIfaceTraffic => ({
...c,
interfaceType: (["ether", "gre", "wg", "other"].includes(c.interfaceType) ? c.interfaceType : "other") as InterfaceType,
rxSeries: c.rxSeries,
txSeries: c.txSeries,
})
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,
txSeries: u.txSeries,
}
}
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 [trafficRetry, setTrafficRetry] = useState(0)
const trafficKey = JSON.stringify([isLive, backendUrl, range, groupMode, trafficRetry])
const [trafficState, setTrafficState] = useState<{ key: string; status: "ready" | "error"; receivedAt?: number; error?: string } | null>(null)
const trafficReady = trafficState?.key === trafficKey && trafficState.status === "ready"
const trafficLoading = trafficState?.key !== trafficKey
const trafficError = trafficState?.key === trafficKey ? trafficState.error : undefined
const [liveServers, setLiveServers] = useState<ServerTraffic[]>([])
const trafficSourceKey = JSON.stringify([isLive, backendUrl])
const [liveServerOwner, setLiveServerOwner] = useState<string | null>(null)
const confirmedLiveServer = isLive && liveServerOwner === trafficSourceKey
&& /^[1-9]\d*$/.test(selectedId) && Number.isSafeInteger(Number(selectedId))
&& liveServers.some(server => server.id === selectedId)
const [serverIfaces, setServerIfaces] = useState<LiveTrafficInterface[]>([])
const [selectedIface, setSelectedIface] = useState("__all__")
const [hideDisabledIfaces, setHideDisabledIfaces] = useState(true)
const [detailRetry, setDetailRetry] = useState(0)
const [detailResult, setDetailResult] = useState<{ key: string; server: ServerTraffic | null; receivedAt?: number; error: string | null } | null>(null)
const [liveUsers, setLiveUsers] = useState<UserTraffic[]>([])
const [liveBoundIfaces, setLiveBoundIfaces] = useState<BoundIfaceTraffic[]>([])
const [flowScope, setFlowScope] = useState<FlowScope>("servers")
const [flowRetry, setFlowRetry] = useState(0)
const flowKey = JSON.stringify([isLive, groupMode, backendUrl, range, flowScope, flowRetry])
const [flowResult, setFlowResult] = useState<{ key: string; receivedAt?: number; monthlyServers?: ServerRead[]; stats?: FlowStatsDto; exporters?: FlowEntityCard[]; clients?: FlowEntityCard[]; error?: string } | null>(null)
const currentFlows = flowResult?.key === flowKey ? flowResult : null
const liveBusy = isLive && groupMode === "flows" && !currentFlows
const liveError = currentFlows?.error
const flowStats = currentFlows?.stats ?? null
const flowExporters = useMemo(() => currentFlows?.exporters ?? [], [currentFlows])
const flowClients = useMemo(() => currentFlows?.clients ?? [], [currentFlows])
const [flowMonth, setFlowMonth] = useState(() => new Date().toISOString().slice(0, 7))
const flowMonthValid = /^(?!0000)\d{4}-(0[1-9]|1[0-2])$/.test(flowMonth) && flowMonth <= new Date().toISOString().slice(0, 7)
const [analyticsRetry, setAnalyticsRetry] = useState(0)
const [analyticsResult, setAnalyticsResult] = useState<{ key: string; receivedAt?: number; data?: FlowAnalyticsDto; monthly?: FlowMonthlyDto; error?: string } | null>(null)
const flowIfaceOwner = JSON.stringify([isLive, backendUrl, groupMode, flowScope, selectedId])
const [flowIfaceSelection, setFlowIfaceSelection] = useState<{ owner: string; iface: string } | null>(null)
const flowIface = flowIfaceSelection?.owner === flowIfaceOwner ? flowIfaceSelection.iface : "__all__"
const setFlowIface = (iface: string) => setFlowIfaceSelection({ owner: flowIfaceOwner, iface })
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 detailKey = JSON.stringify([isLive, backendUrl, effectiveMode, selectedId, range, selectedIface, detailRetry])
const currentDetail = detailResult?.key === detailKey ? detailResult : null
const detailBusy = isLive && effectiveMode === "servers" && Boolean(selectedId) && currentDetail === null
const { sample: liveSample, error: liveStreamError, receivedAt: liveReceivedAt } = useTrafficLive({
enabled: confirmedLiveServer && effectiveMode === "servers",
backendUrl,
serverId: selectedId,
iface: selectedIface,
})
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m"
const { sample: flowLiveSample, error: flowLiveError, receivedAt: flowLiveReceivedAt } = useFlowLive({
enabled: flowLiveEnabled,
backendUrl,
range,
serverId: flowScope === "servers" ? selectedId : undefined,
userId: flowScope === "users" ? selectedId : undefined,
iface: flowIface,
dedup: flowDedup,
excludeMesh: flowExcludeMesh,
excludeOverlay: flowExcludeOverlay,
})
useEffect(() => {
if (!isLive || groupMode === "flows") return
let cancelled = false
const loadLiveTraffic = async (targetRange: Range) => {
const key = JSON.stringify([isLive, backendUrl, targetRange, groupMode, trafficRetry])
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}`),
])
if (cancelled) return
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)
setLiveServerOwner(JSON.stringify([isLive, backendUrl]))
setLiveUsers(mappedUsers)
setLiveBoundIfaces(liveIfaces)
setTrafficState({ key, status: "ready", receivedAt: Date.now() })
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) {
if (!cancelled) setTrafficState({ key, status: "error", error: e instanceof Error ? e.message : "Не удалось загрузить трафик" })
}
}
void loadLiveTraffic(range)
return () => { cancelled = true }
}, [apiFetch, isLive, groupMode, backendUrl, trafficRetry, range])
useEffect(() => {
if (!isLive || effectiveMode !== "flows") return
let cancelled = false
if (range === "30d") {
void listServers(backendUrl).then(servers => {
if (cancelled) return
setFlowResult({ key: flowKey, receivedAt: Date.now(), monthlyServers: servers })
setSelectedId(prev => servers.some(server => String(server.id) === prev) ? prev : String(servers[0]?.id ?? ""))
}).catch(error => {
if (!cancelled) setFlowResult({ key: flowKey, error: error instanceof Error ? error.message : "Не удалось загрузить каталог серверов" })
})
return () => { cancelled = true }
}
void Promise.all([
getTrafficFlows(backendUrl, range),
getFlowExporters(backendUrl, range),
getFlowClients(backendUrl, range),
]).then(([stats, exporters, clients]) => {
if (cancelled) return
setFlowResult({ key: flowKey, receivedAt: Date.now(), stats, exporters: exporters.exporters, clients: clients.clients })
setSelectedId(prev => {
const list = flowScope === "users" ? clients.clients : exporters.exporters
return list.some(x => x.id === prev) ? prev : (list[0]?.id ?? "")
})
}).catch(e => {
if (!cancelled) setFlowResult({ key: flowKey, error: e instanceof Error ? e.message : "Не удалось загрузить потоки" })
})
return () => { cancelled = true }
}, [isLive, effectiveMode, backendUrl, range, flowScope, flowKey])
const analyticsKey = JSON.stringify([flowKey, selectedId, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, analyticsRetry, range === "30d" ? flowMonth : null])
const currentAnalytics = analyticsResult?.key === analyticsKey ? analyticsResult : null
const analyticsLoading = isLive && effectiveMode === "flows" && Boolean(selectedId) && (range !== "30d" || flowMonthValid) && !currentAnalytics
useEffect(() => {
if (!isLive || effectiveMode !== "flows" || !selectedId || (range === "30d" && !flowMonthValid)) return
let cancelled = false
const request = range === "30d"
? getFlowMonthly(backendUrl, { month: flowMonth, serverId: flowScope === "servers" ? selectedId : undefined }).then(monthly => ({ monthly }))
: getFlowAnalytics(backendUrl, {
range, serverId: flowScope === "servers" ? selectedId : undefined,
userId: flowScope === "users" ? selectedId : undefined,
iface: flowIface, dedup: flowDedup, excludeMesh: flowExcludeMesh, excludeOverlay: flowExcludeOverlay,
}).then(data => ({ data }))
void request.then(result => {
if (!cancelled) setAnalyticsResult({ key: analyticsKey, receivedAt: Date.now(), ...result })
}).catch(e => {
if (!cancelled) setAnalyticsResult({ key: analyticsKey, error: e instanceof Error ? e.message : "Не удалось загрузить аналитику" })
})
return () => { cancelled = true }
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, flowDedup, flowExcludeMesh, flowExcludeOverlay, backendUrl, analyticsKey, flowMonth, flowMonthValid])
useEffect(() => {
if (!isLive) return
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
}, [isLive, backendUrl])
const activeServerTraffic = useMemo(() => isLive ? (trafficReady ? liveServers : []) : serverTraffic, [isLive, trafficReady, liveServers])
const activeUserTraffic = useMemo(() => isLive ? (trafficReady ? liveUsers : []) : userTraffic, [isLive, trafficReady, liveUsers])
const activeBoundIfaces = useMemo(() => isLive ? (trafficReady ? liveBoundIfaces : []) : boundIfaces, [isLive, trafficReady, liveBoundIfaces])
const visibleIfaces = useMemo(
() => hideDisabledIfaces ? serverIfaces.filter((i) => !i.disabled && i.running) : serverIfaces,
[serverIfaces, hideDisabledIfaces],
)
useEffect(() => {
if (!confirmedLiveServer || effectiveMode !== "servers") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setServerIfaces([])
setSelectedIface("__all__")
return
}
let cancelled = false
setServerIfaces([])
setSelectedIface("__all__")
apiFetch<{ interfaces: LiveTrafficInterface[] }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}/interfaces`)
.then((res) => { if (!cancelled) setServerIfaces(res.interfaces) })
.catch(() => { if (!cancelled) setServerIfaces([]) })
return () => { cancelled = true }
}, [confirmedLiveServer, selectedId, effectiveMode, apiFetch])
useEffect(() => {
if (!confirmedLiveServer || effectiveMode !== "servers") {
return
}
let cancelled = false
apiFetch<{ server: LiveTrafficServer }>(`/api/traffic/servers/${encodeURIComponent(selectedId)}?range=${encodeURIComponent(range)}&iface=${encodeURIComponent(selectedIface)}`)
.then((res) => {
if (cancelled) return
setDetailResult({ key: detailKey, receivedAt: Date.now(), error: null, server: {
...toLiveServer(res.server),
boundIfaces: liveBoundIfaces.filter((i) => i.serverId === selectedId),
} })
})
.catch((error: unknown) => {
if (!cancelled) setDetailResult({ key: detailKey, server: null, error: error instanceof Error ? error.message : "Не удалось загрузить детали трафика" })
})
return () => { cancelled = true }
}, [confirmedLiveServer, effectiveMode, selectedId, range, selectedIface, apiFetch, liveBoundIfaces, detailKey])
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 monthlyServers = currentFlows?.monthlyServers ?? []
const visibleMonthlyServers = monthlyServers.filter(server => !q || server.name.toLowerCase().includes(q)).sort((a, b) => a.name.localeCompare(b.name))
const monthlyServer = monthlyServers.find(server => String(server.id) === selectedId)
const selFlowCard = range === "30d"
? (monthlyServer ? { id: String(monthlyServer.id), name: monthlyServer.name, country: monthlyServer.country, site: monthlyServer.site } : null)
: flowCards.find((c) => c.id === selectedId) ?? null
const displayedFlow = currentFlows && !liveError ? (flowLiveSample ?? currentAnalytics?.data ?? null) : null
const selServer = useMemo(() => activeServerTraffic.find(s => s.id === selectedId) ?? activeServerTraffic[0], [selectedId, activeServerTraffic])
const detailServer = isLive ? currentDetail?.server : 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 flowLiveHint = flowLiveError ? (flowLiveSample ? "Последние данные · live недоступен" : "История · live недоступен") : flowLiveSample ? "live" : undefined
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) : "—",
hint: flowLiveHint,
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "src",
label: "Уник. src",
value: displayedFlow ? String(displayedFlow.uniqueSrc) : "—",
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "proto",
label: "Топ протокол",
value: displayedFlow?.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") setFlowRetry(value => value + 1)
else setTrafficRetry(value => value + 1)
}}
disabled={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
>
<RefreshCwIcon className={cn("size-4", isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading) && "animate-spin")} />Обновить
</Button>
</>
}
/>
{/* ── mode tab bar ── */}
<div className="border-b bg-background shrink-0">
<div role="group" aria-label="Разделы трафика" className="flex items-center overflow-x-auto px-3 sm:px-6">
{GROUP_MODES.map(({ mode, icon, label }) => (
<button type="button" aria-pressed={effectiveMode === mode} 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 min-w-0 overflow-y-auto p-3 sm:p-6">
<div className="flex flex-col gap-5">
{!(effectiveMode === "flows" && range === "30d") && (!isLive || !(effectiveMode === "flows" ? liveError : trafficError)) && <KpiStatGrid
isLoading={isLive && (effectiveMode === "flows" ? liveBusy : trafficLoading)}
aria-label="Сводка трафика"
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>}
{isLive && effectiveMode !== "flows" && trafficReady && <TrafficDataTime label="Получение списка трафика" receivedAt={trafficState.receivedAt} source="API" />}
{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>
{isLive && <TrafficDataTime label="Получение списка потоков" receivedAt={currentFlows?.receivedAt} source="API" />}
{liveBusy && <p role="status">Загрузка потоков…</p>}
{liveError && <Alert variant="destructive" role="alert"><AlertTitle>Потоки недоступны</AlertTitle><AlertDescription>{liveError}<Button size="sm" variant="outline" onClick={() => setFlowRetry(value => value + 1)}>Повторить загрузку потоков</Button></AlertDescription></Alert>}
{flowError && !liveError && (
<div role="alert" className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{flowError}
</div>
)}
<div className="grid min-w-0 grid-cols-1 xl:grid-cols-[300px_minmax(0,1fr)] gap-5 items-start">
<div className="flex flex-col gap-3">
<div role="group" aria-label="Источник потоков" className="flex gap-1">
<button
type="button"
aria-pressed={flowScope === "servers"}
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"
aria-pressed={flowScope === "users"}
onClick={() => { setFlowScope("users"); setFlowIface("__all__"); if (range === "30d") setRange("1h") }}
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)}
aria-label="Поиск потоков" 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={cn("flex gap-1 flex-wrap", range === "30d" && "hidden")}>
<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>
{range === "30d" && <p className="text-xs text-muted-foreground">Серверы из каталога, включая неактивные. Удалённые серверы в список не входят.</p>}
<div className="flex flex-col gap-2">
{range === "30d" ? visibleMonthlyServers.map(server => <Button key={server.id} variant="outline" className="justify-start whitespace-normal break-all h-auto py-2" aria-pressed={String(server.id) === selectedId} onClick={() => setSelectedId(String(server.id))}>{server.name}</Button>) : sortedFlowCards.map((card) => (
<FlowEntityCardView
key={card.id}
card={card}
selected={card.id === (selFlowCard?.id ?? selectedId)}
onClick={() => { setFlowIface("__all__"); setSelectedId(card.id) }}
/>
))}
{!liveBusy && !liveError && (range === "30d" ? visibleMonthlyServers.length : sortedFlowCards.length) === 0 ? (
<p className="text-xs text-muted-foreground">
{range === "30d" ? (q ? "Нет серверов по этому запросу" : "В каталоге нет серверов") : flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
</p>
) : null}
</div>
</div>
<Frame dense className="w-full min-w-0 flex flex-col">
<FramePanel className="flex-1 min-w-0 p-3 sm:p-5">
{analyticsLoading && <p role="status">Загрузка аналитики…</p>}
{currentAnalytics?.error && <Alert variant="destructive" role="alert"><AlertTitle>Аналитика недоступна</AlertTitle><AlertDescription>{currentAnalytics.error}<Button size="sm" variant="outline" onClick={() => setAnalyticsRetry(value => value + 1)}>Повторить аналитику</Button></AlertDescription></Alert>}
{isLive && (displayedFlow || (monthlyServer && currentAnalytics?.monthly)) && <TrafficDataTime label="Получение аналитики потоков" receivedAt={flowLiveSample ? flowLiveReceivedAt : currentAnalytics?.receivedAt} source={flowLiveSample ? "live-поток" : "API"} />}
<FlowAnalyticsDetail
card={selFlowCard}
analytics={displayedFlow}
monthly={monthlyServer ? currentAnalytics?.monthly : undefined}
monthlyAvailable={flowScope === "servers"}
month={flowMonth}
onMonth={setFlowMonth}
monthValid={flowMonthValid}
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={flowLiveHint}
emptyHint={analyticsLoading ? "Загрузка аналитики…" : currentAnalytics?.error ? "Не удалось получить данные выбранного среза" : flowEmptyHint(flowStats, collectorAlive)}
/>
</FramePanel>
</Frame>
</div>
<FlowOverlaySheet
open={overlayOpen}
onOpenChange={setOverlayOpen}
servers={catalogServers}
backendUrl={backendUrl}
onDone={() => { setFlowRetry(value => value + 1) }}
/>
</div>
) : (
<div className="grid min-w-0 grid-cols-1 xl:grid-cols-[300px_minmax(0,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)}
aria-label="Поиск трафика" 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 type="button" aria-pressed={sortField === field} 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>
{isLive && trafficLoading && <p role="status" className="text-sm text-muted-foreground">Загрузка списка трафика…</p>}
{trafficError && <Alert variant="destructive" role="alert"><AlertTitle>Список трафика недоступен</AlertTitle><AlertDescription>{trafficError}<Button type="button" size="sm" variant="outline" onClick={() => setTrafficRetry(value => value + 1)}>Повторить загрузку списка</Button></AlertDescription></Alert>}
{(!isLive || trafficReady) && (effectiveMode === "servers" ? sortedServers.length : effectiveMode === "users" ? sortedUsers.length : sortedIfaces.length) === 0 && (
<p role="status" className="text-sm text-muted-foreground">
{search.trim() ? "По вашему запросу ничего не найдено" : effectiveMode === "servers" ? "Нет серверов с данными трафика" : effectiveMode === "users" ? "Нет клиентов с данными трафика" : "Нет привязанных интерфейсов с данными трафика"}
</p>
)}
{/* 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 min-w-0 flex flex-col">
<FramePanel className="flex-1 min-w-0 p-3 sm:p-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
type="button" aria-pressed={hideDisabledIfaces}
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
type="button" aria-pressed={selectedIface === "__all__"}
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}`}
type="button" aria-pressed={active}
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>
)}
{isLive && effectiveMode === "servers" && !detailServer && <>
<DetailHeader range={range} setRange={setRange}><h2 className="text-base font-semibold">{selServer?.name ?? "Трафик сервера"}</h2></DetailHeader>
{detailBusy && <p role="status" className="text-sm text-muted-foreground">Загрузка выбранного периода и интерфейса…</p>}
{currentDetail?.error && <Alert variant="destructive" role="alert"><AlertTitle>Детали трафика недоступны</AlertTitle><AlertDescription>{currentDetail.error}<Button type="button" size="sm" variant="outline" onClick={() => setDetailRetry(n => n + 1)}>Повторить загрузку деталей</Button></AlertDescription></Alert>}
</>}
{isLive && effectiveMode === "servers" && detailServer && <>
<TrafficDataTime label="Получение истории сервера" receivedAt={currentDetail?.receivedAt} source="API" />
<TrafficDataTime label="Последнее измерение сервера" receivedAt={liveReceivedAt} source="live-поток" serverAt={liveSample?.at} />
</>}
{effectiveMode === "servers" && detailServer && (
<ServerDetail
sel={detailServer}
range={range}
setRange={setRange}
liveRx={liveSample?.rxMbps}
liveTx={liveSample?.txMbps}
liveHint={liveStreamError
? (liveSample ? "Последнее live-измерение · поток недоступен" : "История · live недоступен")
: liveSample ? "live" : isLive ? "История · ожидание live" : 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>
)
}