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
@@ -5,7 +5,7 @@ import { Flag } from "@/components/flag"
import { Badge } from "@/components/reui/badge"
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
import type { StatisticsBreakdownRow } from "@mmapp/contracts/statistics"
import { STATISTICS_UNBOUND_USER_ID, type StatisticsBreakdownRow } from "@mmapp/contracts/statistics"
export type StatisticsSliceKind = "users" | "servers" | "interfaces" | "countries" | "services" | "asns"
@@ -15,13 +15,16 @@ export function StatisticsBreakdownDataGrid({
selectedId,
onRowClick,
isLoading,
density = "full",
}: {
rows: StatisticsBreakdownRow[]
kind: StatisticsSliceKind
selectedId?: string
onRowClick?: (row: StatisticsBreakdownRow) => void
isLoading?: boolean
density?: "full" | "mini"
}) {
const mini = density === "mini"
const columns: CompactDataGridColumn<StatisticsBreakdownRow>[] = [
{
id: "label",
@@ -29,7 +32,9 @@ export function StatisticsBreakdownDataGrid({
accessorKey: "label",
cell: (row) => (
<span className={cn("flex items-center gap-2", selectedId === row.id && "font-medium")}>
{kind === "countries" && row.id !== "XX" ? <Flag code={row.id} size={16} /> : null}
{kind === "countries" && row.id !== "XX" && row.id !== STATISTICS_UNBOUND_USER_ID ? (
<Flag code={row.id} size={16} />
) : null}
<span className="truncate">{row.label || row.id}</span>
{selectedId === row.id ? (
<Badge variant="outline" size="sm">
@@ -45,18 +50,24 @@ export function StatisticsBreakdownDataGrid({
accessorKey: "bytes",
cell: (row) => <span className="tabular-nums">{formatBytes(row.bytes)}</span>,
},
{
id: "packets",
header: "Пакеты",
accessorKey: "packets",
cell: (row) => <span className="tabular-nums">{row.packets.toLocaleString("ru-RU")}</span>,
},
{
id: "bps",
header: "Средний bitrate",
accessorKey: "bps",
cell: (row) => <span className="tabular-nums">{fmtBps(row.bps)}</span>,
},
...(!mini
? [
{
id: "packets",
header: "Пакеты",
accessorKey: "packets" as const,
cell: (row: StatisticsBreakdownRow) => (
<span className="tabular-nums">{row.packets.toLocaleString("ru-RU")}</span>
),
},
{
id: "bps",
header: "Средний bitrate",
accessorKey: "bps" as const,
cell: (row: StatisticsBreakdownRow) => <span className="tabular-nums">{fmtBps(row.bps)}</span>,
},
]
: []),
{
id: "percent",
header: "Доля",
@@ -0,0 +1,57 @@
"use client"
import { Frame, FrameHeader, FramePanel, FrameTitle } from "@/components/reui/frame"
import {
StatisticsBreakdownDataGrid,
type StatisticsSliceKind,
} from "@/components/data-grids/statistics-breakdown-data-grid"
import { STATISTICS_DIMS } from "@/lib/statistics-dims"
import type { StatisticsBreakdownRow, StatisticsDto } from "@mmapp/contracts/statistics"
const MINI_ROWS = 12
function rowsForKind(data: StatisticsDto, kind: StatisticsSliceKind): StatisticsBreakdownRow[] {
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
}
export function BreakdownDashboard({
data,
hidden,
selectedIdFor,
onRowClick,
isLoading,
}: {
data: StatisticsDto
hidden: Set<StatisticsSliceKind>
selectedIdFor: (kind: StatisticsSliceKind) => string | undefined
onRowClick: (kind: StatisticsSliceKind, row: StatisticsBreakdownRow) => void
isLoading?: boolean
}) {
const dims = STATISTICS_DIMS.filter((d) => !hidden.has(d.id))
return (
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-2">
{dims.map((d) => (
<Frame key={d.id} dense>
<FrameHeader className="border-b">
<FrameTitle>{d.label}</FrameTitle>
</FrameHeader>
<FramePanel className="max-h-80 overflow-auto p-0">
<StatisticsBreakdownDataGrid
rows={rowsForKind(data, d.id).slice(0, MINI_ROWS)}
kind={d.id}
selectedId={selectedIdFor(d.id)}
onRowClick={(row) => onRowClick(d.id, row)}
isLoading={isLoading}
density="mini"
/>
</FramePanel>
</Frame>
))}
</div>
)
}
@@ -0,0 +1,73 @@
"use client"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { STATISTICS_DIMS } from "@/lib/statistics-dims"
import type { StatisticsPivotDim } from "@mmapp/contracts/statistics"
import type { StatisticsSliceKind } from "@/components/data-grids/statistics-breakdown-data-grid"
export function DimensionSelect({
value,
onChange,
label,
}: {
value: StatisticsSliceKind
onChange: (value: StatisticsSliceKind) => void
label?: string
}) {
const current = STATISTICS_DIMS.find((d) => d.id === value)
return (
<label className="flex items-center gap-2">
{label ? <span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span> : null}
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsSliceKind)}>
<SelectTrigger size="sm" className="min-w-40">
<SelectValue>{current?.label ?? "Измерение"}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{STATISTICS_DIMS.map((d) => (
<SelectItem key={d.id} value={d.id}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
export function PivotDimSelect({
value,
onChange,
exclude,
label,
}: {
value: StatisticsPivotDim
onChange: (value: StatisticsPivotDim) => void
exclude?: StatisticsPivotDim
label: string
}) {
const options = STATISTICS_DIMS.filter((d) => d.pivot !== exclude)
const current = STATISTICS_DIMS.find((d) => d.pivot === value)
return (
<label className="flex items-center gap-2">
<span className="text-muted-foreground text-xs whitespace-nowrap">{label}</span>
<Select value={value} onValueChange={(v) => onChange(String(v ?? value) as StatisticsPivotDim)}>
<SelectTrigger size="sm" className="min-w-40">
<SelectValue>{current?.label ?? label}</SelectValue>
</SelectTrigger>
<SelectContent align="start">
{options.map((d) => (
<SelectItem key={d.pivot} value={d.pivot}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
)
}
+38
View File
@@ -0,0 +1,38 @@
"use client"
import { XIcon } from "lucide-react"
import { Badge } from "@/components/reui/badge"
import { Button } from "@/components/ui/button"
export interface SliceChip {
key: string
label: string
}
export function SliceChips({
chips,
onRemove,
}: {
chips: SliceChip[]
onRemove: (key: string) => void
}) {
if (!chips.length) return null
return (
<div className="flex flex-wrap items-center gap-1.5 border-b px-5 py-2">
{chips.map((chip) => (
<Badge key={chip.key} variant="outline" size="sm" className="gap-1 pr-0.5">
{chip.label}
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Снять ${chip.label}`}
onClick={() => onRemove(chip.key)}
>
<XIcon />
</Button>
</Badge>
))}
</div>
)
}
@@ -0,0 +1,89 @@
"use client"
import { useMemo } from "react"
import { CompactDataGrid, type CompactDataGridColumn } from "@/components/data-grids/compact-data-grid"
import { formatBytes } from "@/lib/fmt-rate"
import { cn } from "@/lib/utils"
import type { StatisticsPivotDto } from "@mmapp/contracts/statistics"
interface PivotGridRow {
id: string
label: string
total: number
[key: string]: string | number
}
export function StatisticsPivotGrid({
data,
onCellClick,
isLoading,
}: {
data: StatisticsPivotDto
onCellClick?: (rowId: string, colId: string) => void
isLoading?: boolean
}) {
const rows: PivotGridRow[] = useMemo(
() =>
data.rows.map((r) => {
const next: PivotGridRow = { id: r.id, label: r.label, total: r.total }
for (const col of data.columns) {
next[`c:${col.id}`] = r.cells[col.id] ?? 0
}
return next
}),
[data],
)
const columns: CompactDataGridColumn<PivotGridRow>[] = [
{
id: "label",
header: "Измерение",
accessorKey: "label",
cell: (row) => <span className="truncate font-medium">{row.label}</span>,
},
...data.columns.map((col) => ({
id: `c:${col.id}`,
header: col.label,
accessorKey: `c:${col.id}` as const,
cell: (row: PivotGridRow) => {
const value = Number(row[`c:${col.id}`] ?? 0)
return (
<button
type="button"
className={cn(
"tabular-nums text-left hover:underline",
col.id === "__other__" || row.id === "__other__" ? "text-muted-foreground" : "",
)}
onClick={(e) => {
e.stopPropagation()
if (row.id === "__other__" || col.id === "__other__") return
onCellClick?.(row.id, col.id)
}}
>
{data.metric === "packets" ? value.toLocaleString("ru-RU") : formatBytes(value)}
</button>
)
},
})),
{
id: "total",
header: "Итого",
accessorKey: "total",
cell: (row) => (
<span className="tabular-nums font-medium">
{data.metric === "packets" ? row.total.toLocaleString("ru-RU") : formatBytes(row.total)}
</span>
),
},
]
return (
<CompactDataGrid
data={rows}
columns={columns}
isLoading={isLoading}
emptyTitle="Нет данных сводной"
emptyDescription="Выберите разные измерения строк и колонок."
/>
)
}