"use client" import { Suspense, useCallback, useEffect, useMemo, useState } from "react" import { useSearchParams } from "next/navigation" import { ActivityIcon, DatabaseIcon, GaugeIcon, ServerIcon, UsersIcon, } from "lucide-react" 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 type { Filter } from "@/components/reui/filters" 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, getStatisticsPivot, STATISTICS_UNBOUND_USER_ID, type StatisticsDto, type StatisticsPivotDto, type StatisticsQuery, } from "@/shared/api/statistics" import type { StatisticsBreakdownRow, StatisticsPivotDim } from "@mmapp/contracts/statistics" /** * 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/solution-analytics-8 * · https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/data-grid */ const EMPTY: StatisticsDto = { from: "", to: "", grain: "day", kpis: { bytes: 0, packets: 0, avgBps: 0, users: 0, servers: 0, ifaces: 0, topCountry: "", topService: "", }, series: [], users: [], servers: [], interfaces: [], countries: [], services: [], asns: [], } const EMPTY_PIVOT: StatisticsPivotDto = { rowDim: "country", colDim: "service", metric: "bytes", columns: [], rows: [], otherBytes: 0, } interface CubeSlices { country?: string service?: string asn?: string serverId?: string userId?: string iface?: string } const SLICE_KEYS = ["country", "service", "asn", "serverId", "userId", "iface"] as const function readRange(sp: URLSearchParams): DateRangeYmd { const from = sp.get("from") const to = sp.get("to") if (from && to && from <= to) return { from, to } return rangeForPreset("7d") } 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 readPlanes(sp: URLSearchParams): "unique" | "all" { return sp.get("planes") === "all" ? "all" : "unique" } 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 { const next: CubeSlices = {} for (const key of SLICE_KEYS) { const v = sp.get(key)?.trim() if (v) next[key] = v } return next } function slicesToFilters(slices: CubeSlices): Filter[] { return SLICE_KEYS.flatMap((key) => { const val = slices[key] if (!val) return [] return [{ id: key, field: key, operator: "is", values: [val] }] }) } function filtersToSlices(filters: Filter[]): CubeSlices { const next: CubeSlices = {} for (const f of filters) { const raw = String(f.values[0] ?? "").trim() if (!raw) continue if (f.field === "country") next.country = raw.toUpperCase().slice(0, 2) else if (f.field === "service") next.service = raw else if (f.field === "asn") next.asn = raw.replace(/[^\d]/g, "") else if (f.field === "serverId") next.serverId = raw else if (f.field === "userId") next.userId = raw else if (f.field === "iface") next.iface = raw } return next } function toQuery(range: DateRangeYmd, slices: CubeSlices, planes: "unique" | "all"): StatisticsQuery { const serverId = slices.serverId ? Number(slices.serverId) : undefined const asn = slices.asn != null && slices.asn !== "" ? Number(slices.asn) : undefined return { from: range.from, to: range.to, serverId: Number.isFinite(serverId) && (serverId ?? 0) > 0 ? serverId : undefined, userId: slices.userId, iface: slices.iface, country: slices.country && slices.country.length === 2 ? slices.country : undefined, service: slices.service, asn: Number.isFinite(asn) ? asn : undefined, planes, } } 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 (kind === "interfaces") return slices.iface return undefined } 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 } function StatisticsPageInner() { const searchParams = useSearchParams() const { mode, backendUrl, prefsHydrated } = useDataSource() const isLive = mode === "live" const range = useMemo(() => readRange(searchParams), [searchParams]) const slices = useMemo(() => readSlices(searchParams), [searchParams]) const filters = useMemo(() => slicesToFilters(slices), [slices]) const dim = useMemo(() => readDim(searchParams), [searchParams]) const view = useMemo(() => readView(searchParams), [searchParams]) const planes = useMemo(() => readPlanes(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) const replaceParams = useCallback( (patch: Record) => { const sp = new URLSearchParams(searchParams.toString()) for (const [k, v] of Object.entries(patch)) { if (v) sp.set(k, v) else sp.delete(k) } const qs = sp.toString() if (qs === searchParams.toString()) return window.history.replaceState(null, "", qs ? `/statistics?${qs}` : "/statistics") }, [searchParams], ) const setRange = useCallback( (next: DateRangeYmd) => { replaceParams({ from: next.from, to: next.to }) }, [replaceParams], ) const setSlices = useCallback( (next: CubeSlices) => { replaceParams({ country: next.country, service: next.service, asn: next.asn, serverId: next.serverId, userId: next.userId, iface: next.iface, }) }, [replaceParams], ) useEffect(() => { if (!prefsHydrated || !isLive) return let cancelled = false void (async () => { setLoading(true) setError(null) try { const query = toQuery(range, slices, planes) 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 { if (!cancelled) setLoading(false) } })() return () => { cancelled = true } }, [backendUrl, isLive, prefsHydrated, range, slices, view, pivotRow, pivotCol, planes]) const viewData = isLive ? data : EMPTY const sliced = hasAnySlice(slices) const emptyCube = !isLive || (!loading && viewData.kpis.bytes === 0 && viewData.interfaces.length === 0) function handleRowClick(kind: StatisticsSliceKind, row: StatisticsBreakdownRow) { if (kind === "users" && row.id === STATISTICS_UNBOUND_USER_ID) return if (kind === "interfaces" && row.label.includes("· дубль")) return setSlices(applyDimValue(slices, kind, row.id)) } 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 (
} />
{!isLive ? ( Живые данные выключены Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт. ) : null} {error ? ( Ошибка загрузки {error} ) : null} {!slices.serverId && isLive && !emptyCube ? ( Уникальный объём Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN. ) : null} , iconClassName: "text-muted-foreground", }, { id: "packets", label: "Пакеты", value: kpis.packets.toLocaleString("ru-RU"), hint: kpis.topService ? `топ: ${kpis.topService}` : undefined, icon: , iconClassName: "text-muted-foreground", }, { id: "bps", label: "Средний bitrate", value: fmtBps(kpis.avgBps), icon: , iconClassName: "text-muted-foreground", }, { id: "users", label: "Пользователи", value: String(kpis.users), icon: , iconClassName: "text-muted-foreground", }, { id: "servers", label: "Серверы", value: String(kpis.servers), hint: slices.serverId ? (kpis.ifaces ? `${kpis.ifaces} iface` : undefined) : planes === "all" ? "WAN и дубли в списке" : "без WAN и overlay", icon: , iconClassName: "text-muted-foreground", }, ]} /> replaceParams({ view: next === "pivot" ? "pivot" : "explore" })} options={[ { value: "explore", label: "Разрез" }, { value: "pivot", label: "Сводка" }, ]} /> replaceParams({ planes: next === "all" ? "all" : undefined })} options={[ { value: "unique", label: "Уникальный" }, { value: "all", 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={countLabel} /> { const next = { ...slices } delete next[key as keyof CubeSlices] setSlices(next) }} /> {emptyCube ? ( ) : view === "pivot" ? ( ) : sliced ? (
) } export default function StatisticsPage() { return ( ) }