feat(traffic): показать аналитику IPFIX по серверам и интерфейсам
Docker images / prepare-release (push) Successful in 12s
Docker images / backend-image (push) Successful in 2m10s
Docker images / frontend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 48s
Docker images / publish-release (push) Successful in 11s

Резолвить ifIndex в имена RouterOS, дать вкладке Потоки ту же оболочку сервер/клиент/iface, что у обычного трафика, и обновлять срезы live без перезагрузки.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 01:21:12 +07:00
co-authored by Cursor
parent cf68b59b3f
commit 37167f78e3
20 changed files with 1686 additions and 53 deletions
+166 -43
View File
@@ -17,13 +17,13 @@ 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 { getTrafficFlows } from "@/shared/api/traffic-flow"
import { getFlowAnalytics, getFlowClients, getFlowExporters, getTrafficFlows } from "@/shared/api/traffic-flow"
import { listServers } from "@/shared/api/servers"
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
import { FlowOverlaySheet } from "@/components/traffic/flow-overlay-sheet"
import { DataPageCard } from "@/components/data-page-card"
import type { FlowStatsDto } from "@mmapp/contracts/traffic-flow"
import { FlowAnalyticsDetail, FlowEntityCardView } from "@/components/traffic/flow-analytics-panel"
import type { FlowAnalyticsDto, FlowEntityCard, FlowStatsDto } from "@mmapp/contracts/traffic-flow"
import type { ServerRead } from "@mmapp/contracts/servers"
import { Badge } from "@/components/reui/badge"
import {
@@ -309,6 +309,7 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
}
type GroupMode = "servers" | "users" | "ifaces" | "flows"
type FlowScope = "servers" | "users"
type SortField = "rx" | "tx" | "name" | "sessions"
type SortDir = "asc" | "desc"
@@ -740,7 +741,7 @@ const SORT_FIELDS: Array<{ field: SortField; label: string; modesOnly?: GroupMod
{ field: "rx", label: "RX" },
{ field: "tx", label: "TX" },
{ field: "name", label: "Имя" },
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users"] },
{ field: "sessions", label: "Сессий", modesOnly: ["servers", "users", "flows"] },
]
export default function TrafficPage() {
@@ -765,6 +766,11 @@ export default function TrafficPage() {
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 [overlayOpen, setOverlayOpen] = useState(false)
const [catalogServers, setCatalogServers] = useState<ServerRead[]>([])
const effectiveMode: GroupMode = groupMode
@@ -774,6 +780,15 @@ export default function TrafficPage() {
serverId: selectedId,
iface: selectedIface,
})
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId)
const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({
enabled: flowLiveEnabled,
backendUrl,
range,
serverId: flowScope === "servers" ? selectedId : undefined,
userId: flowScope === "users" ? selectedId : undefined,
iface: flowIface,
})
const toLiveServer = (s: LiveTrafficServer): ServerTraffic => {
return {
@@ -862,20 +877,50 @@ export default function TrafficPage() {
setLiveBusy(true)
setLiveError(null)
try {
const stats = await getTrafficFlows(backendUrl, range)
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])
}, [isLive, backendUrl, range, flowScope])
useEffect(() => {
if (!isLive || effectiveMode !== "flows") return
void loadFlows()
const t = window.setInterval(() => { void loadFlows() }, 5000)
return () => window.clearInterval(t)
}, [isLive, effectiveMode, loadFlows])
useEffect(() => {
if (!isLive || effectiveMode !== "flows" || !selectedId) {
setFlowAnalytics(null)
return
}
void getFlowAnalytics(backendUrl, {
range,
serverId: flowScope === "servers" ? selectedId : undefined,
userId: flowScope === "users" ? selectedId : undefined,
iface: flowIface,
}).then(setFlowAnalytics).catch(() => setFlowAnalytics(null))
}, [isLive, effectiveMode, selectedId, range, flowScope, flowIface, backendUrl])
useEffect(() => {
setFlowIface("__all__")
}, [selectedId, flowScope])
useEffect(() => {
if (!isLive) return
void listServers(backendUrl).then(setCatalogServers).catch(() => setCatalogServers([]))
@@ -924,6 +969,11 @@ export default function TrafficPage() {
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
else if (next === "ifaces") setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
else if (next === "flows") {
setFlowScope("servers")
setFlowIface("__all__")
setSelectedId(flowExporters[0]?.id ?? "")
}
setSortField("rx")
setSortDir("desc")
setSearch("")
@@ -978,6 +1028,22 @@ export default function TrafficPage() {
})
}, [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])
@@ -995,33 +1061,35 @@ export default function TrafficPage() {
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
const ingestLine = flowIngestLine(flowStats)
const flowError = liveError || flowLiveError
const flowKpiItems = [
{
id: "exporters",
label: "Экспортёры",
value: String(flowStats?.exportersOnline ?? 0),
value: String(flowExporters.length),
icon: <ServerIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "bytes",
label: "Байт/мин",
value: flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—",
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(flowStats?.uniqueSrc ?? 0),
value: String(displayedFlow?.uniqueSrc ?? flowStats?.uniqueSrc ?? 0),
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "proto",
label: "Топ протокол",
value: flowStats?.topProto ?? "—",
value: displayedFlow?.topProto ?? flowStats?.topProto ?? "—",
icon: <GitBranchIcon className="size-4" />,
iconClassName: "text-warning",
},
@@ -1107,51 +1175,106 @@ export default function TrafficPage() {
{effectiveMode === "flows" ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex flex-col gap-1 min-w-0">
<p className="text-sm text-muted-foreground">
IPFIX top-разговоры. Счётчики интерфейсов в режимах Серверы / Клиенты / Интерфейсы.
</p>
{ingestLine ? (
<p className="text-xs text-muted-foreground font-mono truncate">
{ingestLine}
</p>
) : null}
<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="flex items-center gap-2 flex-wrap">
)}
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
<div className="flex flex-col gap-3">
<div className="flex gap-1">
{TRAFFIC_RANGE_KEYS.map((key) => (
<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={key}
key={field}
type="button"
onClick={() => setRange(key)}
onClick={() => toggleSort(field)}
className={cn(
"text-[10px] px-2 py-0.5 rounded border transition-colors",
range === key
sortField === field
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{TRAFFIC_RANGE_LABELS[key]}
{label}{sortField === field ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
</button>
))}
</div>
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
<PlusIcon className="size-4" />
Подключить JH
</Button>
<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) ?? "Нет экспортёров 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}
liveHint={displayedFlow?.live ? "live" : undefined}
emptyHint={flowEmptyHint(flowStats)}
/>
</FramePanel>
</Frame>
</div>
{liveError && (
<div className="text-xs text-destructive bg-destructive/10 border border-destructive/20 rounded-md px-3 py-2">
{liveError}
</div>
)}
<DataPageCard>
<TrafficFlowsDataGrid
rows={flowStats?.talkers ?? []}
emptyHint={flowEmptyHint(flowStats)}
/>
</DataPageCard>
<FlowOverlaySheet
open={overlayOpen}
onOpenChange={setOverlayOpen}