feat(statistics): добавить BI-разрез и сводную матрицу
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m23s
Docker images / frontend-image (push) Successful in 3m30s
Docker images / updater-image (push) Successful in 52s
Docker images / backend-image (push) Successful in 3m8s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 14s

Сопоставить клиентов с ifIndex как на карте трафика, чтобы KPI пользователей не обнулялся. На экране — разрез остальных измерений и сводная матрица.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-10 16:07:25 +07:00
co-authored by Cursor
parent b1fd259f10
commit 5bb9066be8
16 changed files with 1119 additions and 199 deletions
+237 -142
View File
@@ -4,10 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import {
ActivityIcon,
CableIcon,
DatabaseIcon,
GaugeIcon,
GlobeIcon,
ServerIcon,
UsersIcon,
} from "lucide-react"
@@ -15,38 +13,45 @@ import { PageHeader } from "@/components/page-header"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { SegmentedControl } from "@/components/form-kit"
import { EmptyState } from "@/components/empty-state"
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
import { DimensionSelect, PivotDimSelect } from "@/components/statistics/dimension-select"
import { SliceChips } from "@/components/statistics/slice-chips"
import { BreakdownDashboard } from "@/components/statistics/breakdown-dashboard"
import { StatisticsPivotGrid } from "@/components/statistics/statistics-pivot-grid"
import {
StatisticsBreakdownDataGrid,
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import type { Filter } from "@/components/reui/filters"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { STATISTICS_FILTER_FIELDS } from "@/lib/data-filters/statistics-filter-fields"
import {
isStatisticsPivotDim,
isStatisticsSliceKind,
STATISTICS_DIMS,
} from "@/lib/statistics-dims"
import { useDataSource } from "@/lib/data-source"
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import { getStatistics, type StatisticsDto, type StatisticsQuery } from "@/shared/api/statistics"
import {
getStatistics,
getStatisticsPivot,
STATISTICS_UNBOUND_USER_ID,
type StatisticsDto,
type StatisticsPivotDto,
type StatisticsQuery,
} from "@/shared/api/statistics"
import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics"
/**
* Отчётный куб трафика — KPI + период + график + табы-гриды.
* BI-куб трафика: критерий → остальные разрезы + pivot.
* Preview: https://reui.io/preview/base/dashboard-1 · https://reui.io/preview/base/stats-12
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/chart-23
* · https://reui.io/preview/base/components/c-date-selector-2 · https://reui.io/preview/base/empty-state-12
* · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/solution-analytics-8
* · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid
*/
const TABS: { id: StatisticsSliceKind; label: string }[] = [
{ id: "users", label: "Пользователи" },
{ id: "servers", label: "Серверы" },
{ id: "interfaces", label: "Интерфейсы" },
{ id: "countries", label: "Страны" },
{ id: "services", label: "Сервисы" },
{ id: "asns", label: "ASN" },
]
const EMPTY: StatisticsDto = {
from: "",
to: "",
@@ -70,6 +75,15 @@ const EMPTY: StatisticsDto = {
asns: [],
}
const EMPTY_PIVOT: StatisticsPivotDto = {
rowDim: "country",
colDim: "service",
metric: "bytes",
columns: [],
rows: [],
otherBytes: 0,
}
interface CubeSlices {
country?: string
service?: string
@@ -88,9 +102,18 @@ function readRange(sp: URLSearchParams): DateRangeYmd {
return rangeForPreset("7d")
}
function readTab(sp: URLSearchParams): StatisticsSliceKind {
const t = sp.get("tab")
return TABS.some((x) => x.id === t) ? (t as StatisticsSliceKind) : "users"
function readDim(sp: URLSearchParams): StatisticsSliceKind {
const t = sp.get("dim") ?? sp.get("tab")
return t && isStatisticsSliceKind(t) ? t : "users"
}
function readView(sp: URLSearchParams): "explore" | "pivot" {
return sp.get("view") === "pivot" ? "pivot" : "explore"
}
function readPivotDim(sp: URLSearchParams, key: string, fallback: StatisticsPivotDim): StatisticsPivotDim {
const v = sp.get(key)
return v && isStatisticsPivotDim(v) ? v : fallback
}
function readSlices(sp: URLSearchParams): CubeSlices {
@@ -140,28 +163,92 @@ function toQuery(range: DateRangeYmd, slices: CubeSlices): StatisticsQuery {
}
}
function selectedIdForTab(tab: StatisticsSliceKind, slices: CubeSlices): string | undefined {
if (tab === "users") return slices.userId
if (tab === "servers") return slices.serverId
if (tab === "countries") return slices.country
if (tab === "services") return slices.service
if (tab === "asns") return slices.asn
if (tab === "interfaces" && slices.serverId && slices.iface) {
function selectedIdForKind(kind: StatisticsSliceKind, slices: CubeSlices): string | undefined {
if (kind === "users") return slices.userId
if (kind === "servers") return slices.serverId
if (kind === "countries") return slices.country
if (kind === "services") return slices.service
if (kind === "asns") return slices.asn
if (kind === "interfaces" && slices.serverId && slices.iface) {
return `${slices.serverId}:${slices.iface}`
}
if (tab === "interfaces") return slices.iface
if (kind === "interfaces") return slices.iface
return undefined
}
function rowsForTab(data: StatisticsDto, tab: StatisticsSliceKind) {
if (tab === "users") return data.users
if (tab === "servers") return data.servers
if (tab === "interfaces") return data.interfaces
if (tab === "countries") return data.countries
if (tab === "services") return data.services
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind) {
if (kind === "users") return data.users
if (kind === "servers") return data.servers
if (kind === "interfaces") return data.interfaces
if (kind === "countries") return data.countries
if (kind === "services") return data.services
return data.asns
}
function hasAnySlice(slices: CubeSlices): boolean {
return SLICE_KEYS.some((k) => Boolean(slices[k]))
}
function hiddenKinds(slices: CubeSlices): Set<StatisticsSliceKind> {
const hidden = new Set<StatisticsSliceKind>()
if (slices.userId) hidden.add("users")
if (slices.serverId) hidden.add("servers")
if (slices.iface) hidden.add("interfaces")
if (slices.country) hidden.add("countries")
if (slices.service) hidden.add("services")
if (slices.asn) hidden.add("asns")
return hidden
}
function applyDimValue(slices: CubeSlices, kind: StatisticsSliceKind, rowId: string): CubeSlices {
const next: CubeSlices = { ...slices }
if (kind === "users") {
if (rowId === STATISTICS_UNBOUND_USER_ID) return next
if (next.userId === rowId) delete next.userId
else next.userId = rowId
} else if (kind === "servers") {
if (next.serverId === rowId) delete next.serverId
else next.serverId = rowId
} else if (kind === "countries") {
if (next.country === rowId) delete next.country
else next.country = rowId
} else if (kind === "services") {
if (next.service === rowId) delete next.service
else next.service = rowId
} else if (kind === "asns") {
if (next.asn === rowId) delete next.asn
else next.asn = rowId
} else {
const colon = rowId.indexOf(":")
const sid = colon >= 0 ? rowId.slice(0, colon) : undefined
const iface = colon >= 0 ? rowId.slice(colon + 1) : rowId
if (next.iface === iface && next.serverId === sid) {
delete next.iface
delete next.serverId
} else {
next.iface = iface
if (sid) next.serverId = sid
}
}
return next
}
function applyPivotDim(slices: CubeSlices, dim: StatisticsPivotDim, id: string): CubeSlices {
const kind = STATISTICS_DIMS.find((d) => d.pivot === dim)?.id ?? "users"
return applyDimValue(slices, kind, id)
}
function chipList(slices: CubeSlices): { key: string; label: string }[] {
const chips: { key: string; label: string }[] = []
if (slices.country) chips.push({ key: "country", label: `страна ${slices.country}` })
if (slices.service) chips.push({ key: "service", label: `сервис ${slices.service}` })
if (slices.asn) chips.push({ key: "asn", label: `ASN ${slices.asn}` })
if (slices.serverId) chips.push({ key: "serverId", label: `сервер ${slices.serverId}` })
if (slices.userId) chips.push({ key: "userId", label: `пользователь ${slices.userId}` })
if (slices.iface) chips.push({ key: "iface", label: `iface ${slices.iface}` })
return chips
}
export default function StatisticsPage() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -171,9 +258,13 @@ export default function StatisticsPage() {
const range = useMemo(() => readRange(searchParams), [searchParams])
const slices = useMemo(() => readSlices(searchParams), [searchParams])
const filters = useMemo(() => slicesToFilters(slices), [slices])
const [tab, setTab] = useState<StatisticsSliceKind>(() => readTab(searchParams))
const dim = useMemo(() => readDim(searchParams), [searchParams])
const view = useMemo(() => readView(searchParams), [searchParams])
const pivotRow = useMemo(() => readPivotDim(searchParams, "pivotRow", "country"), [searchParams])
const pivotCol = useMemo(() => readPivotDim(searchParams, "pivotCol", "service"), [searchParams])
const [data, setData] = useState<StatisticsDto>(EMPTY)
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -218,11 +309,22 @@ export default function StatisticsPage() {
setLoading(true)
setError(null)
try {
const dto = await getStatistics(backendUrl, toQuery(range, slices))
const query = toQuery(range, slices)
const dto = await getStatistics(backendUrl, query)
if (!cancelled) setData(dto)
if (view === "pivot" && pivotRow !== pivotCol) {
const matrix = await getStatisticsPivot(backendUrl, {
...query,
row: pivotRow,
col: pivotCol,
metric: "bytes",
})
if (!cancelled) setPivot(matrix)
}
} catch (e: unknown) {
if (!cancelled) {
setData(EMPTY)
setPivot(EMPTY_PIVOT)
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
}
} finally {
@@ -232,46 +334,40 @@ export default function StatisticsPage() {
return () => {
cancelled = true
}
}, [backendUrl, isLive, prefsHydrated, range, slices])
}, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol])
const rows = rowsForTab(isLive ? data : EMPTY, tab)
const selectedId = selectedIdForTab(tab, slices)
const view = isLive ? data : EMPTY
const viewData = isLive ? data : EMPTY
const sliced = hasAnySlice(slices)
const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0)
function handleRowClick(kind: StatisticsSliceKind, row: { id: string }) {
const next: CubeSlices = { ...slices }
if (kind === "users") {
if (next.userId === row.id) delete next.userId
else next.userId = row.id
} else if (kind === "servers") {
if (next.serverId === row.id) delete next.serverId
else next.serverId = row.id
} else if (kind === "countries") {
if (next.country === row.id) delete next.country
else next.country = row.id
} else if (kind === "services") {
if (next.service === row.id) delete next.service
else next.service = row.id
} else if (kind === "asns") {
if (next.asn === row.id) delete next.asn
else next.asn = row.id
} else {
const colon = row.id.indexOf(":")
const sid = colon >= 0 ? row.id.slice(0, colon) : undefined
const iface = colon >= 0 ? row.id.slice(colon + 1) : row.id
if (next.iface === iface && next.serverId === sid) {
delete next.iface
delete next.serverId
} else {
next.iface = iface
if (sid) next.serverId = sid
}
}
setSlices(next)
function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) {
if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return
setSlices(applyDimValue(slices, kind, row.id))
}
const kpis = view.kpis
const emptyCube = !isLive || (!loading && kpis.bytes === 0)
function handlePivotCell(rowId: string, colId: string) {
if (rowId === "__other__" || colId === "__other__") return
let next = applyPivotDim(slices, pivotRow, rowId)
next = applyPivotDim(next, pivotCol, colId)
replaceParams({
country: next.country,
service: next.service,
asn: next.asn,
serverId: next.serverId,
userId: next.userId,
iface: next.iface,
view: "explore",
})
}
const kpis = viewData.kpis
const chips = chipList(slices)
const countLabel =
view === "pivot"
? `${pivot.rows.length} × ${pivot.columns.length}`
: sliced
? `${STATISTICS_DIMS.filter((d) => !hiddenKinds(slices).has(d.id)).length} разрезов`
: `${rowsForKind(viewData, dim).length} строк`
return (
<div className="flex h-full flex-col">
@@ -343,83 +439,82 @@ export default function StatisticsPage() {
]}
/>
<StatisticsVolumeChart series={view.series} grain={view.grain} />
<StatisticsVolumeChart series={viewData.series} grain={viewData.grain} />
<DataPageCard>
<DataPageToolbar
leading={
<div className="flex flex-wrap items-center gap-3">
<SegmentedControl
value={view}
onChange={(next) => replaceParams({ view: next === "pivot" ? "pivot" : "explore" })}
options={[
{ value: "explore", label: "Разрез" },
{ value: "pivot", label: "Сводка" },
]}
/>
{view === "explore" && !sliced ? (
<DimensionSelect
label="Критерий"
value={dim}
onChange={(next) => replaceParams({ dim: next })}
/>
) : null}
{view === "pivot" ? (
<>
<PivotDimSelect
label="Строки"
value={pivotRow}
exclude={pivotCol}
onChange={(next) => replaceParams({ pivotRow: next })}
/>
<PivotDimSelect
label="Колонки"
value={pivotCol}
exclude={pivotRow}
onChange={(next) => replaceParams({ pivotCol: next })}
/>
</>
) : null}
</div>
}
filters={filters}
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
filterFields={STATISTICS_FILTER_FIELDS}
countLabel={`${rows.length} строк`}
countLabel={countLabel}
/>
{SLICE_KEYS.some((k) => slices[k]) ? (
<div className="flex flex-wrap items-center gap-1.5 border-b px-5 py-2">
{slices.country ? (
<Badge variant="outline" size="sm">страна {slices.country}</Badge>
) : null}
{slices.service ? (
<Badge variant="outline" size="sm">сервис {slices.service}</Badge>
) : null}
{slices.asn ? (
<Badge variant="outline" size="sm">ASN {slices.asn}</Badge>
) : null}
{slices.serverId ? (
<Badge variant="outline" size="sm">сервер {slices.serverId}</Badge>
) : null}
{slices.userId ? (
<Badge variant="outline" size="sm">пользователь {slices.userId}</Badge>
) : null}
{slices.iface ? (
<Badge variant="outline" size="sm">iface {slices.iface}</Badge>
) : null}
</div>
) : null}
<Tabs
value={tab}
onValueChange={(v) => {
const next = String(v) as StatisticsSliceKind
setTab(next)
replaceParams({ tab: next })
}}
className="gap-0"
>
<div className="px-5 pt-2">
<TabsList variant="line" className="w-fit">
<TabsTrigger value="users">
<UsersIcon /> Пользователи
</TabsTrigger>
<TabsTrigger value="servers">
<ServerIcon /> Серверы
</TabsTrigger>
<TabsTrigger value="interfaces">
<CableIcon /> Интерфейсы
</TabsTrigger>
<TabsTrigger value="countries">
<GlobeIcon /> Страны
</TabsTrigger>
<TabsTrigger value="services">Сервисы</TabsTrigger>
<TabsTrigger value="asns">ASN</TabsTrigger>
</TabsList>
</div>
{TABS.map((t) => (
<TabsContent key={t.id} value={t.id}>
{emptyCube ? (
<EmptyState
title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
/>
) : (
<StatisticsBreakdownDataGrid
rows={rowsForTab(view, t.id)}
kind={t.id}
selectedId={t.id === tab ? selectedId : undefined}
onRowClick={(row) => handleRowClick(t.id, row)}
isLoading={loading}
/>
)}
</TabsContent>
))}
</Tabs>
<SliceChips
chips={chips}
onRemove={(key) => {
const next = { ...slices }
delete next[key as keyof CubeSlices]
setSlices(next)
}}
/>
{emptyCube ? (
<EmptyState
title="Нет данных куба"
description="За выбранный период нет IPFIX-фактов. Куб заполняется с момента деплоя, без бэкфилла за год."
/>
) : view === "pivot" ? (
<StatisticsPivotGrid data={isLive ? pivot : EMPTY_PIVOT} onCellClick={handlePivotCell} isLoading={loading} />
) : sliced ? (
<BreakdownDashboard
data={viewData}
hidden={hiddenKinds(slices)}
selectedIdFor={(kind) => selectedIdForKind(kind, slices)}
onRowClick={handleRowClick}
isLoading={loading}
/>
) : (
<StatisticsBreakdownDataGrid
rows={rowsForKind(viewData, dim)}
kind={dim}
selectedId={selectedIdForKind(dim, slices)}
onRowClick={(row) => handleRowClick(dim, row)}
isLoading={loading}
/>
)}
</DataPageCard>
</div>
</div>