+
+
+
diff --git a/web/src/components/dashboards/kiosk/PlaylistPlayer.spec.ts b/web/src/components/dashboards/kiosk/PlaylistPlayer.spec.ts
new file mode 100644
index 0000000000..ae03d00158
--- /dev/null
+++ b/web/src/components/dashboards/kiosk/PlaylistPlayer.spec.ts
@@ -0,0 +1,283 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
+import { QueryClient, VueQueryPlugin } from "@tanstack/vue-query";
+import i18n from "@/locales";
+import PlaylistPlayer from "./PlaylistPlayer.vue";
+import type { PlaylistConfig, PlaylistFrameMessage } from "@/utils/dashboard/playlist";
+
+const { listMock } = vi.hoisted(() => ({ listMock: vi.fn() }));
+
+vi.mock("@/services/dashboards", async (importOriginal) => {
+ const actual: any = await importOriginal();
+ return { ...actual, default: { ...actual.default, list: listMock } };
+});
+
+vi.mock("@/services/health", () => ({ pingBackend: vi.fn().mockResolvedValue(true) }));
+
+vi.mock("vue-router", async (importOriginal) => {
+ const actual: any = await importOriginal();
+ return {
+ ...actual,
+ useRouter: () => ({
+ resolve: ({ path, query }: { path: string; query: Record }) => ({
+ href: `/web${path}?${new URLSearchParams(query).toString()}`,
+ }),
+ }),
+ };
+});
+
+const config: PlaylistConfig = {
+ org: "acme",
+ entries: [
+ { dashboardId: "d1", folderId: "default" },
+ { dashboardId: "d2", folderId: "ops" },
+ { dashboardId: "d3", folderId: "ops" },
+ ],
+ intervalSeconds: 60,
+ slideIndex: 0,
+ period: "1h",
+ theme: "dark",
+ fit: true,
+ timezone: null,
+};
+
+const rows: Record = {
+ default: [
+ { dashboard_id: "d1", title: "Overview", folder_id: "default", folder_name: "Default" },
+ ],
+ ops: [
+ { dashboard_id: "d2", title: "Payments", folder_id: "ops", folder_name: "Ops" },
+ { dashboard_id: "d3", title: "Checkout", folder_id: "ops", folder_name: "Ops" },
+ ],
+};
+
+const source = "o2-tv-playlist" as const;
+type Body = PlaylistFrameMessage extends infer M
+ ? M extends PlaylistFrameMessage
+ ? Omit
+ : never
+ : never;
+
+const frames = () => [...document.body.querySelectorAll("iframe")];
+const frameFor = (index: number) =>
+ document.body.querySelector(
+ `[data-test="dashboard-playlist-frame-${index}"]`,
+ )!;
+const q = (testId: string) => document.body.querySelector(`[data-test="${testId}"]`);
+const text = (testId: string) => q(testId)?.textContent?.replace(/\s+/g, " ").trim();
+
+const post = async (frame: HTMLIFrameElement, body: Body, origin = window.location.origin) => {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { source, ...body },
+ origin,
+ source: frame.contentWindow,
+ }),
+ );
+ await flushPromises();
+};
+
+describe("PlaylistPlayer", () => {
+ let wrapper: VueWrapper | null = null;
+
+ const mountPlayer = async (props: Record = {}) => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ wrapper = mount(PlaylistPlayer, {
+ props: { config, ...props },
+ attachTo: document.body,
+ global: { plugins: [i18n, [VueQueryPlugin, { queryClient }]] },
+ });
+ await flushPromises();
+ return wrapper;
+ };
+
+ beforeEach(() => {
+ listMock.mockReset();
+ listMock.mockImplementation(async (...args: any[]) => ({
+ data: { dashboards: rows[args[6]] ?? [] },
+ }));
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = null;
+ document.body.innerHTML = "";
+ vi.unstubAllGlobals();
+ });
+
+ it("loads the first slide in an inert hidden frame and names it while it starts", async () => {
+ await mountPlayer();
+
+ expect(frames()).toHaveLength(1);
+ const url = new URL(frameFor(0).src, window.location.origin);
+ expect(url.pathname).toBe("/web/dashboards/view");
+ expect(Object.fromEntries(url.searchParams)).toMatchObject({
+ dashboard: "d1",
+ folder: "default",
+ kiosk: "true",
+ embed: "true",
+ period: "1h",
+ theme: "dark",
+ fit: "true",
+ refresh: "5m",
+ });
+ expect(frameFor(0).dataset.role).toBe("pending");
+ expect(frameFor(0).hasAttribute("inert")).toBe(true);
+ expect(text("dashboard-playlist-holding-eyebrow")).toBe("Starting");
+ expect(text("dashboard-playlist-holding-title")).toBe("Overview");
+ expect(text("dashboard-playlist-holding-label")).toBe("1 of 3");
+ expect(text("dashboard-playlist-title")).toBe("Overview");
+ });
+
+ it("shows the slide once its frame reports ready", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "ready", title: "Overview" });
+
+ expect(frameFor(0).dataset.role).toBe("shown");
+ expect(q("dashboard-playlist-holding")).toBeNull();
+ expect(text("dashboard-playlist-current")).toBe("1");
+ expect(text("dashboard-playlist-position")).toBe("of 3");
+ expect(wrapper!.emitted("slide")).toEqual([[0]]);
+ });
+
+ it("ignores messages from another origin or from a window that is not one of its frames", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "ready", title: "Evil" }, "https://evil.example");
+ expect(frameFor(0).dataset.role).toBe("pending");
+
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { source, type: "ready", title: "Self" },
+ origin: window.location.origin,
+ source: window,
+ }),
+ );
+ await flushPromises();
+ expect(frameFor(0).dataset.role).toBe("pending");
+ });
+
+ it("next acts at once: the target loads behind a named holding card, then swaps in", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "ready", title: "Overview" });
+
+ q("dashboard-playlist-next")!.click();
+ await flushPromises();
+ expect(frameFor(1).dataset.role).toBe("pending");
+ expect(text("dashboard-playlist-holding-eyebrow")).toBe("Loading");
+ expect(text("dashboard-playlist-holding-title")).toBe("Payments");
+ expect(text("dashboard-playlist-holding-label")).toBe("2 of 3");
+
+ await post(frameFor(1), { type: "ready", title: "Payments" });
+ expect(frameFor(1).dataset.role).toBe("shown");
+ expect(q("dashboard-playlist-holding")).toBeNull();
+ expect(wrapper!.emitted("slide")).toEqual([[0], [1]]);
+ });
+
+ it("keeps failing panels and slide chips out of the strip", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "ready", title: "Overview" });
+ await post(frameFor(0), {
+ type: "health",
+ health: { level: "warn", failing: 2, outage: false, lastSuccessAt: Date.now() },
+ });
+ expect(q("dashboard-playlist-strip")!.textContent).not.toContain("failing");
+ expect(q("dashboard-playlist-context")).toBeNull();
+ });
+
+ it("skips a missing dashboard under its real name and counts only playable slides", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "missing", reason: "notFound" });
+
+ expect(frameFor(1).dataset.role).toBe("pending");
+
+ await post(frameFor(1), { type: "ready", title: "Payments" });
+ expect(text("dashboard-playlist-current")).toBe("1");
+ expect(text("dashboard-playlist-position")).toBe("of 2");
+ expect(q("dashboard-playlist-position")!.getAttribute("tabindex")).toBe("0");
+ });
+
+ it("says when the link's interval was adjusted", async () => {
+ await mountPlayer({ intervalAdjusted: true });
+ expect(text("dashboard-playlist-interval-notice")).toContain("Interval adjusted to 1 min");
+ });
+
+ it("falls back to page mode only when the response forbids framing", async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ headers: new Headers({ "X-Frame-Options": "DENY" }),
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ await mountPlayer();
+ const frame = frameFor(0);
+ Object.defineProperty(frame, "contentDocument", { get: () => null });
+
+ frame.dispatchEvent(new Event("load"));
+ await flushPromises();
+
+ expect(fetchMock).toHaveBeenCalledWith(frame.src, expect.anything());
+ expect(wrapper!.emitted("fallback")).toEqual([[0]]);
+ });
+
+ it("treats an unreadable frame without framing headers as a load failure, not a block", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("Failed to fetch")));
+ await mountPlayer();
+ const frame = frameFor(0);
+ Object.defineProperty(frame, "contentDocument", { get: () => null });
+
+ frame.dispatchEvent(new Event("load"));
+ await flushPromises();
+
+ expect(wrapper!.emitted("fallback")).toBeUndefined();
+ expect(wrapper!.vm.player.state.slides[0].loadFailed).toBe(true);
+ });
+
+ it("says the backend is unreachable on the visible slide, with the age of what it shows", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "ready", title: "Overview" });
+ const updated = new Date(2026, 9, 14, 9, 41, 0).getTime();
+ await post(frameFor(0), {
+ type: "health",
+ health: { level: "ok", failing: 0, outage: false, lastSuccessAt: updated },
+ });
+
+ window.dispatchEvent(new Event("offline"));
+ await flushPromises();
+
+ expect(text("dashboard-playlist-held")).toBe("Backend unreachable");
+ // vue-query's online manager is global; leave it online for the next test.
+ window.dispatchEvent(new Event("online"));
+ });
+
+ it("holds on an outage, and pauses and exits from the strip, with no edit on the TV", async () => {
+ await mountPlayer();
+ await post(frameFor(0), { type: "ready", title: "Overview" });
+ await post(frameFor(0), {
+ type: "health",
+ health: { level: "bad", failing: 3, outage: true, lastSuccessAt: 1 },
+ });
+ expect(q("dashboard-playlist-held")).not.toBeNull();
+
+ q("dashboard-playlist-pause")!.click();
+ await flushPromises();
+ expect(wrapper!.vm.player.state.paused).toBe(true);
+ expect(q("dashboard-playlist-pause")!.getAttribute("aria-label")).toBe("Resume rotation");
+
+ q("dashboard-playlist-exit")!.click();
+ expect(wrapper!.emitted("exit")).toHaveLength(1);
+ expect(q("dashboard-playlist-edit")).toBeNull();
+ });
+});
diff --git a/web/src/components/dashboards/kiosk/PlaylistPlayer.vue b/web/src/components/dashboards/kiosk/PlaylistPlayer.vue
new file mode 100644
index 0000000000..695843be05
--- /dev/null
+++ b/web/src/components/dashboards/kiosk/PlaylistPlayer.vue
@@ -0,0 +1,340 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/dashboards/kiosk/PlaylistStrip.spec.ts b/web/src/components/dashboards/kiosk/PlaylistStrip.spec.ts
new file mode 100644
index 0000000000..6876fce693
--- /dev/null
+++ b/web/src/components/dashboards/kiosk/PlaylistStrip.spec.ts
@@ -0,0 +1,145 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mount } from "@vue/test-utils";
+import i18n from "@/locales";
+import { raw } from "@/types/i18n";
+import OBadge from "@/lib/core/Badge/OBadge.vue";
+import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
+import PlaylistStrip from "./PlaylistStrip.vue";
+import KioskCountdownRing from "./KioskCountdownRing.vue";
+
+const NOW = Date.UTC(2026, 9, 14, 12, 5, 0);
+const UPDATED = NOW - 2 * 60_000;
+
+const mountStrip = (props: Record = {}) =>
+ mount(PlaylistStrip, {
+ props: {
+ title: raw("Kubernetes overview"),
+ current: 2,
+ total: 5,
+ nextName: raw("Ingest pipeline"),
+ progress: 0.25,
+ nextAt: NOW + 8_000,
+ timeRangeLabel: raw("Past 1 Hour"),
+ lastSuccessAt: UPDATED,
+ timezone: "UTC",
+ ...props,
+ },
+ global: { plugins: [i18n] },
+ });
+
+const byTest = (wrapper: ReturnType, id: string) =>
+ wrapper.find(`[data-test="dashboard-playlist-${id}"]`);
+
+const tooltips = (wrapper: ReturnType) =>
+ wrapper.findAllComponents(OTooltip).map((tip) => String(tip.props("content")));
+
+describe("PlaylistStrip", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("shows the name, then the rotation, range and relative update time grouped right", () => {
+ const wrapper = mountStrip();
+ expect(byTest(wrapper, "title").text()).toBe("Kubernetes overview");
+ const meta = byTest(wrapper, "meta");
+ expect(meta.find('[data-test="dashboard-playlist-current"]').text()).toBe("2");
+ expect(meta.find('[data-test="dashboard-playlist-position"]').text()).toBe("of 5");
+ expect(meta.find('[data-test="dashboard-playlist-time-range"]').text()).toContain(
+ "Past 1 Hour",
+ );
+ expect(meta.find('[data-test="dashboard-playlist-updated"]').text()).toBe("Updated 2m ago");
+ expect(tooltips(wrapper)).toContain("Last refreshed: 12:03:00");
+ });
+
+ it("fills the rotation ring toward the next slide and names it in the tooltip", () => {
+ const ring = mountStrip().findComponent(KioskCountdownRing);
+ expect(ring.props("state")).toBe("running");
+ expect(ring.props("progress")).toBe(0.25);
+ expect(String(ring.props("tooltip"))).toBe("Next: Ingest pipeline at 12:05:08");
+ expect(ring.attributes("aria-label")).toBe("Next: Ingest pipeline at 12:05:08");
+ });
+
+ it("shows a static pause glyph on the ring while paused, and the time it was paused", () => {
+ const wrapper = mountStrip({ paused: true, pausedAt: NOW - 60_000 });
+ const ring = wrapper.findComponent(KioskCountdownRing);
+ expect(ring.props("state")).toBe("paused");
+ expect(String(ring.props("tooltip"))).toBe("Rotation paused since 12:04");
+ expect(byTest(wrapper, "current").exists()).toBe(false);
+ expect(byTest(wrapper, "pause").attributes("aria-label")).toBe("Resume rotation");
+ });
+
+ it("spins the ring while the next slide is still loading", () => {
+ const ring = mountStrip({ waiting: true }).findComponent(KioskCountdownRing);
+ expect(ring.props("state")).toBe("spinning");
+ expect(String(ring.props("tooltip"))).toBe("Loading the next dashboard");
+ });
+
+ it("shows only an unreachable backend as status, and holds the ring", () => {
+ expect(mountStrip().findComponent(OBadge).exists()).toBe(false);
+ const wrapper = mountStrip({ held: true });
+ expect(byTest(wrapper, "held").text()).toBe("Backend unreachable");
+ const ring = wrapper.findComponent(KioskCountdownRing);
+ expect(ring.props("state")).toBe("paused");
+ expect(String(ring.props("tooltip"))).toBe("Rotation held: backend unreachable");
+ });
+
+ it("lists skipped dashboards in the position tooltip only when there are any", () => {
+ expect(tooltips(mountStrip()).some((tip) => tip.startsWith("Skipped"))).toBe(false);
+ const wrapper = mountStrip({ skipped: [raw("Skipped Old: dashboard not found")] });
+ expect(tooltips(wrapper)).toContain("Skipped Old: dashboard not found");
+ });
+
+ it("keeps prev, pause, next and exit in the header, always visible", async () => {
+ const wrapper = mountStrip();
+ for (const id of ["prev", "pause", "next", "exit"]) {
+ expect(byTest(wrapper, id).isVisible()).toBe(true);
+ }
+ expect(byTest(wrapper, "edit").exists()).toBe(false);
+ await byTest(wrapper, "prev").trigger("click");
+ await byTest(wrapper, "pause").trigger("click");
+ await byTest(wrapper, "next").trigger("click");
+ await byTest(wrapper, "exit").trigger("click");
+ expect(Object.keys(wrapper.emitted())).toEqual(
+ expect.arrayContaining(["prev", "toggle-pause", "next", "exit"]),
+ );
+ });
+
+ it("is one compact row the same height as the TV mode strip, with small controls", () => {
+ const wrapper = mountStrip();
+ expect(wrapper.find('[data-test="dashboard-playlist-strip"] > div').classes()).toContain(
+ "h-11",
+ );
+ for (const id of ["prev", "pause", "next", "exit"]) {
+ expect(byTest(wrapper, id).classes()).toEqual(expect.arrayContaining(["h-8", "w-8"]));
+ }
+ });
+
+ it("carries no tab or variable chips: those stay in the bar above the panels", () => {
+ const wrapper = mountStrip();
+ expect(wrapper.findAllComponents(OBadge)).toHaveLength(0);
+ expect(wrapper.find('[data-test^="dashboard-playlist-context"]').exists()).toBe(false);
+ });
+
+ it("drops the time range when no shared range is set", () => {
+ expect(byTest(mountStrip({ timeRangeLabel: null }), "time-range").exists()).toBe(false);
+ });
+});
diff --git a/web/src/components/dashboards/kiosk/PlaylistStrip.vue b/web/src/components/dashboards/kiosk/PlaylistStrip.vue
new file mode 100644
index 0000000000..b6dfd63dca
--- /dev/null
+++ b/web/src/components/dashboards/kiosk/PlaylistStrip.vue
@@ -0,0 +1,209 @@
+
+
+
+
@@ -93,6 +114,7 @@ along with this program. If not, see .
@onDeletePanel="onDeletePanel"
@onViewPanel="onViewPanel"
:viewOnly="viewOnly"
+ :kiosk="kiosk"
:layoutMode="layoutMode"
:data="panels[0] || {}"
:dashboardId="dashboardData.dashboardId"
@@ -192,6 +214,7 @@ along with this program. If not, see .
@onDeletePanel="onDeletePanel"
@onViewPanel="onViewPanel"
:viewOnly="viewOnly"
+ :kiosk="kiosk"
:layoutMode="layoutMode"
:data="item"
:dashboardId="dashboardData.dashboardId"
@@ -268,6 +291,7 @@ along with this program. If not, see .
"
:initialVariableValues="initialVariableValues"
class="panel-variables-margin mb-2"
+ :inert="readOnlySelectors || undefined"
data-test="panel-variables-selector"
/>
@@ -356,6 +380,7 @@ import {
} from "@/composables/dashboard/useDashboardTimeGate";
import { useLoading } from "@/composables/useLoading";
import { GridStack } from "gridstack";
+import { computeFitCellHeight } from "@/utils/dashboard/kiosk";
import {
arePickerValuesEqual,
convertTimeObjToPickerFormat,
@@ -369,6 +394,12 @@ import ViewPanel from "@/components/dashboards/viewPanel/ViewPanel.vue";
import type { LayoutMode } from "@/components/dashboards/layoutMode";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
+// Must match the cellHeight GridStack is initialised with below.
+const DEFAULT_CELL_HEIGHT_PX = 17;
+const FIT_BOTTOM_GAP_PX = 8;
+// Drops the pickers' stacking margins so they sit in the one kiosk row.
+const KIOSK_VARIABLES_CLASS = "mt-0! [&_.mt-1]:mt-0!";
+
export default defineComponent({
name: "RenderDashboardCharts",
emits: [
@@ -451,6 +482,21 @@ export default defineComponent({
type: String as PropType,
default: "live",
},
+ /** Wall-screen display: shows failed panels loudly and turns off drilldown and context menus. */
+ kiosk: {
+ type: Boolean,
+ default: false,
+ },
+ /** Shows the tab bar and variable pickers with their values but takes them out of interaction. */
+ readOnlySelectors: {
+ type: Boolean,
+ default: false,
+ },
+ /** Scales the grid's cell height so every row fits the visible height without scrolling. */
+ fitToScreen: {
+ type: Boolean,
+ default: false,
+ },
},
components: {
@@ -469,6 +515,7 @@ export default defineComponent({
const router = useRouter();
const store = useStore();
const gridStackContainer = ref(null);
+ const dashboardScrollRef = ref(null);
// True while a panel is being dragged or resized — drives the grid backdrop.
const isGridInteracting = ref(false);
const isLayoutEditing = computed(() => props.layoutMode === "edit");
@@ -594,6 +641,17 @@ export default defineComponent({
);
});
+ const kioskHasTabs = computed(() => (props.dashboardData?.tabs?.length ?? 0) > 1);
+
+ const kioskControlsVisible = computed(
+ () =>
+ kioskHasTabs.value ||
+ !!props.dashboardData?.variables?.showDynamicFilters ||
+ [...globalVariables.value, ...currentTabVariables.value].some(
+ (v: any) => !v.hideOnDashboard,
+ ),
+ );
+
// Helper to get panel-scoped variables
const getPanelVariables = (panelId: string) => {
return (
@@ -1095,12 +1153,50 @@ export default defineComponent({
grid.batchUpdate(false);
gridStackUpdateInProgress = false;
+ applyFitToScreen();
window.dispatchEvent(new Event("resize"));
// Re-setup panel observers for the new panels
await setupPanelObservers();
};
+ // Only ever touches the grid once fit has been asked for, so a normal dashboard keeps GridStack's own sizing.
+ let fitApplied = false;
+ const applyFitToScreen = () => {
+ if (!props.fitToScreen && !fitApplied) return;
+ const grid = gridStackInstance;
+ const root = dashboardScrollRef.value;
+ const host = gridStackContainer.value;
+ if (!grid || !root || !(host instanceof HTMLElement)) return;
+ fitApplied = props.fitToScreen;
+ const target = props.fitToScreen
+ ? computeFitCellHeight(
+ root.clientHeight -
+ (host.getBoundingClientRect().top - root.getBoundingClientRect().top) -
+ FIT_BOTTOM_GAP_PX,
+ grid.getRow(),
+ )
+ : DEFAULT_CELL_HEIGHT_PX;
+ if (!target || grid.getCellHeight() === target) return;
+ grid.cellHeight(target);
+ window.dispatchEvent(new Event("resize"));
+ };
+
+ let fitResizeObserver: ResizeObserver | null = null;
+ const observeFit = () => {
+ fitResizeObserver?.disconnect();
+ fitResizeObserver = null;
+ if (props.fitToScreen && dashboardScrollRef.value && typeof ResizeObserver !== "undefined") {
+ fitResizeObserver = new ResizeObserver(() => applyFitToScreen());
+ fitResizeObserver.observe(dashboardScrollRef.value);
+ // The tab bar and variable pickers above the grid can grow after load, which moves the grid down.
+ const content = dashboardScrollRef.value.firstElementChild;
+ if (content) fitResizeObserver.observe(content);
+ }
+ applyFitToScreen();
+ };
+ watch(() => props.fitToScreen, observeFit, { flush: "post" });
+
// Add a method to reset grid layout
const resetGridLayout = async () => {
if (!gridStackInstance) return;
@@ -1288,6 +1384,7 @@ export default defineComponent({
await nextTick(); // Wait for DOM to be ready
initGridStack(); // Initialize the grid system
await nextTick(); // Wait for grid initialization to complete
+ if (props.fitToScreen) observeFit();
// Set up IntersectionObserver for panel visibility (for lazy loading panel-scoped variables)
await setupPanelObservers();
@@ -1472,6 +1569,8 @@ export default defineComponent({
// Final cleanup when component is fully unmounted
onUnmounted(() => {
+ fitResizeObserver?.disconnect();
+ fitResizeObserver = null;
if (gridStackInstance) {
gridStackInstance.destroy(false);
gridStackInstance = null;
@@ -1832,7 +1931,11 @@ export default defineComponent({
);
return {
+ KIOSK_VARIABLES_CLASS,
+ kioskHasTabs,
+ kioskControlsVisible,
store,
+ dashboardScrollRef,
addPanelData,
t,
getPanelLayout,
diff --git a/web/src/views/Dashboards/TvPlaylist.spec.ts b/web/src/views/Dashboards/TvPlaylist.spec.ts
new file mode 100644
index 0000000000..15b51de527
--- /dev/null
+++ b/web/src/views/Dashboards/TvPlaylist.spec.ts
@@ -0,0 +1,145 @@
+// Copyright 2026 OpenObserve Inc.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { flushPromises, shallowMount, type VueWrapper } from "@vue/test-utils";
+import i18n from "@/locales";
+import store from "@/test/unit/helpers/store";
+import TvPlaylist from "./TvPlaylist.vue";
+
+const { route, replace } = vi.hoisted(() => ({
+ route: { path: "/dashboards/tv", query: {} as Record },
+ replace: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("vue-router", async (importOriginal) => {
+ const actual: any = await importOriginal();
+ return {
+ ...actual,
+ useRoute: () => route,
+ useRouter: () => ({
+ replace,
+ resolve: ({ path, query }: { path: string; query: Record }) => ({
+ href: `/web${path}?${new URLSearchParams(query).toString()}`,
+ }),
+ }),
+ };
+});
+
+describe("TvPlaylist view", () => {
+ let wrapper: VueWrapper | null = null;
+ let dispatch: ReturnType;
+
+ const mountView = async (query: Record) => {
+ route.query = query;
+ wrapper = shallowMount(TvPlaylist, { global: { plugins: [i18n, store] } });
+ await flushPromises();
+ return wrapper;
+ };
+
+ beforeEach(() => {
+ replace.mockClear();
+ dispatch = vi.spyOn(store, "dispatch");
+ store.state.selectedOrganization = { ...store.state.selectedOrganization, identifier: "acme" };
+ });
+
+ afterEach(() => {
+ wrapper?.unmount();
+ wrapper = null;
+ dispatch.mockRestore();
+ });
+
+ it("explains a link without dashboards and never enters kiosk", async () => {
+ await mountView({ playlist: ",," });
+ expect(wrapper!.find('[data-test="dashboard-tv-invalid"]').exists()).toBe(true);
+ expect(dispatch).not.toHaveBeenCalledWith("setKioskMode", true);
+ });
+
+ it("enters kiosk with the link's theme and plays the parsed playlist", async () => {
+ await mountView({ playlist: "d1:default,d2:ops", interval: "30s", slide: "2", theme: "dark" });
+
+ expect(dispatch).toHaveBeenCalledWith("setKioskMode", true);
+ expect(store.state.themeOverride).toBe("dark");
+ const player = wrapper!.findComponent({ name: "PlaylistPlayer" });
+ expect(player.props("config")).toMatchObject({
+ org: "acme",
+ intervalSeconds: 30,
+ slideIndex: 1,
+ entries: [
+ { dashboardId: "d1", folderId: "default" },
+ { dashboardId: "d2", folderId: "ops" },
+ ],
+ });
+ });
+
+ it("keeps the shown slide in the URL so a reload resumes there", async () => {
+ await mountView({ org_identifier: "acme", playlist: "d1,d2", interval: "1m" });
+ wrapper!.findComponent({ name: "PlaylistPlayer" }).vm.$emit("slide", 1);
+
+ expect(replace).toHaveBeenCalledWith({
+ path: "/dashboards/tv",
+ query: {
+ org_identifier: "acme",
+ playlist: "d1:default,d2:default",
+ interval: "1m",
+ slide: "2",
+ },
+ });
+ });
+
+ it("falls back to a full page per slide when framing is blocked", async () => {
+ const locationReplace = vi.fn();
+ const realLocation = window.location;
+ Object.defineProperty(window, "location", {
+ value: { ...realLocation, origin: realLocation.origin, replace: locationReplace },
+ configurable: true,
+ });
+ try {
+ await mountView({ org_identifier: "acme", playlist: "d1,d2", interval: "1m" });
+ wrapper!.findComponent({ name: "PlaylistPlayer" }).vm.$emit("fallback", 1);
+
+ const url = new URL(locationReplace.mock.calls[0][0]);
+ expect(url.pathname).toBe("/web/dashboards/view");
+ expect(Object.fromEntries(url.searchParams)).toMatchObject({
+ dashboard: "d2",
+ kiosk: "true",
+ playlist: "d1:default,d2:default",
+ slide: "2",
+ tvpage: "1",
+ });
+ } finally {
+ Object.defineProperty(window, "location", { value: realLocation, configurable: true });
+ }
+ });
+
+ it("offers no edit on the TV: the player gets no dialog and no hold", async () => {
+ await mountView({ org_identifier: "acme", playlist: "d1,d2", interval: "1m" });
+ expect(wrapper!.findComponent({ name: "PlaylistDialog" }).exists()).toBe(false);
+ expect(wrapper!.findComponent({ name: "PlaylistPlayer" }).props()).not.toHaveProperty("hold");
+ });
+
+ it("exits to the dashboard list and leaves kiosk on the way out", async () => {
+ await mountView({ org_identifier: "acme", playlist: "d1" });
+ wrapper!.findComponent({ name: "PlaylistPlayer" }).vm.$emit("exit");
+ expect(replace).toHaveBeenCalledWith({
+ path: "/dashboards",
+ query: { org_identifier: "acme" },
+ });
+
+ wrapper!.unmount();
+ wrapper = null;
+ expect(dispatch).toHaveBeenCalledWith("setKioskMode", false);
+ });
+});
diff --git a/web/src/views/Dashboards/TvPlaylist.vue b/web/src/views/Dashboards/TvPlaylist.vue
new file mode 100644
index 0000000000..56eb354894
--- /dev/null
+++ b/web/src/views/Dashboards/TvPlaylist.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/views/Dashboards/ViewDashboard.vue b/web/src/views/Dashboards/ViewDashboard.vue
index 39f1c5faa7..091c36f47a 100644
--- a/web/src/views/Dashboards/ViewDashboard.vue
+++ b/web/src/views/Dashboards/ViewDashboard.vue
@@ -27,16 +27,20 @@ along with this program. If not, see .
},
isFullscreen ? 'bg-surface-base!' : '',
store.state.printMode === true ? 'pb-6' : '',
+ kioskMode && !kioskPointerActive ? 'cursor-none [&_*]:cursor-none!' : '',
]"
class="h-full"
+ :data-kiosk="kioskMode ? 'true' : undefined"
>
.
@timezone-change="timeGate.flush"
data-test="dashboard-global-date-time-picker"
/>
+
@@ -291,11 +296,11 @@ along with this program. If not, see .
@@ -354,6 +359,13 @@ along with this program. If not, see .
shortcut-id="dashboardFullscreen"
/>
+ .
+
+
+
+
.
@refreshedVariablesDataUpdated="refreshedVariablesDataUpdated"
@variablesManagerReady="onVariablesManagerReady"
:initialVariableValues="initialVariableValues"
- :viewOnly="store.state.printMode"
+ :viewOnly="store.state.printMode || kioskMode"
+ :kiosk="kioskMode"
+ :readOnlySelectors="playlistEmbed || playlistPageActive"
+ :fitToScreen="kioskMode && kioskFit"
:layoutMode="layoutMode"
@layoutChange="onLayoutChange"
:dashboardData="currentDashboardData.data"
@@ -656,6 +691,38 @@ import { isInputFocused, isMacOS } from "@/utils/keyboardShortcuts";
import useBreakpoint from "@/composables/useBreakpoint";
import { queryClient } from "@/composables/query/queryClient";
import { annotationKeys } from "@/services/dashboard_annotations.querykeys";
+import KioskStrip from "@/components/dashboards/kiosk/KioskStrip.vue";
+import KioskSetupPopover from "@/components/dashboards/kiosk/KioskSetupPopover.vue";
+import { KIOSK_PANEL_HEALTH_KEY, useKioskRefresh } from "@/composables/dashboard/useKioskRefresh";
+import {
+ applyKioskTheme,
+ clearKioskTheme,
+ useIdleReveal,
+} from "@/composables/dashboard/useKioskDisplay";
+import {
+ buildKioskQuery,
+ formatRelativePeriod,
+ isFitEnabled,
+ isKioskEnabled,
+ parseKioskTheme,
+ pickKioskParams,
+ resolveKioskRefresh,
+ stripKioskParams,
+} from "@/utils/dashboard/kiosk";
+import {
+ PLAYLIST_VIEW_PATH,
+ isPlaylistEmbed,
+ isPlaylistPage,
+ missingReasonFor,
+ parsePlaylistMisses,
+ parsePlaylistQuery,
+ pickPlaylistParams,
+ stripPlaylistParams,
+} from "@/utils/dashboard/playlist";
+import { usePlaylistFrame, usePlaylistPage } from "@/composables/dashboard/usePlaylistFrame";
+import PlaylistHoldingCard from "@/components/dashboards/kiosk/PlaylistHoldingCard.vue";
+import { pingBackend } from "@/services/health";
+import { raw } from "@/types/i18n";
const DashboardJsonEditor = defineAsyncComponent(() => {
return import("./DashboardJsonEditor.vue");
@@ -694,6 +761,9 @@ export default defineComponent({
OBadge,
OSeparator,
ODialog,
+ KioskStrip,
+ KioskSetupPopover,
+ PlaylistHoldingCard,
},
setup() {
const { t } = useI18nTyped();
@@ -705,6 +775,31 @@ export default defineComponent({
data: {},
});
const showScheduledReportsDialog = ref(false);
+
+ // Set before the first render so a reloaded TV never flashes the app chrome.
+ if (isKioskEnabled(route.query.kiosk) && !store.state.kioskMode) {
+ store.dispatch("setKioskMode", true);
+ }
+ const kioskMode = computed(() => store.state.kioskMode === true);
+ const kioskFit = computed(() => isFitEnabled(route.query.fit));
+ const { active: kioskPointerActive } = useIdleReveal(kioskMode);
+
+ // One slide of a TV playlist: inside the player's frame, or a full page when framing is blocked.
+ const playlistEmbed =
+ isKioskEnabled(route.query.kiosk) &&
+ isPlaylistEmbed(route.query) &&
+ typeof window !== "undefined" &&
+ window.parent !== window;
+ const playlistPageConfig =
+ isKioskEnabled(route.query.kiosk) && !playlistEmbed && isPlaylistPage(route.query)
+ ? parsePlaylistQuery(route.query)
+ : null;
+ // Filled in once the kiosk health exists; a playlist slide never falls back to the dashboard list.
+ const playlistReporters = {
+ missing: (_reason: "notFound" | "forbidden") => false,
+ error: () => false,
+ };
+
const { showPositiveNotification, showErrorNotification } = useNotifications();
const saveConflict = useDashboardSaveConflict();
@@ -851,7 +946,9 @@ export default defineComponent({
provide("selectedTabId", selectedTabId);
const { isAutoRunAvailable, isAutoRunOn, toggleAutoRun } = useAutoRunToggle();
- provide("dashboardAutoRun", isAutoRunOn);
+ // A wall screen has no Refresh button to apply a variable change, so kiosk applies it live.
+ const applyVariablesLive = computed(() => isAutoRunOn.value || kioskMode.value);
+ provide("dashboardAutoRun", applyVariablesLive);
const showRangeInPicker = (value: any) =>
dateTimePicker.value?.setSavedDate(toPickerSavedDate(value, store.state.timezone));
@@ -932,7 +1029,7 @@ export default defineComponent({
// Explicitly dereference to ensure Vue tracks the dependency
const manager = variablesManager.value;
- if (isAutoRunOn.value) return false;
+ if (applyVariablesLive.value) return false;
if (manager && "hasUncommittedChanges" in manager) {
// Access the value (Vue auto-unwraps computed refs in composable returns)
@@ -1084,11 +1181,16 @@ export default defineComponent({
);
if (!dashboard || typeof dashboard !== "object" || !Object.keys(dashboard).length) {
+ if (playlistReporters.missing("notFound")) return;
showErrorNotification(t("dashboard.viewDashboard.dashboardNotFound"));
goBackToDashboardList();
return;
}
} catch (error: any) {
+ const missingReason = missingReasonFor(error?.response?.status);
+ if (missingReason ? playlistReporters.missing(missingReason) : playlistReporters.error()) {
+ return;
+ }
showErrorNotification(error?.message || t("dashboard.viewDashboard.failedToLoadDashboard"));
goBackToDashboardList();
return;
@@ -1204,7 +1306,7 @@ export default defineComponent({
const isSavingLayout = ref(false);
const enterLayoutEdit = () => {
- if (store.state.printMode) return;
+ if (store.state.printMode || kioskMode.value) return;
layoutDraft.start();
};
@@ -1376,7 +1478,7 @@ export default defineComponent({
};
provide(DASHBOARD_FRESH_PANEL_TIME, (panelId) => {
- if (!isAutoRunOn.value) return undefined;
+ if (!applyVariablesLive.value) return undefined;
const panel = currentDashboardData.data?.tabs
?.flatMap((tab: any) => tab.panels ?? [])
.find((p: any) => p.id === panelId);
@@ -1685,43 +1787,120 @@ export default defineComponent({
});
const refreshData = async (withoutCache = false) => {
- if (!arePanelsLoading.value) {
- // CRITICAL FIX: Clear panelIdToBeRefreshed for global refresh
- // This allows all panels to refresh, not just the one previously refreshed
- panelIdToBeRefreshed.value = null;
-
- // A global refresh overrides every per-panel choice; panels read the flag when their query fires.
- shouldRefreshWithoutCachePerPanel.value = { __global: withoutCache === true };
-
- // Annotations added elsewhere never expire this tab's cache, and a fixed range keeps the same key.
- if (dashboardId.value) {
- void queryClient.invalidateQueries({
- queryKey: annotationKeys.dashboard(
- store.state.selectedOrganization.identifier,
- String(dashboardId.value),
- ),
- refetchType: "none",
- });
- }
-
- // Generate new run ID for whole dashboard refresh
- generateNewDashboardRunId();
-
- // Sync panel-level datetime pickers FIRST (updates URL)
- await renderDashboardChartsRef.value?.syncAllPanelDateTimePickers();
-
- // Applied before the recompute below, which reads the applied range and not the picker.
- timeGate.refresh();
-
- // Recompute all panel times after syncing
- // Pass forceRefresh=true to ensure ALL panels refresh (create new time objects)
- computeAllPanelTimes(true);
-
- // Commit all live variable changes to committed state
- renderDashboardChartsRef.value?.commitAllVariables();
- }
+ if (!arePanelsLoading.value) await runRefresh(withoutCache);
};
+ const runRefresh = async (withoutCache = false) => {
+ // CRITICAL FIX: Clear panelIdToBeRefreshed for global refresh
+ // This allows all panels to refresh, not just the one previously refreshed
+ panelIdToBeRefreshed.value = null;
+
+ // A global refresh overrides every per-panel choice; panels read the flag when their query fires.
+ shouldRefreshWithoutCachePerPanel.value = { __global: withoutCache === true };
+
+ // Annotations added elsewhere never expire this tab's cache, and a fixed range keeps the same key.
+ if (dashboardId.value) {
+ void queryClient.invalidateQueries({
+ queryKey: annotationKeys.dashboard(
+ store.state.selectedOrganization.identifier,
+ String(dashboardId.value),
+ ),
+ refetchType: "none",
+ });
+ }
+
+ // Generate new run ID for whole dashboard refresh
+ generateNewDashboardRunId();
+
+ // Sync panel-level datetime pickers FIRST (updates URL)
+ await renderDashboardChartsRef.value?.syncAllPanelDateTimePickers();
+
+ // Applied before the recompute below, which reads the applied range and not the picker.
+ timeGate.refresh();
+
+ // Recompute all panel times after syncing
+ // Pass forceRefresh=true to ensure ALL panels refresh (create new time objects)
+ computeAllPanelTimes(true);
+
+ // Commit all live variable changes to committed state
+ renderDashboardChartsRef.value?.commitAllVariables();
+ };
+
+ // Kiosk runs its own watchdog ticker, so the toolbar timer only drives the normal view.
+ const onAutoRefreshTick = () => {
+ if (!kioskMode.value) refreshData();
+ };
+
+ const kioskRefresh = useKioskRefresh({
+ enabled: kioskMode,
+ intervalSeconds: refreshInterval,
+ isLoading: arePanelsLoading,
+ // The first cycle can fire before the picker exists, while the dashboard definition still loads.
+ refresh: () => (dateTimePicker.value ? runRefresh() : undefined),
+ cancel: () => cancelQuery(),
+ probe: pingBackend,
+ });
+ provide(KIOSK_PANEL_HEALTH_KEY, kioskRefresh.registry);
+
+ const playlistFrame = usePlaylistFrame(playlistEmbed, {
+ title: computed(() => currentDashboardData.data?.title),
+ health: kioskRefresh.health,
+ failingCount: kioskRefresh.failingCount,
+ lastSuccessAt: kioskRefresh.lastSuccessAt,
+ });
+ const playlistPage = usePlaylistPage({
+ enabled: !!playlistPageConfig,
+ config: playlistPageConfig,
+ misses: parsePlaylistMisses(route.query),
+ ready: computed(
+ () =>
+ kioskRefresh.lastSuccessAt.value !== null || kioskRefresh.health.value.level === "bad",
+ ),
+ outage: computed(() => kioskRefresh.health.value.level === "bad"),
+ resolveHref: (query) =>
+ window.location.origin + router.resolve({ path: PLAYLIST_VIEW_PATH, query }).href,
+ });
+ playlistReporters.missing = (reason) => {
+ if (!playlistEmbed) return playlistPage.reportMissing(reason);
+ playlistFrame.reportMissing(reason);
+ return true;
+ };
+ playlistReporters.error = () => {
+ if (!playlistEmbed) return playlistPage.reportError();
+ playlistFrame.reportError();
+ return true;
+ };
+ const playlistPosition = computed(() =>
+ playlistPage.active
+ ? t("dashboard.playlist.position", {
+ current: playlistPage.position.value.index + 1,
+ total: playlistPage.position.value.count,
+ })
+ : null,
+ );
+ const playlistHolding = computed(() => {
+ if (!playlistPage.active) return null;
+ const { index, count } = playlistPage.position.value;
+ const waiting =
+ playlistPage.missing.value !== null ||
+ playlistPage.leaving.value ||
+ (kioskRefresh.lastSuccessAt.value === null && kioskRefresh.health.value.level !== "bad");
+ if (!waiting) return null;
+ const name = currentDashboardData.data?.title
+ ? raw(currentDashboardData.data.title)
+ : t("dashboard.playlist.slideName", { number: index + 1 });
+ const missing = playlistPage.missing.value;
+ return {
+ label: t("dashboard.playlist.loadingSlide", { current: index + 1, total: count }),
+ title: currentDashboardData.data?.title ?? null,
+ note: missing
+ ? missing === "forbidden"
+ ? t("dashboard.playlist.skippedForbidden", { name })
+ : t("dashboard.playlist.skippedNotFound", { name })
+ : null,
+ };
+ });
+
const onDataZoom = (event: any) => {
const selectedDateObj = {
start: new Date(event.start),
@@ -1749,7 +1928,13 @@ export default defineComponent({
onMounted(async () => {
const params = route.query;
- if (params.refresh) {
+ if (params.refresh && kioskMode.value) {
+ // A TV must keep refreshing: a too-low value is raised to the server minimum, not turned off.
+ refreshInterval.value = resolveKioskRefresh(
+ params.refresh,
+ store.state?.zoConfig?.min_auto_refresh_interval,
+ );
+ } else if (params.refresh) {
const refreshInSecs = parseDuration(params.refresh);
if (store.state?.zoConfig?.min_auto_refresh_interval) {
if (refreshInSecs < store.state?.zoConfig?.min_auto_refresh_interval) {
@@ -1762,7 +1947,7 @@ export default defineComponent({
// check if timezone query params exist
// will be used for initial time zone only, so remove it from query
- if (params.timezone) {
+ if (params.timezone && !kioskMode.value) {
// get the query params
const query = {
...route.query,
@@ -1777,8 +1962,10 @@ export default defineComponent({
});
}
- // check if print query params exist
- if (params.print !== undefined) {
+ if (kioskMode.value) {
+ setPrint(false);
+ applyKioskTheme(store, parseKioskTheme(route.query.theme));
+ } else if (params.print !== undefined) {
// set print mode
setPrint(params.print == "true" ? true : false);
} else {
@@ -1926,6 +2113,14 @@ export default defineComponent({
// Get global time params - ensure we always have time params
const timeParams = getQueryParamsForDuration(selectedDate.value);
+ // Before the dashboard loads there is no picker value yet, so keep the range the link arrived with.
+ if (!selectedDate.value) {
+ if (route.query.period) timeParams.period = route.query.period;
+ else if (route.query.from && route.query.to) {
+ timeParams.from = route.query.from;
+ timeParams.to = route.query.to;
+ }
+ }
// Preserve the cell-explorer deep-link params so a shared "Copy link" reopens the drawer.
const cellParams: Record = {};
@@ -1956,6 +2151,9 @@ export default defineComponent({
print: store.state.printMode,
searchtype: route.query.searchtype,
...cellParams, // Keep cell-explorer deep link intact
+ ...(kioskMode.value
+ ? { ...pickKioskParams(route.query), ...pickPlaylistParams(route.query) }
+ : {}),
};
// CRITICAL: Only update URL if query has actually changed
@@ -2119,6 +2317,120 @@ export default defineComponent({
}
};
+ // ===== Kiosk / TV mode =====
+ const minRefreshSeconds = computed(() => store.state?.zoConfig?.min_auto_refresh_interval || 5);
+
+ const kioskTimeRange = computed(() => {
+ const relative =
+ selectedDate.value?.valueType === "relative"
+ ? formatRelativePeriod(selectedDate.value?.relativeTimePeriod, t)
+ : null;
+ if (relative) return relative;
+ // Until the picker resolves the range, moment formats it as "Invalid date".
+ const start = currentTimeObj.value?.start_time?.getTime?.();
+ const end = currentTimeObj.value?.end_time?.getTime?.();
+ if (
+ !Number.isFinite(start) ||
+ !Number.isFinite(end) ||
+ timeString.value.includes("Invalid")
+ ) {
+ return raw("");
+ }
+ return raw(timeString.value.replace(/\s+/g, " ").trim());
+ });
+
+ const leaveKioskState = () => {
+ if (store.state.kioskMode) store.dispatch("setKioskMode", false);
+ clearKioskTheme(store);
+ };
+
+ // Set only when TV mode was entered from the toolbar; a TV link opened directly has no prior view to restore.
+ let refreshBeforeKiosk: number | null = null;
+ const restoreRefreshBeforeKiosk = () => {
+ if (refreshBeforeKiosk === null) return;
+ refreshInterval.value = refreshBeforeKiosk;
+ refreshBeforeKiosk = null;
+ };
+
+ const enterKiosk = async (options) => {
+ if (isEditingLayout.value) return;
+ const query = buildKioskQuery(route.query, options);
+ setPrint(false);
+ store.dispatch("setKioskMode", true);
+ applyKioskTheme(store, parseKioskTheme(query.theme));
+ refreshBeforeKiosk = refreshInterval.value;
+ // Pushed, not replaced, so browser Back also leaves TV mode.
+ await router.push({ query });
+ // Only after the push: the interval watcher rewrites the URL and would otherwise cancel it.
+ refreshInterval.value =
+ options.refreshSeconds > 0 ? Math.max(options.refreshSeconds, minRefreshSeconds.value) : 0;
+ };
+
+ const exitKiosk = async () => {
+ if (!kioskMode.value) return;
+ leaveKioskState();
+ const query = stripPlaylistParams(stripKioskParams(route.query));
+ if (refreshBeforeKiosk !== null) query.refresh = generateDurationLabel(refreshBeforeKiosk);
+ await router.replace({ query });
+ // Only after the replace: the interval watcher rewrites the URL and would otherwise race it.
+ restoreRefreshBeforeKiosk();
+ };
+
+ // Back/Forward across the kiosk entry flips the URL without going through enter/exit.
+ watch(
+ () => isKioskEnabled(route.query.kiosk),
+ (enabled) => {
+ if (enabled === kioskMode.value) return;
+ if (enabled) {
+ store.dispatch("setKioskMode", true);
+ applyKioskTheme(store, parseKioskTheme(route.query.theme));
+ } else {
+ leaveKioskState();
+ restoreRefreshBeforeKiosk();
+ }
+ },
+ );
+
+ const KIOSK_OPEN_POPUP = '[role="dialog"][data-state="open"], [role="menu"], [role="listbox"]';
+ // Read before the popup's own Esc handler closes it, so that Esc only closes the variable dropdown.
+ let escapeHitPopup = false;
+ const onKioskEscapeCapture = (event: KeyboardEvent) => {
+ if (event.key === "Escape") escapeHitPopup = !!document.querySelector(KIOSK_OPEN_POPUP);
+ };
+
+ const isTextEntry = (el: Element | null) =>
+ !!el && (el.matches("input, textarea") || (el as HTMLElement).isContentEditable);
+
+ const onKioskEscape = () => {
+ if (!kioskMode.value) return;
+ const hitPopup = escapeHitPopup || !!document.querySelector(KIOSK_OPEN_POPUP);
+ escapeHitPopup = false;
+ if (hitPopup || isTextEntry(document.activeElement)) return;
+ exitKiosk();
+ };
+
+ // Registered only while in kiosk so it never shadows the global Esc that closes the AI chat.
+ let kioskEscapeId: string | undefined;
+ const toggleKioskEscape = (enabled: boolean) => {
+ const manager = getManager();
+ if (kioskEscapeId) manager?.unregisterById(kioskEscapeId);
+ kioskEscapeId = undefined;
+ window.removeEventListener("keydown", onKioskEscapeCapture, true);
+ const def = getShortcutDef("dashboardKioskExit");
+ const key = def && resolveShortcutKeys(def, isMacOS())[0];
+ if (!enabled || !manager || !def || !key) return;
+ window.addEventListener("keydown", onKioskEscapeCapture, true);
+ kioskEscapeId = manager.register({
+ id: def.id,
+ key,
+ scope: def.scope,
+ // A focused select trigger counts as an input to the manager; onKioskEscape does its own text-entry check.
+ allowInInput: true,
+ handler: onKioskEscape,
+ });
+ };
+ watch(kioskMode, toggleKioskEscape, { immediate: true, flush: "sync" });
+
const openScheduledReports = () => {
if (isLoadingReports.value) return;
@@ -2189,6 +2501,8 @@ export default defineComponent({
document.removeEventListener("fullscreenchange", onFullscreenChange);
window.removeEventListener("beforeunload", onBeforeUnload);
toggleLayoutEscape(false);
+ toggleKioskEscape(false);
+ leaveKioskState();
// Clean up AI dashboard event listener
offDashboardEvent(handleAiDashboardEvent);
@@ -2208,6 +2522,7 @@ export default defineComponent({
// printMode hides the app shell globally, so leaving by browser Back (not ✕) must still clear it.
const clearPrintModeOnLeave = () => {
if (store.state.printMode) setPrint(false);
+ leaveKioskState();
};
// Unsaved layout edits are the store's cached dashboard object, so a confirmed leave must roll them back.
@@ -2309,7 +2624,7 @@ export default defineComponent({
{
id: "dashboardAddPanel",
handler: () => {
- if (isInputFocused() || isEditingLayout.value) return;
+ if (isInputFocused() || isEditingLayout.value || kioskMode.value) return;
addPanelData();
},
},
@@ -2335,14 +2650,14 @@ export default defineComponent({
{
id: "dashboardFullscreen",
handler: () => {
- if (isInputFocused() || isEditingLayout.value) return;
+ if (isInputFocused() || isEditingLayout.value || kioskMode.value) return;
toggleFullscreen();
},
},
{
id: "dashboardExport",
handler: () => {
- if (isInputFocused()) return;
+ if (isInputFocused() || kioskMode.value) return;
// Trigger the whole-dashboard export (ExportDashboard button).
(document.querySelector('[data-test="export-dashboard"]') as HTMLElement | null)?.click();
},
@@ -2447,6 +2762,20 @@ export default defineComponent({
setTimeForVariables,
runId,
onVariablesManagerReady,
+ kioskMode,
+ kioskFit,
+ kioskPointerActive,
+ playlistEmbed,
+ playlistPosition,
+ playlistHolding,
+ playlistPageActive: playlistPage.active,
+ kioskHealth: kioskRefresh.health,
+ kioskLastSuccessAt: kioskRefresh.lastSuccessAt,
+ kioskCountdown: kioskRefresh.countdown,
+ onAutoRefreshTick,
+ kioskTimeRange,
+ enterKiosk,
+ exitKiosk,
};
},
});
diff --git a/web/src/views/Dashboards/viewDashboard.spec.ts b/web/src/views/Dashboards/viewDashboard.spec.ts
index 132c658869..c8b04e7559 100644
--- a/web/src/views/Dashboards/viewDashboard.spec.ts
+++ b/web/src/views/Dashboards/viewDashboard.spec.ts
@@ -15,7 +15,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { shallowMount, flushPromises } from "@vue/test-utils";
-import { inject } from "vue";
+import { inject, type Ref } from "vue";
import { DASHBOARD_FRESH_ON_OPEN } from "@/composables/dashboard/dashboardFreshOnOpen";
// Comprehensive service mocks - these prevent real API calls
vi.mock("@/services/dashboards", async (importOriginal) => {
@@ -60,6 +60,11 @@ vi.mock("@/services/reports", async (importOriginal) => {
});
});
+vi.mock("@/services/health", () => ({
+ pingBackend: vi.fn().mockResolvedValue(true),
+ default: { pingBackend: vi.fn().mockResolvedValue(true) },
+}));
+
vi.mock("@/services/short_url", () => ({
default: {
create: vi.fn().mockResolvedValue({ data: { short_url: "http://short.url" } }),
@@ -115,6 +120,8 @@ const routeUpdateGuards: Array<(to: RouteLike, from: RouteLike) => unknown> = []
// Mutable so a test can say what the previous history entry was; the back
// button prefers real history over rebuilding the folder-scoped list route.
const mockHistoryState: { back: string | null } = { back: null };
+// Extra query params a test wants on the route it mounts with (cleared before every test).
+const mockExtraRouteQuery: Record = {};
// Comprehensive Vue composable mocks
vi.mock("vue-router", () => ({
@@ -145,7 +152,12 @@ vi.mock("vue-router", () => ({
},
useRoute: () => ({
params: { dashboardId: "test-dashboard-1", folderId: "default" },
- query: { dashboard: "test-dashboard-1", folder: "default", tab: "tab-1" },
+ query: {
+ dashboard: "test-dashboard-1",
+ folder: "default",
+ tab: "tab-1",
+ ...mockExtraRouteQuery,
+ },
path: "/dashboard/test-dashboard-1",
}),
}));
@@ -292,6 +304,7 @@ describe("ViewDashboard", () => {
global.mockRouterReplace.mockClear();
global.mockRouterBack.mockClear();
global.mockHistoryState.back = null;
+ Object.keys(mockExtraRouteQuery).forEach((key) => delete mockExtraRouteQuery[key]);
global.mockStoreCommit.mockClear();
global.mockStoreDispatch.mockClear();
global.mockShowPositiveNotification.mockClear();
@@ -2325,4 +2338,381 @@ describe("ViewDashboard", () => {
});
});
});
+
+ describe("time range carried by a link", () => {
+ // A link with ?refresh= rewrites the URL on mount, before the dashboard (and its picker value) has loaded.
+ it.each(["1w", "15m", "2h", "7d", "1M"])(
+ "keeps period=%s when the link also sets a refresh interval",
+ async (period) => {
+ Object.assign(mockExtraRouteQuery, { period, refresh: "1m" });
+ let resolveDashboard: (value: unknown) => void = () => {};
+ vi.mocked(getDashboard).mockImplementationOnce(
+ () => new Promise((resolve) => (resolveDashboard = resolve)),
+ );
+ wrapper = createWrapper();
+ await flushPromises();
+
+ const rewrites = global.mockRouterReplace.mock.calls.map((call) => call[0]?.query);
+ expect(rewrites.length).toBeGreaterThan(0);
+ rewrites.forEach((query) => expect(query?.period).toBe(period));
+
+ resolveDashboard({
+ dashboardId: "test-dashboard-1",
+ title: "Test Dashboard",
+ variables: { list: [] },
+ defaultDatetimeDuration: { type: "relative", relativeTimePeriod: "15m" },
+ tabs: [{ tabId: "tab-1", name: "Tab 1", panels: [] }],
+ });
+ await flushPromises();
+ expect(wrapper.vm.selectedDate).toMatchObject({
+ valueType: "relative",
+ relativeTimePeriod: period,
+ });
+ },
+ );
+
+ it("keeps an absolute from/to when the link also sets a refresh interval", async () => {
+ Object.assign(mockExtraRouteQuery, {
+ from: "1700000000000",
+ to: "1700003600000",
+ refresh: "1m",
+ });
+ vi.mocked(getDashboard).mockImplementationOnce(() => new Promise(() => {}));
+ wrapper = createWrapper();
+ await flushPromises();
+
+ const rewrites = global.mockRouterReplace.mock.calls.map((call) => call[0]?.query);
+ expect(rewrites.length).toBeGreaterThan(0);
+ rewrites.forEach((query) => {
+ expect(query?.from).toBe("1700000000000");
+ expect(query?.to).toBe("1700003600000");
+ });
+ });
+ });
+
+ describe("Kiosk / TV mode", () => {
+ const mountKiosk = async () => {
+ wrapper = createWrapper({
+ global: {
+ plugins: [i18n, store],
+ stubs: {
+ OPageLayout: {
+ name: "OPageLayout",
+ props: ["headerClass"],
+ template:
+ '
',
+ },
+ RenderDashboardCharts: {
+ name: "RenderDashboardCharts",
+ props: ["viewOnly", "kiosk", "fitToScreen", "showTabs", "readOnlySelectors"],
+ setup() {
+ return { applyLive: inject | null>("dashboardAutoRun", null) };
+ },
+ template: '',
+ },
+ },
+ },
+ });
+ await flushPromises();
+ };
+
+ afterEach(() => {
+ global.mockStoreState.kioskMode = false;
+ });
+
+ it("keeps the normal view unchanged: no strip, header shown, panels editable", async () => {
+ await mountKiosk();
+
+ expect(wrapper.vm.kioskMode).toBe(false);
+ expect(wrapper.findComponent({ name: "KioskStrip" }).exists()).toBe(false);
+ const charts = wrapper.findComponent({ name: "RenderDashboardCharts" });
+ expect(charts.props("viewOnly")).toBe(false);
+ expect(charts.props("kiosk")).toBe(false);
+ expect(charts.props("readOnlySelectors")).toBe(false);
+ expect(wrapper.findComponent({ name: "OPageLayout" }).props("headerClass")).toBe("shrink-0");
+ });
+
+ it("opens TV mode with a link that keeps the relative range and adds the chosen options", async () => {
+ wrapper = createWrapper();
+ await flushPromises();
+ global.mockRouterPush.mockClear();
+
+ await wrapper.vm.enterKiosk({ refreshSeconds: 300, theme: "dark", fit: true });
+
+ expect(global.mockStoreDispatch).toHaveBeenCalledWith("setKioskMode", true);
+ expect(global.mockStoreCommit).toHaveBeenCalledWith("setThemeOverride", "dark");
+ expect(wrapper.vm.refreshInterval).toBe(300);
+ const { query } = global.mockRouterPush.mock.calls.at(-1)[0];
+ expect(query).toMatchObject({
+ dashboard: "test-dashboard-1",
+ tab: "tab-1",
+ kiosk: "true",
+ theme: "dark",
+ fit: "true",
+ refresh: "5m",
+ });
+ expect(query.print).toBeUndefined();
+ expect(query.from).toBeUndefined();
+ });
+
+ it("renders the read-only strip and view-only panels without the toolbar", async () => {
+ global.mockStoreState.kioskMode = true;
+ await mountKiosk();
+
+ expect(wrapper.findComponent({ name: "KioskStrip" }).exists()).toBe(true);
+ expect(wrapper.findComponent({ name: "OPageLayout" }).props("headerClass")).toBe("hidden");
+ const charts = wrapper.findComponent({ name: "RenderDashboardCharts" });
+ expect(charts.props("viewOnly")).toBe(true);
+ expect(charts.props("kiosk")).toBe(true);
+ expect(charts.props("readOnlySelectors")).toBe(false);
+ });
+
+ it("passes the strip its countdown and loading state and exits when it asks to", async () => {
+ global.mockStoreState.kioskMode = true;
+ await mountKiosk();
+ global.mockRouterReplace.mockClear();
+
+ const strip = wrapper.findComponent({ name: "KioskStrip" });
+ expect(strip.props("loading")).toBe(wrapper.vm.arePanelsLoading);
+ expect(strip.props()).toHaveProperty("countdown");
+ expect(strip.props()).not.toHaveProperty("exitVisible");
+ strip.vm.$emit("exit");
+ await flushPromises();
+
+ expect(global.mockStoreDispatch).toHaveBeenCalledWith("setKioskMode", false);
+ expect(global.mockRouterReplace.mock.calls.at(-1)[0].query.kiosk).toBeUndefined();
+ });
+
+ it("keeps the tab bar on and applies variable changes live, since a wall has no Refresh button", async () => {
+ global.mockStoreState.kioskMode = true;
+ await mountKiosk();
+ const charts = wrapper.findComponent({ name: "RenderDashboardCharts" });
+ expect(charts.props("showTabs")).toBe(true);
+ expect(charts.attributes("data-apply-live")).toBe("true");
+ expect(wrapper.vm.isVariablesChanged).toBe(false);
+ });
+
+ it("lets Esc close an open variable dropdown without leaving TV mode", async () => {
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ global.mockStoreDispatch.mockClear();
+
+ const listbox = document.createElement("div");
+ listbox.setAttribute("role", "listbox");
+ document.body.appendChild(listbox);
+ // The dropdown closes itself on the same keypress, before the shortcut runs.
+ const closeFirst = () => listbox.remove();
+ document.addEventListener("keydown", closeFirst, { once: true });
+ document.body.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ await flushPromises();
+
+ expect(listbox.isConnected).toBe(false);
+ expect(global.mockStoreDispatch).not.toHaveBeenCalledWith("setKioskMode", false);
+ });
+
+ it("exits on the next Esc while a closed dropdown's trigger still has focus", async () => {
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ global.mockStoreDispatch.mockClear();
+
+ const trigger = document.createElement("button");
+ trigger.setAttribute("role", "combobox");
+ document.body.appendChild(trigger);
+ trigger.focus();
+ trigger.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ await flushPromises();
+ trigger.remove();
+
+ expect(global.mockStoreDispatch).toHaveBeenCalledWith("setKioskMode", false);
+ });
+
+ it("keeps TV mode on Esc while typing in a text field", async () => {
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ global.mockStoreDispatch.mockClear();
+
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ input.focus();
+ input.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
+ await flushPromises();
+ input.remove();
+
+ expect(global.mockStoreDispatch).not.toHaveBeenCalledWith("setKioskMode", false);
+ });
+
+ it("restores the normal view's refresh on exit when TV mode was opened from the toolbar", async () => {
+ // The mock store is not reactive, so kiosk state is set up front.
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ wrapper.vm.refreshInterval = 300;
+ await flushPromises();
+ await wrapper.vm.enterKiosk({ refreshSeconds: 30, theme: "current", fit: false });
+ expect(wrapper.vm.refreshInterval).toBe(30);
+ await flushPromises();
+ global.mockRouterReplace.mockClear();
+
+ await wrapper.vm.exitKiosk();
+ await flushPromises();
+
+ expect(wrapper.vm.refreshInterval).toBe(300);
+ const { query } = global.mockRouterReplace.mock.calls[0][0];
+ expect(query.kiosk).toBeUndefined();
+ expect(query.refresh).toBe("5m");
+ });
+
+ it("keeps the TV link's refresh on exit when the link was opened directly", async () => {
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ wrapper.vm.refreshInterval = 30;
+ await flushPromises();
+ global.mockRouterReplace.mockClear();
+
+ await wrapper.vm.exitKiosk();
+ await flushPromises();
+
+ expect(wrapper.vm.refreshInterval).toBe(30);
+ });
+
+ it("exits on Esc back to the normal view with kiosk params removed", async () => {
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ global.mockRouterReplace.mockClear();
+
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
+ await flushPromises();
+
+ expect(global.mockStoreDispatch).toHaveBeenCalledWith("setKioskMode", false);
+ const { query } = global.mockRouterReplace.mock.calls.at(-1)[0];
+ expect(query.kiosk).toBeUndefined();
+ expect(query.theme).toBeUndefined();
+ });
+
+ it("never shows an invalid range in the strip before the picker resolves", async () => {
+ await mountKiosk();
+ wrapper.vm.selectedDate = { valueType: "absolute", startTime: null, endTime: null };
+ // The selectedDate watcher fills the range from the mocked picker, so the unresolved state is set after it.
+ await flushPromises();
+ wrapper.vm.currentTimeObj = {};
+ wrapper.vm.timeString = " Invalid date - Invalid date ";
+ expect(wrapper.vm.kioskTimeRange).toBe("");
+ });
+
+ it("leaves kiosk when the page is left", async () => {
+ global.mockStoreState.kioskMode = true;
+ wrapper = createWrapper();
+ await flushPromises();
+ global.mockStoreDispatch.mockClear();
+
+ routeLeaveGuards.at(-1)?.();
+
+ expect(global.mockStoreDispatch).toHaveBeenCalledWith("setKioskMode", false);
+ });
+
+ describe("TV playlist slide", () => {
+ const realParent = window.parent;
+ const postMessage = vi.fn();
+
+ beforeEach(() => {
+ postMessage.mockClear();
+ global.mockShowErrorNotification.mockClear();
+ global.mockRouterPush.mockClear();
+ global.mockRouterBack.mockClear();
+ global.mockStoreState.kioskMode = true;
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, "parent", { value: realParent, configurable: true });
+ });
+
+ const insideFrame = () =>
+ Object.defineProperty(window, "parent", { value: { postMessage }, configurable: true });
+
+ it("inside the player frame hides the strip and reports a deleted dashboard instead of leaving for the list", async () => {
+ insideFrame();
+ Object.assign(mockExtraRouteQuery, { kiosk: "true", embed: "true" });
+ vi.mocked(getDashboard).mockRejectedValueOnce({ response: { status: 404 }, message: "x" });
+ await mountKiosk();
+
+ expect(wrapper.findComponent({ name: "KioskStrip" }).exists()).toBe(false);
+ expect(postMessage).toHaveBeenCalledWith(
+ { source: "o2-tv-playlist", type: "missing", reason: "notFound" },
+ window.location.origin,
+ );
+ expect(global.mockShowErrorNotification).not.toHaveBeenCalled();
+ expect(global.mockRouterPush).not.toHaveBeenCalled();
+ expect(global.mockRouterBack).not.toHaveBeenCalled();
+ });
+
+ it("inside the player frame shows the tabs and pickers read-only", async () => {
+ insideFrame();
+ Object.assign(mockExtraRouteQuery, { kiosk: "true", embed: "true" });
+ await mountKiosk();
+
+ const charts = wrapper.findComponent({ name: "RenderDashboardCharts" });
+ expect(charts.props("readOnlySelectors")).toBe(true);
+ expect(charts.props("kiosk")).toBe(true);
+ });
+
+ it("reports a server error as a load failure so the player retries it", async () => {
+ insideFrame();
+ Object.assign(mockExtraRouteQuery, { kiosk: "true", embed: "true" });
+ vi.mocked(getDashboard).mockRejectedValueOnce({ response: { status: 502 }, message: "x" });
+ await mountKiosk();
+
+ expect(postMessage).toHaveBeenCalledWith(
+ { source: "o2-tv-playlist", type: "error" },
+ window.location.origin,
+ );
+ expect(global.mockShowErrorNotification).not.toHaveBeenCalled();
+ });
+
+ it("a normal kiosk link still shows the error and goes back to the list", async () => {
+ Object.assign(mockExtraRouteQuery, { kiosk: "true", embed: "true" });
+ vi.mocked(getDashboard).mockRejectedValueOnce({
+ response: { status: 404 },
+ message: "gone",
+ });
+ await mountKiosk();
+
+ expect(wrapper.findComponent({ name: "KioskStrip" }).exists()).toBe(true);
+ expect(global.mockShowErrorNotification).toHaveBeenCalled();
+ });
+
+ it("the page fallback keeps the playlist in the URL rewrite and shows the slide position", async () => {
+ Object.assign(mockExtraRouteQuery, {
+ kiosk: "true",
+ refresh: "5m",
+ playlist: "test-dashboard-1:default,d2:ops",
+ interval: "1m",
+ slide: "1",
+ tvpage: "1",
+ });
+ await mountKiosk();
+
+ const rewrites = global.mockRouterReplace.mock.calls.map((call) => call[0]?.query);
+ expect(rewrites.length).toBeGreaterThan(0);
+ rewrites.forEach((query) =>
+ expect(query).toMatchObject({
+ playlist: "test-dashboard-1:default,d2:ops",
+ interval: "1m",
+ slide: "1",
+ tvpage: "1",
+ }),
+ );
+ expect(wrapper.findComponent({ name: "KioskStrip" }).props("position")).toBe(
+ "dashboard.playlist.position",
+ );
+ expect(
+ wrapper.findComponent({ name: "RenderDashboardCharts" }).props("readOnlySelectors"),
+ ).toBe(true);
+ });
+ });
+ });
});