Files
manage/frontend/src/components/LineSeriesChart.tsx
T
Developer b7019b33ac feat(widgets): scale chart axes/tooltips with unit + scale options
Consistent graph scaling across every line-chart widget. A new shared
frontend/src/lib/metricFormat.ts picks a decimal prefix (kB/MB/GB, kbps/Mbps,
Gbps, …) from the series magnitude and formats values; LineSeriesChart accepts
unit + scale and formats both the Y-axis ticks and the tooltip with the SAME
prefix (one consistent unit per axis). MetricChartWidget (Prometheus) and
QbittorrentSpeedWidget pass the widget config through; qBit speed defaults to
bytes/sec → MB/s.

WidgetConfigDialog now renders `enum` schema fields as a <Select> dropdown, so
the backend's unit/scale Literal enums become consistent pickers in every graph
widget's config (and any future enum option).

Decimal (x1000) prefixes by default (matches Mbps/MB/s/Grafana).

Tests: 13 new metricFormat tests (auto/fixed scaling, percent, seconds,
nulls, trailing-zero trimming). 179/179 frontend tests pass; tsc + ESLint clean.
2026-07-12 11:46:07 +00:00

127 lines
3.0 KiB
TypeScript

import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import {
type MetricUnit,
type MetricScale,
metricScaleInfo,
formatScaled,
} from "../lib/metricFormat";
export interface SeriesPoint {
t: number;
v: number | null;
}
export interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** Merge multiple time-series into a single recharts-friendly array. */
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>();
for (const s of series) {
for (const p of s.points) {
const existing = map.get(p.t) ?? { time: p.t };
existing[s.label] = p.v;
map.set(p.t, existing);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
interface LineSeriesChartProps {
series: ChartSeries[];
height?: number;
/** Display unit for the Y axis + tooltip (drives decimal-prefix scaling). */
unit?: MetricUnit;
/** "auto" picks a prefix from the data magnitude; k/m/g/t force one. */
scale?: MetricScale;
}
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
export function LineSeriesChart({
series,
height = 300,
unit = "none",
scale = "auto",
}: LineSeriesChartProps) {
// Choose ONE (divisor, suffix) from the series magnitude so the axis and
// tooltip stay consistent (e.g. all values shown in MB/s).
const maxAbs = series.reduce((m, s) => {
for (const p of s.points) {
const v = p.v == null ? 0 : Math.abs(p.v);
if (v > m) m = v;
}
return m;
}, 0);
const scaleInfo = metricScaleInfo(maxAbs, unit, scale);
const fmt = (v: number | null | undefined) =>
formatScaled(v, scaleInfo, unit);
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={mergeSeries(series)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis
tickFormatter={fmt}
tick={{ fontSize: 11 }}
width={56}
className="fill-muted-foreground"
/>
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
formatter={(value) => fmt(Number(value))}
contentStyle={{
backgroundColor: "var(--color-popover)",
border: "1px solid var(--color-border)",
borderRadius: "0.5rem",
color: "var(--color-popover-foreground)",
}}
/>
{series.map((s, i) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
dot={false}
strokeWidth={2}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
);
}