Replace Grafana iframe panel with server-side chart widget
The iframe-based 'panel' widget didn't work: the browser couldn't
authenticate against the OIDC-protected Grafana (Authentik), and
iframes can't carry Bearer tokens or share cross-origin session
cookies. Result: blank iframe or login redirect.
Replace it with a 'chart' widget that queries Grafana's datasource
API server-side:
Backend (GrafanaWidgetSource): POSTs to /api/ds/query with the stored
api_key (which bypasses OIDC), using the widget's configured PromQL
query, datasource_uid, time range, and resolution. Normalizes Grafana's
frame-based response into a simple {series: [{label, points: [{t, v}]}]}
shape. The api_key is never exposed to the browser.
Frontend (GrafanaChartWidget): renders the series data as a recharts
LineChart with dark-mode-aware colors (Tailwind --chart-* tokens),
responsive container, custom tooltip, and per-series lines. Loading
skeleton, error Alert, and empty state. recharts ^3.9.2 added.
The 'link' widget kind (deep-link URL) is unchanged. The 'panel' kind
and GrafanaPanelWidget are fully removed.
Backend: 279 tests pass (+1 net: -2 panel + 3 chart). Frontend: 127
tests pass (net 0: -3 panel + 3 chart). Lint/build green both sides.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
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 = [
|
||||
"hsl(var(--chart-1))",
|
||||
"hsl(var(--chart-2))",
|
||||
"hsl(var(--chart-3))",
|
||||
"hsl(var(--chart-4))",
|
||||
"hsl(var(--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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user