660 lines
27 KiB
TypeScript
660 lines
27 KiB
TypeScript
"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>
|
||
</>}
|
||
</>
|
||
)
|
||
}
|