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