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,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: "Выберите период…",
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user