feat(statistics): добавить раздел статистики трафика
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 4m47s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m19s
Docker images / frontend-image (push) Successful in 4m47s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 2m53s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
Куб IPFIX час+день с AND-слайсами и экраном отчётности /statistics, живой /traffic не меняем. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import {
|
||||
ActivityIcon,
|
||||
CableIcon,
|
||||
DatabaseIcon,
|
||||
GaugeIcon,
|
||||
GlobeIcon,
|
||||
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 { EmptyState } from "@/components/empty-state"
|
||||
import { PeriodSelector, rangeForPreset, type DateRangeYmd } from "@/components/statistics/period-selector"
|
||||
import { StatisticsVolumeChart } from "@/components/statistics/statistics-volume-chart"
|
||||
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 { useDataSource } from "@/lib/data-source"
|
||||
import { fmtBps, formatBytes } from "@/lib/fmt-rate"
|
||||
import { getStatistics, type StatisticsDto, type StatisticsQuery } from "@/shared/api/statistics"
|
||||
|
||||
/**
|
||||
* Отчётный куб трафика — KPI + период + график + табы-гриды.
|
||||
* 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
|
||||
*/
|
||||
|
||||
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: "",
|
||||
grain: "day",
|
||||
kpis: {
|
||||
bytes: 0,
|
||||
packets: 0,
|
||||
avgBps: 0,
|
||||
users: 0,
|
||||
servers: 0,
|
||||
ifaces: 0,
|
||||
topCountry: "",
|
||||
topService: "",
|
||||
},
|
||||
series: [],
|
||||
users: [],
|
||||
servers: [],
|
||||
interfaces: [],
|
||||
countries: [],
|
||||
services: [],
|
||||
asns: [],
|
||||
}
|
||||
|
||||
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 readTab(sp: URLSearchParams): StatisticsSliceKind {
|
||||
const t = sp.get("tab")
|
||||
return TABS.some((x) => x.id === t) ? (t as StatisticsSliceKind) : "users"
|
||||
}
|
||||
|
||||
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): 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,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
return `${slices.serverId}:${slices.iface}`
|
||||
}
|
||||
if (tab === "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
|
||||
return data.asns
|
||||
}
|
||||
|
||||
export default function StatisticsPage() {
|
||||
const router = useRouter()
|
||||
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 [tab, setTab] = useState<StatisticsSliceKind>(() => readTab(searchParams))
|
||||
|
||||
const [data, setData] = useState<StatisticsDto>(EMPTY)
|
||||
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()
|
||||
router.replace(qs ? `/statistics?${qs}` : "/statistics")
|
||||
},
|
||||
[router, 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 dto = await getStatistics(backendUrl, toQuery(range, slices))
|
||||
if (!cancelled) setData(dto)
|
||||
} catch (e: unknown) {
|
||||
if (!cancelled) {
|
||||
setData(EMPTY)
|
||||
setError(e instanceof Error ? e.message : "Не удалось загрузить статистику")
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backendUrl, isLive, prefsHydrated, range, slices])
|
||||
|
||||
const rows = rowsForTab(isLive ? data : EMPTY, tab)
|
||||
const selectedId = selectedIdForTab(tab, slices)
|
||||
const view = isLive ? data : EMPTY
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const kpis = view.kpis
|
||||
const emptyCube = !isLive || (!loading && kpis.bytes === 0)
|
||||
|
||||
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}
|
||||
|
||||
<KpiStatGrid
|
||||
aria-label="Сводка трафика"
|
||||
isLoading={loading}
|
||||
skeletonCount={5}
|
||||
items={[
|
||||
{
|
||||
id: "bytes",
|
||||
label: "Объём",
|
||||
value: formatBytes(kpis.bytes),
|
||||
hint: kpis.topCountry ? `топ: ${kpis.topCountry}` : undefined,
|
||||
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: kpis.ifaces ? `${kpis.ifaces} iface` : undefined,
|
||||
icon: <ServerIcon />,
|
||||
iconClassName: "text-muted-foreground",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<StatisticsVolumeChart series={view.series} grain={view.grain} />
|
||||
|
||||
<DataPageCard>
|
||||
<DataPageToolbar
|
||||
filters={filters}
|
||||
onFiltersChange={(next) => setSlices(filtersToSlices(next))}
|
||||
filterFields={STATISTICS_FILTER_FIELDS}
|
||||
countLabel={`${rows.length} строк`}
|
||||
/>
|
||||
{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>
|
||||
</DataPageCard>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user