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
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:
+166
-43
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user