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>
|
||||
);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
Reference in New Issue
Block a user