From 5bb9066be884dfaaafc3ec170d3d1ea3ddbd65f2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Thu, 10 Sep 2026 16:07:25 +0700 Subject: [PATCH] =?UTF-8?q?feat(statistics):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20BI-=D1=80=D0=B0=D0=B7=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=B8=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BD=D1=83=D1=8E=20?= =?UTF-8?q?=D0=BC=D0=B0=D1=82=D1=80=D0=B8=D1=86=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сопоставить клиентов с ifIndex как на карте трафика, чтобы KPI пользователей не обнулялся. На экране — разрез остальных измерений и сводная матрица. Co-authored-by: Cursor --- app/(main)/statistics/page.tsx | 379 ++++++++++------- backend/package.json | 2 +- backend/src/routes/statistics.ts | 15 +- .../src/services/statistics-aggregate.test.ts | 47 ++- backend/src/services/statistics-aggregate.ts | 390 ++++++++++++++++-- backend/src/services/traffic-flow-engine.ts | 3 +- .../src/services/traffic-flow-ifindex.test.ts | 35 ++ backend/src/services/traffic-flow-ifindex.ts | 49 +++ .../statistics-breakdown-data-grid.tsx | 39 +- components/statistics/breakdown-dashboard.tsx | 57 +++ components/statistics/dimension-select.tsx | 73 ++++ components/statistics/slice-chips.tsx | 38 ++ .../statistics/statistics-pivot-grid.tsx | 89 ++++ lib/statistics-dims.ts | 31 ++ packages/contracts/src/statistics.ts | 42 ++ shared/api/statistics.ts | 29 +- 16 files changed, 1119 insertions(+), 199 deletions(-) create mode 100644 backend/src/services/traffic-flow-ifindex.test.ts create mode 100644 components/statistics/breakdown-dashboard.tsx create mode 100644 components/statistics/dimension-select.tsx create mode 100644 components/statistics/slice-chips.tsx create mode 100644 components/statistics/statistics-pivot-grid.tsx create mode 100644 lib/statistics-dims.ts diff --git a/app/(main)/statistics/page.tsx b/app/(main)/statistics/page.tsx index 7e503aa..c30d097 100644 --- a/app/(main)/statistics/page.tsx +++ b/app/(main)/statistics/page.tsx @@ -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 { + const hidden = new Set() + 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(() => 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(EMPTY) + const [pivot, setPivot] = useState(EMPTY_PIVOT) const [loading, setLoading] = useState(false) const [error, setError] = useState(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 (
@@ -343,83 +439,82 @@ export default function StatisticsPage() { ]} /> - + + replaceParams({ view: next === "pivot" ? "pivot" : "explore" })} + options={[ + { value: "explore", label: "Разрез" }, + { value: "pivot", label: "Сводка" }, + ]} + /> + {view === "explore" && !sliced ? ( + replaceParams({ dim: next })} + /> + ) : null} + {view === "pivot" ? ( + <> + replaceParams({ pivotRow: next })} + /> + replaceParams({ pivotCol: next })} + /> + + ) : null} +
+ } filters={filters} onFiltersChange={(next) => setSlices(filtersToSlices(next))} filterFields={STATISTICS_FILTER_FIELDS} - countLabel={`${rows.length} строк`} + countLabel={countLabel} /> - {SLICE_KEYS.some((k) => slices[k]) ? ( -
- {slices.country ? ( - страна {slices.country} - ) : null} - {slices.service ? ( - сервис {slices.service} - ) : null} - {slices.asn ? ( - ASN {slices.asn} - ) : null} - {slices.serverId ? ( - сервер {slices.serverId} - ) : null} - {slices.userId ? ( - пользователь {slices.userId} - ) : null} - {slices.iface ? ( - iface {slices.iface} - ) : null} -
- ) : null} - { - const next = String(v) as StatisticsSliceKind - setTab(next) - replaceParams({ tab: next }) - }} - className="gap-0" - > -
- - - Пользователи - - - Серверы - - - Интерфейсы - - - Страны - - Сервисы - ASN - -
- {TABS.map((t) => ( - - {emptyCube ? ( - - ) : ( - handleRowClick(t.id, row)} - isLoading={loading} - /> - )} - - ))} -
+ { + const next = { ...slices } + delete next[key as keyof CubeSlices] + setSlices(next) + }} + /> + {emptyCube ? ( + + ) : view === "pivot" ? ( + + ) : sliced ? ( +