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