Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m17s
Docker images / frontend-image (push) Successful in 4m11s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m18s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 8s
- Introduced Suspense for lazy loading in StatisticsPage to optimize rendering. - Refactored StatisticsPage to separate inner logic into StatisticsPageInner for better readability. - Updated PeriodSelector to utilize useCallback for handling state changes, improving performance and clarity. Co-authored-by: Cursor <[email protected]>
559 lines
19 KiB
TypeScript
559 lines
19 KiB
TypeScript
"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<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
|
||
}
|
||
|
||
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<StatisticsDto>(EMPTY)
|
||
const [pivot, setPivot] = useState<StatisticsPivotDto>(EMPTY_PIVOT)
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const replaceParams = useCallback(
|
||
(patch: Record<string, string | undefined>) => {
|
||
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 (
|
||
<div className="flex h-full flex-col">
|
||
<PageHeader
|
||
crumbs={[{ label: "Обзор", href: "/dashboard" }, { label: "Статистика" }]}
|
||
actions={<PeriodSelector range={range} onChange={setRange} />}
|
||
/>
|
||
|
||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||
{!isLive ? (
|
||
<Alert>
|
||
<AlertTitle>Живые данные выключены</AlertTitle>
|
||
<AlertDescription>
|
||
Куб статистики строится из IPFIX. Переключитесь на живой источник, чтобы увидеть отчёт.
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<Alert variant="destructive">
|
||
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||
<AlertDescription>{error}</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
{!slices.serverId && isLive && !emptyCube ? (
|
||
<Alert>
|
||
<AlertTitle>Уникальный объём</AlertTitle>
|
||
<AlertDescription>
|
||
Объём — трафик клиентов на GRE/WG, без повторного учёта JH↔EN и WAN.
|
||
</AlertDescription>
|
||
</Alert>
|
||
) : null}
|
||
|
||
<KpiStatGrid
|
||
aria-label="Сводка трафика"
|
||
isLoading={loading}
|
||
skeletonCount={5}
|
||
items={[
|
||
{
|
||
id: "bytes",
|
||
label: "Объём",
|
||
value: formatBytes(kpis.bytes),
|
||
hint: "GRE/WG клиентов, без hops",
|
||
icon: <DatabaseIcon />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "packets",
|
||
label: "Пакеты",
|
||
value: kpis.packets.toLocaleString("ru-RU"),
|
||
hint: kpis.topService ? `топ: ${kpis.topService}` : undefined,
|
||
icon: <ActivityIcon />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "bps",
|
||
label: "Средний bitrate",
|
||
value: fmtBps(kpis.avgBps),
|
||
icon: <GaugeIcon />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
{
|
||
id: "users",
|
||
label: "Пользователи",
|
||
value: String(kpis.users),
|
||
icon: <UsersIcon />,
|
||
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: <ServerIcon />,
|
||
iconClassName: "text-muted-foreground",
|
||
},
|
||
]}
|
||
/>
|
||
|
||
<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: "Сводка" },
|
||
]}
|
||
/>
|
||
<SegmentedControl
|
||
value={planes}
|
||
onChange={(next) => replaceParams({ planes: next === "all" ? "all" : undefined })}
|
||
options={[
|
||
{ value: "unique", label: "Уникальный" },
|
||
{ value: "all", 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={countLabel}
|
||
/>
|
||
<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>
|
||
)
|
||
}
|
||
|
||
export default function StatisticsPage() {
|
||
return (
|
||
<Suspense fallback={null}>
|
||
<StatisticsPageInner />
|
||
</Suspense>
|
||
)
|
||
}
|