Add Jellyfin Now Playing + Grafana Panel embed widgets

Two new additive widget kinds:

Jellyfin 'now_playing': like the existing 'activity' widget but filters
to only sessions with active playback (NowPlayingItem present + not
paused). Shows who's actually watching right now. The 'activity' kind
is unchanged (shows all sessions including idle).

Grafana 'panel': embeds a single Grafana panel directly in the app via
an iframe, using Grafana's /d-solo/ endpoint (renders one panel without
dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and
time range (from/to, defaults now-1h/now). Includes a fallback 'Open in
Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The
'link' kind is unchanged (still builds a deep-link URL).

Backend: new widget configs + definitions on jellyfin/grafana; source
adapter logic (session filter for now_playing; d-solo embed URL for
panel); 6 new tests.

Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components;
registry bindings; 6 new tests.

278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build
green both sides.
This commit is contained in:
Developer
2026-06-28 15:26:43 +00:00
parent 8d2e4c9bfd
commit b877a32ad8
11 changed files with 453 additions and 4 deletions
@@ -23,6 +23,7 @@ describe("service registry", () => {
it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link",
"panel",
]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts",
+34
View File
@@ -2,7 +2,9 @@ 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 { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget";
@@ -86,6 +88,29 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
component: GrafanaLinkWidget,
},
{
kind: "panel",
name: "Panel embed",
description: "Embed a Grafana panel directly.",
refreshIntervalMs: 0,
defaultConfig: {
dashboard_uid: "",
panel_id: 1,
from_ts: "now-1h",
to_ts: "now",
},
configSchema: {
type: "object",
properties: {
dashboard_uid: { type: "string" },
panel_id: { type: "integer" },
from_ts: { type: "string" },
to_ts: { type: "string" },
},
required: ["dashboard_uid", "panel_id"],
},
component: GrafanaPanelWidget,
},
],
},
prometheus: {
@@ -122,6 +147,15 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinWidget,
},
{
kind: "now_playing",
name: "Now Playing",
description: "Only sessions actively playing media.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinNowPlayingWidget,
},
],
},
nextcloud: {
@@ -0,0 +1,53 @@
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,41 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { SectionCard } from "../components/SectionCard";
import { useWidgetData } from "../hooks/useWidgets";
import type { NowPlayingSession, WidgetInstance } from "../types";
interface Props {
widget: WidgetInstance;
refreshIntervalMs: number;
description?: string;
}
export function JellyfinNowPlayingWidget({
widget,
refreshIntervalMs,
description,
}: Props) {
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
return (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : Array.isArray(sessions) ? (
<SessionActivityPanel
sessions={sessions}
emptyMessage="No one is playing right now."
/>
) : null}
</SectionCard>
);
}
@@ -0,0 +1,78 @@
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();
});
});
@@ -0,0 +1,89 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { JellyfinNowPlayingWidget } from "../JellyfinNowPlayingWidget";
import type { WidgetInstance } from "../../types";
vi.mock("../../hooks/useWidgets", () => ({
useWidgetData: vi.fn(),
}));
const widget: WidgetInstance = {
id: "w1",
service_id: "s1",
widget_kind: "now_playing",
title: "Now Playing",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
};
describe("JellyfinNowPlayingWidget", () => {
it("renders sessions when data is present", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w1",
data: {
sessions: [
{
user: "alice",
title: "Movie",
state: "playing",
type: "Movie",
device: "Web",
session_id: "s1",
transcoding: "no",
transcoding_type: "",
},
],
},
fetched_at: 0,
},
isLoading: false,
} as never);
render(
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("alice")).toBeInTheDocument();
expect(screen.getAllByText("Movie").length).toBeGreaterThan(0);
});
it("renders the now-playing empty message when no sessions", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w1",
data: { sessions: [] },
fetched_at: 0,
},
isLoading: false,
} as never);
render(
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(
screen.getByText("No one is playing right now."),
).toBeInTheDocument();
});
it("renders error state", async () => {
const { useWidgetData } = await import("../../hooks/useWidgets");
vi.mocked(useWidgetData).mockReturnValue({
data: {
widget_id: "w1",
error: "Connection failed",
fetched_at: 0,
},
isLoading: false,
} as never);
render(
<JellyfinNowPlayingWidget widget={widget} refreshIntervalMs={30000} />,
);
expect(screen.getByText("Connection failed")).toBeInTheDocument();
});
});