fix(netflow): изолировать коллектор IPFIX и срезать раздувание базы
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 57s
Docker images / publish-release (push) Successful in 12s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-07 09:50:24 +07:00
co-authored by Cursor
parent e0ddb17539
commit cb799da13a
24 changed files with 1884 additions and 530 deletions
+71 -17
View File
@@ -19,11 +19,11 @@ 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, getTrafficFlows } from "@/shared/api/traffic-flow"
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, FlowStatsDto } from "@mmapp/contracts/traffic-flow"
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 {
@@ -63,18 +63,52 @@ function flowIngestLine(stats: FlowStatsDto | null): string | null {
return `Коллектор: ${listener} · пакеты ${stats.packetsReceived ?? 0} · последний ${last}${exporter}${err}`
}
function flowEmptyHint(stats: FlowStatsDto | null): string | undefined {
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) {
if (stats.listenerBound === false && !collectorAlive) {
return "Коллектор UDP не слушает. Подключите JH ещё раз — ingest включится автоматически."
}
if (stats.listenerBound || collectorAlive) {
return "Коллектор жив, IPFIX ещё не доходит. На jump-host у target Src должен быть 0.0.0.0 (авто)."
}
return "IPFIX ещё не доходит до коллектора. На jump-host у target Src должен быть 0.0.0.0 (авто). На хосте MM проверьте bind 10.255.254.1:4739 после wg-flow."
}
function monthlyToAnalytics(m: FlowMonthlyDto): FlowAnalyticsDto {
const emptySeries = Array(60).fill(0) as number[]
return {
bpsNow: 0,
bytes: m.bytes,
packets: 0,
conversations: 0,
conversationsRaw: 0,
uniqueSrc: 0,
uniqueDst: 0,
topProto: "—",
topCategory: "—",
rxSeries: emptySeries,
txSeries: emptySeries,
applications: [],
protocols: [],
sources: [],
destinations: [],
interfaces: [],
asns: m.asns,
countries: m.countries,
categories: [],
services: m.services,
mapEdges: [],
conversationsList: [],
ifaces: [],
live: false,
degraded: false,
}
}
// ─── data model ───────────────────────────────────────────────────────────────
interface BoundIfaceTraffic {
@@ -298,9 +332,9 @@ const userTraffic: UserTraffic[] = INIT_USERS.map((u) =>
/** Ключи совпадают с `rangeToMinutes` в API (`/api/traffic/...`). */
const TRAFFIC_RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h"] as const
type Range = (typeof TRAFFIC_RANGE_KEYS)[number]
type Range = (typeof TRAFFIC_RANGE_KEYS)[number] | "30d"
const TRAFFIC_RANGE_LABELS: Record<Range, string> = {
const TRAFFIC_RANGE_LABELS: Record<(typeof TRAFFIC_RANGE_KEYS)[number], string> = {
"5m": "5м",
"15m": "15м",
"1h": "1ч",
@@ -781,7 +815,7 @@ export default function TrafficPage() {
serverId: selectedId,
iface: selectedIface,
})
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId)
const flowLiveEnabled = isLive && effectiveMode === "flows" && Boolean(selectedId) && range === "5m"
const { sample: flowLiveSample, error: flowLiveError } = useFlowLive({
enabled: flowLiveEnabled,
backendUrl,
@@ -835,7 +869,7 @@ export default function TrafficPage() {
setLiveBusy(true)
setLiveError(null)
try {
const q = encodeURIComponent(targetRange)
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}`),
@@ -902,8 +936,6 @@ export default function TrafficPage() {
useEffect(() => {
if (!isLive || effectiveMode !== "flows") return
void loadFlows()
const t = window.setInterval(() => { void loadFlows() }, 5000)
return () => window.clearInterval(t)
}, [isLive, effectiveMode, loadFlows])
useEffect(() => {
@@ -911,6 +943,18 @@ export default function TrafficPage() {
setFlowAnalytics(null)
return
}
if (range === "5m") {
setFlowAnalytics(null)
return
}
if (range === "30d") {
const month = new Date().toISOString().slice(0, 7)
void getFlowMonthly(backendUrl, {
month,
serverId: flowScope === "servers" ? selectedId : undefined,
}).then((m) => setFlowAnalytics(monthlyToAnalytics(m))).catch(() => setFlowAnalytics(null))
return
}
void getFlowAnalytics(backendUrl, {
range,
serverId: flowScope === "servers" ? selectedId : undefined,
@@ -969,12 +1013,19 @@ export default function TrafficPage() {
const handleModeChange = (next: GroupMode) => {
setGroupMode(next)
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") {
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")
@@ -1064,7 +1115,10 @@ 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 collectorAlive = Boolean(flowStats?.listenerBound || flowStats?.packetsReceived)
const flowError = liveError
|| (flowLiveError && !(collectorAlive && /live HTTP 500/.test(flowLiveError)) ? flowLiveError : null)
|| (displayedFlow?.degraded ? "Коллектор перегружен: упрощённая аналитика" : null)
const flowKpiItems = [
{
@@ -1258,7 +1312,7 @@ export default function TrafficPage() {
))}
{sortedFlowCards.length === 0 ? (
<p className="text-xs text-muted-foreground">
{flowEmptyHint(flowStats) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
{flowEmptyHint(flowStats, collectorAlive) ?? "Нет экспортёров IPFIX. Подключите jump-host."}
</p>
) : null}
</div>
@@ -1275,7 +1329,7 @@ export default function TrafficPage() {
dedup={flowDedup}
onDedup={setFlowDedup}
liveHint={displayedFlow?.live ? "live" : undefined}
emptyHint={flowEmptyHint(flowStats)}
emptyHint={flowEmptyHint(flowStats, collectorAlive)}
/>
</FramePanel>
</Frame>