feat(traffic): добавить приём Traffic Flow с jump-host
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m1s
Docker images / frontend-image (push) Successful in 3m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 46s
Docker images / publish-release (push) Successful in 12s

Чтобы видеть «кто с кем», а не только объём порта: IPFIX внутри WG на хосте Docker MM, REST-счётчики не трогаем.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-06 21:54:30 +07:00
co-authored by Cursor
parent 5884bd8873
commit 1e9312acbd
24 changed files with 2007 additions and 71 deletions
+3
View File
@@ -54,6 +54,7 @@ import {
type SchedulerJobGridRow,
} from "@/components/data-grids/data-collection-scheduler-data-grid"
import { DataPageCard } from "@/components/data-page-card"
import { NetflowSettingsPanel } from "@/components/traffic/netflow-settings-panel"
import { cn } from "@/lib/utils"
import {
AlertCircleIcon,
@@ -1210,6 +1211,8 @@ export default function DataCollectionPage() {
</div>
</DataPageCard>
{isLive ? <NetflowSettingsPanel backendUrl={backendUrl} enabled={isLive} /> : null}
<OpsPanel
title="Журнал прогонов"
description="SQLite `scheduler_runs` — до 80 записей; раскройте строку для полей и текста ошибки."
+154 -36
View File
@@ -11,13 +11,20 @@ import { StatusDot } from "@/components/status-dot"
import { Flag } from "@/components/flag"
import {
RefreshCwIcon, DownloadIcon, TrendingUpIcon, TrendingDownIcon,
ArrowUpIcon, ArrowDownIcon, ActivityIcon, UsersIcon, CableIcon, ServerIcon, SearchIcon,
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 { requestJson } from "@/shared/api/http-client"
import { 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 type { ServerRead } from "@mmapp/contracts/servers"
import { Badge } from "@/components/reui/badge"
import {
IFACE_TYPE_LABEL,
@@ -276,7 +283,7 @@ const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
"24h": "24ч",
}
type GroupMode = "servers" | "users" | "ifaces"
type GroupMode = "servers" | "users" | "ifaces" | "flows"
type SortField = "rx" | "tx" | "name" | "sessions"
type SortDir = "asc" | "desc"
@@ -701,6 +708,7 @@ 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[] }> = [
@@ -731,6 +739,9 @@ export default function TrafficPage() {
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 [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),
@@ -821,6 +832,30 @@ export default function TrafficPage() {
void loadLiveTraffic(range)
}, [isLive, range, loadLiveTraffic])
const loadFlows = useCallback(async () => {
if (!isLive) return
setLiveBusy(true)
setLiveError(null)
try {
const stats = await getTrafficFlows(backendUrl, range)
setFlowStats(stats)
} catch (e) {
setLiveError(e instanceof Error ? e.message : "Не удалось загрузить потоки")
} finally {
setLiveBusy(false)
}
}, [isLive, backendUrl, range])
useEffect(() => {
if (!isLive || effectiveMode !== "flows") return
void loadFlows()
}, [isLive, effectiveMode, loadFlows])
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
@@ -863,7 +898,7 @@ export default function TrafficPage() {
setGroupMode(next)
if (next === "servers") setSelectedId(activeServerTraffic[0]?.id ?? "srv1")
else if (next === "users") setSelectedId((isLive ? liveUsers : userTraffic)[0]?.id ?? "u1")
else setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
else if (next === "ifaces") setSelectedId((isLive ? liveBoundIfaces : boundIfaces)[0]?.id ?? "")
setSortField("rx")
setSortDir("desc")
setSearch("")
@@ -935,6 +970,68 @@ export default function TrafficPage() {
const visibleSortFields = SORT_FIELDS.filter(s => !s.modesOnly || s.modesOnly.includes(effectiveMode))
const flowKpiItems = [
{
id: "exporters",
label: "Экспортёры",
value: String(flowStats?.exportersOnline ?? 0),
icon: <ServerIcon className="size-4" />,
iconClassName: "text-info",
},
{
id: "bytes",
label: "Байт/мин",
value: flowStats ? fmtRate((flowStats.bytesPerMin * 8) / 1_000_000) : "—",
icon: <ActivityIcon className="size-4" />,
iconClassName: "text-success",
},
{
id: "src",
label: "Уник. src",
value: String(flowStats?.uniqueSrc ?? 0),
icon: <ArrowUpIcon className="size-4" />,
iconClassName: "text-muted-foreground",
},
{
id: "proto",
label: "Топ протокол",
value: 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
@@ -944,7 +1041,10 @@ export default function TrafficPage() {
<Button
variant="outline"
size="sm"
onClick={() => { void loadLiveTraffic(range) }}
onClick={() => {
if (effectiveMode === "flows") void loadFlows()
else void loadLiveTraffic(range)
}}
disabled={isLive && liveBusy}
>
<RefreshCwIcon className={cn("size-4", isLive && liveBusy && "animate-spin")} />Обновить
@@ -975,40 +1075,57 @@ export default function TrafficPage() {
<div className="flex flex-col gap-5">
<KpiStatGrid
aria-label="Сводка трафика"
items={[
{
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",
},
]}
items={effectiveMode === "flows" ? flowKpiItems : counterKpiItems}
/>
<div className="grid grid-cols-[300px_1fr] gap-5 items-start">
{effectiveMode === "flows" ? (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-sm text-muted-foreground">
IPFIX top-разговоры. Счётчики интерфейсов в режимах Серверы / Клиенты / Интерфейсы.
</p>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex gap-1">
{TRAFFIC_RANGE_KEYS.map((key) => (
<button
key={key}
type="button"
onClick={() => setRange(key)}
className={cn(
"text-[10px] px-2 py-0.5 rounded border transition-colors",
range === key
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{TRAFFIC_RANGE_LABELS[key]}
</button>
))}
</div>
<Button size="sm" onClick={() => setOverlayOpen(true)} disabled={!isLive}>
<PlusIcon className="size-4" />
Подключить JH
</Button>
</div>
</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 ?? []} />
</DataPageCard>
<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">
@@ -1127,6 +1244,7 @@ export default function TrafficPage() {
</Frame>
</div>
)}
</div>
</div>
</div>