Files
manage/frontend/src/widgets/GrafanaChartWidget.tsx
T
Developer 94bf830955 Fix invisible chart lines + extract Prometheus labels for multi-series
Two fixes for the Grafana chart widget:

1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the
   CSS variable is named '--color-chart-1' and already contains a hex
   color (#4f8cff). The hsl() wrapper produced invalid CSS, making
   every stroke invisible. Fixed to var(--color-chart-1).

2. Multiple series collision: the backend labeled all Prometheus series
   with the value field name (often just 'Value'), so multiple time
   series collided on the same recharts dataKey and overwrote each
   other. Now extracts meaningful labels from the Grafana frame metadata:
   prefers displayName, then Prometheus metric labels (e.g.
   'instance=server1:9100 mode=iowait'), then falls back to the field
   name. Duplicate labels get a numeric suffix for uniqueness.

Multi-series queries now render correctly: each Prometheus time series
gets its own colored line with a unique label in the legend/tooltip.

280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/
eslint clean.
2026-07-06 10:59:16 +00:00

121 lines
3.0 KiB
TypeScript

import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
interface SeriesPoint {
t: number;
v: number | null;
}
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)",
];
export function GrafanaChartWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const series = data?.data?.series as ChartSeries[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-[300px] w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : series && series.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<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>
) : (
<Alert>
<AlertDescription>
No data. Check your query and datasource_uid in the widget config.
</AlertDescription>
</Alert>
)}
</SectionCard>
);
}