153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
"use client"
|
|
|
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
|
import { Frame, FramePanel } from "@/components/reui/frame"
|
|
import { ChartContainer, ChartTooltip, type ChartConfig } from "@/components/ui/chart"
|
|
import { fmtRate, formatRangeAgo, TRAFFIC_RANGE_MINUTES } from "@/lib/fmt-rate"
|
|
|
|
/**
|
|
* История RX/TX — adapt ReUI PRO chart-23 (multi-line + tooltip).
|
|
* @see https://reui.io/preview/base/chart-23
|
|
* @see https://reui.io/blocks
|
|
*/
|
|
|
|
const chartConfig = {
|
|
rx: { label: "RX", color: "var(--chart-rx)" },
|
|
tx: { label: "TX", color: "var(--chart-tx)" },
|
|
} satisfies ChartConfig
|
|
|
|
function ChartLegendItem({ label, color }: { label: string; color: string }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<div className="size-2 rounded-full" style={{ backgroundColor: color }} />
|
|
<span className="text-muted-foreground text-xs font-medium">{label}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CustomTooltip({
|
|
active,
|
|
payload,
|
|
}: {
|
|
active?: boolean
|
|
payload?: {
|
|
dataKey: string
|
|
color: string
|
|
value: number
|
|
}[]
|
|
}) {
|
|
if (!active || !payload?.length) return null
|
|
return (
|
|
<div className="min-w-[120px] flex flex-col gap-1.5 rounded-lg bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/10">
|
|
{payload.map((item) => (
|
|
<div key={item.dataKey}>
|
|
<div className="text-[10px] font-medium tracking-wider uppercase opacity-70">
|
|
{item.dataKey === "rx" ? "RX" : "TX"}:
|
|
</div>
|
|
<div className="text-sm font-semibold tabular-nums">
|
|
{fmtRate(item.value)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function toChartData(rx: (number|null)[], tx: (number|null)[], rangeMinutes: number) {
|
|
const n = Math.max(rx.length, tx.length, 1)
|
|
const denom = Math.max(n - 1, 1)
|
|
return Array.from({ length: n }, (_, i) => {
|
|
const minutesAgo = Math.round((1 - i / denom) * rangeMinutes)
|
|
return {
|
|
time: formatRangeAgo(minutesAgo),
|
|
rx: typeof rx[i] === "number" && Number.isFinite(rx[i]) && rx[i] >= 0 ? rx[i] : null,
|
|
tx: typeof tx[i] === "number" && Number.isFinite(tx[i]) && tx[i] >= 0 ? tx[i] : null,
|
|
}
|
|
})
|
|
}
|
|
|
|
export function TrafficRxTxChart({
|
|
rx,
|
|
tx,
|
|
range = "1h",
|
|
embedded = false,
|
|
}: {
|
|
rx: (number|null)[]
|
|
tx: (number|null)[]
|
|
range?: string
|
|
/** Skip outer Frame when already inside OpsPanel / Frame. */
|
|
embedded?: boolean
|
|
}) {
|
|
const rangeMinutes = TRAFFIC_RANGE_MINUTES[range] ?? 60
|
|
const data = toChartData(rx, tx, rangeMinutes)
|
|
const tickEvery = Math.max(1, Math.ceil(data.length / 6))
|
|
|
|
const hasRx = data.some(point => point.rx !== null)
|
|
const hasTx = data.some(point => point.tx !== null)
|
|
const hasData = hasRx || hasTx
|
|
const isolatedDot = (direction: "rx" | "tx") => function IsolatedDot({ cx, cy, index }: { cx?: number; cy?: number; index?: number }) {
|
|
if (index == null || cx == null || cy == null || data[index]?.[direction] == null
|
|
|| data[index - 1]?.[direction] != null || data[index + 1]?.[direction] != null) return <g />
|
|
return <circle cx={cx} cy={cy} r={3} fill={`var(--chart-${direction})`} data-isolated-direction={direction} />
|
|
}
|
|
const chart = !hasData ? (
|
|
<p role="status" className="flex min-h-[220px] items-center justify-center text-sm text-muted-foreground">Нет исторических измерений за выбранный период</p>
|
|
) : (
|
|
<div role="region" aria-label="История RX/TX" className="flex flex-col gap-4">
|
|
{(!hasRx || !hasTx) && <p role="status" className="text-sm text-muted-foreground">{!hasRx ? "Нет измерений RX за выбранный период" : "Нет измерений TX за выбранный период"}</p>}
|
|
<ChartContainer config={chartConfig} className="-ms-4 aspect-auto h-[220px] w-full">
|
|
<LineChart data={data} margin={{ top: 5, right: 5, left: 5, bottom: 5 }}>
|
|
<CartesianGrid
|
|
strokeDasharray="4 8"
|
|
vertical={false}
|
|
stroke="var(--border)"
|
|
/>
|
|
<XAxis
|
|
dataKey="time"
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fontSize: 11 }}
|
|
tickMargin={10}
|
|
interval={tickEvery - 1}
|
|
/>
|
|
<YAxis
|
|
axisLine={false}
|
|
tickLine={false}
|
|
tick={{ fontSize: 11 }}
|
|
tickFormatter={(v: number) => fmtRate(Number(v))}
|
|
tickMargin={8}
|
|
width={72}
|
|
/>
|
|
<ChartTooltip content={<CustomTooltip />} />
|
|
{hasRx && <Line
|
|
dataKey="rx"
|
|
type="monotone"
|
|
stroke="var(--chart-rx)"
|
|
strokeWidth={2}
|
|
dot={isolatedDot("rx")}
|
|
/>}
|
|
{hasTx && <Line
|
|
dataKey="tx"
|
|
type="monotone"
|
|
stroke="var(--chart-tx)"
|
|
strokeWidth={2}
|
|
dot={isolatedDot("tx")}
|
|
/>}
|
|
</LineChart>
|
|
</ChartContainer>
|
|
<div className="mb-1 flex items-center justify-center gap-6">
|
|
<ChartLegendItem label="RX (входящий)" color="var(--chart-rx)" />
|
|
<ChartLegendItem label="TX (исходящий)" color="var(--chart-tx)" />
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
if (embedded) return chart
|
|
|
|
return (
|
|
<Frame className="w-full">
|
|
<FramePanel className="flex flex-col gap-6">{chart}</FramePanel>
|
|
</Frame>
|
|
)
|
|
}
|