feat(mcp): подготовить сервер и доступ к приёмке
This commit is contained in:
commit
bf3d50e1ff
3835 files changed
+537122
No files matched your search
@@ -0,0 +1,659 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { type ColumnDef, useTable } from "@tanstack/react-table"
|
||||
import type { FlowMonthlyDto, FlowAnalyticsDto, FlowBreakdownRow, FlowEntityCard, FlowPathRow, FlowTalkerDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { ArrowDownIcon, ArrowUpIcon, GitBranchIcon, GlobeIcon, LayersIcon, NetworkIcon, RouteIcon, ShieldIcon, UsersIcon } from "lucide-react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { KpiStatGrid, type KpiStatItem } from "@/components/reui-kit/kpi-stat-grid"
|
||||
import { TrafficMiniChart } from "@/components/reui-kit/traffic-mini-chart"
|
||||
import { TrafficRxTxChart } from "@/components/reui-kit/traffic-rx-tx-chart"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { TrafficFlowsDataGrid } from "@/components/data-grids/traffic-flows-data-grid"
|
||||
import { FlowTrafficMap } from "@/components/traffic/flow-traffic-map"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { fmtRate } from "@/lib/fmt-rate"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function hasMeasurement(value: number | undefined): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
const RANGE_KEYS = ["5m", "15m", "1h", "4h", "24h", "30d"] as const
|
||||
const RANGE_LABELS: Record<string, string> = {
|
||||
"5m": "5м",
|
||||
"15m": "15м",
|
||||
"1h": "1ч",
|
||||
"4h": "4ч",
|
||||
"24h": "24ч",
|
||||
"30d": "Месяц",
|
||||
}
|
||||
|
||||
|
||||
export function FlowEntityCardView({
|
||||
card,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
card: FlowEntityCard
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"text-left w-full rounded-lg border p-3 transition-colors hover:bg-muted/50",
|
||||
selected ? "border-primary bg-primary/5" : "border-border bg-card",
|
||||
card.status === "offline" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<StatusDot status={card.status} />
|
||||
<span className="text-xs font-medium truncate">{card.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
{card.country !== "UN" ? <Flag code={card.country} /> : null}
|
||||
{card.site}
|
||||
</span>
|
||||
</div>
|
||||
<TrafficMiniChart rx={card.rxSeries} tx={card.txSeries} />
|
||||
<div className="flex justify-between mt-2 gap-2">
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowDownIcon className="size-3 text-success" />
|
||||
<span className="font-mono font-medium text-success">{fmtRate(card.rxNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[11px]">
|
||||
<ArrowUpIcon className="size-3 text-info" />
|
||||
<span className="font-mono font-medium text-info">{fmtRate(card.txNow)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 text-[10px] text-muted-foreground">
|
||||
<GitBranchIcon className="size-3" />{card.sessions}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type SessionFilter = {
|
||||
kind: "application" | "category" | "service" | "asn" | "country" | "protocol" | "source" | "destination" | "iface" | "client" | "en"
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function talkerMatchesFilter(row: FlowTalkerDto, filter: SessionFilter): boolean {
|
||||
switch (filter.kind) {
|
||||
case "application": return row.application === filter.value
|
||||
case "category": return row.category === filter.value
|
||||
case "service": return row.service === filter.value
|
||||
case "asn": return String(row.dstAsn ?? "") === filter.value
|
||||
case "country": return row.dstCountry === filter.value
|
||||
case "protocol": return row.protoName === filter.value
|
||||
case "source": return row.src === filter.value
|
||||
case "destination": return row.dst === filter.value
|
||||
case "iface": return row.inIface === filter.value
|
||||
case "client": return (row.clientId || "unknown") === filter.value
|
||||
case "en": return (row.enId || "") === filter.value
|
||||
}
|
||||
}
|
||||
|
||||
function FlowBreakdownGrid({
|
||||
rows,
|
||||
empty,
|
||||
country,
|
||||
monthly = false,
|
||||
onPick,
|
||||
}: {
|
||||
rows: FlowBreakdownRow[]
|
||||
empty?: string
|
||||
country?: boolean
|
||||
monthly?: boolean
|
||||
onPick?: (row: FlowBreakdownRow) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FlowBreakdownRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "label",
|
||||
accessorKey: "label",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Имя</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium truncate">
|
||||
{country ? <Flag code={row.original.id} /> : null}
|
||||
{row.original.label}
|
||||
</span>
|
||||
<div className="h-1 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary"
|
||||
style={{ width: `${Math.min(100, row.original.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "share",
|
||||
accessorKey: "percent",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Доля</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs tabular-nums">{row.original.percent.toFixed(1)}%</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[country],
|
||||
)
|
||||
|
||||
const visibleColumns = useMemo(() => monthly ? columns.filter(column => column.id !== "rate") : columns, [columns, monthly])
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: rows,
|
||||
columns: visibleColumns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage={empty ?? "Нет данных за период"}
|
||||
onRowClick={onPick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowPathsGrid({
|
||||
rows,
|
||||
empty,
|
||||
onPick,
|
||||
}: {
|
||||
rows: FlowPathRow[]
|
||||
empty?: string
|
||||
onPick?: (row: FlowPathRow) => void
|
||||
}) {
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FlowPathRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "client",
|
||||
accessorKey: "clientName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Клиент</span>,
|
||||
cell: ({ row }) => <span className="text-sm font-medium">{row.original.clientName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "ifaces",
|
||||
accessorKey: "ifaces",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Ifaces</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs text-muted-foreground">{row.original.ifaces}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "jh",
|
||||
accessorKey: "serverName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">JH</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.serverName}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "in",
|
||||
accessorKey: "inIface",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">In</span>,
|
||||
cell: ({ row }) => <span className="font-mono text-xs">{row.original.inIface}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "en",
|
||||
accessorKey: "enName",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">EN</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.enName || "—"}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "dst",
|
||||
accessorKey: "dst",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Dest</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex min-w-0 flex-col gap-0.5 text-xs">
|
||||
<span className="font-mono">{row.original.dst}</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{[row.original.category, row.original.service].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: rows,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
})
|
||||
|
||||
return (
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={rows.length}
|
||||
emptyMessage={empty ?? "Нет путей"}
|
||||
onRowClick={onPick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FlowAnalyticsDetail({
|
||||
card,
|
||||
analytics,
|
||||
monthly,
|
||||
monthlyAvailable = true,
|
||||
month,
|
||||
onMonth,
|
||||
monthValid,
|
||||
range,
|
||||
onRange,
|
||||
selectedIface,
|
||||
onIface,
|
||||
dedup,
|
||||
onDedup,
|
||||
excludeMesh,
|
||||
onExcludeMesh,
|
||||
excludeOverlay,
|
||||
onExcludeOverlay,
|
||||
liveHint,
|
||||
emptyHint,
|
||||
}: {
|
||||
card: (Pick<FlowEntityCard, "id" | "name" | "country" | "site"> & Partial<FlowEntityCard>) | null
|
||||
analytics: FlowAnalyticsDto | null
|
||||
monthly?: FlowMonthlyDto
|
||||
monthlyAvailable?: boolean
|
||||
month: string
|
||||
onMonth: (value: string) => void
|
||||
monthValid: boolean
|
||||
range: string
|
||||
onRange: (r: string) => void
|
||||
selectedIface: string
|
||||
onIface: (name: string) => void
|
||||
dedup: boolean
|
||||
onDedup: (value: boolean) => void
|
||||
excludeMesh: boolean
|
||||
onExcludeMesh: (value: boolean) => void
|
||||
excludeOverlay: boolean
|
||||
onExcludeOverlay: (value: boolean) => void
|
||||
liveHint?: string
|
||||
emptyHint?: string
|
||||
}) {
|
||||
const [slice, setSlice] = useState("applications")
|
||||
const [sessionFilter, setSessionFilter] = useState<SessionFilter | null>(null)
|
||||
const rxNow = analytics ? analytics.bpsNow / 1_000_000 : (card?.rxNow ?? 0)
|
||||
const bytes = analytics?.bytes ?? card?.bytes ?? 0
|
||||
const sessionRows = (analytics?.conversationsList ?? []).filter((row) =>
|
||||
sessionFilter ? talkerMatchesFilter(row, sessionFilter) : true,
|
||||
)
|
||||
|
||||
function pickBreakdown(kind: SessionFilter["kind"], row: FlowBreakdownRow) {
|
||||
setSessionFilter({ kind, value: row.id, label: row.label })
|
||||
setSlice("sessions")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between mb-3 gap-3">
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
{card?.status && <StatusDot status={card.status} />}
|
||||
<h2 className="text-base font-semibold truncate">{card?.name ?? "Выберите сервер или клиента"}</h2>
|
||||
<span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
{card?.country && card.country !== "UN" ? <Flag code={card.country} /> : null}
|
||||
{card?.site}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
disabled={range === "30d"}
|
||||
id="flow-dedup"
|
||||
checked={dedup}
|
||||
onCheckedChange={onDedup}
|
||||
/>
|
||||
<Label htmlFor="flow-dedup" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Без дублей
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
disabled={range === "30d"}
|
||||
id="flow-exclude-overlay"
|
||||
checked={excludeOverlay}
|
||||
onCheckedChange={onExcludeOverlay}
|
||||
/>
|
||||
<Label htmlFor="flow-exclude-overlay" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Без overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
disabled={range === "30d"}
|
||||
id="flow-exclude-mesh"
|
||||
checked={excludeMesh}
|
||||
onCheckedChange={onExcludeMesh}
|
||||
/>
|
||||
<Label htmlFor="flow-exclude-mesh" className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Без mesh
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{RANGE_KEYS.filter(r => r !== "30d" || monthlyAvailable).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => onRange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={cn(
|
||||
"h-7 px-2 text-xs rounded border transition-colors",
|
||||
range === r
|
||||
? "border-primary bg-primary/10 text-primary font-medium"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{range === "30d" && <div className="mb-3 flex flex-col gap-2">
|
||||
<Label htmlFor="flow-report-month">Месяц отчёта (UTC)</Label>
|
||||
<Input id="flow-report-month" type="month" value={month} max={new Date().toISOString().slice(0, 7)} onChange={event => onMonth(event.target.value)} aria-invalid={!monthValid} aria-describedby={!monthValid ? "flow-report-month-error" : undefined} />
|
||||
{!monthValid && <p id="flow-report-month-error" role="alert" className="text-sm text-destructive">Выберите существующий месяц не позднее текущего.</p>}
|
||||
</div>}
|
||||
{range === "30d" && <p className="text-sm text-muted-foreground mb-3">Календарный месяц по UTC. Фильтры интерфейса, дублей, overlay и mesh к этому отчёту не применяются.</p>}
|
||||
{monthly && range === "30d" && <section aria-label="Месячный отчёт" className="flex flex-col gap-4">
|
||||
<p className="text-sm">Период: {monthly.month} (UTC) · Объём: {formatBytes(monthly.bytes)}</p>
|
||||
<div><h3 className="text-sm font-medium mb-2">Страны</h3><FlowBreakdownGrid rows={monthly.countries} country monthly /></div>
|
||||
<div><h3 className="text-sm font-medium mb-2">Сервисы</h3><FlowBreakdownGrid rows={monthly.services} monthly /></div>
|
||||
<div><h3 className="text-sm font-medium mb-2">ASN</h3><FlowBreakdownGrid rows={monthly.asns} monthly /></div>
|
||||
</section>}
|
||||
{analytics?.ifaces && analytics.ifaces.length > 0 ? (
|
||||
<div className="mb-3 pb-3 border-b">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] text-muted-foreground mr-1">Интерфейс:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onIface("__all__")}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||||
selectedIface === "__all__"
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
)}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
{analytics.ifaces.map((iface) => {
|
||||
const active = selectedIface === iface.name
|
||||
return (
|
||||
<button
|
||||
key={`${iface.name}:${iface.index}`}
|
||||
type="button"
|
||||
onClick={() => onIface(iface.name)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[10px] font-medium transition-all",
|
||||
active
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/40",
|
||||
)}
|
||||
>
|
||||
{iface.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1.5">по iface, без дедупа</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{analytics && <>
|
||||
<TrafficRxTxChart
|
||||
rx={analytics?.rxSeries ?? card?.rxSeries ?? []}
|
||||
tx={analytics?.txSeries ?? card?.txSeries ?? []}
|
||||
range={range}
|
||||
/>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<KpiStatGrid
|
||||
aria-label="Скорость потоков"
|
||||
items={([
|
||||
{
|
||||
id: "bps-now",
|
||||
label: "Скорость сейчас",
|
||||
value: fmtRate(rxNow),
|
||||
hint: liveHint,
|
||||
icon: <ArrowDownIcon className="size-4" />,
|
||||
iconClassName: "text-success",
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
label: "Payload",
|
||||
value: formatBytes(bytes),
|
||||
hint: "inner IPFIX",
|
||||
icon: <ArrowUpIcon className="size-4" />,
|
||||
iconClassName: "text-info",
|
||||
},
|
||||
{
|
||||
id: "overlay",
|
||||
label: "JH↔EN overlay",
|
||||
value: hasMeasurement(analytics?.bpsOverlay) ? fmtRate(analytics.bpsOverlay / 1_000_000) : "Нет данных",
|
||||
hint: hasMeasurement(analytics?.bytesOverlay) ? formatBytes(analytics.bytesOverlay) : undefined,
|
||||
icon: <ShieldIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "wire",
|
||||
label: "Wire GRE",
|
||||
value: hasMeasurement(analytics?.bpsWire) ? fmtRate(analytics.bpsWire / 1_000_000) : "Нет данных",
|
||||
hint: "счётчик iface",
|
||||
icon: <RouteIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
...(!excludeMesh
|
||||
? [{
|
||||
id: "mesh",
|
||||
label: "Mesh",
|
||||
value: hasMeasurement(analytics?.bytesMesh) ? formatBytes(analytics.bytesMesh) : "Нет данных",
|
||||
hint: "клиент↔клиент",
|
||||
icon: <NetworkIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
id: "flows",
|
||||
label: "Сессии",
|
||||
value: String(analytics?.conversations ?? card?.sessions ?? 0),
|
||||
hint: analytics?.conversationsRaw != null && analytics.conversationsRaw !== analytics.conversations
|
||||
? `до дедупа ${analytics.conversationsRaw}`
|
||||
: undefined,
|
||||
icon: <GitBranchIcon className="size-4" />,
|
||||
iconClassName: "text-warning",
|
||||
},
|
||||
{
|
||||
id: "uniq",
|
||||
label: "Уник. src / dst",
|
||||
value: `${analytics?.uniqueSrc ?? 0} / ${analytics?.uniqueDst ?? 0}`,
|
||||
icon: <UsersIcon className="size-4" />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
{
|
||||
id: "category",
|
||||
label: "Топ категория",
|
||||
value: analytics?.topCategory ?? "—",
|
||||
icon: <LayersIcon className="size-4" />,
|
||||
iconClassName: "text-primary",
|
||||
},
|
||||
] satisfies KpiStatItem[])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">Аналитика потребления</p>
|
||||
{liveHint === "live" ? <Badge variant="success-light" size="sm">live</Badge> : null}
|
||||
</div>
|
||||
<Tabs value={slice} onValueChange={(v) => setSlice(String(v))} className="gap-3">
|
||||
<TabsList variant="line" className="flex w-full shrink-0 flex-wrap gap-y-2 group-data-horizontal/tabs:h-auto [&_[data-slot=tabs-trigger]]:h-9 [&_[data-slot=tabs-trigger]]:flex-none">
|
||||
<TabsTrigger value="applications">Приложения</TabsTrigger>
|
||||
<TabsTrigger value="categories">Категории</TabsTrigger>
|
||||
<TabsTrigger value="services">Сервисы</TabsTrigger>
|
||||
<TabsTrigger value="asns">ASN</TabsTrigger>
|
||||
<TabsTrigger value="countries">Страны</TabsTrigger>
|
||||
<TabsTrigger value="map">Карта</TabsTrigger>
|
||||
<TabsTrigger value="protocols">Протоколы</TabsTrigger>
|
||||
<TabsTrigger value="sources">Источники</TabsTrigger>
|
||||
<TabsTrigger value="destinations">Назначения</TabsTrigger>
|
||||
<TabsTrigger value="paths">Пути</TabsTrigger>
|
||||
<TabsTrigger value="sessions">Сессии</TabsTrigger>
|
||||
<TabsTrigger value="interfaces">Интерфейсы</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="applications">
|
||||
<FlowBreakdownGrid rows={analytics?.applications ?? []} onPick={(row) => pickBreakdown("application", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="categories">
|
||||
<FlowBreakdownGrid rows={analytics?.categories ?? []} onPick={(row) => pickBreakdown("category", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="services">
|
||||
<FlowBreakdownGrid rows={analytics?.services ?? []} onPick={(row) => pickBreakdown("service", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="asns">
|
||||
<FlowBreakdownGrid rows={analytics?.asns ?? []} onPick={(row) => pickBreakdown("asn", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="countries">
|
||||
<FlowBreakdownGrid rows={analytics?.countries ?? []} country onPick={(row) => pickBreakdown("country", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="map">
|
||||
<FlowTrafficMap
|
||||
edges={analytics?.mapEdges ?? []}
|
||||
onSelectCountry={(iso) => {
|
||||
setSessionFilter({ kind: "country", value: iso, label: iso })
|
||||
setSlice("sessions")
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="protocols">
|
||||
<FlowBreakdownGrid rows={analytics?.protocols ?? []} onPick={(row) => pickBreakdown("protocol", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="sources">
|
||||
<FlowBreakdownGrid rows={analytics?.sources ?? []} onPick={(row) => pickBreakdown("source", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="destinations">
|
||||
<FlowBreakdownGrid rows={analytics?.destinations ?? []} onPick={(row) => pickBreakdown("destination", row)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="paths">
|
||||
<FlowPathsGrid
|
||||
rows={analytics?.paths ?? []}
|
||||
empty="Нет путей за период"
|
||||
onPick={(row) => {
|
||||
setSessionFilter({
|
||||
kind: "client",
|
||||
value: row.clientId,
|
||||
label: `${row.clientName} → ${row.enName || row.dst}`,
|
||||
})
|
||||
setSlice("sessions")
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="sessions">
|
||||
<p role="status" aria-label="Выборка сессий" className="text-xs text-muted-foreground">
|
||||
Показано {sessionRows.length} из {analytics?.conversationsList.length ?? 0} в выборке.
|
||||
{" "}Сессии с наибольшим объёмом трафика (до 50). Фильтр применяется к этой выборке; другие сессии могут не отображаться.
|
||||
</p>
|
||||
{sessionFilter ? (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GlobeIcon className="size-3.5 text-muted-foreground" />
|
||||
<Badge variant="secondary" size="sm">Фильтр: {sessionFilter.label}</Badge>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-primary"
|
||||
onClick={() => setSessionFilter(null)}
|
||||
>
|
||||
сбросить
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<TrafficFlowsDataGrid
|
||||
rows={sessionRows}
|
||||
emptyHint={sessionFilter ? "В полученной выборке нет сессий по выбранному фильтру" : emptyHint ?? "В полученной выборке нет сессий"}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="interfaces">
|
||||
<FlowBreakdownGrid
|
||||
rows={analytics?.interfaces ?? []}
|
||||
empty="Нет данных по интерфейсам"
|
||||
onPick={(row) => pickBreakdown("iface", row)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { can } from "@/lib/auth"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/reui/alert"
|
||||
import { AlertCircleIcon } from "lucide-react"
|
||||
import { getTrafficFlowHostStatus, reconcileTrafficFlowHost } from "@/shared/api/traffic-flow"
|
||||
import type { TrafficFlowHostStatus } from "@mmapp/contracts/traffic-flow"
|
||||
|
||||
export function FlowHostPanel({ backendUrl, enabled = true, onConfigured }: {
|
||||
backendUrl: string; enabled?: boolean; onConfigured?: (configured: boolean) => void
|
||||
}) {
|
||||
const [status, setStatus] = useState<TrafficFlowHostStatus | null>(null)
|
||||
const [receivedAt, setReceivedAt] = useState<number | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [operationError, setOperationError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const operation = useRef<AbortController | null>(null)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const accept = useCallback((value: TrafficFlowHostStatus) => {
|
||||
setStatus(value); setReceivedAt(Date.now()); setError(null); onConfigured?.(value.configured)
|
||||
}, [onConfigured])
|
||||
useEffect(() => () => { operation.current?.abort() }, [])
|
||||
useEffect(() => {
|
||||
if (!enabled || !can("mm:traffic:read") || busy) return
|
||||
const controller = new AbortController()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
async function refresh() {
|
||||
try { const value = await getTrafficFlowHostStatus(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
||||
catch (reason) { if (!controller.signal.aborted) setError(reason instanceof Error ? reason.message : "Статус хоста не получен") }
|
||||
finally { if (!controller.signal.aborted) timer = setTimeout(() => { void refresh() }, 10_000) }
|
||||
}
|
||||
void refresh()
|
||||
return () => { controller.abort(); if (timer) clearTimeout(timer) }
|
||||
}, [accept, backendUrl, enabled, busy, revision])
|
||||
|
||||
async function apply() {
|
||||
if (operation.current) return
|
||||
const controller = new AbortController(); operation.current = controller
|
||||
setBusy(true); setOperationError(null)
|
||||
try { const value = await reconcileTrafficFlowHost(backendUrl, { signal: controller.signal }); if (!controller.signal.aborted) accept(value) }
|
||||
catch (reason) { if (!controller.signal.aborted) setOperationError(reason instanceof Error ? reason.message : "Применение не подтверждено; обновите состояние хоста") }
|
||||
finally { operation.current = null; if (!controller.signal.aborted) setBusy(false) }
|
||||
}
|
||||
if (!enabled || !can("mm:traffic:read")) return null
|
||||
return <div className="flex flex-col gap-3 rounded-md border px-4 py-3" aria-label="Автоматизация WireGuard на хосте">
|
||||
<p className="text-sm font-medium">WireGuard на сервере MM</p>
|
||||
<p role="status" className="text-sm">{!status ? "Получение состояния агента…" : !status.configured ? "Ручной режим. Установите агент из раздела «Файлы хоста»." : !status.reachable ? "Агент недоступен" : status.configMatches ? "Настройки применены" : "Настройки требуют проверки или применения"}</p>
|
||||
{error && receivedAt ? <p className="text-xs text-muted-foreground">Показано последнее состояние: {new Date(receivedAt).toLocaleString("ru-RU")}</p> : null}
|
||||
{status?.configured && status.reachable ? <>
|
||||
<p className="text-xs text-muted-foreground">Интерфейс: {status.interfaceUp ? status.addresses.join(", ") : "не поднят"} · UDP {status.listenPort}</p>
|
||||
<ul className="flex flex-col gap-1 text-xs text-muted-foreground" aria-label="Handshake пиров">
|
||||
{status.peers.map(peer => <li key={peer.publicKey}>{peer.allowedIps.join(", ") || "Без адреса"} · {peer.lastHandshakeAt ? "Handshake: " + new Date(peer.lastHandshakeAt * 1000).toLocaleString("ru-RU") + ((receivedAt ?? 0) / 1000 - peer.lastHandshakeAt > 180 ? " (давно)" : "") : "Ожидание handshake"}</li>)}
|
||||
</ul>
|
||||
</> : null}
|
||||
{operationError || error || status?.error ? <Alert variant="warning"><AlertCircleIcon /><AlertTitle>Состояние хоста требует внимания</AlertTitle><AlertDescription>{operationError || error || status?.error}</AlertDescription></Alert> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => setRevision(value => value + 1)}>Обновить состояние хоста</Button>
|
||||
{status?.configured && can("mm:traffic:write") ? <Button type="button" size="sm" disabled={busy || !status.reachable} onClick={() => { void apply() }}>{busy ? "Применение…" : "Применить настройки хоста"}</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { FormField } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { CodeBlock, downloadText } from "@/components/reui-kit/code-export-sheet"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle,
|
||||
SheetDescription, SheetFooter, SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { FlowHostPanel } from "./flow-host-panel"
|
||||
import { applyTrafficFlowOverlay } from "@/shared/api/traffic-flow"
|
||||
import type { ServerRead } from "@mmapp/contracts/servers"
|
||||
import type { TrafficFlowOverlayResult } from "@mmapp/contracts/traffic-flow"
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
InfoIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
function FlowOverlaySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
servers,
|
||||
backendUrl,
|
||||
onDone,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
servers: ServerRead[]
|
||||
backendUrl: string
|
||||
onDone?: (result: TrafficFlowOverlayResult) => void
|
||||
}) {
|
||||
const jumpHosts = useMemo(
|
||||
() => servers.filter((s) => s.enabled && s.type === "jump-host"),
|
||||
[servers],
|
||||
)
|
||||
const [serverId, setServerId] = useState("")
|
||||
const [endpoint, setEndpoint] = useState("")
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<TrafficFlowOverlayResult | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [tab, setTab] = useState("linux")
|
||||
const [disableGreFastPath, setDisableGreFastPath] = useState(false)
|
||||
|
||||
const [resetContext, setResetContext] = useState({ open: false, jumpHosts })
|
||||
if (resetContext.open !== open || resetContext.jumpHosts !== jumpHosts) {
|
||||
setResetContext({ open, jumpHosts })
|
||||
if (open) {
|
||||
setResult(null)
|
||||
setCopied(false)
|
||||
setTab("linux")
|
||||
setDisableGreFastPath(false)
|
||||
const first = jumpHosts[0]
|
||||
const nextId = first ? String(first.id) : ""
|
||||
setServerId(nextId)
|
||||
setEndpoint(first?.host ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
function handleServerChange(id: string) {
|
||||
setServerId(id)
|
||||
const selected = jumpHosts.find((s) => String(s.id) === id)
|
||||
if (selected) setEndpoint(selected.host)
|
||||
}
|
||||
|
||||
const formats = result?.hostFiles ?? []
|
||||
const active = formats.find((f) => f.id === tab) ?? formats[0]
|
||||
const selectedHost = jumpHosts.find((s) => String(s.id) === serverId)
|
||||
const selectedLabel = selectedHost
|
||||
? `${selectedHost.name || selectedHost.host} (${selectedHost.host})`
|
||||
: "Выберите сервер…"
|
||||
const canSubmit = Boolean(serverId && endpoint.trim()) && !busy
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!serverId || !endpoint.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await applyTrafficFlowOverlay(backendUrl, serverId, endpoint.trim(), {
|
||||
disableGreFastPath,
|
||||
})
|
||||
setResult(res)
|
||||
setTab(res.hostFiles[0]?.id ?? "linux")
|
||||
if (res.ok) toast.success(`wg-flow на ${res.address}`)
|
||||
else toast.warning(res.hostError || "MikroTik настроен; требуется применение на хосте")
|
||||
onDone?.(res)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Не удалось подключить JH")
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopy() {
|
||||
const code = active?.code ?? ""
|
||||
if (!code) return
|
||||
void navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
toast.success("Скопировано")
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||
>
|
||||
<SheetHeader className="shrink-0 gap-1 border-b px-5 pt-5 pb-4 pr-12">
|
||||
<SheetTitle className="text-base font-semibold tracking-tight">
|
||||
Подключить jump-host
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настроит wg-flow и IPFIX на MikroTik. Если агент Docker установлен, автоматически применит пир на Linux-хосте MM.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
|
||||
<FormField label="Jump-host" required>
|
||||
<Select
|
||||
value={serverId || undefined}
|
||||
onValueChange={(v) => handleServerChange(String(v ?? ""))}
|
||||
>
|
||||
<SelectTrigger aria-label="Jump-host" className="h-9 w-full min-w-0">
|
||||
<SelectValue>{selectedLabel}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" className="min-w-(--anchor-width)">
|
||||
{jumpHosts.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.name || s.host} ({s.host})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="Публичный IP или DNS jump-host"
|
||||
required
|
||||
hint="Куда хост MM (wg-quick) стучится по UDP 13232. Не контейнер backend."
|
||||
>
|
||||
<Input aria-label="Публичный IP или DNS jump-host"
|
||||
className="font-mono"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="jh.example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormField>
|
||||
<FormField
|
||||
label="GRE slow-path (IPFIX inner)"
|
||||
hint="allow-fast-path=no на GRE этого JH. Inner YouTube попадёт в Traffic Flow, но вырастет CPU. По умолчанию выкл."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch aria-label="Выключить FastPath на GRE" checked={disableGreFastPath} onCheckedChange={setDisableGreFastPath} />
|
||||
<span className="text-sm text-muted-foreground">Выключить FastPath на GRE</span>
|
||||
</div>
|
||||
</FormField>
|
||||
{result ? (
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<Alert variant={result.stage === "host-pending" ? "warning" : "success"}>
|
||||
<InfoIcon />
|
||||
<AlertTitle>{result.stage === "applied" ? "Настройки применены" : result.stage === "host-pending" ? "MikroTik настроен, хост ожидает применения" : "Установка агента на Linux-хосте"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{result.hostError || (result.stage === "applied" ? "Пир добавлен без перезапуска WireGuard. Ожидайте handshake и поступления IPFIX в панели NetFlow." : "Выполните установщик из вкладки «Агент Docker» от root на сервере MM. Он однократно пересоздаст backend. Экспорт содержит приватный ключ — храните его как секрет.")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FlowHostPanel backendUrl={backendUrl} />
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{result.steps.map((s) => (
|
||||
<li key={s} className="flex items-start gap-2 text-sm">
|
||||
<CheckIcon className="mt-0.5 size-3.5 shrink-0 text-success" />
|
||||
<span>{s}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{formats.length > 0 && active ? (
|
||||
<div className="flex min-h-0 flex-col gap-3">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
setTab(String(v))
|
||||
setCopied(false)
|
||||
}}
|
||||
className="shrink-0 gap-0"
|
||||
>
|
||||
<TabsList className="h-9 w-full">
|
||||
{formats.map((f) => (
|
||||
<TabsTrigger key={f.id} value={f.id} className="flex-1 px-2 text-xs sm:text-sm">
|
||||
{f.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Frame dense className="flex min-h-0 flex-col overflow-hidden">
|
||||
<FramePanel className="relative flex min-h-0 flex-col overflow-hidden p-0">
|
||||
<ScrollArea className="h-full min-h-0 max-h-[min(52vh,22rem)]">
|
||||
<CodeBlock code={active.code} className="whitespace-pre break-normal" />
|
||||
</ScrollArea>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => downloadText(active.filename, active.code)}
|
||||
>
|
||||
<DownloadIcon className="size-3.5" />
|
||||
Файл
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={handleCopy}>
|
||||
{copied ? <CheckIcon className="size-3.5" /> : <CopyIcon className="size-3.5" />}
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SheetFooter className="shrink-0 flex-row items-center justify-between gap-3 border-t px-5 py-3.5 sm:flex-row">
|
||||
<SheetClose render={<Button type="button" variant="outline" className="shrink-0" />}>
|
||||
Закрыть
|
||||
</SheetClose>
|
||||
<Button disabled={!canSubmit} onClick={() => { void handleSubmit() }}>
|
||||
{busy ? "Подключение…" : "Подключить"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
export { FlowOverlaySheet }
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { type ColumnDef, useTable } from "@tanstack/react-table"
|
||||
import type { FlowMapEdge } from "@mmapp/contracts/traffic-flow"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { DataGridShell } from "@/components/data-grids/shared/data-grid-shell"
|
||||
import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { fmtRate } from "@/lib/fmt-rate"
|
||||
|
||||
const W = 640
|
||||
const H = 280
|
||||
const STROKES = [
|
||||
"var(--chart-1)",
|
||||
"var(--chart-2)",
|
||||
"var(--chart-3)",
|
||||
"var(--color-success)",
|
||||
"var(--chart-5)",
|
||||
]
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(2)} ГБ`
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} МБ`
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)} КБ`
|
||||
return `${n} Б`
|
||||
}
|
||||
|
||||
function FlowTrafficMap({
|
||||
edges,
|
||||
onSelectCountry,
|
||||
}: {
|
||||
edges: FlowMapEdge[]
|
||||
onSelectCountry?: (country: string) => void
|
||||
}) {
|
||||
const [hover, setHover] = useState<string | null>(null)
|
||||
|
||||
const layout = useMemo(() => {
|
||||
const sources = [...new Map(edges.map((e) => [e.fromId, e])).values()]
|
||||
const dests = [...new Map(edges.map((e) => [e.toCountry, e])).values()]
|
||||
const srcY = (i: number) => sources.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, sources.length - 1)
|
||||
const dstY = (i: number) => dests.length <= 1 ? H / 2 : 36 + (i * (H - 72)) / Math.max(1, dests.length - 1)
|
||||
const srcPos = new Map(sources.map((s, i) => [s.fromId, { x: 88, y: srcY(i), label: s.fromLabel, country: s.fromCountry }]))
|
||||
const dstPos = new Map(dests.map((d, i) => [d.toCountry, { x: 552, y: dstY(i), country: d.toCountry }]))
|
||||
const maxBytes = Math.max(...edges.map((e) => e.bytes), 1)
|
||||
return { srcPos, dstPos, maxBytes }
|
||||
}, [edges])
|
||||
|
||||
const columns = useMemo<ColumnDef<DataGridFeatures, FlowMapEdge>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "from",
|
||||
accessorKey: "fromLabel",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Источник</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
{row.original.fromCountry && row.original.fromCountry !== "UN"
|
||||
? <Flag code={row.original.fromCountry} />
|
||||
: null}
|
||||
{row.original.fromLabel}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_FIRST, cellClassName: DATA_GRID_CELL_PAD_FIRST },
|
||||
},
|
||||
{
|
||||
id: "to",
|
||||
accessorKey: "toCountry",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Назначение</span>,
|
||||
cell: ({ row }) => (
|
||||
<span className="flex items-center gap-1.5 text-sm">
|
||||
{/^[a-z]{2}$/i.test(row.original.toCountry)
|
||||
? <Flag code={row.original.toCountry} />
|
||||
: null}
|
||||
{row.original.toCountry}
|
||||
{row.original.toAsn ? <span className="font-mono text-[10px] text-muted-foreground">AS{row.original.toAsn}</span> : null}
|
||||
</span>
|
||||
),
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "cat",
|
||||
accessorKey: "category",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Категория</span>,
|
||||
cell: ({ row }) => <span className="text-xs">{row.original.category}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "rate",
|
||||
accessorFn: (r) => r.bps,
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Скорость</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{fmtRate(row.original.bps / 1_000_000)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD, cellClassName: DATA_GRID_CELL_PAD },
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorKey: "bytes",
|
||||
header: () => <span className="text-xs font-medium text-muted-foreground">Байты</span>,
|
||||
cell: ({ row }) => <span className="text-xs tabular-nums">{formatBytes(row.original.bytes)}</span>,
|
||||
meta: { headerClassName: DATA_GRID_CELL_PAD_LAST, cellClassName: DATA_GRID_CELL_PAD_LAST },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
data: edges,
|
||||
columns,
|
||||
getRowId: (row, i) => `${row.fromId}-${row.toCountry}-${i}`,
|
||||
})
|
||||
|
||||
if (!edges.length) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-8 text-center">
|
||||
Страны подтянутся из кэша RIPEstat
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Frame>
|
||||
<FramePanel className="p-3">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="w-full h-[220px]" role="img" aria-label="Карта потоков откуда куда">
|
||||
{edges.map((e, i) => {
|
||||
const from = layout.srcPos.get(e.fromId)
|
||||
const to = layout.dstPos.get(e.toCountry)
|
||||
if (!from || !to) return null
|
||||
const id = `${e.fromId}-${e.toCountry}`
|
||||
const midX = (from.x + to.x) / 2
|
||||
const d = `M ${from.x} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${to.x} ${to.y}`
|
||||
const sw = 1.25 + 7 * (e.bytes / layout.maxBytes)
|
||||
const active = hover === id
|
||||
return (
|
||||
<path
|
||||
key={id}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke={STROKES[i % STROKES.length]}
|
||||
strokeWidth={active ? sw + 1.5 : sw}
|
||||
strokeOpacity={active ? 1 : 0.72}
|
||||
className="cursor-pointer"
|
||||
onMouseEnter={() => setHover(id)}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
onClick={() => onSelectCountry?.(e.toCountry)}
|
||||
>
|
||||
<title>
|
||||
{`${e.fromLabel} → ${e.toCountry} · ${e.category} · ${formatBytes(e.bytes)}`}
|
||||
</title>
|
||||
</path>
|
||||
)
|
||||
})}
|
||||
{[...layout.srcPos.values()].map((n) => (
|
||||
<g key={`s-${n.label}`}>
|
||||
<circle cx={n.x} cy={n.y} r="7" className="fill-primary" />
|
||||
<text x={n.x - 14} y={n.y + 4} textAnchor="end" className="fill-foreground text-[11px]">
|
||||
{n.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{[...layout.dstPos.values()].map((n) => (
|
||||
<g key={`d-${n.country}`}>
|
||||
<circle cx={n.x} cy={n.y} r="7" className="fill-chart-2" />
|
||||
<text x={n.x + 14} y={n.y + 4} className="fill-foreground text-[11px]">
|
||||
{n.country}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{hover ? (
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Нажмите дугу, чтобы отфильтровать сессии по стране назначения
|
||||
</p>
|
||||
) : null}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={edges.length}
|
||||
emptyMessage="Нет рёбер с известной страной"
|
||||
onRowClick={(row) => onSelectCountry?.(row.toCountry)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { FlowTrafficMap }
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { GeoipStatusDto } from "@mmapp/contracts/geoip"
|
||||
import { can } from "@/lib/auth"
|
||||
import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "@/shared/api/geoip"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { AlertCircleIcon, DownloadIcon } from "lucide-react"
|
||||
|
||||
const message = (error: unknown) => error instanceof Error ? error.message : "Не удалось выполнить операцию GeoIP"
|
||||
const date = (value: string | null | undefined) => value ? new Date(value).toLocaleString("ru-RU") : "—"
|
||||
export function GeoipSettingsSection({ backendUrl }: { backendUrl: string }) {
|
||||
return <GeoipSettingsContent key={backendUrl} backendUrl={backendUrl} />
|
||||
}
|
||||
function GeoipSettingsContent({ backendUrl }: { backendUrl: string }) {
|
||||
const [status, setStatus] = useState<GeoipStatusDto | null>(null)
|
||||
const [receivedAt, setReceivedAt] = useState<string | null>(null)
|
||||
const [readError, setReadError] = useState<string | null>(null)
|
||||
const [opError, setOpError] = useState<string | null>(null)
|
||||
const [needsReview, setNeedsReview] = useState(false)
|
||||
const [outcome, setOutcome] = useState<string | null>(null)
|
||||
const [readBusy, setReadBusy] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [autoOn, setAutoOn] = useState(false)
|
||||
const [intervalHours, setIntervalHours] = useState("")
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const dirtyRef = useRef(false)
|
||||
const lock = useRef(false)
|
||||
const reader = useRef<{ active: boolean; revision: number; controller?: AbortController }>({ active: true, revision: 0 })
|
||||
useEffect(() => {
|
||||
const state = reader.current; state.active = true
|
||||
return () => { state.active = false; state.revision++; state.controller?.abort() }
|
||||
}, [])
|
||||
const load = useCallback(async () => {
|
||||
if (!reader.current.active || lock.current || !can("mm:traffic:read")) return
|
||||
const revision = ++reader.current.revision; reader.current.controller?.abort()
|
||||
const controller = new AbortController(); reader.current.controller = controller
|
||||
const current = () => reader.current.active && reader.current.revision === revision && !controller.signal.aborted
|
||||
setReadBusy(true)
|
||||
try {
|
||||
const data = await getGeoipStatus(backendUrl, { signal: controller.signal })
|
||||
if (!current()) return
|
||||
setStatus(data); setReadError(null); setReceivedAt(new Date().toISOString())
|
||||
if (!dirtyRef.current) { setAutoOn(data.settings.enabled); setIntervalHours(String(data.settings.updateIntervalSec / 3600)) }
|
||||
} catch (error) { if (current()) setReadError(message(error)) }
|
||||
finally { if (current()) setReadBusy(false) }
|
||||
}, [backendUrl])
|
||||
useEffect(() => { queueMicrotask(() => { void load() }) }, [load])
|
||||
const writable = can("mm:traffic:write") && Boolean(status) && !readError
|
||||
function begin() {
|
||||
if (!reader.current.active || lock.current || !writable || status?.running) return false
|
||||
lock.current = true; reader.current.revision++; reader.current.controller?.abort(); setReadBusy(false); setBusy(true); setOpError(null); setOutcome(null)
|
||||
return true
|
||||
}
|
||||
function finish() { lock.current = false; if (reader.current.active) setBusy(false) }
|
||||
async function save() {
|
||||
const hours = Number(intervalHours)
|
||||
if (!/^(?:\d+)(?:\.\d+)?$/.test(intervalHours) || !Number.isSafeInteger(hours * 3600) || hours < 6 || hours > 720) { setOpError("Укажите интервал от 6 до 720 часов с точностью до секунды"); return }
|
||||
if (!dirty || !begin()) return
|
||||
try {
|
||||
const data = await putGeoipSettings(backendUrl, { enabled: autoOn, updateIntervalSec: hours * 3600 })
|
||||
if (!reader.current.active) return
|
||||
setStatus(data.status); setReceivedAt(new Date().toISOString()); dirtyRef.current = false; setDirty(false)
|
||||
setOutcome("Сохранение настроек GeoIP подтверждено")
|
||||
} catch (error) { if (reader.current.active) setOpError(message(error)) }
|
||||
finally { finish() }
|
||||
}
|
||||
async function update() {
|
||||
if (needsReview || !begin()) return
|
||||
try {
|
||||
const data = await runGeoipUpdateNow(backendUrl)
|
||||
if (!reader.current.active) return
|
||||
const snap = data.snapshot
|
||||
setOutcome(snap.skipped ? "Прогон GeoIP пропущен" : data.ok ? "Обновление GeoIP подтверждено" : "Обновление GeoIP завершилось частично или с ошибками")
|
||||
if (snap.errors.length || snap.fatalError) setOpError([snap.fatalError, ...snap.errors].filter(Boolean).join("; "))
|
||||
setOutcome(prev => prev + `. Проверено: ${snap.checked}; скачано: ${snap.downloaded}; без изменений: ${snap.skippedUnchanged}; байт: ${snap.bytes}.`)
|
||||
} catch (error) { if (reader.current.active) { setNeedsReview(true); setOpError(message(error) + ". Результат операции неизвестен; обновите статус перед повтором.") } }
|
||||
finally { finish() }
|
||||
if (reader.current.active) await load()
|
||||
}
|
||||
const markDirty = () => { dirtyRef.current = true; setDirty(true) }
|
||||
return <OpsPanel title="GeoIP-базы (GeoLite2)" description="Страны и ASN потоков определяются по локальным базам MaxMind GeoLite2 с зеркала P3TERX." contentClassName="px-5 py-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center gap-2"><Badge variant={status?.countryLoaded ? "success" : "secondary"}>Country: {status ? status.countryLoaded ? "загружена" : "не загружена" : "—"}</Badge><Badge variant={status?.asnLoaded ? "success" : "secondary"}>ASN: {status ? status.asnLoaded ? "загружена" : "не загружена" : "—"}</Badge></div>
|
||||
{!can("mm:traffic:read") ? <p role="status">Нет доступа к данным GeoIP</p> : <p role="status" className="text-xs text-muted-foreground">{readBusy ? "Загрузка GeoIP…" : status ? "Статус GeoIP получен: " + date(receivedAt) : "Статус GeoIP не получен"}</p>}
|
||||
{readError ? <Alert variant="warning"><AlertCircleIcon aria-hidden="true" /><AlertTitle>{status ? "GeoIP не обновлён" : "GeoIP не получен"}</AlertTitle><AlertDescription>{readError}{status ? ". Показаны последние подтверждённые данные: " + date(receivedAt) : ""}</AlertDescription></Alert> : null}
|
||||
{opError ? <Alert variant="destructive"><AlertCircleIcon aria-hidden="true" /><AlertTitle>Операция GeoIP требует внимания</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert> : null}
|
||||
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал планировщика и состояние баз. Повторное обновление запустит новую операцию.</p><Button type="button" size="sm" variant="outline" aria-label="Разрешить новое обновление GeoIP" disabled={!writable || busy || readBusy || status?.running} onClick={() => { setNeedsReview(false); setOpError(null) }}>Разрешить повтор GeoIP</Button></div> : null}
|
||||
{outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="flex items-center gap-3"><FormToggle ariaLabel="Автообновление GeoIP" checked={autoOn} disabled={!writable || busy} onChange={value => { markDirty(); setAutoOn(value) }} /><span className="text-sm">Автообновление{!status ? ": —" : ""}</span></div>
|
||||
<FormField label="Интервал проверки (часов)" hint="От 6 до 720 часов"><Input aria-label="Интервал GeoIP в часах" className="font-mono" value={intervalHours} onChange={event => { markDirty(); setIntervalHours(event.target.value) }} inputMode="decimal" disabled={!writable || busy} /></FormField>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Сборка Country: {date(status?.settings.countryBuildAt)} · ASN: {date(status?.settings.asnBuildAt)} · проверка: {date(status?.settings.lastCheckAt)}{status?.settings.lastError ? " · ошибка: " + status.settings.lastError : ""}</p>
|
||||
<p className="text-xs text-muted-foreground">Каталог: <span className="font-mono break-all">{status?.dir ?? "—"}</span>. До загрузки базы и при отсутствии записи используется RIPEstat.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" disabled={!writable || busy || !dirty || status?.running} onClick={() => { void save() }}>Сохранить GeoIP</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={!writable || busy || needsReview || status?.running} onClick={() => { void update() }}><DownloadIcon aria-hidden="true" className="size-4" />Обновить сейчас</Button>
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy || readBusy || !can("mm:traffic:read")} onClick={() => { void load() }}>Обновить статус GeoIP</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Данные: MaxMind GeoLite2 (CC BY-SA 4.0), зеркало P3TERX/GeoLite.mmdb.</p>
|
||||
</OpsPanel>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import type { FlowPurgeDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { AlertCircleIcon, LoaderCircleIcon } from "lucide-react"
|
||||
|
||||
export function formatFlowPurgeResult(result: FlowPurgeDto): string {
|
||||
const rows =
|
||||
result.deleted.buckets +
|
||||
result.deleted.minuteStats +
|
||||
result.deleted.minuteDims +
|
||||
result.deleted.dailyDims +
|
||||
(result.deleted.hourFacts ?? 0) +
|
||||
(result.deleted.dailyFacts ?? 0)
|
||||
return `Удалено строк: ${rows}.`
|
||||
}
|
||||
|
||||
export function NetflowPurgeConfirm({
|
||||
open,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean
|
||||
busy?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={(v) => { if (!v && !busy) onCancel() }}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive">
|
||||
<AlertCircleIcon />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Сбросить данные NetFlow?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Будут удалены сессии и агрегаты (minute/daily) в PostgreSQL.
|
||||
Ключи WireGuard, пиры JH, настройки коллектора и кэш RIPE сохранятся.
|
||||
На время операции приём IPFIX остановится.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busy} onClick={onCancel}>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={busy}
|
||||
onClick={(event) => { event.preventDefault(); onConfirm() }}
|
||||
>
|
||||
{busy ? <LoaderCircleIcon className="size-4 animate-spin" /> : null}
|
||||
{busy ? "Сброс…" : "Сбросить"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { ApiClientError } from "@/shared/api/http-client"
|
||||
import { can } from "@/lib/auth"
|
||||
import { emptyNetflowDraft, netflowDraftFromSettings, netflowDraftPatch, type NetflowDraft } from "@/shared/api/netflow-settings-form"
|
||||
import { FormField, FormToggle } from "@/components/form-kit"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { CodeExportSheet, type CodeExportFormat } from "@/components/reui-kit/code-export-sheet"
|
||||
import type { TrafficFlowSettingsDto } from "@mmapp/contracts/traffic-flow"
|
||||
import { GeoipSettingsSection } from "./geoip-settings-section"
|
||||
import { FlowHostPanel } from "./flow-host-panel"
|
||||
import {
|
||||
generateTrafficFlowKeys,
|
||||
getTrafficFlowHostFiles,
|
||||
getTrafficFlowSettings,
|
||||
purgeTrafficFlowData,
|
||||
putTrafficFlowSettings,
|
||||
} from "@/shared/api/traffic-flow"
|
||||
import { formatFlowPurgeResult, NetflowPurgeConfirm } from "@/components/traffic/netflow-purge-dialog"
|
||||
import { KeyRoundIcon, DownloadIcon, InfoIcon, AlertCircleIcon } from "lucide-react"
|
||||
|
||||
const HOST_STEPS = [
|
||||
"Создайте ключи хоста, откройте «Файлы хоста» и скачайте вкладку «Агент Docker».",
|
||||
"Выполните sudo bash install-netflow-agent.sh /opt/cdn-mm на хосте Docker. При установке backend будет пересоздан.",
|
||||
"Обновите статус хоста. Дальнейшие подключения автоматически применяются агентом.",
|
||||
"Firewall: разрешите UDP listen WireGuard. UDP 4739 наружу не открывайте.",
|
||||
"Установщик публикует IPFIX только на адресе wg-flow. Ручные файлы доступны в остальных вкладках экспорта.",
|
||||
"Проверка: wg show · ss -ulnp | grep 4739 · в этой панели — last datagram.",
|
||||
]
|
||||
|
||||
function NetflowSettingsPanel(props: {backendUrl:string;enabled:boolean}) {
|
||||
return <NetflowSettingsContent key={JSON.stringify([props.backendUrl,props.enabled])} {...props}/>
|
||||
}
|
||||
function NetflowSettingsContent({
|
||||
backendUrl,
|
||||
enabled,
|
||||
}: {
|
||||
backendUrl: string
|
||||
enabled: boolean
|
||||
}) {
|
||||
const [settings,setSettings] = useState<TrafficFlowSettingsDto|null>(null)
|
||||
const [automatic,setAutomatic] = useState(false)
|
||||
const [draft,setDraft] = useState<NetflowDraft>(emptyNetflowDraft)
|
||||
const dirty = useRef(new Set<keyof NetflowDraft>())
|
||||
const [dirtyCount,setDirtyCount] = useState(0)
|
||||
const [receivedAt,setReceivedAt] = useState<string|null>(null)
|
||||
const [readError,setReadError] = useState<string|null>(null)
|
||||
const [opError,setOpError] = useState<string|null>(null)
|
||||
const [outcome,setOutcome] = useState<string|null>(null)
|
||||
const [needsReview,setNeedsReview] = useState(false)
|
||||
const [busy,setBusy] = useState(false)
|
||||
const [readBusy,setReadBusy] = useState(false)
|
||||
const [exportOpen,setExportOpen] = useState(false)
|
||||
const [formats,setFormats] = useState<CodeExportFormat[]>([])
|
||||
const [purgeOpen,setPurgeOpen] = useState(false)
|
||||
const lock = useRef(false)
|
||||
const reader = useRef<{active:boolean;revision:number;controller?:AbortController;operation?:AbortController}>({active:true,revision:0})
|
||||
useEffect(()=>{const state=reader.current;state.active=true;return()=>{state.active=false;state.revision++;state.controller?.abort();state.operation?.abort()}},[])
|
||||
const accept = useCallback((data:TrafficFlowSettingsDto)=>{
|
||||
setSettings(data);setReceivedAt(new Date().toISOString());setReadError(null)
|
||||
const fresh=netflowDraftFromSettings(data)
|
||||
setDraft(current=>{const next={...fresh};for(const field of dirty.current)Object.assign(next,{[field]:current[field]});return next})
|
||||
},[])
|
||||
const load = useCallback(async()=>{
|
||||
if(!enabled||!can("mm:traffic:read")||!reader.current.active||lock.current)return
|
||||
const revision=++reader.current.revision;reader.current.controller?.abort()
|
||||
const controller=new AbortController();reader.current.controller=controller
|
||||
const current=()=>reader.current.active&&reader.current.revision===revision&&!controller.signal.aborted
|
||||
setReadBusy(true)
|
||||
try{const data=await getTrafficFlowSettings(backendUrl,{signal:controller.signal});if(current())accept(data)}
|
||||
catch(error){if(current())setReadError(error instanceof Error?error.message:"Не удалось загрузить NetFlow")}
|
||||
finally{if(current())setReadBusy(false)}
|
||||
},[accept,backendUrl,enabled])
|
||||
useEffect(()=>{queueMicrotask(()=>{void load()})},[load])
|
||||
const writable=enabled&&can("mm:traffic:write")&&Boolean(settings)&&!readError
|
||||
const disabled=!writable||busy
|
||||
function change<K extends keyof NetflowDraft>(field:K,value:NetflowDraft[K]){dirty.current.add(field);setDirtyCount(dirty.current.size);setDraft(current=>({...current,[field]:value}))}
|
||||
function begin(){
|
||||
if(!reader.current.active||lock.current||!writable)return null
|
||||
lock.current=true;reader.current.revision++;reader.current.controller?.abort();setReadBusy(false);setBusy(true);setOpError(null);setOutcome(null)
|
||||
const controller=new AbortController();reader.current.operation=controller;return controller
|
||||
}
|
||||
function finish(){lock.current=false;if(reader.current.active)setBusy(false)}
|
||||
const message=(error:unknown)=>error instanceof Error?error.message:"Не удалось выполнить операцию NetFlow"
|
||||
async function handleSave(){
|
||||
if(!dirty.current.size)return
|
||||
let patch;try{patch=netflowDraftPatch(draft,dirty.current)}catch(error){setOpError(message(error));return}
|
||||
const controller=begin();if(!controller)return
|
||||
try{const data=await putTrafficFlowSettings(backendUrl,patch,{signal:controller.signal});if(!reader.current.active)return
|
||||
dirty.current.clear();setDirtyCount(0);accept(data.settings);setOutcome("Сохранение настроек NetFlow подтверждено")
|
||||
if(data.listenerError!==undefined)setOpError("Настройки сохранены; запуск приёма требует внимания: "+data.listenerError)
|
||||
}catch(error){if(reader.current.active)setOpError(message(error)+". Подтверждение сохранения не получено; черновик сохранён.")}
|
||||
finally{finish()}
|
||||
}
|
||||
async function handleKeys(){
|
||||
if(needsReview)return;const controller=begin();if(!controller)return
|
||||
try{const data=await generateTrafficFlowKeys(backendUrl,{signal:controller.signal});if(!reader.current.active)return;accept(data.settings);setOutcome(data.created?"Создание ключей NetFlow подтверждено":"Ключи NetFlow уже созданы")}
|
||||
catch(error){if(reader.current.active){setNeedsReview(true);setOpError(message(error)+". Результат операции неизвестен; проверьте статус и журнал перед повтором.")}}
|
||||
finally{finish()}
|
||||
}
|
||||
async function handlePurgeConfirm(){
|
||||
if(needsReview)return;const controller=begin();if(!controller)return
|
||||
try{const data=await purgeTrafficFlowData(backendUrl,{signal:controller.signal});if(!reader.current.active)return
|
||||
setPurgeOpen(false);setOutcome("Сброс данных NetFlow подтверждён. "+formatFlowPurgeResult(data))
|
||||
const warnings=[data.partitionError===undefined?null:"Обслуживание таблиц: "+data.partitionError,data.listenerError===undefined?null:"Запуск приёма: "+data.listenerError,data.eventError===undefined?null:"Запись журнала: "+data.eventError].filter(Boolean)
|
||||
if(warnings.length)setOpError(warnings.join("; "))
|
||||
}catch(error){if(reader.current.active){setPurgeOpen(false);setNeedsReview(true);setOpError(error instanceof ApiClientError && error.status === 409 ? "Новый сброс не запущен: " + message(error) + ". Проверьте журнал перед новым сбросом." : message(error)+". Результат операции неизвестен; проверьте статус и журнал перед повтором.")}}
|
||||
finally{finish()}
|
||||
if(reader.current.active)await load()
|
||||
}
|
||||
async function handleExport(){
|
||||
if(!settings?.hasHostPrivateKey)return;const controller=begin();if(!controller)return
|
||||
try{const data=await getTrafficFlowHostFiles(backendUrl,{signal:controller.signal});if(!reader.current.active)return;setFormats(data.files);setExportOpen(true)}
|
||||
catch(error){if(reader.current.active)setOpError(message(error))}
|
||||
finally{finish()}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="netflow" aria-label="Настройки NetFlow">
|
||||
<OpsPanel
|
||||
title="Traffic Flow / NetFlow (IPFIX)"
|
||||
description="Дополнение к сбору счётчиков REST. Приём только через WireGuard на хосте Docker MM."
|
||||
contentClassName="px-5 py-4 flex flex-col gap-4"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2"><Badge variant={settings?.listenerBound ? "success" : "secondary"}>{settings ? settings.listenerBound ? "Приём UDP: " + settings.listenerAddress : "Приём UDP не готов" : "Приём UDP: —"}</Badge></div>
|
||||
<p role="status" className="text-xs text-muted-foreground">{!can("mm:traffic:read") ? "Нет доступа к данным NetFlow" : readBusy ? "Загрузка NetFlow…" : settings ? "Настройки NetFlow получены: " + new Date(receivedAt!).toLocaleString("ru-RU") : "Настройки NetFlow не получены"}</p>
|
||||
{readError ? <Alert variant="warning"><AlertCircleIcon aria-hidden="true"/><AlertTitle>{settings ? "NetFlow не обновлён" : "NetFlow не получен"}</AlertTitle><AlertDescription>{readError}{settings ? ". Показаны последние подтверждённые данные: " + new Date(receivedAt!).toLocaleString("ru-RU") : ""}</AlertDescription></Alert> : null}
|
||||
{opError ? <Alert variant="destructive"><AlertCircleIcon aria-hidden="true"/><AlertTitle>Операция NetFlow требует внимания</AlertTitle><AlertDescription>{opError}</AlertDescription></Alert> : null}
|
||||
{outcome ? <p role="status" className="text-sm">{outcome}</p> : null}
|
||||
{needsReview ? <div className="flex flex-col gap-2"><p className="text-xs text-muted-foreground">Проверьте журнал и состояние NetFlow. Повтор запустит новую операцию.</p><Button size="sm" variant="outline" aria-label="Разрешить новую операцию NetFlow" disabled={disabled || readBusy} onClick={() => { setNeedsReview(false);setOpError(null) }}>Разрешить повтор NetFlow</Button></div> : null}
|
||||
<Alert>
|
||||
<InfoIcon />
|
||||
<AlertTitle>Ключи и порт IPFIX</AlertTitle>
|
||||
<AlertDescription>
|
||||
Приватный ключ включён в экспорт. Файлы отражают сохранённые настройки. Порт IPFIX публикуйте только на адресе wg-flow, не на 0.0.0.0.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<FormToggle ariaLabel="Принимать IPFIX" disabled={disabled} checked={draft.enabled} onChange={value => change("enabled",value)} />
|
||||
<span className="text-sm">Принимать IPFIX{settings ? "" : ": —"}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<FormField label="Collector IP" hint="Адрес в туннеле, куда JH шлёт flow">
|
||||
<Input className="font-mono" aria-label="Collector IP" disabled={disabled || automatic} value={draft.collectorIp} onChange={(e) => change("collectorIp", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Префикс overlay">
|
||||
<Input className="font-mono" aria-label="Префикс overlay" disabled={disabled || automatic} value={draft.prefix} onChange={(e) => change("prefix", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="UDP IPFIX">
|
||||
<Input className="font-mono" aria-label="UDP IPFIX" disabled={disabled || automatic} value={draft.flowListenPort} onChange={(e) => change("flowListenPort", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="WG listen">
|
||||
<Input className="font-mono" aria-label="WG listen" disabled={disabled || automatic} value={draft.wgListenPort} onChange={(e) => change("wgListenPort", e.target.value)} />
|
||||
</FormField>
|
||||
<FormField label="Публичный endpoint хоста MM" hint="IP или DNS, который видят JH">
|
||||
<Input className="font-mono" aria-label="Публичный endpoint хоста MM" disabled={disabled} value={draft.publicEndpoint} onChange={(e) => change("publicEndpoint", e.target.value)} placeholder="203.0.113.10" />
|
||||
</FormField>
|
||||
<FormField label="Public key хоста">
|
||||
<Input className="font-mono text-xs" aria-label="Public key хоста" readOnly value={settings ? settings.hostPublicKey || "Ключи не созданы" : "—"} />
|
||||
</FormField>
|
||||
<FormField label="Хранение (часов)">
|
||||
<Input aria-label="Хранение (часов)" disabled={disabled} value={draft.retentionHours} onChange={(e) => change("retentionHours", e.target.value)} inputMode="numeric" />
|
||||
</FormField>
|
||||
<FormField label="Top-N разговоров">
|
||||
<Input aria-label="Top-N разговоров" disabled={disabled} value={draft.topN} onChange={(e) => change("topN", e.target.value)} inputMode="numeric" />
|
||||
</FormField>
|
||||
<div className="sm:col-span-2 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FormToggle
|
||||
ariaLabel="Порог доли на карте" disabled={disabled} checked={draft.shareOn}
|
||||
onChange={value => change("shareOn",value)}
|
||||
/>
|
||||
<span className="text-sm">Порог доли на карте{settings ? "" : ": —"}</span>
|
||||
</div>
|
||||
<FormField
|
||||
label="Минимум % окна"
|
||||
hint="Узел сервиса, если доля байт окна ≥ N%. Выключить — показать все распознанные бренды (макс. 20)"
|
||||
>
|
||||
<Input
|
||||
aria-label="Минимум % окна" value={draft.mapServiceMinSharePct}
|
||||
onChange={(e) => change("mapServiceMinSharePct", e.target.value)}
|
||||
inputMode="decimal"
|
||||
disabled={disabled || !draft.shareOn}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
Последний пакет:{" "}
|
||||
{settings?.lastDatagramAt
|
||||
? new Date(settings.lastDatagramAt).toLocaleString("ru-RU")
|
||||
: settings ? "Не получен" : "—"}
|
||||
{settings?.lastExporterIp ? ` · ${settings.lastExporterIp}` : ""}
|
||||
{settings?.lastError ? ` · ${settings.lastError}` : ""}
|
||||
</p>
|
||||
|
||||
<FlowHostPanel backendUrl={backendUrl} enabled={enabled} onConfigured={setAutomatic} />
|
||||
{automatic ? <p className="text-xs text-muted-foreground">Адрес, префикс и порты закреплены установкой агента. Для их изменения повторите установщик.</p> : <div className="rounded-md border px-4 py-3 flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Туннель на сервере Docker MM</p>
|
||||
<ol className="text-xs text-muted-foreground flex flex-col gap-1.5 list-decimal pl-4">
|
||||
{HOST_STEPS.map((s) => (
|
||||
<li key={s}>{s.replaceAll("4739", settings ? String(settings.flowListenPort) : "IPFIX")}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={disabled || !dirtyCount} onClick={() => { void handleSave() }}>
|
||||
Сохранить NetFlow
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={disabled || needsReview} onClick={() => { void handleKeys() }}>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
Ключи хоста
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={disabled || !settings?.hasHostPrivateKey} aria-label="Скачать файлы хоста NetFlow" onClick={() => { void handleExport() }}>
|
||||
<DownloadIcon className="size-4" />
|
||||
Файлы хоста
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={busy || readBusy || !enabled || !can("mm:traffic:read")} onClick={() => { void load() }}>Обновить статус NetFlow</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 rounded-md border border-destructive/30 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Сбросить данные NetFlow</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Удалит сессии и агрегаты в PostgreSQL. Ключи WG и пиры не трогает.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={disabled || needsReview}
|
||||
onClick={() => setPurgeOpen(true)}
|
||||
>
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</OpsPanel>
|
||||
</section>
|
||||
|
||||
<GeoipSettingsSection backendUrl={backendUrl} />
|
||||
|
||||
<CodeExportSheet
|
||||
open={exportOpen}
|
||||
onClose={() => { setExportOpen(false);setFormats([]) }}
|
||||
title="Файлы для хоста Docker MM"
|
||||
description="Сохранённые настройки; экспорт содержит приватный ключ."
|
||||
formats={formats}
|
||||
/>
|
||||
|
||||
<NetflowPurgeConfirm
|
||||
open={purgeOpen}
|
||||
busy={busy}
|
||||
onConfirm={() => { void handlePurgeConfirm() }}
|
||||
onCancel={() => { if (!busy) setPurgeOpen(false) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export { NetflowSettingsPanel }
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Receipt time is not the timestamp of a network measurement. */
|
||||
export function TrafficDataTime({ label, receivedAt, source, serverAt, serverTimeLabel = "метка сервера" }: {
|
||||
label: string
|
||||
receivedAt?: number | null
|
||||
source: "API" | "live-поток"
|
||||
serverAt?: string
|
||||
serverTimeLabel?: string
|
||||
}) {
|
||||
if (receivedAt == null || !Number.isFinite(receivedAt)) return null
|
||||
const date = new Date(receivedAt)
|
||||
return (
|
||||
<p aria-label={label} className="text-xs text-muted-foreground mb-3 break-words">
|
||||
Получено ({source}): <time dateTime={date.toISOString()}>{date.toLocaleString("ru-RU")}</time>
|
||||
{serverAt && Number.isFinite(Date.parse(serverAt))
|
||||
? <> · {serverTimeLabel}: <time dateTime={serverAt}>{new Date(serverAt).toLocaleString("ru-RU")}</time></>
|
||||
: " · время измерения не предоставлено"}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
|
||||
import type { TrafficObservationMeta } from "@/lib/traffic-observations"
|
||||
|
||||
export function TrafficObservationStatus({ observations: o, unknown }: { observations?: TrafficObservationMeta; unknown: boolean }) {
|
||||
const labels = { error: "Ошибка опроса трафика", pending: "Опрос трафика выполняется", stale: "Измерения трафика устарели",
|
||||
disabled: "Опрос трафика отключён", unknown: "Скорость пока не измерена", current: "Скорость пока не измерена" }
|
||||
const warning = unknown || !!o?.error || o?.currentState && o.currentState !== "current"
|
||||
return <>
|
||||
{warning && <Alert variant="warning" role="status" className="mb-3">
|
||||
<AlertTitle>{o?.error && o.currentState === "current" ? "Часть измерений недоступна" : labels[o?.currentState ?? "unknown"]}</AlertTitle>
|
||||
<AlertDescription>{o?.error || "История сохранена. Для текущей скорости нужны достоверные свежие измерения."}</AlertDescription>
|
||||
</Alert>}
|
||||
{o?.lastAttemptAt && <p aria-label="Попытка опроса трафика" className="text-xs text-muted-foreground mb-3">
|
||||
Последняя попытка: <time dateTime={o.lastAttemptAt}>{new Date(o.lastAttemptAt).toLocaleString("ru-RU")}</time>
|
||||
{o.lastSucceededAt && <> · успешный сбор: <time dateTime={o.lastSucceededAt}>{new Date(o.lastSucceededAt).toLocaleString("ru-RU")}</time></>}
|
||||
</p>}
|
||||
</>
|
||||
}
|
||||
Reference in new issue
Block a user