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:
Developer
2026-07-06 10:19:57 +00:00
parent b877a32ad8
commit 447775048c
15 changed files with 1023 additions and 185 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ describe("service registry", () => {
it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link",
"panel",
"chart",
]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts",
+21 -11
View File
@@ -2,7 +2,7 @@ import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { GrafanaPanelWidget } from "../widgets/GrafanaPanelWidget";
import { GrafanaChartWidget } from "../widgets/GrafanaChartWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -89,27 +89,37 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
component: GrafanaLinkWidget,
},
{
kind: "panel",
name: "Panel embed",
description: "Embed a Grafana panel directly.",
refreshIntervalMs: 0,
kind: "chart",
name: "Chart",
description: "Live time-series chart from a Grafana datasource query.",
refreshIntervalMs: 60_000,
defaultConfig: {
dashboard_uid: "",
panel_id: 1,
datasource_uid: "prometheus",
query: "",
from_ts: "now-1h",
to_ts: "now",
interval_ms: 30_000,
max_data_points: 100,
},
configSchema: {
type: "object",
properties: {
dashboard_uid: { type: "string" },
panel_id: { type: "integer" },
datasource_uid: {
type: "string",
description: "Grafana datasource UID (e.g. 'prometheus')",
},
query: {
type: "string",
description: "Query expression (e.g. PromQL)",
},
from_ts: { type: "string" },
to_ts: { type: "string" },
interval_ms: { type: "integer" },
max_data_points: { type: "integer" },
},
required: ["dashboard_uid", "panel_id"],
required: ["query"],
},
component: GrafanaPanelWidget,
component: GrafanaChartWidget,
},
],
},
+120
View File
@@ -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>
);
}
@@ -1,53 +0,0 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ExternalLink } from "lucide-react";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function GrafanaPanelWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const embedUrl = data?.data?.embed_url as string | 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>
) : embedUrl ? (
<div className="flex flex-col gap-2">
<iframe
src={embedUrl}
title={widget.title}
className="h-[300px] w-full rounded-lg border border-border"
loading="lazy"
/>
<Button variant="outline" size="sm" asChild>
<a href={embedUrl} target="_blank" rel="noopener noreferrer">
Open in Grafana
<ExternalLink className="ml-2 h-3 w-3" />
</a>
</Button>
</div>
) : (
<Alert>
<AlertDescription>No Grafana panel configured.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { GrafanaChartWidget } from "../GrafanaChartWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "chart",
title: "CPU Usage",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
function mockData(data: unknown, error?: string) {
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
data: error
? { widget_id: "w1", error, fetched_at: 0 }
: { widget_id: "w1", data, fetched_at: 0 },
isLoading: false,
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("GrafanaChartWidget", () => {
it("renders a chart with series data", () => {
mockData({
series: [
{
label: "cpu",
points: [
{ t: 1000, v: 0.5 },
{ t: 2000, v: 0.8 },
],
},
],
});
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
// recharts renders an SVG; the title from SectionCard should be present.
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Grafana api_key is required for chart queries");
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/api_key is required/i)).toBeInTheDocument();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
@@ -1,78 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { GrafanaPanelWidget } from "../GrafanaPanelWidget";
import type { WidgetInstance } from "../../types";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w2",
service_id: "s2",
widget_kind: "panel",
title: "CPU Usage",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
describe("GrafanaPanelWidget", () => {
it("renders an iframe with the embed URL", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w2",
data: {
embed_url:
"http://grafana:3000/d-solo/ov/manage?panelId=4&from=now-1h&to=now&kiosk=tv",
},
fetched_at: 0,
},
isLoading: false,
} as never);
const { container } = render(
<GrafanaPanelWidget widget={widget} refreshIntervalMs={0} />,
);
const iframe = container.querySelector("iframe");
expect(iframe).not.toBeNull();
expect(iframe?.getAttribute("src")).toContain("d-solo/ov/manage");
expect(iframe?.getAttribute("src")).toContain("panelId=4");
});
it("renders Open in Grafana fallback link", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w2",
data: {
embed_url:
"http://grafana:3000/d-solo/ov/manage?panelId=4&from=now-1h&to=now&kiosk=tv",
},
fetched_at: 0,
},
isLoading: false,
} as never);
render(<GrafanaPanelWidget widget={widget} refreshIntervalMs={0} />);
expect(screen.getByText("Open in Grafana")).toBeInTheDocument();
});
it("renders error state", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w2",
error: "dashboard_uid is required",
fetched_at: 0,
},
isLoading: false,
} as never);
render(<GrafanaPanelWidget widget={widget} refreshIntervalMs={0} />);
expect(screen.getByText("dashboard_uid is required")).toBeInTheDocument();
});
});
+1
View File
@@ -1,6 +1,7 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { GrafanaChartWidget } from "./GrafanaChartWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget";