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]>
106 lines
3.5 KiB
TypeScript
106 lines
3.5 KiB
TypeScript
"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>
|
|
)
|
|
}
|