feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
|
||||
export function LineSeriesChart({
|
||||
series,
|
||||
height = 300,
|
||||
}: LineSeriesChartProps) {
|
||||
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 tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => formatTime(Number(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "0.5rem",
|
||||
color: "hsl(var(--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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { LineSeriesChart } from "../LineSeriesChart";
|
||||
import type { ChartSeries } from "../LineSeriesChart";
|
||||
|
||||
describe("LineSeriesChart", () => {
|
||||
it("renders without crashing with series data", () => {
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
label: "cpu",
|
||||
points: [
|
||||
{ t: 1000, v: 0.5 },
|
||||
{ t: 2000, v: 0.8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { container } = render(<LineSeriesChart series={series} />);
|
||||
// ResponsiveContainer renders a wrapper div even in jsdom
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders without crashing with empty series", () => {
|
||||
const { container } = render(<LineSeriesChart series={[]} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders with custom height", () => {
|
||||
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
||||
const { container } = render(
|
||||
<LineSeriesChart series={series} height={200} />,
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user