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

Куб IPFIX час+день с AND-слайсами и экраном отчётности /statistics, живой /traffic не меняем.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-10 11:57:47 +07:00
co-authored by Cursor
parent a80caf5676
commit 3687bb8fa2
33 changed files with 3551 additions and 6 deletions
@@ -0,0 +1,65 @@
import type { DateSelectorI18nConfig } from "@/components/reui/date-selector"
/** Русские подписи ReUI DateSelector (шапка /statistics). */
export const DATE_SELECTOR_RU: DateSelectorI18nConfig = {
selectDate: "Выбрать дату",
apply: "Применить",
cancel: "Отмена",
clear: "Сбросить",
today: "Сегодня",
filterTypes: {
is: "равно",
before: "до",
after: "после",
between: "между",
},
periodTypes: {
day: "День",
month: "Месяц",
quarter: "Квартал",
halfYear: "Полугодие",
year: "Год",
},
months: [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
],
monthsShort: [
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
],
quarters: ["I кв.", "II кв.", "III кв.", "IV кв."],
halfYears: ["1-е полугодие", "2-е полугодие"],
weekdays: [
"Воскресенье",
"Понедельник",
"Вторник",
"Среда",
"Четверг",
"Пятница",
"Суббота",
],
weekdaysShort: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
placeholder: "Выберите дату…",
rangePlaceholder: "Выберите период…",
}
+188
View File
@@ -0,0 +1,188 @@
"use client"
import { useMemo } from "react"
import { format } from "date-fns"
import { ru } from "date-fns/locale"
import { CalendarIcon } from "lucide-react"
import {
DateSelector,
type DateSelectorValue,
} from "@/components/reui/date-selector"
import { DATE_SELECTOR_RU } from "@/components/statistics/date-selector-i18n"
import { Button } from "@/components/ui/button"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
/**
* Период отчёта — DateSelector в Popover (c-date-selector-2).
* @see https://reui.io/preview/base/components/c-date-selector-2
* @see https://reui.io/docs/components/base/date-selector
*/
export type PeriodPreset = "today" | "24h" | "7d" | "30d" | "month"
export interface DateRangeYmd {
from: string
to: string
}
const PRESETS: { id: PeriodPreset; label: string }[] = [
{ id: "today", label: "Сегодня" },
{ id: "24h", label: "24 ч" },
{ id: "7d", label: "7 д" },
{ id: "30d", label: "30 д" },
{ id: "month", label: "Месяц" },
]
function pad2(n: number): string {
return String(n).padStart(2, "0")
}
export function formatYmd(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
export function parseYmd(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
if (!m) return null
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
return Number.isNaN(d.getTime()) ? null : d
}
export function rangeForPreset(preset: PeriodPreset, now = new Date()): DateRangeYmd {
const to = formatYmd(now)
if (preset === "today") return { from: to, to }
if (preset === "24h") {
const from = new Date(now)
from.setDate(from.getDate() - 1)
return { from: formatYmd(from), to }
}
if (preset === "7d") {
const from = new Date(now)
from.setDate(from.getDate() - 6)
return { from: formatYmd(from), to }
}
if (preset === "30d") {
const from = new Date(now)
from.setDate(from.getDate() - 29)
return { from: formatYmd(from), to }
}
const start = new Date(now.getFullYear(), now.getMonth(), 1)
return { from: formatYmd(start), to }
}
export function dateSelectorToRange(value: DateSelectorValue): DateRangeYmd | null {
if (value.period === "day") {
if (value.operator === "between") {
if (!value.startDate || !value.endDate) return null
const a = formatYmd(value.startDate)
const b = formatYmd(value.endDate)
return a <= b ? { from: a, to: b } : { from: b, to: a }
}
if (value.startDate) {
const d = formatYmd(value.startDate)
return { from: d, to: d }
}
}
if (value.period === "month" && value.year != null && value.month != null) {
const start = new Date(value.year, value.month, 1)
const end = new Date(value.year, value.month + 1, 0)
return { from: formatYmd(start), to: formatYmd(end) }
}
if (value.period === "year" && value.year != null) {
return { from: `${value.year}-01-01`, to: `${value.year}-12-31` }
}
if (value.period === "quarter" && value.year != null && value.quarter != null) {
const startMonth = value.quarter * 3
const start = new Date(value.year, startMonth, 1)
const end = new Date(value.year, startMonth + 3, 0)
return { from: formatYmd(start), to: formatYmd(end) }
}
if (value.period === "half-year" && value.year != null && value.halfYear != null) {
const startMonth = value.halfYear * 6
const start = new Date(value.year, startMonth, 1)
const end = new Date(value.year, startMonth + 6, 0)
return { from: formatYmd(start), to: formatYmd(end) }
}
return null
}
export function rangeToSelector(range: DateRangeYmd): DateSelectorValue {
return {
period: "day",
operator: "between",
startDate: parseYmd(range.from) ?? undefined,
endDate: parseYmd(range.to) ?? undefined,
}
}
function formatRangeLabel(range: DateRangeYmd): string {
const from = parseYmd(range.from)
const to = parseYmd(range.to)
if (!from || !to) return "Период"
if (range.from === range.to) return format(from, "d MMM yyyy", { locale: ru })
return `${format(from, "d MMM", { locale: ru })} ${format(to, "d MMM yyyy", { locale: ru })}`
}
export function PeriodSelector({
range,
onChange,
}: {
range: DateRangeYmd
onChange: (next: DateRangeYmd) => void
}) {
const selectorValue = useMemo(() => rangeToSelector(range), [range])
function handleSelectorChange(value: DateSelectorValue) {
const next = dateSelectorToRange(value)
if (!next) return
if (next.from === range.from && next.to === range.to) return
onChange(next)
}
const activePreset = PRESETS.find((p) => {
const r = rangeForPreset(p.id)
return r.from === range.from && r.to === range.to
})?.id
return (
<div className="flex flex-wrap items-center gap-1.5">
{PRESETS.map((p) => (
<Button
key={p.id}
type="button"
variant={activePreset === p.id ? "secondary" : "ghost"}
size="sm"
onClick={() => onChange(rangeForPreset(p.id))}
>
{p.label}
</Button>
))}
<Popover>
<PopoverTrigger
render={
<Button type="button" variant="outline" size="sm" className="min-w-40 justify-between" />
}
>
<CalendarIcon className="size-3.5" />
<span className="tabular-nums">{formatRangeLabel(range)}</span>
</PopoverTrigger>
<PopoverContent align="end" className="w-auto min-w-[32rem] max-w-[min(100vw-2rem,42rem)] p-3">
<DateSelector
value={selectorValue}
onChange={handleSelectorChange}
allowRange
defaultPeriodType="day"
defaultFilterType="between"
periodTypes={["day", "month", "year"]}
showTwoMonths
weekStartsOn={1}
maxYear={2035}
dayDateFormat="dd.MM.yyyy"
i18n={DATE_SELECTOR_RU}
className="sm:w-[470px]"
/>
</PopoverContent>
</Popover>
</div>
)
}
@@ -0,0 +1,105 @@
"use client"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"
import { Frame, FramePanel } from "@/components/reui/frame"
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
import { formatBytes } from "@/lib/fmt-rate"
import type { StatisticsSeriesPoint } from "@mmapp/contracts/statistics"
/**
* Объём трафика за период — adapt ReUI chart-23 (байты, не live bps).
* @see https://reui.io/preview/base/chart-23
*/
const chartConfig = {
bytes: { label: "Объём", color: "var(--chart-1)" },
} satisfies ChartConfig
function formatTick(iso: string, grain: "hour" | "day"): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso.slice(0, 10)
if (grain === "hour") {
return `${String(d.getHours()).padStart(2, "0")}:00`
}
return d.toLocaleDateString("ru-RU", { day: "2-digit", month: "short" })
}
function CustomTooltip({
active,
payload,
}: {
active?: boolean
payload?: { payload: { label: string; bytes: number } }[]
}) {
if (!active || !payload?.length) return null
const row = payload[0]?.payload
if (!row) return null
return (
<div className="flex min-w-[120px] flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">{row.label}</div>
<div className="text-sm font-semibold tabular-nums">{formatBytes(row.bytes)}</div>
</div>
)
}
export function StatisticsVolumeChart({
series,
grain,
}: {
series: StatisticsSeriesPoint[]
grain: "hour" | "day"
}) {
const data = series.map((p) => ({
t: p.t,
bytes: p.bytes,
label: formatTick(p.t, grain),
}))
const tickEvery = Math.max(1, Math.ceil(data.length / 8))
return (
<Frame className="w-full">
<FramePanel className="flex flex-col gap-4">
<div className="flex items-baseline justify-between gap-2">
<h2 className="text-sm font-medium">Объём по времени</h2>
<span className="text-muted-foreground text-xs">
{grain === "hour" ? "по часам" : "по суткам"}
</span>
</div>
{data.length === 0 ? (
<p className="text-muted-foreground py-10 text-center text-sm">Нет данных за выбранный период</p>
) : (
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
<AreaChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
<CartesianGrid strokeDasharray="4 8" vertical={false} stroke="var(--border)" />
<XAxis
dataKey="label"
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickMargin={10}
interval={tickEvery - 1}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 11 }}
tickFormatter={(v: number) => formatBytes(Number(v))}
tickMargin={8}
width={72}
/>
<ChartTooltip content={<CustomTooltip />} />
<Area
dataKey="bytes"
type="monotone"
stroke="var(--chart-1)"
fill="var(--chart-1)"
fillOpacity={0.15}
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
)}
</FramePanel>
</Frame>
)
}