feat: dashboard TV mode (#15356)

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: ktx-abhay <abhay.padamani@kiara.tech>
Co-authored-by: Abhay Padamani <141122205+ktx-abhay@users.noreply.github.com>
This commit is contained in:
ktx-vaidehi
2026-10-26 05:49:08 +00:00
committed by GitHub
co-authored by github-actions[bot] ktx-abhay Abhay Padamani
parent 613a71d627
commit 1ea4ff6790
71 changed files with 9804 additions and 119 deletions
@@ -9131,6 +9131,31 @@
"kilobytes": "cd701e61b9d28e0a",
"kilobytesBinary": "3a455f9fb962418e",
"kilobytesSi": "cda7a467e08841bf",
"kiosk": {
"exit": "5f6fbe6658df08e9",
"fit": "1be7f43782737226",
"fitHelp": "6a2fb3ebe421036a",
"hung": "9ac47e069ffdf676",
"nextRefreshAt": "80fb01f0c9e4647c",
"nextRetryAt": "b34f71548556dd60",
"open": "51f2e71a03358360",
"panelFailed": "f8c9d26f13962ea2",
"panelStale": "40c9e59c5e152b0a",
"panelsFailing": "0e803a24888e68b2",
"paused": "28fe8882140d8cca",
"queryError": "ebe64ca0fe755428",
"refresh": "8ac11280d5378782",
"refreshHelp": "1eb0709f1d0c023d",
"refreshOff": "9dc9e60adf424269",
"staleSince": "d04c24f1a795d283",
"theme": "efb52e7172b77731",
"themeCurrent": "e0d1b68224bf0b31",
"themeDark": "60acc53f13a5d1bf",
"themeLight": "dbcd5e7bb7a0f538",
"tvMode": "7b43e407f2ace69b",
"unreachable": "0480323ce43aec7a",
"updatedAt": "2f87419441e6111b"
},
"labelOfVariable": "7df60cafd57f651f",
"labelPosition": "31712f1ded62f8fd",
"labelRequired": "5746426fd5252c2d",
@@ -9341,6 +9366,63 @@
"pivotTotal": "c9b3c38247f744e1",
"pivotTotalForField": "cff16c0478b052b8",
"placeholderAll": "b5c7aed7cd2a3085",
"playlist": {
"action": "100bc9735877fdaf",
"actionTooltip": "169a9d8e67ca8bf3",
"add": "9fd728c66c9a256b",
"addHint": "479cfc08ceae9da1",
"addLabel": "2bf381a1c85abf0a",
"added": "6b02e0d363a4af1c",
"backToDashboards": "382431184c270c20",
"copied": "8d525e5f158b9afe",
"copyLink": "dbf362d4f210c780",
"countOfMax": "60ed39d6ebb6de57",
"dashboards": "a53bcafb67d960df",
"dragToReorder": "ef86a0ed928794a4",
"emptyDescription": "1bb67ed1bc45a5e1",
"emptyStateDescription": "e839f1436be6af06",
"emptyStateTitle": "ca21e87ca7ed5b6a",
"emptyTitle": "3058944175932cce",
"exit": "d17d84a604994b72",
"fullPlaceholder": "9fb69569842c5ae7",
"held": "73839bc9f89e0199",
"intervalAdjusted": "08eb1c51b319578e",
"invalidDescription": "9babb7398024c157",
"invalidTitle": "b799368ed434808c",
"linkCopied": "cbfeb28b99de2dfc",
"loading": "dc380888c4e2c776",
"loadingSlide": "091d93421685ebf2",
"moveDown": "40bb50da160cdc21",
"moveUp": "c66feb5eb8f217c7",
"next": "94ea8f0796827fc4",
"nextAt": "84830a0b543b5d05",
"noMatches": "90804415fcc0fada",
"notFound": "e3ebaa16dd9d9b9f",
"ofTotal": "43db84087c320671",
"open": "eafd2479d088a144",
"ownDefault": "28cce5fef01a873b",
"pause": "0134df4b82b05bda",
"pausedSince": "2ee3dd02ba77cf09",
"position": "35d900116a142548",
"previous": "6c417dbed9c48e96",
"refreshNote": "b6fe1701b9e04bba",
"remove": "c3812fc4acb861d5",
"resume": "cac58013cda6352b",
"searchFailed": "3aea3eece090a3b7",
"searchPlaceholder": "39dac3236c9c9418",
"showEachFor": "53b7a4b5544dff9e",
"skippedForbidden": "f91782b501f28fed",
"skippedNotFound": "61c661f7d99a29ed",
"slideName": "e7c56ed573b776fe",
"starting": "aeed4d26bb5f0c31",
"timeRange": "58acc5109e034622",
"timeRangeHelp": "c4e7c9062d495de6",
"title": "100bc9735877fdaf",
"tryAgain": "d8b8392e2c542950",
"unreachable": "e952e2e61d8c0423",
"variablesNote": "a9b48ac5ec2987fa",
"waiting": "f9e91852d68eeaeb"
},
"print": "447ce30a1c39c584",
"promQLChartConfig": {
"aggAvgDesc": "3784448275b7d95b",
@@ -19949,6 +20031,7 @@
"alertDetail": "86af069d405ec051",
"awsMarketplaceSetup": "7d9b4c09f9a569b1",
"createReport": "8765b770b6eea296",
"dashboardTvPlaylist": "100bc9735877fdaf",
"domainManagement": "787e40843d6a17ff",
"editAlert": "55d309d6da250466",
"editGroup": "dd5f2d9ece73e898",
@@ -22248,9 +22331,14 @@
"dashboardAddPanel": "72fc75dff16ebc06",
"dashboardExport": "17accf41de0ec1d5",
"dashboardFullscreen": "c13ff73b9098f3d2",
"dashboardKioskExit": "5f6fbe6658df08e9",
"dashboardLayoutExit": "70203c0409c63026",
"dashboardLayoutRedo": "0d63542f78aed908",
"dashboardLayoutUndo": "6f8390f08f72bbdb",
"dashboardPlaylistExit": "4c5dffea46127514",
"dashboardPlaylistNext": "94ea8f0796827fc4",
"dashboardPlaylistPause": "48720e2c7068caa5",
"dashboardPlaylistPrev": "6c417dbed9c48e96",
"dashboardRefresh": "babd715f8fe4f9a3",
"dashboardSave": "5d2d9259532a715b",
"dashboardsListAdd": "c7c4cad42171aa5c",
@@ -22389,6 +22477,7 @@
"alertTemplates": "5302401030154af0",
"alerts": "f62e9267d196c761",
"cipherKeys": "4f09c1c378d793c6",
"dashboardTvPlaylist": "100bc9735877fdaf",
"dashboards": "54fbf186c69e6863",
"dashboardsList": "a53bcafb67d960df",
"dashboardsPanel": "514929ee06065dad",
+30
View File
@@ -174,4 +174,34 @@ describe("ThemeSwitcher", () => {
expect(localStorageMock.setItem).toHaveBeenCalledWith("theme", "dark");
});
});
describe("kiosk theme override", () => {
it("follows a forced theme without saving it as the preference", async () => {
localStorageMock.getItem.mockReturnValue("light");
const store = createStore({
state: { theme: "light", themeOverride: null as string | null },
mutations: {
appTheme(state, payload) {
state.theme = payload;
},
},
actions: {
appTheme(context, payload) {
context.commit("appTheme", payload);
},
},
});
wrapper = mount(ThemeSwitcher, { global: { plugins: [store, i18n] } });
await wrapper.vm.$nextTick();
localStorageMock.setItem.mockClear();
store.state.themeOverride = "dark";
store.state.theme = "dark";
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.vm.darkMode).toBe(true);
expect(localStorageMock.setItem).not.toHaveBeenCalledWith("theme", "dark");
});
});
});
+2 -1
View File
@@ -93,7 +93,8 @@ export default defineComponent({
const setTheme = (theme: any) => {
try {
localStorage.setItem("theme", theme);
// A kiosk ?theme= is for that screen only and must not become the saved preference.
if (!store.state.themeOverride) localStorage.setItem("theme", theme);
} catch (error) {
// Handle localStorage not available
console.warn("localStorage not available for theme storage:", error);
@@ -15,6 +15,7 @@
import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { mount, flushPromises } from "@vue/test-utils";
import { ref } from "vue";
import PanelContainer from "@/components/dashboards/PanelContainer.vue";
import PanelErrorButtons from "@/components/dashboards/PanelErrorButtons.vue";
import i18n from "@/locales";
@@ -23,6 +24,7 @@ import router from "@/test/unit/helpers/router";
import config from "@/aws-exports";
import { needsConfirmation, normalizePrefill } from "@/utils/alerts/alertPrefill";
import { getAlertSource } from "@/utils/alerts/alertSourceRegistry";
import { KIOSK_PANEL_HEALTH_KEY } from "@/composables/dashboard/useKioskRefresh";
// Mock shortURL service
vi.mock("@/services/short_url", () => ({
@@ -197,7 +199,7 @@ describe("PanelContainer", () => {
}
});
const createWrapper = (props = {}, extraStubs = {}) => {
const createWrapper = (props = {}, extraStubs = {}, provide = {}) => {
return mount(PanelContainer, {
props: {
...defaultProps,
@@ -205,6 +207,7 @@ describe("PanelContainer", () => {
},
global: {
plugins: [i18n, store, router],
provide,
stubs: {
ODialog: ODialogStub,
ChartRenderer: { template: '<div data-test="chart-renderer"></div>' },
@@ -450,6 +453,144 @@ describe("PanelContainer", () => {
});
});
describe("Kiosk mode", () => {
const emitRendererError = async (error: unknown) => {
wrapper.findComponent({ name: "PanelSchemaRenderer" }).vm.$emit("error", error);
await wrapper.vm.$nextTick();
};
it("shows a failed panel loudly, with the error text inside the panel", async () => {
wrapper = createWrapper({ kiosk: true });
await emitRendererError({ message: "stream k8s_events not found", code: 404 });
const overlay = wrapper.find('[data-test="dashboard-panel-kiosk-error"]');
expect(overlay.exists()).toBe(true);
expect(overlay.text()).toContain("Panel failed to load");
expect(overlay.text()).toContain("stream k8s_events not found");
expect(wrapper.attributes("data-kiosk-failed")).toBe("true");
});
it("reduces a raw SQL parser error to one readable line", async () => {
wrapper = createWrapper({ kiosk: true });
await emitRendererError(
'Error# SQL error: ParserError("Expected: an SQL statement, found: SELEKT at Line: 1, Column: 1")\nstack line',
);
const summary = wrapper.find('[data-test="dashboard-panel-kiosk-error-summary"]');
expect(summary.text()).toBe(
"Query error: Expected: an SQL statement, found: SELEKT at Line: 1, Column: 1",
);
expect(summary.text()).not.toContain("ParserError");
expect(summary.text()).not.toContain("stack line");
// Wraps up to three lines in a narrow panel instead of cutting one line mid-word.
expect(summary.find(".line-clamp-3").exists()).toBe(true);
expect(summary.classes()).toContain("break-words");
});
it("keeps today's tooltip-only error outside kiosk", async () => {
wrapper = createWrapper();
await emitRendererError({ message: "stream k8s_events not found", code: 404 });
expect(wrapper.find('[data-test="dashboard-panel-kiosk-error"]').exists()).toBe(false);
expect(wrapper.attributes("data-kiosk-failed")).toBeUndefined();
});
it("reports its error and its recovery to the dashboard's health registry", async () => {
const registry = {
register: vi.fn(),
unregister: vi.fn(),
report: vi.fn(),
outage: ref(false),
};
wrapper = createWrapper(
{ kiosk: true },
{},
{ [KIOSK_PANEL_HEALTH_KEY as symbol]: registry },
);
const id = defaultProps.data.id;
expect(registry.register).toHaveBeenCalledWith(id);
await emitRendererError({ message: "502 Bad Gateway", code: 502 });
expect(registry.report).toHaveBeenLastCalledWith(id, {
message: "502 Bad Gateway",
code: 502,
});
await emitRendererError({ message: "", code: "" });
expect(registry.report).toHaveBeenLastCalledWith(id, { message: "", code: "" });
expect(wrapper.find('[data-test="dashboard-panel-kiosk-error"]').exists()).toBe(false);
wrapper.unmount();
expect(registry.unregister).toHaveBeenCalledWith(id);
wrapper = null;
});
it("hides the routine warning icons so only a failure draws the eye", async () => {
wrapper = createWrapper({ kiosk: true });
await emitRendererError({ message: "boom", code: 500 });
expect(wrapper.find('[data-test="panel-error-data"]').exists()).toBe(false);
});
it("dims the last render and marks it stale while the backend is unreachable", async () => {
const registry = {
register: vi.fn(),
unregister: vi.fn(),
report: vi.fn(),
outage: ref(false),
};
wrapper = createWrapper(
{ kiosk: true },
{},
{ [KIOSK_PANEL_HEALTH_KEY as symbol]: registry },
);
const body = () => wrapper.find('[data-test="dashboard-panel-body"]');
expect(wrapper.find('[data-test="dashboard-panel-kiosk-stale"]').exists()).toBe(false);
expect(body().classes()).not.toContain("opacity-60");
registry.outage.value = true;
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-test="dashboard-panel-kiosk-stale"]').text()).toBe("Stale");
expect(body().classes()).toContain("opacity-60");
expect(wrapper.find('[data-test="panel-schema-renderer"]').exists()).toBe(true);
});
it("shows a loading skeleton instead of an empty body until the first result or error", async () => {
wrapper = createWrapper({ kiosk: true });
wrapper.findComponent({ name: "PanelSchemaRenderer" }).vm.setNoData("No Data");
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-test="dashboard-panel-kiosk-loading"]').exists()).toBe(true);
await emitRendererError({ message: "bad query", code: 400 });
expect(wrapper.find('[data-test="dashboard-panel-kiosk-loading"]').exists()).toBe(false);
expect(wrapper.find('[data-test="dashboard-panel-kiosk-error"]').exists()).toBe(true);
});
it("keeps the skeleton through the first load only, never on later refreshes", async () => {
wrapper = createWrapper({ kiosk: true });
wrapper.findComponent({ name: "PanelSchemaRenderer" }).vm.setNoData("No Data");
const skeleton = () => wrapper.find('[data-test="dashboard-panel-kiosk-loading"]').exists();
wrapper.vm.handleLoadingStateChange(true);
await wrapper.vm.$nextTick();
expect(skeleton()).toBe(true);
wrapper.vm.handleLoadingStateChange(false);
await wrapper.vm.$nextTick();
expect(skeleton()).toBe(false);
wrapper.vm.handleLoadingStateChange(true);
await wrapper.vm.$nextTick();
expect(skeleton()).toBe(false);
});
it("never shows the skeleton outside kiosk", async () => {
wrapper = createWrapper();
wrapper.findComponent({ name: "PanelSchemaRenderer" }).vm.setNoData("No Data");
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-test="dashboard-panel-kiosk-loading"]').exists()).toBe(false);
});
});
describe("Warning States", () => {
it("should show dependent ad-hoc variable warning", async () => {
// Create conditions for dependentAdHocVariable to be true
@@ -1392,6 +1533,24 @@ describe("PanelContainer", () => {
expect(wrapper.vm.confirmDeletePanelDialog).toBe(opens);
});
it.each([["kiosk"], ["viewOnly"]])("ignores every hover shortcut in %s", async (prop) => {
const routerPushSpy = vi.spyOn(router, "push");
wrapper = createWrapper({ [prop]: true });
wrapper.vm.isCurrentlyHoveredPanel = true;
for (const key of ["Delete", "Backspace", "d", "e", "v", "i"]) {
window.dispatchEvent(new KeyboardEvent("keydown", { key }));
}
await wrapper.vm.$nextTick();
expect(wrapper.vm.confirmDeletePanelDialog).toBe(false);
expect(wrapper.vm.showViewPanel).toBe(false);
expect(wrapper.emitted("onViewPanel")).toBeUndefined();
expect(toastMock).not.toHaveBeenCalled();
expect(routerPushSpy).not.toHaveBeenCalled();
routerPushSpy.mockRestore();
});
});
});
@@ -17,6 +17,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
class="flex h-full flex-col overflow-hidden"
:class="kioskFailed ? 'outline-status-error-text outline-2 -outline-offset-2' : undefined"
:data-kiosk-failed="kiosk ? (kioskFailed ? 'true' : 'false') : undefined"
@mouseover="() => (isCurrentlyHoveredPanel = true)"
@mouseleave="() => (isCurrentlyHoveredPanel = false)"
:data-test="`dashboard-panel-container`"
@@ -32,7 +34,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<PanelBar
class="@container/panelbar w-full flex-nowrap"
:class="{ 'border-b-transparent': isPanelLoading }"
:class="{
'border-b-transparent': isPanelLoading,
'bg-status-error-bg!': kioskFailed,
}"
data-test="dashboard-panel-bar"
>
<OIcon
@@ -81,7 +86,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OTag>
<OTag
v-if="
exemplarsEligible && exemplarsOn && PanleSchemaRendererRef?.exemplarsStatus === 'empty'
!kiosk &&
exemplarsEligible &&
exemplarsOn &&
PanleSchemaRendererRef?.exemplarsStatus === 'empty'
"
variant="default-soft"
size="sm"
@@ -210,8 +218,17 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
<OTooltip side="bottom" :content="t('panel.fullScreen')" shortcut-id="panelView" />
</OButton>
<OBadge
v-if="kioskStale"
variant="error-soft"
size="sm"
icon="cloud-off"
class="shrink-0"
data-test="dashboard-panel-kiosk-stale"
>{{ t("dashboard.kiosk.panelStale") }}</OBadge
>
<OButton
v-if="dependentAdHocVariable"
v-if="dependentAdHocVariable && !kiosk"
variant="ghost-warning"
size="icon"
@click="showViewPanel = true"
@@ -227,7 +244,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<!-- show error here -->
<!-- The title gives way first; this clips only past the title's floor, so refresh and the menu always stay in the panel. -->
<!-- On a wall screen only a failure may draw the eye; the failed panel says so in its body. -->
<PanelErrorButtons
v-if="!kiosk"
class="min-w-0 shrink-[0.001] overflow-x-clip"
:error="errorData"
:maxQueryRangeWarning="maxQueryRangeWarning"
@@ -395,7 +414,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div
class="drag-cancel relative min-h-0 flex-1"
:class="curatedBadge ? 'opacity-60' : undefined"
:class="curatedBadge || kioskStale ? 'opacity-60' : undefined"
data-test="dashboard-panel-body"
:data-curated-stale="curatedBadge ? 'true' : 'false'"
>
@@ -448,6 +467,36 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@send-to-ai-chat="(value, append) => $emit('sendToAiChat', value, append)"
></PanelSchemaRenderer>
<!-- Until the first result or error lands, an empty body would read as "No Data" on a TV. -->
<div
v-if="kiosk && !kioskSettled && !kioskFailed"
class="bg-surface-base absolute inset-0 z-10 flex flex-col gap-2 p-3"
data-test="dashboard-panel-kiosk-loading"
>
<OSkeleton type="text" class="w-1/3" />
<OSkeleton class="min-h-0 flex-1" />
</div>
<!-- On a wall screen a tooltip-only error is invisible, and fallback data would look healthy. -->
<div
v-if="kioskFailed"
class="bg-status-error-bg text-status-error-text absolute inset-0 z-10 flex flex-col justify-center gap-1.5 overflow-hidden p-3 text-xs"
role="alert"
data-test="dashboard-panel-kiosk-error"
>
<span class="flex items-center gap-1 text-sm font-semibold">
<OIcon name="error" size="sm" />
{{ t("dashboard.kiosk.panelFailed") }}
</span>
<!-- At most three lines on a wall screen; the full server text stays one hover away. -->
<span class="min-w-0 break-words" data-test="dashboard-panel-kiosk-error-summary">
<OTruncatedText :lines="3" :tooltip="false">{{
t("dashboard.kiosk.queryError", { detail: kioskErrorSummary })
}}</OTruncatedText>
<OTooltip :content="kioskErrorFull" max-width="30rem" />
</span>
</div>
<!-- A wrong label VALUE leaves the panel present, fresh and blank, and a
blank tile is read as a zero — so it refuses to imply a number. -->
<div
@@ -513,7 +562,10 @@ import {
watch,
onBeforeUnmount,
onMounted,
inject,
} from "vue";
import { KIOSK_PANEL_HEALTH_KEY } from "@/composables/dashboard/useKioskRefresh";
import { summarizePanelError } from "@/utils/dashboard/kiosk";
import { panelDownloadRegistry, panelCsvRegistry } from "@/utils/panelDownloadRegistry";
import PanelSchemaRenderer from "./PanelSchemaRenderer.vue";
import { useStore } from "vuex";
@@ -528,6 +580,8 @@ import useNotifications from "@/composables/useNotifications";
import { useDashboardSaveConflict } from "@/composables/dashboard/useDashboardSaveConflict";
import OButton from "@/lib/core/Button/OButton.vue";
import OTag from "@/lib/core/Badge/OTag.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import ODropdown from "@/lib/overlay/Dropdown/ODropdown.vue";
import ODropdownItem from "@/lib/overlay/Dropdown/ODropdownItem.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
@@ -609,9 +663,12 @@ export default defineComponent({
"showLegendsButton",
"isTimeRangeWaiting",
"layoutMode",
"kiosk",
],
components: {
OTag,
OBadge,
OSkeleton,
PanelSchemaRenderer,
PanelBar,
QueryInspector,
@@ -1120,13 +1177,55 @@ export default defineComponent({
});
const errorData = ref("");
const kioskHealth = inject(KIOSK_PANEL_HEALTH_KEY, null);
const onError = (error: any) => {
if (typeof error === "string") {
errorData.value = error;
} else {
errorData.value = error?.message ?? "";
}
if (props.data?.id) {
kioskHealth?.report(props.data.id, {
message: errorData.value,
code: typeof error === "string" ? null : error?.code,
});
}
};
const kioskFailed = computed(() => !!props.kiosk && !!errorData.value);
const kioskErrorSummary = computed(() => raw(summarizePanelError(errorData.value)));
const kioskErrorFull = computed(() => raw(errorData.value));
const kioskStale = computed(() => !!props.kiosk && !!kioskHealth?.outage.value);
const kioskSawLoading = ref(false);
const kioskSettledTimeout = ref(false);
// Latched: once the first result or error is in, later refreshes never bring the skeleton back.
const kioskSettledOnce = ref(false);
const kioskSettled = computed(() => {
if (kioskSettledOnce.value || kioskSettledTimeout.value || errorData.value) return true;
// Before any load starts only real data counts; tables report noData "" before they have rows.
if (kioskSawLoading.value) return !isPanelLoading.value;
return !PanleSchemaRendererRef.value?.noData && props.data?.type !== "table";
});
watch(isPanelLoading, (loading) => {
if (loading) kioskSawLoading.value = true;
});
watch(kioskSettled, (settled) => {
if (settled) kioskSettledOnce.value = true;
});
// A panel that is genuinely empty and never loads (cache hit) must not keep its skeleton forever.
let kioskSettleTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(() => {
if (props.kiosk)
kioskSettleTimer = setTimeout(() => (kioskSettledTimeout.value = true), 15000);
});
onBeforeUnmount(() => {
if (kioskSettleTimer) clearTimeout(kioskSettleTimer);
});
onMounted(() => {
if (props.data?.id) kioskHealth?.register(props.data.id);
});
onBeforeUnmount(() => {
if (props.data?.id) kioskHealth?.unregister(props.data.id);
});
const isPartialData = ref(false);
@@ -1183,6 +1282,7 @@ export default defineComponent({
// Direct keydown listener — avoids ShortcutManager conflicts when many
// panels are mounted at the same time. Only fires when this panel is hovered.
const handlePanelKeydown = (e: KeyboardEvent) => {
if (props.viewOnly || props.kiosk) return;
if (!isCurrentlyHoveredPanel.value) return;
if (isInputFocused()) return;
if (isLayoutEditing.value && !["v", "V", "i", "I"].includes(e.key)) return;
@@ -1264,6 +1364,11 @@ export default defineComponent({
refreshTooltip,
onError,
errorData,
kioskFailed,
kioskErrorSummary,
kioskErrorFull,
kioskStale,
kioskSettled,
isPanelLoading,
handleLoadingStateChange,
limitNumberOfSeriesWarningMessage,
@@ -43,14 +43,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:data="tableRendererData"
:config="panelSchema.config"
:enable-filtering="!!panelSchema.config?.table_filtering && !store.state.printMode"
@row-click="onChartClick"
@row-click="
(params: any, ...rest: any[]) => !store.state.kioskMode && onChartClick(params, ...rest)
"
/>
<TableRenderer
v-else-if="panelSchema.type == 'table'"
:data="tableRendererData"
:value-mapping="panelSchema?.config?.mappings ?? []"
@row-click="onChartClick"
@explore-cell="(params) => exploreCellInLogs(params.columnId, params.value, params.row)"
@row-click="
(params: any, ...rest: any[]) => !store.state.kioskMode && onChartClick(params, ...rest)
"
@explore-cell="
(params) =>
!store.state.kioskMode && exploreCellInLogs(params.columnId, params.value, params.row)
"
@format-column="onFormatColumn"
ref="tableRendererRef"
:wrap-cells="panelSchema.config?.wrap_table_cells"
@@ -58,8 +65,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:rows-per-page="panelSchema.config?.table_pagination_rows_per_page"
:enable-filtering="!!panelSchema.config?.table_filtering && !store.state.printMode"
:enable-column-format="enableColumnFormat"
:drilldown-columns="store.state.printMode ? [] : drilldownColumnAliases"
:drilldown-all-columns="!store.state.printMode && drilldownAllColumns"
:drilldown-columns="
store.state.printMode || store.state.kioskMode ? [] : drilldownColumnAliases
"
:drilldown-all-columns="
!store.state.printMode && !store.state.kioskMode && drilldownAllColumns
"
/>
<div
v-else-if="panelSchema.type == 'html'"
@@ -99,11 +110,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:height="chartPanelHeight"
:label="raw(panelSchema?.title)"
:render-type="panelSchema?.type === 'metric' ? 'svg' : 'canvas'"
@updated:data-zoom="onDataZoom"
@updated:data-zoom="(event) => !store.state.kioskMode && onDataZoom(event)"
@error="errorDetail = $event"
@click="onChartClick"
@contextmenu="onChartContextMenu"
@domcontextmenu="onChartDomContextMenu"
@click="
(params: any, ...rest: any[]) => !store.state.kioskMode && onChartClick(params, ...rest)
"
@contextmenu="(event) => !store.state.kioskMode && onChartContextMenu(event)"
@domcontextmenu="(event) => !store.state.kioskMode && onChartDomContextMenu(event)"
:find-line-on-context-menu="findsLineOnContextMenu"
@mouseover="exemplarInteraction.onMouseOver"
@mouseout="exemplarInteraction.onMouseOut"
@@ -0,0 +1,71 @@
// 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 <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import { raw } from "@/types/i18n";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import KioskCountdownRing from "./KioskCountdownRing.vue";
const CIRCUMFERENCE = 2 * Math.PI * 11;
const mountRing = (props: Record<string, unknown>) =>
mount(KioskCountdownRing, {
props: { tooltip: raw("Next refresh at 23:16:30"), ...props },
slots: { default: "<i data-test='inner' />" },
});
const progressArc = (wrapper: ReturnType<typeof mountRing>) => wrapper.findAll("circle")[1];
describe("KioskCountdownRing", () => {
it("fills the arc by the elapsed share of the wait", () => {
const wrapper = mountRing({ progress: 0.25 });
const offset = Number(progressArc(wrapper).attributes("stroke-dashoffset"));
expect(offset).toBeCloseTo(CIRCUMFERENCE * 0.75);
expect(wrapper.attributes("data-progress")).toBe("25");
expect(wrapper.find('[data-test="inner"]').exists()).toBe(true);
});
it("clamps progress outside 0 to 1", () => {
expect(mountRing({ progress: 3 }).attributes("data-progress")).toBe("100");
expect(mountRing({ progress: -1 }).attributes("data-progress")).toBe("0");
});
it("spins a short arc while loading", () => {
const wrapper = mountRing({ state: "spinning", progress: 0.5 });
expect(wrapper.find("svg").classes()).toContain("motion-safe:animate-spin");
expect(progressArc(wrapper).attributes("stroke-dasharray")).toBe("22 70");
});
it("draws only a dashed track when off", () => {
const wrapper = mountRing({ state: "off" });
const circles = wrapper.findAll("circle");
expect(circles).toHaveLength(1);
expect(circles[0].attributes("stroke-dasharray")).toBe("3 4");
});
it("shows a full muted ring when paused", () => {
const wrapper = mountRing({ state: "paused", progress: 0.3 });
expect(progressArc(wrapper).attributes("stroke-dashoffset")).toBe("0");
expect(progressArc(wrapper).classes()).toContain("stroke-text-secondary");
});
it("is focusable and names itself with its tooltip", () => {
const wrapper = mountRing({});
expect(wrapper.attributes("tabindex")).toBe("0");
expect(wrapper.attributes("aria-label")).toBe("Next refresh at 23:16:30");
expect(wrapper.findComponent(OTooltip).props("content")).toBe("Next refresh at 23:16:30");
});
});
@@ -0,0 +1,82 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<span
class="relative inline-flex size-7.5 shrink-0 items-center justify-center rounded-full"
tabindex="0"
:aria-label="tooltip"
:data-state="state"
:data-progress="Math.round(clamped * 100)"
>
<svg
viewBox="0 0 28 28"
class="absolute inset-0 size-full -rotate-90"
:class="state === 'spinning' ? 'motion-safe:animate-spin' : undefined"
aria-hidden="true"
>
<circle
cx="14"
cy="14"
r="11"
class="stroke-border-default fill-none"
stroke-width="2.2"
:stroke-dasharray="state === 'off' ? '3 4' : undefined"
/>
<circle
v-if="state !== 'off'"
cx="14"
cy="14"
r="11"
class="fill-none transition-[stroke-dashoffset] duration-1000 ease-linear"
:class="state === 'paused' ? 'stroke-text-secondary opacity-60' : 'stroke-accent'"
stroke-width="2.6"
stroke-linecap="round"
:stroke-dasharray="state === 'spinning' ? '22 70' : CIRCUMFERENCE"
:stroke-dashoffset="dashOffset"
/>
</svg>
<span class="text-text-secondary relative inline-flex items-center">
<slot />
</span>
<OTooltip :content="tooltip" />
</span>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { I18nText } from "@/types/i18n";
const CIRCUMFERENCE = 2 * Math.PI * 11;
const props = withDefaults(
defineProps<{
/** Share of the wait already elapsed, 0 to 1. */
progress?: number;
state?: "running" | "spinning" | "paused" | "off";
tooltip: I18nText;
}>(),
{ progress: 0, state: "running" },
);
const clamped = computed(() => Math.min(1, Math.max(0, props.progress)));
const dashOffset = computed(() => {
if (props.state === "spinning" || props.state === "paused") return 0;
return CIRCUMFERENCE * (1 - clamped.value);
});
</script>
@@ -0,0 +1,149 @@
// 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 <http://www.gnu.org/licenses/>.
import { afterEach, describe, expect, it, vi } from "vitest";
import { flushPromises, mount, type VueWrapper } from "@vue/test-utils";
import i18n from "@/locales";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import KioskSetupPopover from "./KioskSetupPopover.vue";
const body = (selector: string) => document.body.querySelector<HTMLElement>(selector);
describe("KioskSetupPopover", () => {
let wrapper: VueWrapper<any> | null = null;
const mountPopover = (props: Record<string, unknown> = {}) => {
wrapper = mount(KioskSetupPopover, {
props,
attachTo: document.body,
global: { plugins: [i18n] },
});
return wrapper;
};
const openPopover = async () => {
await wrapper!.find('[data-test="dashboard-kiosk-btn"]').trigger("click");
await flushPromises();
};
const clickOpen = async () => {
body('[data-test="dashboard-kiosk-open"]')!.click();
await flushPromises();
};
afterEach(() => {
sessionStorage.clear();
vi.restoreAllMocks();
wrapper?.unmount();
wrapper = null;
document.body.innerHTML = "";
});
it("is an icon-only toolbar button with a TV mode tooltip", () => {
mountPopover();
const button = wrapper!.find('[data-test="dashboard-kiosk-btn"]');
expect(button.text()).toBe("");
expect(button.attributes("aria-label")).toBe("TV mode");
expect(wrapper!.findComponent(OTooltip).props("content")).toBe("TV mode");
});
it("holds only the fields and the open action, with no copy link or summary", async () => {
mountPopover();
await openPopover();
const popover = body('[data-test="dashboard-kiosk-popover"]')!;
expect(popover.querySelector('[data-test="dashboard-kiosk-refresh"]')).not.toBeNull();
expect(popover.querySelector('[data-test="dashboard-kiosk-theme"]')).not.toBeNull();
expect(popover.querySelector('[data-test="dashboard-kiosk-fit"]')).not.toBeNull();
expect(popover.querySelector('[data-test="dashboard-kiosk-open"]')).not.toBeNull();
expect(popover.querySelector('[data-test="dashboard-kiosk-copy-link"]')).toBeNull();
expect(popover.querySelector('[data-test="dashboard-kiosk-summary"]')).toBeNull();
expect(popover.querySelector("p")).toBeNull();
});
it("defaults an off refresh to 5 minutes and leaves fit off", async () => {
mountPopover({ currentRefreshSeconds: 0 });
await openPopover();
await clickOpen();
expect(wrapper!.emitted("open")?.[0]).toEqual([
{ refreshSeconds: 300, theme: "current", fit: false },
]);
});
it("keeps the dashboard's own refresh and emits the chosen theme and fit", async () => {
mountPopover({ currentRefreshSeconds: 60 });
await openPopover();
body('[data-test="dashboard-kiosk-theme-dark"]')!.click();
body('[data-test="dashboard-kiosk-fit-btn"]')!.click();
await flushPromises();
await clickOpen();
expect(wrapper!.emitted("open")?.[0]).toEqual([
{ refreshSeconds: 60, theme: "dark", fit: true },
]);
});
it("remembers the last theme and fit for the session, but follows the dashboard's refresh", async () => {
mountPopover({ currentRefreshSeconds: 60 });
await openPopover();
body('[data-test="dashboard-kiosk-theme-light"]')!.click();
body('[data-test="dashboard-kiosk-fit-btn"]')!.click();
await flushPromises();
await clickOpen();
wrapper!.unmount();
mountPopover({ currentRefreshSeconds: 900 });
await openPopover();
await clickOpen();
expect(wrapper!.emitted("open")?.[0]).toEqual([
{ refreshSeconds: 900, theme: "light", fit: true },
]);
});
it("does not remember choices from a popover closed without opening TV mode", async () => {
mountPopover();
await openPopover();
body('[data-test="dashboard-kiosk-theme-dark"]')!.click();
await flushPromises();
wrapper!.vm.$.setupState.open = false;
await flushPromises();
await openPopover();
await clickOpen();
expect(wrapper!.emitted("open")?.[0]?.[0]).toMatchObject({ theme: "current", fit: false });
});
it("still works when session storage is unavailable", async () => {
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new Error("blocked");
});
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new Error("blocked");
});
mountPopover({ currentRefreshSeconds: 60 });
await openPopover();
await clickOpen();
expect(wrapper!.emitted("open")?.[0]).toEqual([
{ refreshSeconds: 60, theme: "current", fit: false },
]);
});
it("raises a refresh below the server minimum", async () => {
mountPopover({ currentRefreshSeconds: 5, minRefreshSeconds: 30 });
await openPopover();
await clickOpen();
expect(wrapper!.emitted("open")?.[0]?.[0]).toMatchObject({ refreshSeconds: 30 });
});
});
@@ -0,0 +1,199 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<OPopover v-model:open="open" align="end">
<template #trigger>
<OButton
variant="outline"
size="icon-toolbar"
icon-left="tv"
:active="open"
:aria-label="t('dashboard.kiosk.tvMode')"
data-test="dashboard-kiosk-btn"
>
<OTooltip v-if="!open" :content="t('dashboard.kiosk.tvMode')" />
</OButton>
</template>
<div
class="flex w-[min(18.5rem,calc(100vw-1.5rem))] flex-col gap-3 p-3"
data-test="dashboard-kiosk-popover"
>
<OSelect
:label="t('dashboard.kiosk.refresh')"
:model-value="refreshSeconds"
:options="refreshOptions"
size="md"
:searchable="false"
data-test="dashboard-kiosk-refresh"
@update:model-value="(value) => (refreshSeconds = Number(value) || 0)"
>
<template #tooltip>
<OTooltip :content="t('dashboard.kiosk.refreshHelp')" />
</template>
</OSelect>
<OToggleGroup
:model-value="theme"
type="single"
:label="t('dashboard.kiosk.theme')"
label-position="top"
data-test="dashboard-kiosk-theme"
@update:model-value="onThemeChange"
>
<OToggleGroupItem
v-for="option in themeOptions"
:key="option.value"
:value="option.value"
size="sm"
:data-test="`dashboard-kiosk-theme-${option.value}`"
>
{{ option.label }}
</OToggleGroupItem>
</OToggleGroup>
<OSwitch v-model="fit" :label="t('dashboard.kiosk.fit')" data-test="dashboard-kiosk-fit">
<template #tooltip>
<OTooltip :content="t('dashboard.kiosk.fitHelp')" />
</template>
</OSwitch>
<OButton
variant="primary"
size="sm"
icon-left="tv"
block
data-test="dashboard-kiosk-open"
@click="openKiosk"
>
{{ t("dashboard.kiosk.open") }}
</OButton>
</div>
</OPopover>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import OPopover from "@/lib/overlay/Popover/OPopover.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import { useI18nTyped } from "@/types/i18n";
import {
DEFAULT_MIN_REFRESH_SECONDS,
KIOSK_DEFAULT_REFRESH_SECONDS,
KIOSK_REFRESH_CHOICES,
formatRefreshLabel,
type KioskLinkOptions,
type KioskThemeChoice,
} from "@/utils/dashboard/kiosk";
const props = withDefaults(
defineProps<{
/** The dashboard's current auto-refresh, in seconds (0 = off). */
currentRefreshSeconds?: number;
minRefreshSeconds?: number;
}>(),
{
currentRefreshSeconds: 0,
minRefreshSeconds: DEFAULT_MIN_REFRESH_SECONDS,
},
);
const emit = defineEmits<{
open: [options: KioskLinkOptions];
}>();
const { t } = useI18nTyped();
const open = ref(false);
const refreshSeconds = ref(0);
const theme = ref<KioskThemeChoice>("current");
const fit = ref(false);
const SETUP_STORAGE_KEY = "oo_kiosk_setup";
const readSavedSetup = (): { theme: KioskThemeChoice; fit: boolean } => {
try {
const saved = JSON.parse(sessionStorage.getItem(SETUP_STORAGE_KEY) ?? "{}");
const savedTheme = ["current", "light", "dark"].includes(saved?.theme)
? saved.theme
: "current";
return { theme: savedTheme, fit: saved?.fit === true };
} catch {
return { theme: "current", fit: false };
}
};
const saveSetup = () => {
try {
sessionStorage.setItem(
SETUP_STORAGE_KEY,
JSON.stringify({ theme: theme.value, fit: fit.value }),
);
} catch {
/* storage unavailable: the choice still applies to this link */
}
};
const defaultRefresh = () => {
const current = props.currentRefreshSeconds;
if (current > 0) return Math.max(current, props.minRefreshSeconds);
return Math.max(KIOSK_DEFAULT_REFRESH_SECONDS, props.minRefreshSeconds);
};
// Refresh follows the dashboard as it is now; theme and fit are what was last chosen in this session.
watch(open, (isOpen) => {
if (!isOpen) return;
refreshSeconds.value = defaultRefresh();
const saved = readSavedSetup();
theme.value = saved.theme;
fit.value = saved.fit;
});
const refreshOptions = computed(() => {
const values = new Set<number>(KIOSK_REFRESH_CHOICES);
if (refreshSeconds.value > 0) values.add(refreshSeconds.value);
return [...values]
.sort((a, b) => a - b)
.map((value) => ({
label: formatRefreshLabel(value, t),
value,
disabled: value > 0 && value < props.minRefreshSeconds,
}));
});
const themeOptions = computed(() => [
{ value: "current" as const, label: t("dashboard.kiosk.themeCurrent") },
{ value: "light" as const, label: t("dashboard.kiosk.themeLight") },
{ value: "dark" as const, label: t("dashboard.kiosk.themeDark") },
]);
// Clicking the pressed item emits an empty value; a theme choice is always required.
const onThemeChange = (value: unknown) => {
if (value === "current" || value === "light" || value === "dark") theme.value = value;
};
const openKiosk = () => {
saveSetup();
open.value = false;
emit("open", { refreshSeconds: refreshSeconds.value, theme: theme.value, fit: fit.value });
};
</script>
@@ -0,0 +1,179 @@
// 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 <http://www.gnu.org/licenses/>.
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 OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import type { KioskHealth } from "@/composables/dashboard/useKioskRefresh";
import KioskStrip from "./KioskStrip.vue";
import KioskCountdownRing from "./KioskCountdownRing.vue";
const NOW = Date.UTC(2026, 9, 13, 14, 7, 9);
const LAST = NOW - 2 * 60_000;
const mountStrip = (health: KioskHealth, extra: Record<string, unknown> = {}) =>
mount(KioskStrip, {
props: {
title: "Kubernetes overview",
timeRangeLabel: raw("Past 15 Minutes"),
refreshSeconds: 60,
health,
lastSuccessAt: LAST,
timezone: "UTC",
countdown: { remainingMs: 18_000, totalMs: 60_000, at: NOW + 18_000, retrying: false },
...extra,
},
global: { plugins: [i18n] },
});
const ring = (wrapper: ReturnType<typeof mountStrip>) => wrapper.findComponent(KioskCountdownRing);
describe("KioskStrip", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it("shows the playlist position only when the screen is a playlist slide", () => {
expect(
mountStrip({ level: "ok" }).find('[data-test="dashboard-kiosk-position"]').exists(),
).toBe(false);
const slide = mountStrip({ level: "ok" }, { position: raw("2 of 5") });
expect(slide.find('[data-test="dashboard-kiosk-position"]').text()).toBe("2 of 5");
});
it("shows the name on the left and the range, refresh and relative update time grouped right", () => {
const wrapper = mountStrip({ level: "ok" });
expect(wrapper.find('[data-test="dashboard-kiosk-title"]').text()).toBe("Kubernetes overview");
const meta = wrapper.find('[data-test="dashboard-kiosk-meta"]');
expect(meta.find('[data-test="dashboard-kiosk-time-range"]').text()).toContain(
"Past 15 Minutes",
);
expect(meta.find('[data-test="dashboard-kiosk-refresh"]').exists()).toBe(true);
expect(meta.find('[data-test="dashboard-kiosk-updated"]').text()).toBe("Updated 2m ago");
expect(wrapper.findComponent(OBadge).exists()).toBe(false);
});
it("puts the exact update time in the tooltip", () => {
const wrapper = mountStrip({ level: "ok" });
const tooltip = wrapper
.findAllComponents(OTooltip)
.find((tip) => String(tip.props("content")).startsWith("Last refreshed"));
expect(tooltip?.props("content")).toBe("Last refreshed: 14:05:09");
});
it("counts down to the next load on the ring", () => {
const wrapper = mountStrip({ level: "ok" });
expect(ring(wrapper).props("state")).toBe("running");
expect(ring(wrapper).props("progress")).toBeCloseTo(0.7);
expect(ring(wrapper).props("tooltip")).toBe("Next refresh at 14:07:27");
expect(ring(wrapper).attributes("aria-label")).toBe("Next refresh at 14:07:27");
});
it("spins only the ring's arc while panels load; the refresh icon stays still", () => {
const wrapper = mountStrip({ level: "ok" }, { loading: true });
expect(ring(wrapper).props("state")).toBe("spinning");
expect(ring(wrapper).find("svg").classes()).toContain("motion-safe:animate-spin");
const icon = ring(wrapper).findComponent(OIcon);
expect(icon.classes()).toContain("text-accent");
expect(icon.classes()).not.toContain("motion-safe:animate-spin");
});
it("leaves the range out until it is known, instead of printing an invalid date", () => {
const wrapper = mountStrip({ level: "ok" }, { timeRangeLabel: raw("") });
expect(wrapper.find('[data-test="dashboard-kiosk-time-range"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain("Invalid");
});
it("keeps a compact row with the name as the most prominent text", () => {
const wrapper = mountStrip({ level: "warn", kind: "failing", count: 1 });
const row = wrapper.find('[data-test="dashboard-kiosk-strip"] > div');
expect(row.classes()).toContain("h-11");
expect(row.classes()).toContain("text-sm");
expect(
wrapper.find('[data-test="dashboard-kiosk-title"]').element.parentElement?.classList,
).toContain("text-xl");
expect(wrapper.findComponent(OBadge).props("size")).toBe("lg");
expect(wrapper.find('[data-test="dashboard-kiosk-exit-btn"]').classes()).toContain("h-8");
});
it("warns that auto-refresh is off, since that screen will go stale", () => {
const wrapper = mountStrip({ level: "ok" }, { refreshSeconds: 0, countdown: null });
const badge = wrapper.findComponent(OBadge);
expect(badge.attributes("data-health")).toBe("refresh-off");
expect(badge.props("variant")).toBe("warning-soft");
expect(badge.text()).toBe("Auto-refresh off");
expect(ring(wrapper).props("state")).toBe("off");
});
it("says Loading before the first update", () => {
const wrapper = mountStrip({ level: "ok" }, { lastSuccessAt: null });
expect(wrapper.find('[data-test="dashboard-kiosk-updated"]').text()).toBe("Loading...");
});
it("flags failing panels as a warning", () => {
const badge = mountStrip({ level: "warn", kind: "failing", count: 2 }).findComponent(OBadge);
expect(badge.props("variant")).toBe("warning-soft");
expect(badge.text()).toBe("2 panels failing");
});
it("flags a stale screen as an error with the time it stopped", () => {
const badge = mountStrip({ level: "bad", kind: "stale", since: LAST }).findComponent(OBadge);
expect(badge.props("variant")).toBe("error-soft");
expect(badge.text()).toBe("Not updating since 14:05:09");
});
it("names the retry time on the ring during an outage", () => {
const wrapper = mountStrip(
{ level: "bad", kind: "unreachable", retryInSeconds: 10 },
{ countdown: { remainingMs: 10_000, totalMs: 20_000, at: NOW + 10_000, retrying: true } },
);
expect(ring(wrapper).props("tooltip")).toBe("Next retry at 14:07:19");
});
it("keeps the loading and off wording on the ring", async () => {
const wrapper = mountStrip({ level: "ok" }, { loading: true });
expect(ring(wrapper).props("tooltip")).toBe("Loading...");
await wrapper.setProps({ loading: false, refreshSeconds: 0, countdown: null });
expect(ring(wrapper).props("tooltip")).toBe("Auto-refresh off");
});
it("explains an outage and the retry countdown", () => {
const badge = mountStrip({
level: "bad",
kind: "unreachable",
retryInSeconds: 10,
}).findComponent(OBadge);
expect(badge.text()).toBe("Backend unreachable, retry in 10s");
});
it("always shows the exit icon at the end of the strip", async () => {
const wrapper = mountStrip({ level: "ok" });
const exit = wrapper.find('[data-test="dashboard-kiosk-exit-btn"]');
expect(exit.element.parentElement?.lastElementChild).toBe(exit.element);
expect(exit.classes()).not.toContain("invisible");
expect(exit.attributes("aria-label")).toBe("Exit TV mode");
await exit.trigger("click");
expect(wrapper.emitted("exit")).toHaveLength(1);
});
});
@@ -0,0 +1,195 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="shrink-0" data-test="dashboard-kiosk-strip">
<div
class="bg-surface-panel border-border-default px-page-edge flex h-11 min-w-0 items-center gap-3 border-b text-sm"
>
<span class="text-text-heading flex min-w-0 items-center gap-2 text-xl font-semibold">
<OIcon name="dashboard" size="md" class="text-accent shrink-0" />
<OTruncatedText data-test="dashboard-kiosk-title">{{ title }}</OTruncatedText>
</span>
<OBadge
v-if="position"
variant="default-outline"
size="lg"
class="shrink-0 tabular-nums"
data-test="dashboard-kiosk-position"
>{{ position }}</OBadge
>
<span class="flex-1" />
<OBadge
v-if="status"
:variant="status.variant"
size="lg"
dot
class="shrink-0"
data-test="dashboard-kiosk-health"
:data-health="status.kind"
>{{ status.text }}</OBadge
>
<div
class="bg-surface-subtle rounded-surface flex shrink-0 items-center gap-3 px-3 py-0.5"
data-test="dashboard-kiosk-meta"
>
<span
v-if="timeRangeLabel"
class="text-text-secondary flex items-center gap-1.5 whitespace-nowrap max-md:hidden"
data-test="dashboard-kiosk-time-range"
>
<OIcon name="schedule" size="sm" />
{{ timeRangeLabel }}
</span>
<span class="flex items-center gap-2">
<KioskCountdownRing
:progress="ringProgress"
:state="ringState"
:tooltip="ringTooltip"
data-test="dashboard-kiosk-refresh"
>
<OIcon
:name="ringState === 'off' ? 'sync-disabled' : 'refresh'"
size="sm"
:class="ringState === 'spinning' ? 'text-accent' : undefined"
/>
</KioskCountdownRing>
<KioskUpdatedAt
:last-success-at="lastSuccessAt"
:timezone="timezone"
data-test="dashboard-kiosk-updated"
/>
</span>
</div>
<OButton
variant="outline"
size="icon-sm"
icon-left="close"
:aria-label="t('dashboard.kiosk.exit')"
data-test="dashboard-kiosk-exit-btn"
@click="emit('exit')"
>
<OTooltip :content="t('dashboard.kiosk.exit')" shortcut-id="dashboardKioskExit" />
</OButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import type { BadgeVariant } from "@/lib/core/Badge/OBadge.types";
import { useI18nTyped, type I18nText } from "@/types/i18n";
import type { KioskCountdown, KioskHealth } from "@/composables/dashboard/useKioskRefresh";
import { formatClockTime } from "@/utils/dashboard/kiosk";
import KioskCountdownRing from "./KioskCountdownRing.vue";
import KioskUpdatedAt from "./KioskUpdatedAt.vue";
const props = withDefaults(
defineProps<{
title?: string;
timeRangeLabel: I18nText;
refreshSeconds?: number;
health: KioskHealth;
lastSuccessAt?: number | null;
timezone?: string;
loading?: boolean;
countdown?: KioskCountdown | null;
/** "2 of 5" when this screen is one slide of a TV playlist. */
position?: I18nText | null;
}>(),
{
title: "",
refreshSeconds: 0,
lastSuccessAt: null,
timezone: undefined,
loading: false,
countdown: null,
position: null,
},
);
const emit = defineEmits<{ exit: [] }>();
const { t } = useI18nTyped();
const formatTime = (ms: number) => formatClockTime(ms, props.timezone);
type RingState = "running" | "spinning" | "paused" | "off";
const ringState = computed<RingState>(() => {
if (props.loading) return "spinning";
if (!(props.refreshSeconds > 0)) return "off";
return props.countdown ? "running" : "paused";
});
const ringProgress = computed(() => {
const countdown = props.countdown;
if (!countdown || !(countdown.totalMs > 0)) return 0;
return 1 - countdown.remainingMs / countdown.totalMs;
});
const ringTooltip = computed(() => {
if (ringState.value === "spinning") return t("common.loading");
if (ringState.value === "off") return t("dashboard.kiosk.refreshOff");
if (ringState.value === "paused") return t("dashboard.kiosk.paused");
const time = formatTime(props.countdown?.at ?? Date.now());
return props.countdown?.retrying
? t("dashboard.kiosk.nextRetryAt", { time })
: t("dashboard.kiosk.nextRefreshAt", { time });
});
interface StripStatus {
kind: string;
variant: BadgeVariant;
text: I18nText;
}
const healthStatus = (health: KioskHealth): StripStatus | null => {
if (health.level === "paused") {
return { kind: "paused", variant: "default-soft", text: t("dashboard.kiosk.paused") };
}
if (health.level === "warn" && health.kind === "hung") {
return { kind: "warn", variant: "warning-soft", text: t("dashboard.kiosk.hung") };
}
if (health.level === "warn") {
const text = t("dashboard.kiosk.panelsFailing", { count: health.count }, health.count);
return { kind: "warn", variant: "warning-soft", text };
}
if (health.level === "bad" && health.kind === "stale") {
const text = t("dashboard.kiosk.staleSince", { time: formatTime(health.since) });
return { kind: "bad", variant: "error-soft", text };
}
if (health.level === "bad") {
const text = t("dashboard.kiosk.unreachable", { seconds: health.retryInSeconds });
return { kind: "bad", variant: "error-soft", text };
}
return null;
};
// A TV that never refreshes goes stale silently, so "off" is a warning, not a caption.
const status = computed<StripStatus | null>(
() =>
healthStatus(props.health) ??
(props.refreshSeconds > 0
? null
: { kind: "refresh-off", variant: "warning-soft", text: t("dashboard.kiosk.refreshOff") }),
);
</script>
@@ -0,0 +1,67 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<span
class="text-text-heading font-medium whitespace-nowrap tabular-nums"
:tabindex="lastSuccessAt ? 0 : undefined"
>
{{ updatedText }}
<OTooltip v-if="lastSuccessAt" :content="exactTimeText" />
</span>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import { useI18nTyped } from "@/types/i18n";
import { formatAge, formatClockTime } from "@/utils/dashboard/kiosk";
const props = withDefaults(
defineProps<{
lastSuccessAt?: number | null;
timezone?: string;
}>(),
{ lastSuccessAt: null, timezone: undefined },
);
const { t } = useI18nTyped();
const now = ref(Date.now());
let ageTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
ageTimer = setInterval(() => (now.value = Date.now()), 10_000);
});
onBeforeUnmount(() => {
if (ageTimer) clearInterval(ageTimer);
});
watch(
() => props.lastSuccessAt,
() => (now.value = Date.now()),
);
const updatedText = computed(() =>
props.lastSuccessAt
? t("dashboard.kiosk.updatedAt", { time: formatAge(now.value - props.lastSuccessAt, t) })
: t("common.loading"),
);
const exactTimeText = computed(() =>
t("refreshButton.lastRefreshed", {
time: formatClockTime(props.lastSuccessAt ?? 0, props.timezone),
}),
);
</script>
@@ -0,0 +1,243 @@
// 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 <http://www.gnu.org/licenses/>.
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 store from "@/test/unit/helpers/store";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import PlaylistDialog from "./PlaylistDialog.vue";
import type { PlaylistDialogEntry } from "@/utils/dashboard/playlist";
const { listMock, copyMock } = vi.hoisted(() => ({
listMock: vi.fn(),
copyMock: vi.fn().mockResolvedValue(true),
}));
vi.mock("@/services/dashboards", async (importOriginal) => {
const actual: any = await importOriginal();
return { ...actual, default: { ...actual.default, list: listMock } };
});
vi.mock("@/utils/clipboard", () => ({ copyToClipboard: copyMock }));
vi.mock("vue-router", async (importOriginal) => {
const actual: any = await importOriginal();
return {
...actual,
useRouter: () => ({
resolve: ({ path, query }: { path: string; query: Record<string, string> }) => ({
href: `/web${path}?${new URLSearchParams(query).toString()}`,
}),
}),
};
});
const row = (id: string, title: string, folderId: string, folderName: string) => ({
dashboard_id: id,
title,
folder_id: folderId,
folder_name: folderName,
version: 8,
hash: "1",
});
const entries: PlaylistDialogEntry[] = [
{ dashboardId: "d1", folderId: "default", title: "Overview", folderName: "Default" },
{ dashboardId: "d2", folderId: "ops", title: "Payments", folderName: "Ops" },
];
const body = () => document.body;
const q = (testId: string) => body().querySelector<HTMLElement>(`[data-test="${testId}"]`);
const click = async (testId: string) => {
q(testId)!.click();
await flushPromises();
};
const itemTitles = () =>
[...body().querySelectorAll('[data-test^="dashboard-playlist-item-"]')].map((li) =>
li.textContent?.replace(/\s+/g, " ").trim(),
);
describe("PlaylistDialog", () => {
let wrapper: VueWrapper<any> | null = null;
const mountDialog = async (props: Record<string, unknown> = {}) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
wrapper = mount(PlaylistDialog, {
props: { open: true, entries, ...props },
attachTo: document.body,
global: { plugins: [i18n, store, [VueQueryPlugin, { queryClient }]] },
});
await flushPromises();
return wrapper;
};
beforeEach(() => {
store.state.selectedOrganization = { ...store.state.selectedOrganization, identifier: "acme" };
listMock.mockReset();
copyMock.mockClear();
});
afterEach(() => {
wrapper?.unmount();
wrapper = null;
document.body.innerHTML = "";
});
it("lists the selected dashboards in order and keeps explanations behind info icons", async () => {
await mountDialog();
expect(q("dashboard-playlist-count")!.textContent).toContain("2 of 50");
expect(itemTitles()[0]).toContain("Overview");
expect(itemTitles()[1]).toContain("Payments");
expect(itemTitles()[1]).toContain("Ops");
expect(q("dashboard-playlist-summary")).toBeNull();
expect(body().textContent).not.toContain("Each dashboard opens on its first tab");
const tips = wrapper!.findAllComponents(OTooltip).map((tip) => String(tip.props("content")));
expect(tips).toEqual(
expect.arrayContaining([
"Searches every folder; each result shows its folder",
"Each dashboard opens on its first tab with its own variable defaults",
"One range for all dashboards",
"Shrinks panels to the screen height so nothing scrolls.",
]),
);
});
it("reorders and removes rows, then opens with the edited order and settings", async () => {
await mountDialog();
await click("dashboard-playlist-down-0");
expect(itemTitles()[0]).toContain("Payments");
await click("dashboard-playlist-remove-1");
expect(itemTitles()).toHaveLength(1);
await click("dashboard-playlist-theme-dark");
await click("o-dialog-primary-btn");
const [[config]] = wrapper!.emitted("open") as any[];
expect(config).toMatchObject({
org: "acme",
entries: [{ dashboardId: "d2", folderId: "ops" }],
intervalSeconds: 60,
slideIndex: 0,
period: "1h",
theme: "dark",
fit: false,
});
expect(wrapper!.emitted("update:open")?.at(-1)).toEqual([false]);
});
it("adds dashboards from any folder through the search and marks those already added", async () => {
listMock.mockResolvedValue({
data: {
dashboards: [
row("d2", "Payments", "ops", "Ops"),
row("d9", "Revenue", "finance", "Finance"),
],
},
});
await mountDialog();
const input = body().querySelector<HTMLInputElement>(
'[data-test="dashboard-playlist-search"] input',
)!;
input.value = "re";
input.dispatchEvent(new Event("input"));
await new Promise((resolve) => setTimeout(resolve, 450));
await flushPromises();
expect(listMock).toHaveBeenCalledWith(0, 100, "name", false, "", "acme", "", "re");
expect(q("dashboard-playlist-result-d2")!.textContent).toContain("Added");
await click("dashboard-playlist-add-d9");
expect(itemTitles()).toHaveLength(3);
expect(itemTitles()[2]).toContain("Revenue");
expect(itemTitles()[2]).toContain("Finance");
});
it("stops adding at 50 dashboards", async () => {
const many = Array.from({ length: 50 }, (_, i) => ({
dashboardId: `d${i}`,
folderId: "default",
title: `Dash ${i}`,
folderName: "Default",
}));
await mountDialog({ entries: many });
expect(q("dashboard-playlist-count")!.textContent).toContain("50 of 50");
const input = body().querySelector<HTMLInputElement>(
'[data-test="dashboard-playlist-search"] input',
)!;
expect(input.disabled).toBe(true);
expect(input.placeholder).toContain("50 max");
});
it("copies a TV player link that encodes the whole playlist", async () => {
await mountDialog();
await click("o-dialog-secondary-btn");
const [href] = copyMock.mock.calls[0];
const url = new URL(href);
expect(url.pathname).toBe("/web/dashboards/tv");
expect(Object.fromEntries(url.searchParams)).toEqual({
org_identifier: "acme",
playlist: "d1:default,d2:ops",
interval: "1m",
slide: "1",
period: "1h",
});
});
it("confirms the copy on the button itself", async () => {
await mountDialog();
await click("o-dialog-secondary-btn");
expect(q("o-dialog-secondary-btn")!.textContent).toContain("Copied");
});
it("offers short slides and labels the cross-folder add", async () => {
await mountDialog();
expect(body().textContent).toContain("Add dashboards");
expect(q("dashboard-playlist-add-info")).not.toBeNull();
const values = (wrapper!.findComponent({ name: "OSelect" }).props("options") as any[]).map(
(option) => option.value,
);
expect(values.slice(0, 4)).toEqual([10, 15, 20, 30]);
});
it("disables both actions while the playlist is empty", async () => {
await mountDialog({ entries: [] });
expect(q("dashboard-playlist-empty")).not.toBeNull();
expect((q("o-dialog-primary-btn") as HTMLButtonElement).disabled).toBe(true);
expect((q("o-dialog-secondary-btn") as HTMLButtonElement).disabled).toBe(true);
});
it("names rows that carry only ids from their folder and flags gone ones", async () => {
listMock.mockResolvedValue({
data: { dashboards: [row("d1", "Overview", "default", "Default")] },
});
await mountDialog({
entries: [
{ dashboardId: "d1", folderId: "default" },
{ dashboardId: "gone", folderId: "default" },
],
});
await flushPromises();
expect(itemTitles()[0]).toContain("Overview");
expect(q("dashboard-playlist-missing-1")!.textContent).toContain("Not found");
});
});
@@ -0,0 +1,549 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<ODialog
:open="open"
size="md"
:title="t('dashboard.playlist.title')"
:neutral-button-label="t('common.cancel')"
neutral-button-variant="outline"
:secondary-button-label="
copied ? t('dashboard.playlist.copied') : t('dashboard.playlist.copyLink')
"
secondary-button-variant="outline"
:secondary-button-disabled="!items.length"
:primary-button-label="t('dashboard.playlist.open')"
:primary-button-disabled="!items.length"
data-test="dashboard-playlist-dialog"
@update:open="(value) => emit('update:open', value)"
@click:neutral="emit('update:open', false)"
@click:secondary="copyLink"
@click:primary="openPlaylist"
>
<div class="flex flex-col gap-5">
<section class="flex flex-col gap-2">
<span class="text-text-heading flex items-center gap-1.5 text-sm font-medium">
<OIcon name="add" size="sm" class="text-accent" />
{{ t("dashboard.playlist.addLabel") }}
<OIcon
name="info-outline"
size="sm"
class="text-text-secondary cursor-help"
data-test="dashboard-playlist-add-info"
><OTooltip :content="t('dashboard.playlist.addHint')"
/></OIcon>
</span>
<OSearchInput
v-model="query"
:debounce="400"
:disabled="full"
:placeholder="
full
? t('dashboard.playlist.fullPlaceholder', { max: maxEntries })
: t('dashboard.playlist.searchPlaceholder')
"
data-test="dashboard-playlist-search"
/>
<div
v-if="searchTerm"
class="border-border-default rounded-default flex max-h-48 flex-col overflow-y-auto border"
data-test="dashboard-playlist-results"
>
<div v-if="search.isLoading.value" class="flex flex-col gap-2 p-2">
<OSkeleton v-for="n in 3" :key="n" type="text" class="h-4" />
</div>
<div
v-else-if="search.isError.value"
class="text-status-error-text flex items-center gap-2 px-2 py-1.5 text-sm"
data-test="dashboard-playlist-search-error"
>
<OIcon name="error" size="sm" class="shrink-0" />
<span class="flex-1">{{ t("dashboard.playlist.searchFailed") }}</span>
<OButton variant="outline" size="xs" @click="search.refetch()">
{{ t("common.retry") }}
</OButton>
</div>
<template v-else-if="results.length">
<div
v-for="hit in results"
:key="entryKey(hit)"
class="flex min-w-0 items-center gap-2 px-2 py-1"
:data-test="`dashboard-playlist-result-${hit.dashboardId}`"
>
<OIcon name="dashboard" size="sm" class="text-text-secondary shrink-0" />
<OTruncatedText class="min-w-0 flex-1 text-sm">{{ hit.title }}</OTruncatedText>
<OBadge variant="default-soft" size="sm" icon="folder" class="max-w-40 shrink-0">
<OTruncatedText>{{ hit.folderName }}</OTruncatedText>
</OBadge>
<OButton
v-if="isAdded(hit)"
variant="ghost-success"
size="xs"
icon-left="check"
disabled
class="shrink-0"
>
{{ t("dashboard.playlist.added") }}
</OButton>
<OButton
v-else
variant="ghost-primary"
size="xs"
icon-left="add"
class="shrink-0"
:disabled="full"
:data-test="`dashboard-playlist-add-${hit.dashboardId}`"
@click="addHit(hit)"
>
{{ t("dashboard.playlist.add") }}
</OButton>
</div>
</template>
<p v-else class="text-text-secondary px-2 py-1.5 text-sm">
{{ t("dashboard.playlist.noMatches", { query: searchTerm }) }}
</p>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<span class="text-text-heading flex items-center gap-1.5 text-sm font-medium">
{{ t("dashboard.playlist.dashboards") }}
<OIcon
name="info-outline"
size="sm"
class="text-text-secondary cursor-help"
data-test="dashboard-playlist-dashboards-info"
><OTooltip :content="t('dashboard.playlist.variablesNote')"
/></OIcon>
</span>
<span
class="text-text-secondary text-xs tabular-nums"
data-test="dashboard-playlist-count"
>
{{ t("dashboard.playlist.countOfMax", { count: items.length, max: maxEntries }) }}
</span>
</div>
<ol
v-if="items.length"
class="border-border-default rounded-default divide-border-default flex flex-col divide-y border"
data-test="dashboard-playlist-items"
>
<li
v-for="(item, index) in items"
:key="entryKey(item)"
class="flex min-w-0 items-center gap-2 px-2 py-1"
:class="[
dragIndex === index ? 'opacity-50' : '',
dropIndex === index && dragIndex !== index ? 'bg-surface-subtle' : '',
]"
:draggable="isArmed(entryKey(item))"
:data-test="`dashboard-playlist-item-${index}`"
@dragstart="dragIndex = index"
@dragover.prevent="onDragOver(index)"
@drop.prevent="onDrop(index)"
@dragend="endDrag"
>
<OIcon
name="drag-indicator"
size="sm"
class="text-text-secondary shrink-0 cursor-grab"
:title="t('dashboard.playlist.dragToReorder')"
:data-test="`dashboard-playlist-grip-${index}`"
@mousedown="arm(entryKey(item))"
/>
<span class="text-text-secondary w-5 shrink-0 text-xs tabular-nums">
{{ index + 1 }}
</span>
<OIcon name="dashboard" size="sm" class="text-accent shrink-0" />
<OTruncatedText class="text-text-body min-w-0 flex-1 text-sm">
{{ describe(item).title }}
</OTruncatedText>
<OBadge
v-if="describe(item).missing"
variant="warning-soft"
size="sm"
class="shrink-0"
:data-test="`dashboard-playlist-missing-${index}`"
>
{{ t("dashboard.playlist.notFound") }}
</OBadge>
<OBadge
v-else-if="describe(item).folderName"
variant="default-soft"
size="sm"
icon="folder"
class="max-w-40 shrink-0 max-md:hidden"
>
<OTruncatedText>{{ describe(item).folderName }}</OTruncatedText>
</OBadge>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="keyboard-arrow-up"
:disabled="index === 0"
:aria-label="t('dashboard.playlist.moveUp')"
:data-test="`dashboard-playlist-up-${index}`"
@click="move(index, index - 1)"
>
<OTooltip :content="t('dashboard.playlist.moveUp')" />
</OButton>
<OButton
variant="ghost"
size="icon-xs-sq"
icon-left="keyboard-arrow-down"
:disabled="index === items.length - 1"
:aria-label="t('dashboard.playlist.moveDown')"
:data-test="`dashboard-playlist-down-${index}`"
@click="move(index, index + 1)"
>
<OTooltip :content="t('dashboard.playlist.moveDown')" />
</OButton>
<OButton
variant="ghost-muted"
size="icon-xs-sq"
icon-left="close"
:aria-label="t('dashboard.playlist.remove')"
:data-test="`dashboard-playlist-remove-${index}`"
@click="items.splice(index, 1)"
>
<OTooltip :content="t('dashboard.playlist.remove')" />
</OButton>
</li>
</ol>
<OEmptyState
v-else
size="inline"
icon="tv"
:title="t('dashboard.playlist.emptyTitle')"
:description="t('dashboard.playlist.emptyDescription')"
data-test="dashboard-playlist-empty"
/>
</section>
<section class="grid grid-cols-2 gap-4 max-md:grid-cols-1">
<OSelect
:label="t('dashboard.playlist.showEachFor')"
:model-value="intervalSeconds"
:options="intervalOptions"
size="md"
:searchable="false"
data-test="dashboard-playlist-interval"
@update:model-value="onIntervalChange"
>
<template #tooltip>
<OTooltip :content="t('dashboard.playlist.refreshNote')" />
</template>
</OSelect>
<OSelect
:label="t('dashboard.playlist.timeRange')"
:model-value="period ?? OWN_DEFAULT"
:options="periodOptions"
size="md"
:searchable="false"
data-test="dashboard-playlist-period"
@update:model-value="onPeriodChange"
>
<template #tooltip>
<OTooltip :content="t('dashboard.playlist.timeRangeHelp')" />
</template>
</OSelect>
<OToggleGroup
:model-value="theme"
type="single"
:label="t('dashboard.kiosk.theme')"
label-position="top"
data-test="dashboard-playlist-theme"
@update:model-value="onThemeChange"
>
<OToggleGroupItem
v-for="option in themeOptions"
:key="option.value"
:value="option.value"
size="sm"
:data-test="`dashboard-playlist-theme-${option.value}`"
>
{{ option.label }}
</OToggleGroupItem>
</OToggleGroup>
<OSwitch
v-model="fit"
class="self-end"
:label="t('dashboard.kiosk.fit')"
data-test="dashboard-playlist-fit"
>
<template #tooltip>
<OTooltip :content="t('dashboard.kiosk.fitHelp')" />
</template>
</OSwitch>
</section>
</div>
</ODialog>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useQueries, useQuery } from "@tanstack/vue-query";
import ODialog from "@/lib/overlay/Dialog/ODialog.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OSelect from "@/lib/forms/Select/OSelect.vue";
import OSwitch from "@/lib/forms/Switch/OSwitch.vue";
import OSearchInput from "@/lib/forms/SearchInput/OSearchInput.vue";
import OSkeleton from "@/lib/feedback/Skeleton/OSkeleton.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import useDragHandle from "@/composables/useDragHandle";
import { useOrgId } from "@/composables/query/useOrgId";
import {
dashboardsByFolderQuery,
dashboardsSearchQuery,
toSearchHit,
type DashboardSearchHit,
} from "@/services/dashboards.queries";
import { copyToClipboard } from "@/utils/clipboard";
import { raw, useI18nTyped } from "@/types/i18n";
import {
formatRefreshLabel,
formatRelativePeriod,
type KioskThemeChoice,
} from "@/utils/dashboard/kiosk";
import {
PLAYLIST_DEFAULT_INTERVAL_SECONDS,
PLAYLIST_INTERVAL_CHOICES,
PLAYLIST_MAX_ENTRIES,
PLAYLIST_PERIOD_CHOICES,
PLAYLIST_TV_PATH,
buildPlaylistQuery,
type PlaylistConfig,
type PlaylistDialogEntry,
type PlaylistEntry,
} from "@/utils/dashboard/playlist";
const props = defineProps<{
open: boolean;
entries: PlaylistDialogEntry[];
}>();
const emit = defineEmits<{
"update:open": [open: boolean];
open: [config: PlaylistConfig];
}>();
const { t } = useI18nTyped();
const router = useRouter();
const org = useOrgId();
const OWN_DEFAULT = "own";
const COPIED_MS = 2500;
const DEFAULT_PERIOD = "1h";
const maxEntries = PLAYLIST_MAX_ENTRIES;
const items = ref<PlaylistDialogEntry[]>([]);
const intervalSeconds = ref(PLAYLIST_DEFAULT_INTERVAL_SECONDS);
const period = ref<string | null>(DEFAULT_PERIOD);
const theme = ref<KioskThemeChoice>("current");
const fit = ref(false);
const query = ref("");
const entryKey = (entry: PlaylistEntry) => `${entry.folderId}/${entry.dashboardId}`;
// Each opening starts from what the caller passes in, not from the last edit.
watch(
() => props.open,
(isOpen) => {
if (!isOpen) return;
items.value = props.entries.slice(0, PLAYLIST_MAX_ENTRIES).map((entry) => ({ ...entry }));
intervalSeconds.value = PLAYLIST_DEFAULT_INTERVAL_SECONDS;
period.value = DEFAULT_PERIOD;
theme.value = "current";
fit.value = false;
query.value = "";
},
{ immediate: true },
);
const full = computed(() => items.value.length >= PLAYLIST_MAX_ENTRIES);
const searchTerm = computed(() => query.value.trim());
const search = useQuery(() =>
Object.assign(dashboardsSearchQuery(org.value, searchTerm.value), {
enabled: props.open && !!org.value && !!searchTerm.value,
}),
);
const results = computed<DashboardSearchHit[]>(() => search.data.value ?? []);
const isAdded = (hit: PlaylistEntry) =>
items.value.some((item) => entryKey(item) === entryKey(hit));
const addHit = (hit: DashboardSearchHit) => {
if (full.value || isAdded(hit)) return;
items.value.push({ ...hit });
};
// Rows opened from a link carry only ids, so their names come from each folder's (cached) list.
const foldersToResolve = computed(() => [
...new Set(
items.value.filter((item) => !item.title || !item.folderName).map((item) => item.folderId),
),
]);
const folderLists = useQueries({
queries: computed(() =>
foldersToResolve.value.map((folderId) =>
Object.assign(dashboardsByFolderQuery(org.value, folderId), {
enabled: props.open && !!org.value,
}),
),
),
});
const resolvedByKey = computed(() => {
const found = new Map<string, DashboardSearchHit>();
const settled = new Set<string>();
foldersToResolve.value.forEach((folderId, i) => {
const result = folderLists.value[i];
if (!result || result.isLoading) return;
settled.add(folderId);
(result.data ?? []).map(toSearchHit).forEach((hit) => found.set(entryKey(hit), hit));
});
return { found, settled };
});
const describe = (item: PlaylistDialogEntry) => {
const hit = resolvedByKey.value.found.get(entryKey(item));
const settled = resolvedByKey.value.settled.has(item.folderId);
return {
title: raw(item.title || hit?.title || item.dashboardId),
folderName: item.folderName || hit?.folderName || null,
missing: !item.title && !hit && settled,
};
};
const move = (from: number, to: number) => {
if (to < 0 || to >= items.value.length || from === to) return;
const [moved] = items.value.splice(from, 1);
items.value.splice(to, 0, moved);
};
const { arm, isArmed } = useDragHandle();
const dragIndex = ref<number | null>(null);
const dropIndex = ref<number | null>(null);
const onDragOver = (index: number) => {
if (dragIndex.value !== null) dropIndex.value = index;
};
const endDrag = () => {
dragIndex.value = null;
dropIndex.value = null;
};
const onDrop = (index: number) => {
const from = dragIndex.value;
endDrag();
if (from !== null) move(from, index);
};
const intervalOptions = computed(() => {
const values = new Set<number>(PLAYLIST_INTERVAL_CHOICES);
values.add(intervalSeconds.value);
return [...values]
.sort((a, b) => a - b)
.map((value) => ({ label: formatRefreshLabel(value, t), value }));
});
const periodOptions = computed(() => {
const values = new Set<string>(PLAYLIST_PERIOD_CHOICES);
if (period.value) values.add(period.value);
return [
{ label: t("dashboard.playlist.ownDefault"), value: OWN_DEFAULT },
...[...values].map((value) => ({
label: formatRelativePeriod(value, t) ?? raw(value),
value,
})),
];
});
const onIntervalChange = (value: unknown) => {
const seconds = Number(value);
if (seconds > 0) intervalSeconds.value = seconds;
};
const onPeriodChange = (value: unknown) => {
if (typeof value !== "string" || !value) return;
period.value = value === OWN_DEFAULT ? null : value;
};
const themeOptions = computed(() => [
{ value: "current" as const, label: t("dashboard.kiosk.themeCurrent") },
{ value: "light" as const, label: t("dashboard.kiosk.themeLight") },
{ value: "dark" as const, label: t("dashboard.kiosk.themeDark") },
]);
// Clicking the pressed item emits an empty value; a theme choice is always required.
const onThemeChange = (value: unknown) => {
if (value === "current" || value === "light" || value === "dark") theme.value = value;
};
const buildConfig = (): PlaylistConfig => ({
org: org.value,
entries: items.value.map(({ dashboardId, folderId }) => ({ dashboardId, folderId })),
intervalSeconds: intervalSeconds.value,
slideIndex: 0,
period: period.value,
theme: theme.value,
fit: fit.value,
timezone: null,
});
const copied = ref(false);
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
// The button itself confirms the copy, since a toast can land out of sight on a TV.
const copyLink = async () => {
const href = router.resolve({
path: PLAYLIST_TV_PATH,
query: buildPlaylistQuery(buildConfig()),
}).href;
const ok = await copyToClipboard(window.location.origin + href, t, {
successMessage: t("dashboard.playlist.linkCopied"),
timeout: 5000,
});
if (!ok) return;
copied.value = true;
if (copiedTimer) clearTimeout(copiedTimer);
copiedTimer = setTimeout(() => (copied.value = false), COPIED_MS);
};
onBeforeUnmount(() => {
if (copiedTimer) clearTimeout(copiedTimer);
});
const openPlaylist = () => {
if (!items.value.length) return;
emit("open", buildConfig());
emit("update:open", false);
};
</script>
@@ -0,0 +1,63 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="bg-surface-base absolute inset-0 z-20 flex flex-col items-center justify-center gap-4 text-center"
data-test="dashboard-playlist-holding"
>
<OSpinner size="lg" />
<p
v-if="eyebrow"
class="text-text-secondary text-lg font-medium"
data-test="dashboard-playlist-holding-eyebrow"
>
{{ eyebrow }}
</p>
<p
v-if="title"
class="text-text-heading text-3xl font-semibold"
data-test="dashboard-playlist-holding-title"
>
{{ title }}
</p>
<p
class="text-text-secondary text-xl tabular-nums"
data-test="dashboard-playlist-holding-label"
>
{{ label }}
</p>
<p v-if="note" class="text-text-secondary text-lg" data-test="dashboard-playlist-holding-note">
{{ note }}
</p>
</div>
</template>
<script setup lang="ts">
import OSpinner from "@/lib/feedback/Spinner/OSpinner.vue";
import type { I18nText } from "@/types/i18n";
withDefaults(
defineProps<{
label: I18nText;
title?: string | null;
note?: I18nText | null;
/** "Starting" or "Loading", above the name. */
eyebrow?: I18nText | null;
}>(),
{ title: null, note: null, eyebrow: null },
);
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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<string, string> }) => ({
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<string, any[]> = {
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<M, "source">
: never
: never;
const frames = () => [...document.body.querySelectorAll<HTMLIFrameElement>("iframe")];
const frameFor = (index: number) =>
document.body.querySelector<HTMLIFrameElement>(
`[data-test="dashboard-playlist-frame-${index}"]`,
)!;
const q = (testId: string) => document.body.querySelector<HTMLElement>(`[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<any> | null = null;
const mountPlayer = async (props: Record<string, unknown> = {}) => {
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();
});
});
@@ -0,0 +1,340 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div
class="bg-surface-base flex h-full min-h-0 flex-col"
:class="pointerActive ? '' : 'cursor-none [&_*]:cursor-none!'"
data-test="dashboard-playlist-player"
>
<PlaylistStrip
:title="slideName(currentIndex)"
:current="positionOf(currentIndex).current"
:total="positionOf(currentIndex).total"
:next-name="slideName(player.nextIndex.value)"
:progress="player.progress.value"
:next-at="player.nextAt.value"
:skipped="skipped"
:time-range-label="timeRangeLabel"
:held="player.held.value"
:paused="player.state.paused"
:paused-at="player.state.pausedAt"
:waiting="player.waiting.value"
:last-success-at="currentHealth?.lastSuccessAt ?? null"
:timezone="timezone"
@prev="player.prev()"
@next="player.next()"
@toggle-pause="player.togglePause()"
@exit="emit('exit')"
/>
<OBanner
v-if="intervalNoticeVisible"
variant="info"
icon="schedule"
dense
bar
:content="
t('dashboard.playlist.intervalAdjusted', {
interval: formatRefreshLabel(config.intervalSeconds, t),
})
"
data-test="dashboard-playlist-interval-notice"
/>
<div class="relative min-h-0 flex-1">
<!-- Inert: a dashboard that focuses an element inside its frame would otherwise swallow the next key press. -->
<iframe
v-for="slot in frames"
:key="slot.key"
:ref="(el) => setFrameEl(slot.key, el)"
:src="slideSrc(slot.index)"
:title="slideName(slot.index)"
inert
tabindex="-1"
class="pointer-events-none absolute inset-0 size-full border-0 transition-opacity duration-300"
:class="frameClass(slot)"
:data-test="`dashboard-playlist-frame-${slot.index}`"
:data-role="frameRole(slot)"
@load="onFrameLoad(slot.key, $event)"
/>
<PlaylistHoldingCard
v-if="holdingIndex !== null"
:eyebrow="
player.state.shown ? t('dashboard.playlist.loading') : t('dashboard.playlist.starting')
"
:title="slideName(holdingIndex)"
:label="positionLabel(holdingIndex)"
/>
<div
v-if="player.state.phase === 'empty'"
class="bg-surface-base absolute inset-0 z-30 flex items-center justify-center"
data-test="dashboard-playlist-empty-state"
>
<OEmptyState
icon="tv"
:title="t('dashboard.playlist.emptyStateTitle')"
:description="t('dashboard.playlist.emptyStateDescription')"
:action-label="t('dashboard.playlist.tryAgain')"
action-icon="refresh"
@action="player.retry()"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { useQueries } from "@tanstack/vue-query";
import OBanner from "@/lib/feedback/Banner/OBanner.vue";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import { useShortcuts } from "@/lib/vue-shortcut-manager";
import PlaylistStrip from "@/components/dashboards/kiosk/PlaylistStrip.vue";
import PlaylistHoldingCard from "@/components/dashboards/kiosk/PlaylistHoldingCard.vue";
import { usePlaylistPlayer, type PlayerSlot } from "@/composables/dashboard/usePlaylistPlayer";
import { useIdleReveal } from "@/composables/dashboard/useKioskDisplay";
import { dashboardsByFolderQuery, toSearchHit } from "@/services/dashboards.queries";
import { pingBackend } from "@/services/health";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import { formatRefreshLabel, formatRelativePeriod } from "@/utils/dashboard/kiosk";
import {
PLAYLIST_VIEW_PATH,
buildSlideQuery,
isFramingBlocked,
isPlaylistFrameMessage,
type PlaylistConfig,
} from "@/utils/dashboard/playlist";
const props = withDefaults(
defineProps<{
config: PlaylistConfig;
timezone?: string;
/** The link's interval was clamped or unreadable, so say which one is in use. */
intervalAdjusted?: boolean;
}>(),
{ timezone: undefined, intervalAdjusted: false },
);
const emit = defineEmits<{
slide: [index: number];
fallback: [index: number];
exit: [];
}>();
const { t } = useI18nTyped();
const router = useRouter();
const INTERVAL_NOTICE_MS = 8000;
const entryCount = props.config.entries.length;
const player = usePlaylistPlayer({
count: entryCount,
intervalSeconds: props.config.intervalSeconds,
startIndex: props.config.slideIndex,
onSlideShown: (index) => emit("slide", index),
onFallback: (index) => emit("fallback", index),
probe: pingBackend,
});
const currentIndex = computed(() => player.currentIndex.value);
const currentHealth = computed(() => player.state.slides[currentIndex.value]?.health ?? null);
const frames = computed(() =>
[player.state.leaving, player.state.shown, player.state.pending].filter(
(slot): slot is PlayerSlot => !!slot,
),
);
// Names come from each folder's (usually cached) list, so the first slide is named before its frame starts.
const folderIds = [...new Set(props.config.entries.map((entry) => entry.folderId))];
const folderLists = useQueries({
queries: computed(() =>
folderIds.map((folderId) => dashboardsByFolderQuery(props.config.org, folderId)),
),
});
const listedNames = computed(() => {
const found = new Map<string, string>();
folderLists.value.forEach((result) => {
(result.data ?? []).map(toSearchHit).forEach((hit) => {
found.set(`${hit.folderId}/${hit.dashboardId}`, hit.title);
});
});
return found;
});
const names = computed(() =>
props.config.entries.map(
(entry, index) =>
player.state.slides[index]?.title ||
listedNames.value.get(`${entry.folderId}/${entry.dashboardId}`) ||
entry.dashboardId,
),
);
const slideName = (index: number) => raw(names.value[index] ?? "");
/** "2 of 3" counts only dashboards that can be shown. */
const positionOf = (index: number) => {
const playable = player.state.slides
.map((slide, i) => ({ i, ok: !slide.missing || i === index }))
.filter((slide) => slide.ok);
const current = playable.findIndex((slide) => slide.i === index) + 1;
return { current: Math.max(1, current), total: playable.length };
};
const positionLabel = (index: number): I18nText =>
t("dashboard.playlist.position", positionOf(index));
const skipped = computed(() =>
player.state.slides.flatMap((slide, index) => {
if (!slide.missing) return [];
const name = slideName(index);
return [
slide.missing === "forbidden"
? t("dashboard.playlist.skippedForbidden", { name })
: t("dashboard.playlist.skippedNotFound", { name }),
];
}),
);
const holdingIndex = computed(() => {
if (player.state.phase === "empty") return null;
if (!player.state.shown) return currentIndex.value;
return player.jumpTarget.value;
});
const timeRangeLabel = computed(() => formatRelativePeriod(props.config.period, t));
const srcCache = new Map<number, string>();
const slideSrc = (index: number) => {
let src = srcCache.get(index);
if (!src) {
src = router.resolve({
path: PLAYLIST_VIEW_PATH,
query: buildSlideQuery(props.config, index, "frame"),
}).href;
srcCache.set(index, src);
}
return src;
};
const frameRole = (slot: PlayerSlot) => {
if (slot === player.state.shown) return "shown";
if (slot === player.state.pending) return "pending";
return "leaving";
};
const frameClass = (slot: PlayerSlot) => {
const role = frameRole(slot);
if (role === "shown") return "z-10 opacity-100";
if (role === "leaving") return "z-0 opacity-100";
return "z-0 opacity-0";
};
const frameEls = new Map<number, HTMLIFrameElement>();
const setFrameEl = (key: number, el: unknown) => {
if (el instanceof HTMLIFrameElement) frameEls.set(key, el);
else frameEls.delete(key);
};
const keyForSource = (source: MessageEventSource | null) => {
if (!source) return null;
for (const [key, el] of frameEls) {
if (el.contentWindow === source) return key;
}
return null;
};
const onWindowMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (!isPlaylistFrameMessage(event.data)) return;
const key = keyForSource(event.source);
if (key !== null) player.onMessage(key, event.data);
};
/** Only the response headers can tell a framing block from a browser error page (offline, reset). */
const checkFraming = async (key: number, src: string) => {
if (typeof navigator !== "undefined" && navigator.onLine === false) {
player.onFrameFailed(key);
return;
}
try {
const response = await fetch(src, { credentials: "same-origin", cache: "no-store" });
if (isFramingBlocked(response.headers, window.location.origin)) player.onFrameBlocked(key);
else player.onFrameFailed(key);
} catch {
player.onFrameFailed(key);
}
};
const onFrameLoad = (key: number, event: Event) => {
const el = event.target as HTMLIFrameElement | null;
let readable = false;
try {
readable = !!el?.contentDocument;
} catch {
readable = false;
}
if (readable) player.onFrameLoaded(key);
else if (el) void checkFraming(key, el.src);
};
const onOnline = () => player.onOnline();
const onOffline = () => player.onOffline();
const { active: pointerActive } = useIdleReveal(computed(() => true));
const intervalNoticeVisible = ref(props.intervalAdjusted);
let intervalNoticeTimer: ReturnType<typeof setTimeout> | null = null;
const whenFree = (action: () => void) => () => {
if (document.querySelector('[role="dialog"][data-state="open"], [role="menu"]')) return;
action();
};
useShortcuts([
{ id: "dashboardPlaylistPrev", handler: whenFree(() => player.prev()) },
{ id: "dashboardPlaylistNext", handler: whenFree(() => player.next()) },
{ id: "dashboardPlaylistPause", handler: whenFree(() => player.togglePause()) },
{ id: "dashboardPlaylistExit", handler: whenFree(() => emit("exit")) },
]);
onMounted(() => {
window.addEventListener("message", onWindowMessage);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
if (intervalNoticeVisible.value) {
intervalNoticeTimer = setTimeout(
() => (intervalNoticeVisible.value = false),
INTERVAL_NOTICE_MS,
);
}
player.start();
});
onBeforeUnmount(() => {
window.removeEventListener("message", onWindowMessage);
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
if (intervalNoticeTimer) clearTimeout(intervalNoticeTimer);
player.stop();
frameEls.clear();
});
defineExpose({ player });
</script>
@@ -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 <http://www.gnu.org/licenses/>.
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<string, unknown> = {}) =>
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<typeof mountStrip>, id: string) =>
wrapper.find(`[data-test="dashboard-playlist-${id}"]`);
const tooltips = (wrapper: ReturnType<typeof mountStrip>) =>
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);
});
});
@@ -0,0 +1,209 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="shrink-0" data-test="dashboard-playlist-strip">
<div
class="bg-surface-panel border-border-default px-page-edge flex h-11 min-w-0 items-center gap-3 border-b text-sm max-md:gap-2"
>
<span class="text-text-heading flex min-w-0 items-center gap-2 text-xl font-semibold">
<OIcon name="dashboard" size="md" class="text-accent shrink-0" />
<OTruncatedText data-test="dashboard-playlist-title">{{ title }}</OTruncatedText>
</span>
<span class="flex-1" />
<OBadge
v-if="held"
variant="error-soft"
size="lg"
dot
class="shrink-0"
data-test="dashboard-playlist-held"
>{{ t("dashboard.playlist.unreachable") }}</OBadge
>
<div
class="bg-surface-subtle rounded-surface flex shrink-0 items-center gap-3 px-3 py-0.5"
data-test="dashboard-playlist-meta"
>
<span class="flex items-center gap-2 font-semibold whitespace-nowrap tabular-nums">
<KioskCountdownRing
:progress="progress"
:state="ringState"
:tooltip="ringTooltip"
data-test="dashboard-playlist-rotation"
>
<OIcon v-if="ringState === 'paused'" name="pause" size="sm" />
<span
v-else
class="text-text-heading text-xs font-semibold"
data-test="dashboard-playlist-current"
>{{ current }}</span
>
</KioskCountdownRing>
<span
class="text-text-heading font-medium"
:tabindex="skipped.length ? 0 : undefined"
data-test="dashboard-playlist-position"
>
{{ t("dashboard.playlist.ofTotal", { total }) }}
<OTooltip v-if="skipped.length" :content="skippedText" />
</span>
</span>
<span
v-if="timeRangeLabel"
class="text-text-secondary flex items-center gap-1.5 whitespace-nowrap max-md:hidden"
data-test="dashboard-playlist-time-range"
>
<OIcon name="schedule" size="sm" />
{{ timeRangeLabel }}
</span>
<KioskUpdatedAt
:last-success-at="lastSuccessAt"
:timezone="timezone"
class="max-md:hidden"
data-test="dashboard-playlist-updated"
/>
</div>
<span class="flex shrink-0 items-center gap-1.5" data-test="dashboard-playlist-controls">
<OButton
variant="outline"
size="icon-sm"
icon-left="navigate-before"
:aria-label="t('dashboard.playlist.previous')"
data-test="dashboard-playlist-prev"
@click="emit('prev')"
>
<OTooltip
:content="t('dashboard.playlist.previous')"
shortcut-id="dashboardPlaylistPrev"
/>
</OButton>
<OButton
variant="outline"
size="icon-sm"
:icon-left="paused ? 'play-arrow' : 'pause'"
:aria-label="paused ? t('dashboard.playlist.resume') : t('dashboard.playlist.pause')"
data-test="dashboard-playlist-pause"
@click="emit('toggle-pause')"
>
<OTooltip
:content="paused ? t('dashboard.playlist.resume') : t('dashboard.playlist.pause')"
shortcut-id="dashboardPlaylistPause"
/>
</OButton>
<OButton
variant="outline"
size="icon-sm"
icon-left="navigate-next"
:aria-label="t('dashboard.playlist.next')"
data-test="dashboard-playlist-next"
@click="emit('next')"
>
<OTooltip :content="t('dashboard.playlist.next')" shortcut-id="dashboardPlaylistNext" />
</OButton>
</span>
<OButton
variant="outline"
size="icon-sm"
icon-left="close"
:aria-label="t('dashboard.playlist.exit')"
data-test="dashboard-playlist-exit"
@click="emit('exit')"
>
<OTooltip :content="t('dashboard.playlist.exit')" shortcut-id="dashboardPlaylistExit" />
</OButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OBadge from "@/lib/core/Badge/OBadge.vue";
import OButton from "@/lib/core/Button/OButton.vue";
import OIcon from "@/lib/core/Icon/OIcon.vue";
import OTooltip from "@/lib/overlay/Tooltip/OTooltip.vue";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import { raw, useI18nTyped, type I18nText } from "@/types/i18n";
import KioskCountdownRing from "./KioskCountdownRing.vue";
import KioskUpdatedAt from "./KioskUpdatedAt.vue";
import { formatClockTime } from "@/utils/dashboard/kiosk";
const props = withDefaults(
defineProps<{
title: I18nText;
/** 1-based position among the dashboards that can be shown. */
current: number;
total: number;
/** Name of the slide the rotation moves to next. */
nextName?: I18nText | null;
/** Share of this slide's dwell already elapsed, 0 to 1. */
progress?: number;
/** When the rotation moves on, as epoch ms. */
nextAt?: number | null;
/** One line per dashboard that is skipped (deleted or no access). */
skipped?: I18nText[];
timeRangeLabel?: I18nText | null;
held?: boolean;
paused?: boolean;
pausedAt?: number | null;
waiting?: boolean;
lastSuccessAt?: number | null;
timezone?: string;
}>(),
{
nextName: null,
progress: 0,
nextAt: null,
skipped: () => [],
timeRangeLabel: null,
held: false,
paused: false,
pausedAt: null,
waiting: false,
lastSuccessAt: null,
timezone: undefined,
},
);
const emit = defineEmits<{
prev: [];
next: [];
"toggle-pause": [];
exit: [];
}>();
const { t } = useI18nTyped();
const skippedText = computed(() => raw(props.skipped.join(" · ")));
type RingState = "running" | "spinning" | "paused";
const ringState = computed<RingState>(() => {
if (props.paused || props.held) return "paused";
return props.waiting ? "spinning" : "running";
});
const ringTooltip = computed(() => {
if (props.held) return t("dashboard.playlist.held");
if (props.paused) {
return t("dashboard.playlist.pausedSince", {
time: formatClockTime(props.pausedAt ?? Date.now(), props.timezone, false),
});
}
if (props.waiting) return t("dashboard.playlist.waiting");
const time = formatClockTime(props.nextAt ?? Date.now(), props.timezone);
return t("dashboard.playlist.nextAt", { name: props.nextName ?? raw(""), time });
});
</script>
@@ -0,0 +1,113 @@
// 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 <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { effectScope, nextTick, ref } from "vue";
import { createStore } from "vuex";
import { applyKioskTheme, clearKioskTheme, useIdleReveal } from "./useKioskDisplay";
const makeStore = (theme: string) =>
createStore({
state: { theme, themeOverride: null as string | null },
mutations: {
appTheme(state, payload) {
state.theme = payload;
},
setThemeOverride(state, payload) {
state.themeOverride = payload;
},
},
actions: {
appTheme(context, payload) {
context.commit("appTheme", payload);
},
},
});
describe("kiosk theme override", () => {
beforeEach(() => {
localStorage.setItem("theme", "light");
document.documentElement.classList.remove("dark");
});
afterEach(() => {
localStorage.removeItem("theme");
document.documentElement.classList.remove("dark");
});
it("forces the theme for this screen without touching the saved preference", () => {
const store = makeStore("light");
const setItem = vi.spyOn(Storage.prototype, "setItem");
applyKioskTheme(store, "dark");
expect(store.state.theme).toBe("dark");
expect(store.state.themeOverride).toBe("dark");
expect(document.documentElement.classList.contains("dark")).toBe(true);
expect(setItem).not.toHaveBeenCalledWith("theme", expect.anything());
expect(localStorage.getItem("theme")).toBe("light");
setItem.mockRestore();
});
it("restores the saved preference when the override is cleared", () => {
const store = makeStore("light");
applyKioskTheme(store, "dark");
clearKioskTheme(store);
expect(store.state.themeOverride).toBeNull();
expect(store.state.theme).toBe("light");
expect(document.documentElement.classList.contains("dark")).toBe(false);
});
it("leaves the theme alone when nothing was forced", () => {
const store = makeStore("dark");
clearKioskTheme(store);
applyKioskTheme(store, null);
expect(store.state.theme).toBe("dark");
});
});
describe("useIdleReveal", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("shows on entry and on real movement, ignores a still cursor, then hides after the timeout", async () => {
const enabled = ref(false);
const scope = effectScope();
const { active } = scope.run(() => useIdleReveal(enabled, 3000))!;
expect(active.value).toBe(false);
enabled.value = true;
await nextTick();
expect(active.value).toBe(true);
vi.advanceTimersByTime(3000);
expect(active.value).toBe(false);
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 10, clientY: 10 }));
expect(active.value).toBe(true);
vi.advanceTimersByTime(3000);
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 10, clientY: 10 }));
expect(active.value).toBe(false);
enabled.value = false;
await nextTick();
window.dispatchEvent(new MouseEvent("mousemove", { clientX: 40, clientY: 40 }));
expect(active.value).toBe(false);
scope.stop();
});
});
@@ -0,0 +1,102 @@
// 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 <http://www.gnu.org/licenses/>.
import { getCurrentInstance, onBeforeUnmount, ref, watch, type Ref } from "vue";
import type { Store } from "vuex";
import { switchThemeMode } from "@/utils/theme";
import type { KioskTheme } from "@/utils/dashboard/kiosk";
const IDLE_EVENTS = ["mousemove", "pointerdown", "touchstart", "keydown"] as const;
const readSavedTheme = (): KioskTheme => {
try {
return localStorage.getItem("theme") === "dark" ? "dark" : "light";
} catch {
return "light";
}
};
const applyMode = (store: Store<any>, mode: KioskTheme) => {
switchThemeMode(mode, () => {
document.documentElement.classList.toggle("dark", mode === "dark");
store.dispatch("appTheme", mode);
});
};
/** Forces a theme for this screen only; the override flag stops ThemeSwitcher from persisting it. */
export const applyKioskTheme = (store: Store<any>, theme: KioskTheme | null) => {
if (!theme) {
clearKioskTheme(store);
return;
}
store.commit("setThemeOverride", theme);
applyMode(store, theme);
};
export const clearKioskTheme = (store: Store<any>) => {
if (!store.state.themeOverride) return;
store.commit("setThemeOverride", null);
applyMode(store, readSavedTheme());
};
/** True for `timeoutMs` after any pointer or key activity while `enabled`; hides the idle cursor on a wall screen. */
export const useIdleReveal = (enabled: Ref<boolean>, timeoutMs = 3000) => {
const active = ref(false);
let timer: ReturnType<typeof setTimeout> | null = null;
const poke = () => {
active.value = true;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
active.value = false;
}, timeoutMs);
};
let lastX: number | null = null;
let lastY: number | null = null;
// Chrome fires synthetic mousemoves at a still cursor whenever the layout under it changes (panels loading).
const onActivity = (event: Event) => {
if (event.type === "mousemove" && event instanceof MouseEvent) {
const moved = event.clientX !== lastX || event.clientY !== lastY;
lastX = event.clientX;
lastY = event.clientY;
if (!moved) return;
}
poke();
};
const detach = () => {
IDLE_EVENTS.forEach((event) => window.removeEventListener(event, onActivity));
if (timer) clearTimeout(timer);
timer = null;
active.value = false;
};
watch(
enabled,
(on) => {
detach();
if (!on) return;
IDLE_EVENTS.forEach((event) => window.addEventListener(event, onActivity, { passive: true }));
poke();
},
{ immediate: true },
);
if (getCurrentInstance()) onBeforeUnmount(detach);
return { active, poke, stop: detach };
};
@@ -0,0 +1,316 @@
// 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 <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { effectScope, nextTick, ref, type EffectScope } from "vue";
import { useKioskRefresh, type UseKioskRefresh } from "./useKioskRefresh";
const setHidden = (hidden: boolean) => {
Object.defineProperty(document, "hidden", { configurable: true, get: () => hidden });
document.dispatchEvent(new Event("visibilitychange"));
};
const setOnline = (online: boolean) => {
Object.defineProperty(navigator, "onLine", { configurable: true, get: () => online });
};
describe("useKioskRefresh", () => {
let api: UseKioskRefresh;
let scope: EffectScope | null = null;
const enabled = ref(false);
const intervalSeconds = ref(10);
const isLoading = ref(false);
const refresh = vi.fn();
const cancel = vi.fn();
const setup = async (on = true, probe?: () => Promise<boolean>) => {
scope = effectScope();
api = scope.run(() =>
useKioskRefresh({ enabled, intervalSeconds, isLoading, refresh, cancel, probe }),
)!;
enabled.value = on;
await nextTick();
};
// Mimics one dashboard load: panels start loading, then finish.
const finishLoad = async () => {
isLoading.value = true;
await nextTick();
isLoading.value = false;
await nextTick();
vi.advanceTimersByTime(300);
};
beforeEach(() => {
vi.useFakeTimers();
enabled.value = false;
intervalSeconds.value = 10;
isLoading.value = false;
refresh.mockReset();
cancel.mockReset();
setOnline(true);
Object.defineProperty(document, "hidden", { configurable: true, get: () => false });
});
afterEach(() => {
api?.stop();
scope?.stop();
scope = null;
enabled.value = false;
vi.useRealTimers();
});
it("does nothing while kiosk is off", async () => {
await setup(false);
api.onTick();
vi.advanceTimersByTime(60000);
expect(refresh).not.toHaveBeenCalled();
expect(api.health.value).toEqual({ level: "ok" });
});
it("refreshes on its own every interval and records the success", async () => {
await setup();
await finishLoad();
expect(api.lastSuccessAt.value).not.toBeNull();
vi.advanceTimersByTime(10000);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("skips a tick while a normal slow query is still loading", async () => {
await setup();
await finishLoad();
api.onTick();
isLoading.value = true;
await nextTick();
refresh.mockClear();
vi.advanceTimersByTime(10000);
expect(refresh).not.toHaveBeenCalled();
expect(cancel).not.toHaveBeenCalled();
});
it("cancels a hung query and refreshes anyway, then says so", async () => {
await setup();
await finishLoad();
api.onTick();
isLoading.value = true;
await nextTick();
refresh.mockClear();
vi.advanceTimersByTime(40000);
expect(cancel).toHaveBeenCalledTimes(1);
expect(refresh).toHaveBeenCalledTimes(1);
isLoading.value = false;
await nextTick();
vi.advanceTimersByTime(300);
expect(api.health.value).toEqual({ level: "warn", kind: "hung" });
});
it("counts failing panels and clears them when they recover", async () => {
await setup();
api.registry.register("a");
api.registry.register("b");
api.registry.report("a", { message: "stream not found", code: 400 });
await finishLoad();
expect(api.health.value).toEqual({ level: "warn", kind: "failing", count: 1 });
api.registry.report("a", { message: "" });
await nextTick();
expect(api.health.value).toEqual({ level: "ok" });
});
it("backs off while every panel fails with a server error and refreshes as soon as the network is back", async () => {
await setup();
api.registry.register("a");
api.registry.report("a", { message: "502 Bad Gateway", code: 502 });
await finishLoad();
const health = api.health.value;
expect(health.level).toBe("bad");
expect(health).toMatchObject({ kind: "unreachable", retryInSeconds: 5 });
// The regular tick does not pile on while the retry timer owns refreshes.
refresh.mockClear();
vi.advanceTimersByTime(4000);
expect(refresh).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(refresh).toHaveBeenCalledTimes(1);
refresh.mockClear();
window.dispatchEvent(new Event("online"));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not treat a client error on every panel as an outage", async () => {
await setup();
api.registry.register("a");
api.registry.report("a", { message: "bad query", code: 400 });
await finishLoad();
expect(api.health.value).toEqual({ level: "warn", kind: "failing", count: 1 });
});
it("marks the screen stale when nothing succeeded for twice the interval", async () => {
await setup();
await finishLoad();
const since = api.lastSuccessAt.value;
isLoading.value = true;
await nextTick();
vi.advanceTimersByTime(21000);
expect(api.health.value).toEqual({ level: "bad", kind: "stale", since });
});
it("pauses while the tab is hidden and refreshes once when it is shown again", async () => {
await setup();
await finishLoad();
setHidden(true);
expect(api.health.value).toEqual({ level: "paused" });
vi.advanceTimersByTime(30000);
expect(refresh).not.toHaveBeenCalled();
setHidden(false);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("restarts its ticker when the interval changes", async () => {
await setup();
await finishLoad();
intervalSeconds.value = 60;
await nextTick();
vi.advanceTimersByTime(30000);
expect(refresh).not.toHaveBeenCalled();
vi.advanceTimersByTime(30000);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("counts down to the next tick and starts over after it fires", async () => {
const start = Date.UTC(2026, 9, 22, 23, 16, 0);
vi.setSystemTime(start);
await setup();
expect(api.countdown.value).toEqual({
remainingMs: 10000,
totalMs: 10000,
at: start + 10000,
retrying: false,
});
vi.advanceTimersByTime(4000);
expect(api.countdown.value).toMatchObject({ remainingMs: 6000, at: start + 10000 });
vi.advanceTimersByTime(6000);
expect(api.countdown.value).toMatchObject({ remainingMs: 10000, at: start + 20000 });
});
it("counts down to the retry during an outage", async () => {
await setup();
api.registry.register("a");
api.registry.report("a", { message: "502 Bad Gateway", code: 502 });
await finishLoad();
expect(api.countdown.value).toMatchObject({ remainingMs: 5000, totalMs: 5000, retrying: true });
const retryIn = (api.countdown.value?.at ?? 0) - Date.now();
expect(retryIn).toBeGreaterThan(4000);
expect(retryIn).toBeLessThanOrEqual(5000);
vi.advanceTimersByTime(2000);
// The clock ticks once a second, so the countdown is accurate to a second.
expect(api.countdown.value?.totalMs).toBe(5000);
expect(api.countdown.value?.remainingMs).toBeGreaterThanOrEqual(3000);
expect(api.countdown.value?.remainingMs).toBeLessThan(4000);
});
it("has no countdown while auto-refresh is off, the tab is hidden or kiosk is off", async () => {
await setup(false);
expect(api.countdown.value).toBeNull();
enabled.value = true;
intervalSeconds.value = 0;
await nextTick();
expect(api.countdown.value).toBeNull();
intervalSeconds.value = 10;
await nextTick();
setHidden(true);
expect(api.countdown.value).toBeNull();
setHidden(false);
});
it("forces a real load when the first paint came from the browser cache", async () => {
await setup();
vi.advanceTimersByTime(3300);
expect(refresh).toHaveBeenCalledTimes(1);
expect(api.lastSuccessAt.value).toBeNull();
await finishLoad();
expect(api.lastSuccessAt.value).not.toBeNull();
});
it("checks the backend first and fires no refresh while it is down, so panels keep their last render", async () => {
const probe = vi.fn().mockResolvedValue(false);
await setup(true, probe);
await finishLoad();
await vi.advanceTimersByTimeAsync(2000);
refresh.mockClear();
probe.mockClear();
await vi.advanceTimersByTimeAsync(10000);
expect(probe).toHaveBeenCalled();
expect(refresh).not.toHaveBeenCalled();
expect(api.health.value).toMatchObject({ level: "bad", kind: "unreachable" });
expect(api.registry.outage.value).toBe(true);
probe.mockResolvedValue(true);
await vi.advanceTimersByTimeAsync(12000);
expect(refresh).toHaveBeenCalledTimes(1);
});
it("does not refresh at all while the browser is offline", async () => {
await setup();
await finishLoad();
setOnline(false);
vi.advanceTimersByTime(10000);
expect(refresh).not.toHaveBeenCalled();
expect(api.health.value).toMatchObject({ level: "bad", kind: "unreachable" });
setOnline(true);
window.dispatchEvent(new Event("online"));
expect(refresh).toHaveBeenCalledTimes(1);
});
it("treats a finished cycle as an outage when the backend stopped answering mid-way", async () => {
const probe = vi.fn().mockResolvedValue(true);
await setup(true, probe);
await finishLoad();
await vi.advanceTimersByTimeAsync(0);
expect(api.lastSuccessAt.value).not.toBeNull();
refresh.mockClear();
probe.mockResolvedValueOnce(true).mockResolvedValue(false);
await vi.advanceTimersByTimeAsync(10000);
expect(refresh).toHaveBeenCalledTimes(1);
await finishLoad();
await vi.advanceTimersByTimeAsync(2000);
expect(api.health.value).toMatchObject({ level: "bad", kind: "unreachable" });
});
it("does not call one slow health check an outage: a miss is confirmed before the screen says so", async () => {
const probe = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
await setup(true, probe);
await finishLoad();
await vi.advanceTimersByTimeAsync(0);
expect(api.health.value.level).not.toBe("bad");
await vi.advanceTimersByTimeAsync(2000);
expect(probe).toHaveBeenCalledTimes(2);
expect(api.health.value.level).toBe("ok");
expect(api.lastSuccessAt.value).not.toBeNull();
});
});
@@ -0,0 +1,414 @@
// 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 <http://www.gnu.org/licenses/>.
import {
computed,
getCurrentInstance,
onBeforeUnmount,
reactive,
ref,
watch,
type ComputedRef,
type InjectionKey,
type Ref,
} from "vue";
import { hungAfterMs, retryDelayMs, staleAfterMs } from "@/utils/dashboard/kiosk";
export interface KioskPanelError {
message: string;
code?: string | number | null;
}
/** Panels report into this so kiosk mode can count failures; absent outside the dashboard view. */
export interface KioskPanelHealthRegistry {
register: (panelId: string) => void;
unregister: (panelId: string) => void;
report: (panelId: string, error: KioskPanelError | null) => void;
/** True while the screen is known to be out of date (backend unreachable or nothing refreshed). */
outage: ComputedRef<boolean>;
}
export const KIOSK_PANEL_HEALTH_KEY: InjectionKey<KioskPanelHealthRegistry> =
Symbol("kiosk-panel-health");
export type KioskHealth =
| { level: "ok" }
| { level: "paused" }
| { level: "warn"; kind: "hung" }
| { level: "warn"; kind: "failing"; count: number }
| { level: "bad"; kind: "stale"; since: number }
| { level: "bad"; kind: "unreachable"; retryInSeconds: number };
/** Time left until the next automatic load, for the strip's countdown ring. */
export interface KioskCountdown {
remainingMs: number;
totalMs: number;
/** Epoch ms of the next load. */
at: number;
/** True while the next load is the backoff retry after an outage. */
retrying: boolean;
}
export interface UseKioskRefreshOptions {
enabled: Ref<boolean>;
intervalSeconds: Ref<number>;
isLoading: Ref<boolean>;
/** Starts a refresh without the "panels still loading" guard. */
refresh: () => unknown;
cancel: () => unknown;
/** Cheap liveness check; when it fails no refresh is fired, so panels keep their last render. */
probe?: () => Promise<boolean>;
now?: () => number;
}
export interface UseKioskRefresh {
registry: KioskPanelHealthRegistry;
onTick: () => void;
health: ComputedRef<KioskHealth>;
failingCount: ComputedRef<number>;
lastSuccessAt: Ref<number | null>;
/** Null while auto-refresh is off or the tab is hidden. */
countdown: ComputedRef<KioskCountdown | null>;
stop: () => void;
}
// Lets the panels' error emits land before the finished cycle is judged.
const SETTLE_DELAY_MS = 250;
// A refresh served entirely from cache never flips the loading flag, so the cycle is closed by this instead.
const NO_LOAD_FALLBACK_MS = 2000;
// Panels restored from the browser cache never flip the loading flag, so the first cycle forces a real load after this.
const INITIAL_LOAD_FALLBACK_MS = 3000;
const CLOCK_MS = 1000;
// One slow health check (a page still loading its own assets) must not read as an outage, so a miss is asked again.
const PROBE_CONFIRM_MS = 2000;
const isClientError = (error: KioskPanelError) => String(error.code ?? "").startsWith("4");
export const useKioskRefresh = (options: UseKioskRefreshOptions): UseKioskRefresh => {
const now = options.now ?? (() => Date.now());
const panels = reactive(new Map<string, KioskPanelError | null>());
const failingCount = computed(() => {
let count = 0;
panels.forEach((error) => {
if (error) count++;
});
return count;
});
const clock = ref(now());
const enabledAt = ref(now());
const lastSuccessAt = ref<number | null>(null);
const consecutiveFailures = ref(0);
const nextRetryAt = ref<number | null>(null);
const retryWindowMs = ref(0);
const nextTickAt = ref<number | null>(null);
const hungForced = ref(false);
const paused = ref(false);
let cycleOpen = false;
let initialCycle = false;
let sawLoading = false;
let probing = false;
// Bumped on stop so a probe that answers after kiosk was left is ignored.
let epoch = 0;
let cycleForced = false;
let cycleStartedAt = 0;
let missedWhileHidden = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let fallbackTimer: ReturnType<typeof setTimeout> | null = null;
let clockTimer: ReturnType<typeof setInterval> | null = null;
let tickTimer: ReturnType<typeof setInterval> | null = null;
const clearTimer = (timer: ReturnType<typeof setTimeout> | null) => {
if (timer) clearTimeout(timer);
return null;
};
const isHidden = () => typeof document !== "undefined" && document.hidden === true;
const isOffline = () => typeof navigator !== "undefined" && navigator.onLine === false;
const isUnreachable = () => {
if (isOffline()) return true;
const errors = [...panels.values()];
if (!errors.length || errors.some((error) => !error)) return false;
return errors.every((error) => error && !isClientError(error));
};
const confirmReachable = async () => {
if (!options.probe) return true;
if (await options.probe()) return true;
if (isOffline()) return false;
await new Promise((resolve) => setTimeout(resolve, PROBE_CONFIRM_MS));
return options.probe();
};
const scheduleRetry = () => {
retryTimer = clearTimer(retryTimer);
const delay = retryDelayMs(consecutiveFailures.value, options.intervalSeconds.value);
clock.value = now();
retryWindowMs.value = delay;
nextRetryAt.value = clock.value + delay;
retryTimer = setTimeout(() => {
retryTimer = null;
if (isHidden()) {
missedWhileHidden = true;
return;
}
startCycle(false);
}, delay);
};
const recordOutage = () => {
cycleOpen = false;
fallbackTimer = clearTimer(fallbackTimer);
consecutiveFailures.value++;
scheduleRetry();
};
const recordSuccess = () => {
consecutiveFailures.value = 0;
nextRetryAt.value = null;
retryTimer = clearTimer(retryTimer);
hungForced.value = cycleForced;
lastSuccessAt.value = now();
};
const judgeCycle = async () => {
if (isUnreachable()) return recordOutage();
if (options.probe) {
const started = epoch;
const reachable = await confirmReachable();
if (started !== epoch) return;
if (!reachable) return recordOutage();
}
recordSuccess();
};
const finishCycle = () => {
if (!cycleOpen || options.isLoading.value) return;
fallbackTimer = clearTimer(fallbackTimer);
if (initialCycle && !sawLoading) {
initialCycle = false;
startCycle(false);
return;
}
initialCycle = false;
cycleOpen = false;
void judgeCycle();
};
const settleSoon = () => {
settleTimer = clearTimer(settleTimer);
settleTimer = setTimeout(() => {
settleTimer = null;
finishCycle();
}, SETTLE_DELAY_MS);
};
const openCycle = (forced: boolean, fallbackMs: number) => {
cycleOpen = true;
sawLoading = options.isLoading.value;
cycleForced = forced;
cycleStartedAt = now();
if (forced) hungForced.value = true;
fallbackTimer = clearTimer(fallbackTimer);
fallbackTimer = setTimeout(() => {
fallbackTimer = null;
if (!options.isLoading.value) settleSoon();
}, fallbackMs);
};
const runCycle = (forced: boolean) => {
openCycle(forced, NO_LOAD_FALLBACK_MS);
options.refresh();
};
// Checking first means an outage never wipes the panels with a failed refresh.
function startCycle(forced: boolean) {
if (isOffline()) return recordOutage();
if (!options.probe) return runCycle(forced);
const started = epoch;
probing = true;
void confirmReachable().then((reachable) => {
probing = false;
if (started !== epoch) return;
if (reachable) runCycle(forced);
else recordOutage();
});
}
const onTick = () => {
if (!options.enabled.value) return;
if (isHidden()) {
missedWhileHidden = true;
return;
}
// The retry timer owns refreshes until the backend answers again.
if (consecutiveFailures.value > 0 || probing) return;
if (options.isLoading.value) {
if (cycleStartedAt && now() - cycleStartedAt >= hungAfterMs(options.intervalSeconds.value)) {
options.cancel();
startCycle(true);
}
return;
}
startCycle(false);
};
const refreshNow = () => {
retryTimer = clearTimer(retryTimer);
nextRetryAt.value = null;
startCycle(false);
};
const onVisibilityChange = () => {
paused.value = isHidden();
if (!paused.value && missedWhileHidden) {
missedWhileHidden = false;
refreshNow();
}
};
const onOnline = () => {
if (consecutiveFailures.value > 0) refreshNow();
};
const restartTicker = () => {
if (tickTimer) clearInterval(tickTimer);
tickTimer = null;
nextTickAt.value = null;
const seconds = options.intervalSeconds.value;
if (!options.enabled.value || seconds <= 0) return;
nextTickAt.value = now() + seconds * 1000;
tickTimer = setInterval(() => {
nextTickAt.value = now() + seconds * 1000;
onTick();
}, seconds * 1000);
};
const start = () => {
enabledAt.value = now();
clock.value = now();
paused.value = isHidden();
// The mount (or the entry into kiosk) is itself a load, so the first finished cycle counts as a success.
initialCycle = true;
openCycle(false, INITIAL_LOAD_FALLBACK_MS);
clockTimer = setInterval(() => {
clock.value = now();
}, CLOCK_MS);
restartTicker();
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("online", onOnline);
};
const stop = () => {
epoch++;
probing = false;
initialCycle = false;
retryTimer = clearTimer(retryTimer);
settleTimer = clearTimer(settleTimer);
fallbackTimer = clearTimer(fallbackTimer);
if (clockTimer) clearInterval(clockTimer);
clockTimer = null;
if (tickTimer) clearInterval(tickTimer);
tickTimer = null;
cycleOpen = false;
cycleStartedAt = 0;
missedWhileHidden = false;
consecutiveFailures.value = 0;
nextRetryAt.value = null;
retryWindowMs.value = 0;
nextTickAt.value = null;
hungForced.value = false;
paused.value = false;
lastSuccessAt.value = null;
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibilityChange);
}
if (typeof window !== "undefined") window.removeEventListener("online", onOnline);
};
watch(
options.enabled,
(enabled) => {
stop();
if (enabled) start();
},
{ immediate: true },
);
watch(options.intervalSeconds, () => {
if (options.enabled.value) restartTicker();
});
watch(options.isLoading, (loading) => {
if (!options.enabled.value) return;
if (loading) {
if (cycleOpen) sawLoading = true;
return;
}
settleSoon();
});
const health = computed<KioskHealth>(() => {
if (!options.enabled.value) return { level: "ok" };
if (paused.value) return { level: "paused" };
if (consecutiveFailures.value > 0) {
const remaining = Math.max(0, (nextRetryAt.value ?? clock.value) - clock.value);
return { level: "bad", kind: "unreachable", retryInSeconds: Math.ceil(remaining / 1000) };
}
const interval = options.intervalSeconds.value;
const since = lastSuccessAt.value ?? enabledAt.value;
if (interval > 0 && clock.value - since > staleAfterMs(interval)) {
return { level: "bad", kind: "stale", since };
}
if (hungForced.value) return { level: "warn", kind: "hung" };
if (failingCount.value > 0)
return { level: "warn", kind: "failing", count: failingCount.value };
return { level: "ok" };
});
const countdown = computed<KioskCountdown | null>(() => {
if (!options.enabled.value || paused.value) return null;
const retrying = consecutiveFailures.value > 0 && nextRetryAt.value !== null;
const target = retrying ? nextRetryAt.value : nextTickAt.value;
const totalMs = retrying ? retryWindowMs.value : options.intervalSeconds.value * 1000;
if (target === null || !(totalMs > 0)) return null;
const remainingMs = Math.min(totalMs, Math.max(0, target - clock.value));
return { remainingMs, totalMs, at: target, retrying };
});
const registry: KioskPanelHealthRegistry = {
register: (id) => {
if (!panels.has(id)) panels.set(id, null);
},
unregister: (id) => {
panels.delete(id);
},
report: (id, error) => {
panels.set(id, error?.message ? error : null);
},
outage: computed(() => health.value.level === "bad"),
};
if (getCurrentInstance()) onBeforeUnmount(stop);
return { registry, onTick, health, failingCount, lastSuccessAt, countdown, stop };
};
export default useKioskRefresh;
@@ -0,0 +1,209 @@
// 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 <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computed, nextTick, ref } from "vue";
import type { KioskHealth } from "./useKioskRefresh";
import { toSlideHealth, usePlaylistFrame, usePlaylistPage } from "./usePlaylistFrame";
import type { PlaylistConfig } from "@/utils/dashboard/playlist";
const signals = () => {
const health = ref<KioskHealth>({ level: "ok" });
const failing = ref(0);
return {
health,
failing,
input: {
title: ref<string | undefined>("Overview"),
health: computed(() => health.value),
failingCount: computed(() => failing.value),
lastSuccessAt: ref<number | null>(null),
},
};
};
describe("usePlaylistFrame", () => {
const postMessage = vi.fn();
const realParent = window.parent;
beforeEach(() => {
postMessage.mockClear();
Object.defineProperty(window, "parent", { value: { postMessage }, configurable: true });
});
afterEach(() => {
Object.defineProperty(window, "parent", { value: realParent, configurable: true });
});
const sent = () => postMessage.mock.calls.map(([data]) => data.type);
it("says hello, then ready once the first refresh lands, then health changes, all same-origin", async () => {
const s = signals();
usePlaylistFrame(true, s.input);
expect(postMessage).toHaveBeenCalledWith(
{ source: "o2-tv-playlist", type: "hello" },
window.location.origin,
);
s.input.lastSuccessAt.value = 1000;
await nextTick();
expect(postMessage.mock.calls[1][0]).toEqual({
source: "o2-tv-playlist",
type: "ready",
title: "Overview",
});
expect(postMessage.mock.calls[2][0].health).toEqual({
level: "ok",
failing: 0,
outage: false,
lastSuccessAt: 1000,
});
s.health.value = { level: "warn", kind: "failing", count: 2 };
s.failing.value = 2;
await nextTick();
expect(postMessage.mock.calls.at(-1)![0].health).toMatchObject({ level: "warn", failing: 2 });
expect(postMessage.mock.calls.every(([, origin]) => origin === window.location.origin)).toBe(
true,
);
});
it("counts a known outage as ready so the player can hold on it", async () => {
const s = signals();
usePlaylistFrame(true, s.input);
s.health.value = { level: "bad", kind: "unreachable", retryInSeconds: 5 };
await nextTick();
expect(sent()).toEqual(["hello", "ready", "health"]);
expect(postMessage.mock.calls[2][0].health.outage).toBe(true);
});
it("reports a missing dashboard and a failed load", () => {
const frame = usePlaylistFrame(true, signals().input);
frame.reportMissing("forbidden");
frame.reportError();
expect(postMessage.mock.calls.slice(1).map(([data]) => data)).toEqual([
{ source: "o2-tv-playlist", type: "missing", reason: "forbidden" },
{ source: "o2-tv-playlist", type: "error" },
]);
});
it("posts nothing outside a playlist frame", () => {
const frame = usePlaylistFrame(false, signals().input);
frame.reportMissing("notFound");
expect(postMessage).not.toHaveBeenCalled();
});
it("maps kiosk health to a slide report", () => {
expect(toSlideHealth({ level: "paused" }, 0, null).level).toBe("ok");
expect(toSlideHealth({ level: "warn", kind: "hung" }, 0, 5)).toEqual({
level: "warn",
failing: 0,
outage: false,
lastSuccessAt: 5,
});
});
});
describe("usePlaylistPage (framing blocked)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
const config: PlaylistConfig = {
org: "acme",
entries: [
{ dashboardId: "d1", folderId: "default" },
{ dashboardId: "d2", folderId: "ops" },
],
intervalSeconds: 30,
slideIndex: 0,
period: null,
theme: "current",
fit: false,
timezone: null,
};
const setup = (misses = 0) => {
const ready = ref(false);
const outage = ref(false);
const navigate = vi.fn();
const page = usePlaylistPage({
enabled: true,
config,
misses,
ready: computed(() => ready.value),
outage: computed(() => outage.value),
resolveHref: (query) => `/view?${new URLSearchParams(query).toString()}`,
navigate,
});
const target = () => new URLSearchParams(navigate.mock.calls.at(-1)![0].split("?")[1]);
return { page, ready, outage, navigate, target };
};
it("moves to the next slide's page one interval after this one is ready", async () => {
const { ready, navigate, target } = setup();
vi.advanceTimersByTime(60_000);
expect(navigate).not.toHaveBeenCalled();
ready.value = true;
await nextTick();
vi.advanceTimersByTime(30_000);
expect(target().get("dashboard")).toBe("d2");
expect(target().get("slide")).toBe("2");
expect(target().get("tvpage")).toBe("1");
});
it("holds during an outage and moves on after recovery", async () => {
const { ready, outage, navigate } = setup();
ready.value = true;
outage.value = true;
await nextTick();
vi.advanceTimersByTime(30_000);
expect(navigate).not.toHaveBeenCalled();
outage.value = false;
await nextTick();
expect(navigate).toHaveBeenCalledTimes(1);
});
it("skips a missing dashboard and waits a full interval once every slide was missing", () => {
const first = setup();
expect(first.page.reportMissing("notFound")).toBe(true);
vi.advanceTimersByTime(3_000);
expect(first.target().get("miss")).toBe("1");
const last = setup(1);
last.page.reportMissing("notFound");
vi.advanceTimersByTime(3_000);
expect(last.navigate).not.toHaveBeenCalled();
vi.advanceTimersByTime(30_000);
expect(last.target().get("miss")).toBeNull();
});
it("does nothing on a normal kiosk page", () => {
const navigate = vi.fn();
const page = usePlaylistPage({
enabled: false,
config: null,
misses: 0,
ready: computed(() => true),
outage: computed(() => false),
resolveHref: () => "",
navigate,
});
expect(page.reportMissing("notFound")).toBe(false);
expect(page.reportError()).toBe(false);
});
});
@@ -0,0 +1,193 @@
// 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 <http://www.gnu.org/licenses/>.
import {
computed,
getCurrentInstance,
onBeforeUnmount,
ref,
watch,
type ComputedRef,
type Ref,
} from "vue";
import type { KioskHealth } from "@/composables/dashboard/useKioskRefresh";
import { LOAD_RETRY_MS } from "@/composables/dashboard/usePlaylistPlayer";
import {
PLAYLIST_MESSAGE_SOURCE,
buildSlideQuery,
type PlaylistConfig,
type PlaylistFrameMessage,
type SlideHealthReport,
} from "@/utils/dashboard/playlist";
export type MissingReason = "notFound" | "forbidden";
interface SlideSignals {
title: Ref<string | undefined>;
health: ComputedRef<KioskHealth>;
failingCount: ComputedRef<number>;
lastSuccessAt: Ref<number | null>;
}
type FrameMessageBody = PlaylistFrameMessage extends infer M
? M extends PlaylistFrameMessage
? Omit<M, "source">
: never
: never;
export const toSlideHealth = (
health: KioskHealth,
failing: number,
lastSuccessAt: number | null,
): SlideHealthReport => {
if (health.level === "bad") {
return { level: "bad", failing, outage: true, lastSuccessAt };
}
if (health.level === "warn") return { level: "warn", failing, outage: false, lastSuccessAt };
return { level: "ok", failing: 0, outage: false, lastSuccessAt };
};
/** True once the slide has something worth showing: a finished first refresh, or a known outage. */
const isSlideReady = (signals: SlideSignals) =>
signals.lastSuccessAt.value !== null || signals.health.value.level === "bad";
/** Inside a player frame: reports boot, readiness, health and a missing dashboard to the parent page. */
export const usePlaylistFrame = (enabled: boolean, signals: SlideSignals) => {
const post = (body: FrameMessageBody) => {
if (!enabled || typeof window === "undefined" || window.parent === window) return;
// Same-origin only: the player is served from this app.
window.parent.postMessage({ source: PLAYLIST_MESSAGE_SOURCE, ...body }, window.location.origin);
};
let readySent = false;
let lastHealth = "";
const report = () => {
if (!enabled || !isSlideReady(signals)) return;
if (!readySent) {
readySent = true;
post({ type: "ready", title: signals.title.value ?? "" });
}
const health = toSlideHealth(
signals.health.value,
signals.failingCount.value,
signals.lastSuccessAt.value,
);
const serialized = JSON.stringify(health);
if (serialized === lastHealth) return;
lastHealth = serialized;
post({ type: "health", health });
};
if (enabled) {
post({ type: "hello" });
watch([signals.lastSuccessAt, signals.health, signals.failingCount, signals.title], report);
}
return {
reportMissing: (reason: MissingReason) => post({ type: "missing", reason }),
reportError: () => post({ type: "error" }),
};
};
export interface UsePlaylistPageOptions {
enabled: boolean;
config: PlaylistConfig | null;
misses: number;
ready: ComputedRef<boolean>;
outage: ComputedRef<boolean>;
/** Builds an absolute href for the given dashboard-view query. */
resolveHref: (query: Record<string, string>) => string;
navigate?: (href: string) => void;
}
const SKIP_DELAY_MS = 3000;
const ALL_MISSING_MIN_MS = 15000;
/** Fallback when framing is blocked: this page shows one slide and replaces itself with the next. */
export const usePlaylistPage = (options: UsePlaylistPageOptions) => {
const navigate = options.navigate ?? ((href: string) => window.location.replace(href));
const config = options.config;
const active = options.enabled && !!config;
const count = config?.entries.length ?? 0;
const index = config?.slideIndex ?? 0;
const missing = ref<MissingReason | null>(null);
const leaving = ref(false);
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
let leaveTimer: ReturnType<typeof setTimeout> | null = null;
let dwellDue = false;
const go = (target: number, misses = 0) => {
if (!config) return;
leaving.value = true;
navigate(options.resolveHref(buildSlideQuery(config, target, "page", misses)));
};
const advance = () => {
if (options.outage.value) return;
go((index + 1) % count);
};
const stop = () => {
if (dwellTimer) clearTimeout(dwellTimer);
if (leaveTimer) clearTimeout(leaveTimer);
dwellTimer = null;
leaveTimer = null;
};
if (active && config) {
watch(
options.ready,
(ready) => {
if (!ready || dwellTimer || dwellDue) return;
dwellTimer = setTimeout(() => {
dwellTimer = null;
dwellDue = true;
advance();
}, config.intervalSeconds * 1000);
},
{ immediate: true },
);
watch(options.outage, (outage) => {
if (!outage && dwellDue) advance();
});
}
const reportMissing = (reason: MissingReason) => {
if (!active) return false;
missing.value = reason;
const misses = options.misses + 1;
// Every slide missing in a row: wait one full interval before trying the loop again.
const delay =
misses >= count
? Math.max(ALL_MISSING_MIN_MS, (config?.intervalSeconds ?? 0) * 1000)
: SKIP_DELAY_MS;
leaveTimer = setTimeout(() => go((index + 1) % count, misses >= count ? 0 : misses), delay);
return true;
};
const reportError = () => {
if (!active) return false;
leaveTimer = setTimeout(() => go(index, options.misses), LOAD_RETRY_MS);
return true;
};
if (getCurrentInstance()) onBeforeUnmount(stop);
const position = computed(() => ({ index, count }));
return { active, missing, leaving, position, reportMissing, reportError, stop };
};
@@ -0,0 +1,506 @@
// 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 <http://www.gnu.org/licenses/>.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
BOOT_STALL_MS,
JUMP_WATCHDOG_MS,
LOAD_RETRY_MS,
MISSING_RECHECK_MS,
PROBE_MS,
READY_CAP_MS,
usePlaylistPlayer,
type PlayerSlot,
} from "./usePlaylistPlayer";
import type { SlideHealthReport } from "@/utils/dashboard/playlist";
const source = "o2-tv-playlist" as const;
const healthy: SlideHealthReport = { level: "ok", failing: 0, outage: false, lastSuccessAt: 1 };
const outage: SlideHealthReport = { level: "bad", failing: 3, outage: true, lastSuccessAt: 1 };
const setup = (
over: {
count?: number;
interval?: number;
start?: number;
probe?: () => Promise<boolean>;
} = {},
) => {
const shown: number[] = [];
const fallback = vi.fn();
const player = usePlaylistPlayer({
count: over.count ?? 3,
intervalSeconds: over.interval ?? 60,
startIndex: over.start ?? 0,
onSlideShown: (index) => shown.push(index),
onFallback: fallback,
probe: over.probe,
});
player.start();
const ready = (slot: PlayerSlot | null, title = `Dash ${slot?.index}`) => {
if (!slot) throw new Error("no slot");
player.onMessage(slot.key, { source, type: "ready", title });
};
const readyPending = (title?: string) => ready(player.state.pending, title);
const health = (slot: PlayerSlot | null, report: SlideHealthReport) =>
player.onMessage(slot!.key, { source, type: "health", health: report });
return { player, shown, fallback, ready, readyPending, health };
};
describe("usePlaylistPlayer", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("loads the start slide first and shows it once it is ready", () => {
const { player, shown, readyPending } = setup({ start: 1 });
expect(player.state.phase).toBe("starting");
expect(player.state.shown).toBeNull();
expect(player.state.pending?.index).toBe(1);
readyPending("Payments");
expect(player.state.phase).toBe("playing");
expect(player.state.shown?.index).toBe(1);
expect(player.state.slides[1].title).toBe("Payments");
expect(shown).toEqual([1]);
});
it("preloads the next slide before the dwell ends and swaps instantly when due", () => {
const { player, shown, readyPending } = setup({ interval: 60 });
readyPending();
vi.advanceTimersByTime(49_000);
expect(player.state.pending).toBeNull();
vi.advanceTimersByTime(1_000);
expect(player.state.pending?.index).toBe(1);
readyPending();
expect(player.state.shown?.index).toBe(0);
vi.advanceTimersByTime(10_000);
expect(player.state.shown?.index).toBe(1);
expect(shown).toEqual([0, 1]);
});
it("keeps at most the shown frame, one preloaded frame and a fading one, and drops the old frame", () => {
const { player, readyPending } = setup({ interval: 30 });
readyPending();
const first = player.state.shown!;
for (let loop = 0; loop < 20; loop++) {
vi.advanceTimersByTime(20_000);
readyPending();
vi.advanceTimersByTime(10_000);
const live = [player.state.leaving, player.state.shown, player.state.pending].filter(Boolean);
expect(live.length).toBeLessThanOrEqual(3);
vi.advanceTimersByTime(500);
expect(player.state.leaving).toBeNull();
}
expect(player.state.shown?.key).not.toBe(first.key);
expect(player.state.shown?.index).toBe(20 % 3);
});
it("keeps the current slide while the next is still loading and swaps when it is ready", () => {
const { player, readyPending } = setup({ interval: 30 });
readyPending();
vi.advanceTimersByTime(30_000);
expect(player.state.shown?.index).toBe(0);
expect(player.waiting.value).toBe(true);
readyPending();
expect(player.state.shown?.index).toBe(1);
expect(player.waiting.value).toBe(false);
});
it("shows a booted but slow slide anyway once it has been starting for the ready cap", () => {
const { player, readyPending } = setup({ interval: 10 });
readyPending();
vi.advanceTimersByTime(10_000);
const slow = player.state.pending!;
player.onMessage(slow.key, { source, type: "hello" });
vi.advanceTimersByTime(READY_CAP_MS - 1);
expect(player.state.shown?.key).not.toBe(slow.key);
vi.advanceTimersByTime(1);
expect(player.state.shown?.key).toBe(slow.key);
});
it("keeps waiting for a frame that is still downloading on a slow network, without restarting or falling back", () => {
const { player, readyPending, fallback } = setup({ interval: 10 });
readyPending();
vi.advanceTimersByTime(10_000);
const slow = player.state.pending!;
vi.advanceTimersByTime(10 * 60_000);
expect(player.state.pending?.key).toBe(slow.key);
expect(player.state.shown?.index).toBe(0);
expect(fallback).not.toHaveBeenCalled();
});
it("reloads a loaded frame whose app never starts, backing off each time", () => {
const { player, readyPending } = setup({ interval: 10 });
readyPending();
vi.advanceTimersByTime(10_000);
const first = player.state.pending!;
player.onFrameLoaded(first.key);
vi.advanceTimersByTime(BOOT_STALL_MS);
const second = player.state.pending!;
expect(second.key).not.toBe(first.key);
expect(second.index).toBe(first.index);
player.onFrameLoaded(second.key);
vi.advanceTimersByTime(BOOT_STALL_MS);
expect(player.state.pending?.key).toBe(second.key);
vi.advanceTimersByTime(BOOT_STALL_MS);
expect(player.state.pending?.key).not.toBe(second.key);
});
it("measures the dwell from the swap, and preloads early enough after a slow load", () => {
const { player, shown, readyPending } = setup({ interval: 10 });
vi.advanceTimersByTime(8_000);
readyPending();
expect(shown).toEqual([0]);
// An 8s load makes the lead the whole 10s dwell, so the next slide starts loading at once.
expect(player.state.pending?.index).toBe(1);
readyPending();
vi.advanceTimersByTime(9_999);
expect(shown).toEqual([0]);
vi.advanceTimersByTime(1);
expect(shown).toEqual([0, 1]);
});
it("skips a deleted or forbidden dashboard, records it, and does not reload it every loop", () => {
const { player, readyPending } = setup({ interval: 30 });
readyPending();
vi.advanceTimersByTime(30_000);
const missing = player.state.pending!;
player.onMessage(missing.key, { source, type: "missing", reason: "forbidden" });
expect(player.state.slides[1].missing).toBe("forbidden");
expect(player.state.pending?.index).toBe(2);
readyPending();
expect(player.state.shown?.index).toBe(2);
vi.advanceTimersByTime(30_000);
expect(player.state.pending?.index).toBe(0);
readyPending();
vi.advanceTimersByTime(30_000);
expect(player.state.pending?.index).toBe(2);
vi.advanceTimersByTime(MISSING_RECHECK_MS);
readyPending();
vi.advanceTimersByTime(30_000);
readyPending();
vi.advanceTimersByTime(30_000);
expect(player.state.pending?.index).toBe(1);
});
it("goes to the empty state when every dashboard is missing, then retries", () => {
const { player } = setup({ count: 2 });
player.onMessage(player.state.pending!.key, { source, type: "missing", reason: "notFound" });
player.onMessage(player.state.pending!.key, { source, type: "missing", reason: "notFound" });
expect(player.state.phase).toBe("empty");
expect(player.state.pending).toBeNull();
vi.advanceTimersByTime(60_000);
expect(player.state.phase).toBe("starting");
expect(player.state.pending?.index).toBe(0);
});
it("retries a slide that failed to load instead of skipping it, at a steady pace", () => {
const { player, readyPending, health } = setup({ interval: 30 });
readyPending();
vi.advanceTimersByTime(30_000);
const failed = player.state.pending!;
player.onMessage(failed.key, { source, type: "error" });
expect(player.state.pending?.status).toBe("failed");
health(player.state.shown, healthy);
expect(player.state.pending?.key).toBe(failed.key);
expect(player.state.shown?.index).toBe(0);
vi.advanceTimersByTime(LOAD_RETRY_MS);
expect(player.state.pending?.index).toBe(1);
expect(player.state.pending?.key).not.toBe(failed.key);
});
it("holds rotation during an outage and resumes when the probe sees the backend again", async () => {
let reachable = false;
const { player, readyPending, health } = setup({
interval: 30,
probe: () => Promise.resolve(reachable),
});
readyPending();
health(player.state.shown, outage);
vi.advanceTimersByTime(20_000);
readyPending();
await vi.advanceTimersByTimeAsync(10_000);
expect(player.held.value).toBe(true);
expect(player.state.shown?.index).toBe(0);
reachable = true;
await vi.advanceTimersByTimeAsync(PROBE_MS);
expect(player.state.shown?.index).toBe(1);
});
it("recovers by itself after going offline, even if no frame ever posts again", async () => {
const { player, readyPending, health } = setup({
interval: 10,
probe: () => Promise.resolve(true),
});
readyPending();
health(player.state.shown, outage);
vi.advanceTimersByTime(10_000);
const dead = player.state.pending!;
player.onFrameFailed(dead.key);
player.onOnline();
await vi.advanceTimersByTimeAsync(0);
const fresh = player.state.pending!;
expect(fresh.key).not.toBe(dead.key);
expect(fresh.index).toBe(1);
readyPending();
expect(player.state.shown?.index).toBe(1);
});
it("never swaps in, even on a key press, a slide that came up during an outage", () => {
const { player, readyPending, health } = setup({ interval: 10 });
readyPending();
player.next();
const target = player.state.pending!;
health(target, outage);
readyPending();
expect(player.state.shown?.index).toBe(0);
health(target, healthy);
expect(player.state.shown?.index).toBe(1);
});
it("holds on an offline event and resumes within a probe of the network coming back", async () => {
const { player, readyPending } = setup({ interval: 15, probe: () => Promise.resolve(true) });
readyPending();
player.onOffline();
expect(player.held.value).toBe(true);
vi.advanceTimersByTime(15_000);
const dead = player.state.pending!;
player.onFrameFailed(dead.key);
expect(player.state.shown?.index).toBe(0);
player.onOnline();
await vi.advanceTimersByTimeAsync(0);
expect(player.held.value).toBe(false);
expect(player.state.pending?.key).not.toBe(dead.key);
readyPending();
expect(player.state.shown?.index).toBe(1);
});
it("calls the backend down only after two failed pings, and reloads the failed slide as soon as one answers", async () => {
let reachable = false;
const { player, readyPending } = setup({
interval: 15,
probe: () => Promise.resolve(reachable),
});
readyPending();
vi.advanceTimersByTime(15_000);
const failed = player.state.pending!;
player.onMessage(failed.key, { source, type: "error" });
await vi.advanceTimersByTimeAsync(PROBE_MS);
expect(player.state.backendDown).toBe(false);
await vi.advanceTimersByTimeAsync(PROBE_MS);
expect(player.state.backendDown).toBe(true);
expect(player.held.value).toBe(true);
reachable = true;
await vi.advanceTimersByTimeAsync(PROBE_MS);
expect(player.state.backendDown).toBe(false);
expect(player.state.pending?.key).not.toBe(failed.key);
expect(player.state.pending?.status).toBe("loading");
});
it("pauses rotation (not data) and resumes with the remaining dwell", () => {
const { player, readyPending } = setup({ interval: 60 });
readyPending();
vi.advanceTimersByTime(20_000);
player.togglePause();
expect(player.state.paused).toBe(true);
expect(player.state.pausedAt).not.toBeNull();
vi.advanceTimersByTime(10 * 60_000);
expect(player.state.shown?.index).toBe(0);
expect(player.progress.value).toBeCloseTo(1 / 3, 1);
player.togglePause();
vi.advanceTimersByTime(30_000);
readyPending();
vi.advanceTimersByTime(10_000);
expect(player.state.shown?.index).toBe(1);
});
it("says when the next slide comes and names it, skipping one known to be gone", () => {
vi.setSystemTime(Date.UTC(2026, 9, 14, 12, 0, 0));
const shownAt = Date.now();
const { player, readyPending } = setup({ interval: 30 });
readyPending();
vi.advanceTimersByTime(10_000);
expect(player.nextAt.value).toBe(shownAt + 30_000);
expect(player.nextIndex.value).toBe(1);
player.state.slides[1].missing = "notFound";
player.state.slides[1].missingAt = Date.now();
expect(player.nextIndex.value).toBe(2);
player.togglePause();
expect(player.nextAt.value).toBeNull();
vi.advanceTimersByTime(5_000);
player.togglePause();
expect(player.nextAt.value).toBe(shownAt + 30_000 + 5_000);
});
it("keeps the next slide loaded while paused, so Next is instant", () => {
const { player, readyPending } = setup({ interval: 15 });
readyPending();
player.togglePause();
expect(player.state.pending?.index).toBe(1);
readyPending();
vi.advanceTimersByTime(10 * 60_000);
expect(player.state.shown?.index).toBe(0);
player.next();
expect(player.state.shown?.index).toBe(1);
expect(player.state.paused).toBe(true);
expect(player.state.pending?.index).toBe(2);
});
it("next and previous act on the first press, show what they wait for, and work while paused", () => {
const { player, readyPending } = setup({ count: 4 });
readyPending();
player.togglePause();
player.next();
expect(player.state.pending?.index).toBe(1);
expect(player.jumpTarget.value).toBe(1);
readyPending();
expect(player.state.shown?.index).toBe(1);
expect(player.jumpTarget.value).toBeNull();
player.prev();
player.prev();
expect(player.state.pending?.index).toBe(3);
readyPending();
expect(player.state.shown?.index).toBe(3);
expect(player.state.paused).toBe(true);
});
it("next then previous before the next slide is ready leaves the shown slide up, with no card or reload", () => {
const { player, readyPending } = setup({ count: 3 });
readyPending();
const shownKey = player.state.shown!.key;
player.next();
expect(player.jumpTarget.value).toBe(1);
player.prev();
expect(player.jumpTarget.value).toBeNull();
expect(player.state.jump).toBe(false);
expect(player.state.pending).toBeNull();
expect(player.state.shown?.key).toBe(shownKey);
});
it("next then previous after the swap loads the previous slide behind its card and clears it when ready", () => {
const { player, readyPending } = setup({ count: 3, interval: 60 });
readyPending();
vi.advanceTimersByTime(50_000);
readyPending();
player.next();
expect(player.state.shown?.index).toBe(1);
player.prev();
expect(player.jumpTarget.value).toBe(0);
const target = player.state.pending!;
player.onMessage(target.key, { source, type: "hello" });
readyPending();
expect(player.state.shown?.key).toBe(target.key);
expect(player.jumpTarget.value).toBeNull();
});
it("previous while that slide is still loading keeps one target and clears the card once it is ready", () => {
const { player, readyPending } = setup({ count: 4 });
readyPending();
player.next();
player.next();
player.prev();
expect(player.state.pending?.index).toBe(1);
expect(player.jumpTarget.value).toBe(1);
readyPending();
expect(player.state.shown?.index).toBe(1);
expect(player.jumpTarget.value).toBeNull();
});
it("reloads a prev/next target that never starts, keeping the card up, and shows a started one at the watchdog", () => {
const { player } = setup({ count: 3 });
player.onMessage(player.state.pending!.key, { source, type: "ready", title: "A" });
player.next();
const first = player.state.pending!;
vi.advanceTimersByTime(JUMP_WATCHDOG_MS);
const second = player.state.pending!;
expect(second.key).not.toBe(first.key);
expect(second.index).toBe(1);
expect(player.jumpTarget.value).toBe(1);
player.onMessage(second.key, { source, type: "hello" });
vi.advanceTimersByTime(2 * JUMP_WATCHDOG_MS);
expect(player.state.shown?.key).toBe(second.key);
expect(player.jumpTarget.value).toBeNull();
});
it("falls back to page mode only when framing is blocked, never for a slow or failed frame", () => {
const slow = setup({ start: 2 });
vi.advanceTimersByTime(30 * 60_000);
slow.player.onFrameFailed(slow.player.state.pending!.key);
expect(slow.fallback).not.toHaveBeenCalled();
const blocked = setup({ start: 1 });
blocked.player.onFrameBlocked(blocked.player.state.pending!.key);
expect(blocked.fallback).toHaveBeenCalledWith(1);
expect(blocked.player.state.fallback).toBe(true);
});
it("ignores messages from frames it no longer owns", () => {
const { player, readyPending } = setup();
readyPending();
player.onMessage(999, { source, type: "missing", reason: "notFound" });
expect(player.state.slides.every((slide) => !slide.missing)).toBe(true);
});
it("stops every timer", () => {
const { player, readyPending } = setup();
readyPending();
player.stop();
expect(vi.getTimerCount()).toBe(0);
});
});
@@ -0,0 +1,608 @@
// 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 <http://www.gnu.org/licenses/>.
import { computed, getCurrentInstance, onBeforeUnmount, reactive } from "vue";
import type { PlaylistFrameMessage, SlideHealthReport } from "@/utils/dashboard/playlist";
export type PlayerSlotStatus = "loading" | "ready" | "failed";
/** One iframe; a new key is a new frame, so a dropped slot frees its whole page. */
export interface PlayerSlot {
key: number;
index: number;
status: PlayerSlotStatus;
/** The app inside the frame has started and can talk to the player. */
booted: boolean;
/** The frame's document finished loading (readable); the app may still be starting. */
loaded: boolean;
createdAt: number;
}
export interface SlideRecord {
title: string | null;
health: SlideHealthReport | null;
missing: "notFound" | "forbidden" | null;
missingAt: number | null;
loadFailed: boolean;
}
export type PlayerPhase = "starting" | "playing" | "empty";
export interface UsePlaylistPlayerOptions {
count: number;
intervalSeconds: number;
startIndex: number;
/** A new slide is on screen (used to keep `slide=` in the URL current). */
onSlideShown?: (index: number) => void;
/** Framing is blocked; the page continues with a full page load per slide. */
onFallback?: (index: number) => void;
/** Cheap backend liveness check, used to end a hold once the backend answers again. */
probe?: () => Promise<boolean>;
now?: () => number;
}
/** A booted slide still loading after this long is shown anyway, so a hung query cannot stall the loop. */
export const READY_CAP_MS = 60000;
/** A loaded document whose app never starts is reloaded after this long (doubling per attempt). */
export const BOOT_STALL_MS = 30000;
export const LOAD_RETRY_MS = 60000;
/** A prev/next target that is not ready after this long is reloaded (doubling per attempt) while its card stays up. */
export const JUMP_WATCHDOG_MS = 30000;
export const PROBE_MS = 10000;
/** A deleted or forbidden dashboard is skipped without loading until this long has passed. */
export const MISSING_RECHECK_MS = 300000;
const MIN_PRELOAD_LEAD_MS = 10000;
const MAX_STALL_MS = 300000;
const FADE_MS = 400;
const CLOCK_MS = 250;
const EMPTY_RETRY_MIN_MS = 30000;
export const usePlaylistPlayer = (options: UsePlaylistPlayerOptions) => {
const now = options.now ?? (() => Date.now());
const count = Math.max(1, options.count);
const intervalMs = options.intervalSeconds * 1000;
const state = reactive({
phase: "starting" as PlayerPhase,
shown: null as PlayerSlot | null,
pending: null as PlayerSlot | null,
leaving: null as PlayerSlot | null,
slides: Array.from({ length: count }, (): SlideRecord => ({
title: null,
health: null,
missing: null,
missingAt: null,
loadFailed: false,
})),
paused: false,
pausedAt: null as number | null,
dwellDue: false,
/** A prev/next press: swap as soon as the target is ready, whatever holds rotation. */
jump: false,
/** The backend answered again while the shown slide still reports an outage. */
recovered: false,
/** The player itself saw the backend or the network go away (offline event or repeated failed pings). */
backendDown: false,
dwellElapsed: 0,
dwellSince: null as number | null,
clock: now(),
fallback: false,
});
let seq = 0;
let missStreak = 0;
let stopped = false;
let stallAttempt = 0;
let probeMisses = 0;
let watchdogAttempt = 0;
// Direction of the last prev/next press, so a missing slide is skipped the way the user is going.
let step = 1;
// Learnt from real loads so the next slide is ready when its turn comes.
let lastLoadMs = 0;
let dwellTimer: ReturnType<typeof setTimeout> | null = null;
let preloadTimer: ReturnType<typeof setTimeout> | null = null;
let capTimer: ReturnType<typeof setTimeout> | null = null;
let stallTimer: ReturnType<typeof setTimeout> | null = null;
let watchdogTimer: ReturnType<typeof setTimeout> | null = null;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let fadeTimer: ReturnType<typeof setTimeout> | null = null;
let probeTimer: ReturnType<typeof setInterval> | null = null;
let clockTimer: ReturnType<typeof setInterval> | null = null;
const clear = (timer: ReturnType<typeof setTimeout> | null) => {
if (timer) clearTimeout(timer);
return null;
};
const wrap = (index: number) => ((index % count) + count) % count;
const knownMissing = (index: number) => {
const slide = state.slides[index];
return !!slide.missing && now() - (slide.missingAt ?? 0) < MISSING_RECHECK_MS;
};
/** The next slide in `step` direction that is not known to be gone. */
const nextPlayable = (from: number, step: number) => {
for (let i = 1; i <= count; i++) {
const index = wrap(from + step * i);
if (!knownMissing(index)) return index;
}
return wrap(from + step);
};
const outage = computed(() => {
const index = state.shown?.index;
return index !== undefined && state.slides[index].health?.outage === true;
});
const held = computed(() => (outage.value && !state.recovered) || state.backendDown);
const elapsedMs = () =>
state.dwellElapsed + (state.dwellSince !== null ? state.clock - state.dwellSince : 0);
const progress = computed(() =>
state.shown ? Math.min(1, Math.max(0, elapsedMs() / intervalMs)) : 0,
);
/** Epoch ms the dwell ends while running; null while paused or before the first slide. */
const nextAt = computed(() =>
state.shown && state.dwellSince !== null
? state.dwellSince + intervalMs - state.dwellElapsed
: null,
);
const currentIndex = computed(
() => state.shown?.index ?? state.pending?.index ?? options.startIndex,
);
/** Rotation is due but the next slide is still loading. */
const waiting = computed(
() => !!state.shown && (state.dwellDue || state.jump) && state.pending?.status !== "ready",
);
/** Slide a prev/next press is waiting for, while it loads. */
const jumpTarget = computed(() =>
state.shown && state.jump && state.pending && state.pending.status !== "ready"
? state.pending.index
: null,
);
/** Slide the rotation moves to next: the one already loading, or the next one not known to be gone. */
const nextIndex = computed(() => state.pending?.index ?? nextPlayable(currentIndex.value, 1));
const leadMs = () => Math.min(intervalMs, Math.max(MIN_PRELOAD_LEAD_MS, lastLoadMs * 1.5 + 2000));
const isRunning = () => !!state.shown && !state.paused;
const fallBack = (index: number) => {
if (state.fallback) return;
state.fallback = true;
stop();
options.onFallback?.(index);
};
const armStall = (slot: PlayerSlot) => {
stallTimer = clear(stallTimer);
if (!slot.loaded || slot.booted) return;
const delay = Math.min(MAX_STALL_MS, BOOT_STALL_MS * 2 ** stallAttempt);
stallTimer = setTimeout(() => {
stallTimer = null;
if (state.pending !== slot || slot.booted) return;
stallAttempt++;
setPending(slot.index);
}, delay);
};
function setPending(index: number) {
watchdogTimer = clear(watchdogTimer);
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
retryTimer = clear(retryTimer);
state.pending = {
key: ++seq,
index,
status: "loading",
booted: false,
loaded: false,
createdAt: now(),
};
// Every replacement of a prev/next target keeps a watchdog, so its card can never outlive it.
if (state.jump && state.shown) armWatchdog(state.pending);
}
const preloadNext = () => {
preloadTimer = clear(preloadTimer);
if (!state.shown || state.pending) return;
setPending(nextPlayable(state.shown.index, 1));
};
const scheduleDwell = () => {
dwellTimer = clear(dwellTimer);
preloadTimer = clear(preloadTimer);
// Paused, the next slide stays loaded (its frame refreshes itself) so a manual Next is instant.
if (!isRunning()) {
if (state.paused) preloadNext();
return;
}
const remaining = intervalMs - elapsedMs();
if (remaining <= 0) {
state.dwellDue = true;
tryAdvance();
return;
}
dwellTimer = setTimeout(() => {
dwellTimer = null;
state.clock = now();
state.dwellDue = true;
tryAdvance();
}, remaining);
if (state.pending) return;
const preloadIn = remaining - leadMs();
if (preloadIn <= 0) preloadNext();
else preloadTimer = setTimeout(preloadNext, preloadIn);
};
/** Freezes or resumes the dwell clock to match the pause state. */
const syncRunning = () => {
state.clock = now();
if (isRunning()) {
if (state.dwellSince === null) state.dwellSince = state.clock;
} else if (state.dwellSince !== null) {
state.dwellElapsed += state.clock - state.dwellSince;
state.dwellSince = null;
}
scheduleDwell();
};
const stopProbing = () => {
if (probeTimer) clearInterval(probeTimer);
probeTimer = null;
};
const show = (slot: PlayerSlot) => {
watchdogTimer = clear(watchdogTimer);
watchdogAttempt = 0;
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
fadeTimer = clear(fadeTimer);
state.leaving = state.shown;
state.shown = slot;
state.pending = null;
state.dwellDue = false;
state.jump = false;
state.recovered = false;
state.dwellElapsed = 0;
state.dwellSince = null;
state.phase = "playing";
missStreak = 0;
if (state.leaving) {
fadeTimer = setTimeout(() => {
fadeTimer = null;
state.leaving = null;
}, FADE_MS);
}
syncRunning();
syncProbing();
options.onSlideShown?.(slot.index);
};
function tryAdvance() {
if (stopped) return;
const pending = state.pending;
if (!pending) {
if (state.dwellDue) preloadNext();
return;
}
if (pending.status !== "ready") return;
if (!state.shown) return show(pending);
// A slide that came up during an outage has nothing to show; the current one keeps its last render.
if (state.slides[pending.index].health?.outage) return;
if (state.jump) return show(pending);
if (!state.dwellDue || state.paused || held.value) return;
show(pending);
}
const markReady = (slot: PlayerSlot) => {
if (slot.status === "ready") return;
slot.status = "ready";
if (slot !== state.pending) return;
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
stallAttempt = 0;
lastLoadMs = Math.max(0, now() - slot.createdAt);
tryAdvance();
};
const goEmpty = () => {
state.phase = "empty";
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
state.pending = null;
retryTimer = clear(retryTimer);
retryTimer = setTimeout(retry, Math.max(EMPTY_RETRY_MIN_MS, intervalMs));
};
const onMissing = (slot: PlayerSlot, reason: "notFound" | "forbidden") => {
const record = state.slides[slot.index];
record.missing = reason;
record.missingAt = now();
record.health = null;
if (slot !== state.pending) return;
missStreak++;
if (missStreak >= count) return goEmpty();
setPending(nextPlayable(slot.index, state.jump ? step : 1));
};
/** The slide could not load (network or server); keep it as pending and retry it, never skip it. */
const onLoadError = (slot: PlayerSlot) => {
state.slides[slot.index].loadFailed = true;
if (slot !== state.pending) return;
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
slot.status = "failed";
retryTimer = clear(retryTimer);
retryTimer = setTimeout(() => {
retryTimer = null;
if (state.pending === slot) setPending(slot.index);
}, LOAD_RETRY_MS);
syncProbing();
};
const findSlot = (key: number) =>
[state.shown, state.pending, state.leaving].find((slot) => slot?.key === key) ?? null;
const markBooted = (slot: PlayerSlot) => {
if (slot.booted) return;
slot.booted = true;
stallTimer = clear(stallTimer);
if (slot === state.pending && slot.status === "loading") {
capTimer = clear(capTimer);
capTimer = setTimeout(() => {
capTimer = null;
markReady(slot);
}, READY_CAP_MS);
}
};
/** Feed a validated message from the frame with this key. */
const onMessage = (key: number, message: PlaylistFrameMessage) => {
if (stopped) return;
const slot = findSlot(key);
if (!slot) return;
const record = state.slides[slot.index];
switch (message.type) {
case "hello":
markBooted(slot);
return;
case "ready":
markBooted(slot);
record.title = message.title || record.title;
record.missing = null;
record.missingAt = null;
record.loadFailed = false;
markReady(slot);
return;
case "health":
record.health = message.health;
if (slot === state.shown && !message.health.outage) state.recovered = false;
syncProbing();
tryAdvance();
return;
case "missing":
markBooted(slot);
onMissing(slot, message.reason);
return;
case "error":
markBooted(slot);
onLoadError(slot);
return;
}
};
/** The frame's document loaded and is readable; its app should start shortly. */
const onFrameLoaded = (key: number) => {
const slot = findSlot(key);
if (!slot || slot.loaded) return;
slot.loaded = true;
if (slot === state.pending) armStall(slot);
};
/** Response headers forbid framing: only then does the player give up on frames. */
const onFrameBlocked = (key: number) => {
const slot = findSlot(key);
if (slot) fallBack(slot.index);
};
/** The frame shows a browser error page (offline, connection reset): retry it. */
const onFrameFailed = (key: number) => {
const slot = findSlot(key);
if (slot && !slot.booted) onLoadError(slot);
};
/** Backend reachable again: end the hold, and replace frames that may have died while it was down. */
const recover = (reloadLoading: boolean) => {
if (stopped) return;
probeMisses = 0;
state.backendDown = false;
const pending = state.pending;
if (
pending &&
(pending.status === "failed" ||
(reloadLoading && pending.status === "loading") ||
state.slides[pending.index].health?.outage === true)
) {
setPending(pending.index);
}
if (outage.value) state.recovered = true;
syncProbing();
tryAdvance();
};
const runProbe = async () => {
if (!options.probe || stopped) return;
const reachable = await options.probe().catch(() => false);
if (stopped) return;
if (reachable) {
if (held.value) recover(false);
else probeMisses = 0;
syncProbing();
return;
}
// Two misses in a row, so one slow answer never shows an outage.
probeMisses++;
if (probeMisses >= 2 && !state.backendDown) {
state.backendDown = true;
syncProbing();
}
};
function syncProbing() {
const needed = held.value || state.pending?.status === "failed";
if (needed && options.probe) {
if (!probeTimer) probeTimer = setInterval(() => void runProbe(), PROBE_MS);
} else {
stopProbing();
}
}
/** The browser came back online: check the backend now rather than at the next retry. */
const onOnline = () => {
if (!options.probe) return recover(true);
void options.probe().then((reachable) => {
if (reachable) recover(true);
});
};
/** The browser went offline: say so on screen and hold rotation until it is back. */
const onOffline = () => {
state.backendDown = true;
syncProbing();
};
const armWatchdog = (slot: PlayerSlot) => {
watchdogTimer = clear(watchdogTimer);
const delay = Math.min(MAX_STALL_MS, JUMP_WATCHDOG_MS * 2 ** watchdogAttempt);
watchdogTimer = setTimeout(() => {
watchdogTimer = null;
if (stopped || !state.jump || state.pending !== slot || slot.status === "ready") return;
// A started app shows what it has; one that never started is loaded again.
if (slot.booted) return markReady(slot);
watchdogAttempt++;
setPending(slot.index);
}, delay);
};
/** Drops a prev/next that is no longer wanted, so its card cannot outlive it. */
const cancelJump = () => {
state.jump = false;
watchdogTimer = clear(watchdogTimer);
watchdogAttempt = 0;
if (state.pending && state.pending.status !== "ready") {
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
retryTimer = clear(retryTimer);
state.pending = null;
}
syncRunning();
};
const jumpBy = (direction: number) => {
if (state.phase === "empty") return retry();
step = direction;
const base = state.jump && state.pending ? state.pending.index : currentIndex.value;
const target = nextPlayable(base, direction);
// Back to the slide already on screen: nothing to load, the press just undoes the last one.
if (state.shown && target === state.shown.index) return cancelJump();
state.jump = true;
if (state.pending?.index !== target || state.pending.status === "failed") {
watchdogAttempt = 0;
setPending(target);
}
const pending = state.pending;
if (pending && pending.status !== "ready" && !watchdogTimer) armWatchdog(pending);
tryAdvance();
};
const next = () => jumpBy(1);
const prev = () => jumpBy(-1);
const togglePause = () => {
state.paused = !state.paused;
state.pausedAt = state.paused ? now() : null;
syncRunning();
};
function retry() {
retryTimer = clear(retryTimer);
missStreak = 0;
state.slides.forEach((slide) => {
slide.missing = null;
slide.missingAt = null;
});
state.phase = state.shown ? "playing" : "starting";
setPending(state.shown ? wrap(state.shown.index + 1) : options.startIndex);
}
function stop() {
stopped = true;
dwellTimer = clear(dwellTimer);
preloadTimer = clear(preloadTimer);
capTimer = clear(capTimer);
stallTimer = clear(stallTimer);
watchdogTimer = clear(watchdogTimer);
retryTimer = clear(retryTimer);
fadeTimer = clear(fadeTimer);
stopProbing();
if (clockTimer) clearInterval(clockTimer);
clockTimer = null;
}
const start = () => {
stopped = false;
setPending(wrap(options.startIndex));
clockTimer = setInterval(() => {
state.clock = now();
}, CLOCK_MS);
};
if (getCurrentInstance()) onBeforeUnmount(stop);
return {
state,
held,
progress,
currentIndex,
waiting,
jumpTarget,
nextIndex,
nextAt,
start,
stop,
next,
prev,
togglePause,
retry,
onMessage,
onFrameLoaded,
onFrameBlocked,
onFrameFailed,
onOnline,
onOffline,
};
};
export type PlaylistPlayer = ReturnType<typeof usePlaylistPlayer>;
export default usePlaylistPlayer;
+42
View File
@@ -2163,6 +2163,48 @@ describe("useRoutes (router.ts)", () => {
expect(routeGuard).toHaveBeenCalledWith(mockTo, mockFrom, mockNext);
});
it("sends a playlist link on the dashboard view to the TV player", async () => {
const { routeGuard } = await import("@/utils/zincutils");
vi.mocked(routeGuard).mockClear();
const { homeChildRoutes } = useRoutes();
const route = findRoute(homeChildRoutes, "viewDashboard");
const next = vi.fn();
route.beforeEnter(
{ query: { org_identifier: "acme", playlist: "d1:f1,d2", interval: "2m", slide: "2" } },
{},
next,
);
expect(next).toHaveBeenCalledWith({
path: "/dashboards/tv",
query: { org_identifier: "acme", playlist: "d1:f1,d2:default", interval: "2m", slide: "2" },
replace: true,
});
expect(routeGuard).not.toHaveBeenCalled();
});
it("keeps the TV player's own page fallback on the dashboard view", async () => {
const { routeGuard } = await import("@/utils/zincutils");
const { homeChildRoutes } = useRoutes();
const route = findRoute(homeChildRoutes, "viewDashboard");
const to = { query: { dashboard: "d1", playlist: "d1,d2", tvpage: "1", kiosk: "true" } };
const next = vi.fn();
route.beforeEnter(to, {}, next);
expect(routeGuard).toHaveBeenCalledWith(to, {}, next);
});
it("registers the TV playlist route behind the route guard", async () => {
const { routeGuard } = await import("@/utils/zincutils");
const { homeChildRoutes } = useRoutes();
const route = findRoute(homeChildRoutes, "dashboardTvPlaylist");
expect(route.path).toBe("/dashboards/tv");
expect(route.meta.titleKey).toBe("routeTitles.dashboardTvPlaylist");
const next = vi.fn();
route.beforeEnter({}, {}, next);
expect(routeGuard).toHaveBeenCalledWith({}, {}, next);
});
it("should call routeGuard for functionList route beforeEnter", async () => {
const { routeGuard } = await import("@/utils/zincutils");
const { homeChildRoutes } = useRoutes();
+25
View File
@@ -27,6 +27,12 @@ import store from "@/stores";
import { hasMetricsEditorParams } from "@/utils/metrics/metricsEditorParams";
import { PA_ROUTES } from "@/utils/rum/productAnalyticsRoutes";
import { forgetLinkRequest } from "@/utils/slackLink";
import {
PLAYLIST_TV_PATH,
buildPlaylistQuery,
isPlaylistPage,
parsePlaylistQuery,
} from "@/utils/dashboard/playlist";
const Search = () => import("@/plugins/logs/Index.vue");
const SearchJobInspector = () => import("@/plugins/logs/SearchJobInspector.vue");
@@ -164,6 +170,7 @@ const profilesRouteGuard = (to: any, from: any, next: any) => {
};
const ViewDashboard = () => import("@/views/Dashboards/ViewDashboard.vue");
const TvPlaylist = () => import("@/views/Dashboards/TvPlaylist.vue");
const AddPanel = () => import("@/views/Dashboards/addPanel/AddPanel.vue");
const StreamExplorer = () => import("@/views/StreamExplorer.vue");
const LogStream = () => import("@/views/LogStream.vue");
@@ -705,6 +712,24 @@ const useRoutes = () => {
meta: {
titleKey: "routeTitles.viewDashboard",
},
beforeEnter(to: any, from: any, next: any) {
// A playlist link to the dashboard view is played by the TV player, except its own page fallback.
const query = to.query ?? {};
const playlist = isPlaylistPage(query) ? null : parsePlaylistQuery(query);
if (playlist) {
next({ path: PLAYLIST_TV_PATH, query: buildPlaylistQuery(playlist), replace: true });
return;
}
routeGuard(to, from, next);
},
},
{
path: "/dashboards/tv",
name: "dashboardTvPlaylist",
component: TvPlaylist,
meta: {
titleKey: "routeTitles.dashboardTvPlaylist",
},
beforeEnter(to: any, from: any, next: any) {
routeGuard(to, from, next);
},
+19 -8
View File
@@ -29,7 +29,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
'flex-col',
]"
>
<header class="o2-app-header shrink-0" :class="store.state.printMode === true ? 'hidden' : ''">
<header
class="o2-app-header shrink-0"
:class="store.state.printMode === true || store.state.kioskMode ? 'hidden' : ''"
>
<!-- Every bar that sits above the toolbar, in one measured wrapper so
`--navbar-height` accounts for whichever of them is actually showing.
The wrapper always renders — an unconditional ref keeps the observer
@@ -83,7 +86,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="flex min-h-0 flex-1">
<ONavbar
v-if="store.state.printMode !== true"
v-if="store.state.printMode !== true && !store.state.kioskMode"
class="max-md:hidden"
:links-list="navLinks"
:mini-mode="miniMode"
@@ -136,10 +139,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Main Panel -->
<main
data-test="main-content"
class="bg-surface-chrome-deeper flex min-h-0 flex-col pe-2 pb-2 max-md:pe-0"
class="bg-surface-chrome-deeper flex min-h-0 flex-col"
:class="store.state.kioskMode ? '' : 'pe-2 pb-2 max-md:pe-0'"
:style="{
width:
!store.state.isAiChatEnabled || isChatOverlay
!store.state.isAiChatEnabled || isChatOverlay || store.state.kioskMode
? '100%'
: store.state.isAiChatExpanded
? '50%'
@@ -149,8 +153,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Content card — all pages render inside this. The border stays present in both
themes (transparent in light) so toggling dark mode can't shift page content by 1px. -->
<div
class="bg-surface-base rounded-surface flex min-h-0 flex-1 flex-col overflow-hidden border shadow-md"
:class="isDark ? 'border-border-default' : 'border-transparent'"
class="bg-surface-base flex min-h-0 flex-1 flex-col overflow-hidden"
:class="[
store.state.kioskMode ? '' : 'rounded-surface border shadow-md',
isDark ? 'border-border-default' : 'border-transparent',
]"
>
<div
v-if="isLoading"
@@ -158,7 +165,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
class="o2-content-scroll h-full flex-1 overflow-y-auto"
>
<FirstDataNotice
v-if="firstDataNotice.visible.value && router.currentRoute.value.name !== 'home'"
v-if="
firstDataNotice.visible.value &&
router.currentRoute.value.name !== 'home' &&
!store.state.kioskMode
"
class="mx-3 mt-3"
:stream="firstDataNotice.stream.value"
:arrived-at="firstDataNotice.arrivedAt.value"
@@ -176,7 +187,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Right Panel (AI Chat - unified for both general and context-specific usage) -->
<aside
v-show="store.state.isAiChatEnabled && isLoading"
v-show="store.state.isAiChatEnabled && isLoading && !store.state.kioskMode"
class="o2-sidebar o2-sidebar-right bg-surface-chrome-deeper shrink-0 overflow-y-auto"
:class="[
isDark ? 'dark-mode-chat-container' : 'light-mode-chat-container',
+10
View File
@@ -184,6 +184,16 @@ describe("OBadge", () => {
expect(wrapper.classes().join(" ")).toContain("px-2");
});
it("applies lg size classes with a larger status dot", () => {
const wrapper = mount(OBadge, {
props: { size: "lg", dot: true },
slots: { default: "x" },
});
expect(wrapper.classes()).toContain("text-sm");
expect(wrapper.classes()).toContain("py-1.5");
expect(wrapper.find('[aria-hidden="true"]').classes()).toContain("size-2.5");
});
// ── Root element tag ────────────────────────────────────────────────────
it("renders as <span> by default", () => {
+2 -1
View File
@@ -71,7 +71,8 @@ export type BadgeVariant =
| "indigo-soft";
/** Size controls padding and font-size only. */
export type BadgeSize = "xs" | "sm" | "md";
/** `lg` is for read-at-a-distance status, such as the dashboard TV-mode strip. */
export type BadgeSize = "xs" | "sm" | "md" | "lg";
/**
* Corner shape:
+6 -3
View File
@@ -199,6 +199,7 @@ const sizeClasses: Record<NonNullable<BadgeProps["size"]>, string> = {
xs: "px-1.5 py-0.5 text-3xs gap-0.5",
sm: "px-2.5 py-1.5 text-2xs gap-1",
md: "px-2.5 py-2 text-xs gap-1.5",
lg: "px-3 py-1.5 text-sm gap-2",
};
// ── Shape (corner radius) class map ───────────────────────────────────────
@@ -209,7 +210,9 @@ const shapeClasses: Record<NonNullable<BadgeProps["shape"]>, string> = {
};
// ── Trailing segment padding per size ────────────────────────────────────
const trailingSizeClasses = computed(() => (props.size === "md" ? "ps-1.5 ms-1" : "ps-1 ms-0.5"));
const trailingSizeClasses = computed(() =>
props.size === "md" || props.size === "lg" ? "ps-1.5 ms-1" : "ps-1 ms-0.5",
);
// ── Root element classes ──────────────────────────────────────────────────
const classes = computed(() => [
@@ -274,8 +277,8 @@ function handleRemove(e: MouseEvent): void {
<span
v-if="dot"
:class="[
'inline-block size-1.75 shrink-0 rounded-full bg-current',
size === 'sm' ? 'me-0' : 'me-0',
'inline-block shrink-0 rounded-full bg-current',
size === 'lg' ? 'size-2.5' : 'size-1.75',
]"
aria-hidden="true"
/>
+2
View File
@@ -301,6 +301,7 @@ import TrendingDownIcon from "~icons/material-symbols/trending-down";
import TrendingFlatIcon from "~icons/material-symbols/trending-flat";
import TroubleshootIcon from "~icons/material-symbols/troubleshoot";
import UpdateIcon from "~icons/material-symbols/update";
import TvIcon from "~icons/material-symbols/tv-outline";
import VerifiedIcon from "~icons/material-symbols/verified-outline";
import ViewColumnIcon from "~icons/material-symbols/view-column-outline";
import VolumeOffIcon from "~icons/material-symbols/volume-off-outline";
@@ -683,6 +684,7 @@ export const iconRegistry = {
"trending-flat": TrendingFlatIcon,
troubleshoot: TroubleshootIcon,
update: UpdateIcon,
tv: TvIcon,
verified: VerifiedIcon,
"view-column": ViewColumnIcon,
"volume-off": VolumeOffIcon,
@@ -118,6 +118,7 @@ export const SHORTCUT_MODULES: ShortcutModule[] = [
"shortcuts.pages.dashboardsList",
"shortcuts.pages.dashboards",
"shortcuts.pages.dashboardsPanel",
"shortcuts.pages.dashboardTvPlaylist",
"shortcuts.pages.panelEditor",
],
},
@@ -346,6 +347,11 @@ export const SHORTCUT_REGISTRY: ShortcutGroup[] = [
key: "escape",
descriptionKey: "shortcuts.actions.dashboardLayoutExit",
},
{
id: "dashboardKioskExit",
key: "escape",
descriptionKey: "shortcuts.actions.dashboardKioskExit",
},
{
id: "dashboardFullscreen",
key: "f",
@@ -355,6 +361,36 @@ export const SHORTCUT_REGISTRY: ShortcutGroup[] = [
],
},
// ── Dashboard TV playlist ────────────────────────────────────────────────
{
pageKey: "shortcuts.pages.dashboardTvPlaylist",
scope: "dashboard-tv",
shortcuts: [
{
id: "dashboardPlaylistPrev",
key: "left",
display: "←",
descriptionKey: "shortcuts.actions.dashboardPlaylistPrev",
},
{
id: "dashboardPlaylistNext",
key: "right",
display: "→",
descriptionKey: "shortcuts.actions.dashboardPlaylistNext",
},
{
id: "dashboardPlaylistPause",
key: "space",
descriptionKey: "shortcuts.actions.dashboardPlaylistPause",
},
{
id: "dashboardPlaylistExit",
key: "escape",
descriptionKey: "shortcuts.actions.dashboardPlaylistExit",
},
],
},
// ── Dashboard panel (hover — handled by PanelContainer, display-only) ────
{
pageKey: "shortcuts.pages.dashboardsPanel",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "تصدير اللوحة",
"fullscreen": "عرض ملء الشاشة",
"exitFullscreen": "الخروج من وضع ملء الشاشة",
"kiosk": {
"tvMode": "وضع التلفاز",
"refresh": "التحديث كل",
"refreshHelp": "عدد مرات إعادة تحميل التلفاز لبياناته. الإيقاف يترك الشاشة غير محدّثة.",
"theme": "السمة",
"themeCurrent": "الحالية",
"themeLight": "فاتح",
"themeDark": "داكن",
"fit": "احتواء جميع اللوحات في شاشة واحدة",
"fitHelp": "يصغّر اللوحات لتناسب ارتفاع الشاشة فلا يلزم التمرير.",
"open": "فتح وضع التلفاز",
"exit": "الخروج من وضع التلفاز",
"refreshOff": "التحديث التلقائي متوقف",
"nextRefreshAt": "التحديث التالي في {time}",
"nextRetryAt": "المحاولة التالية في {time}",
"updatedAt": "تم التحديث {time}",
"paused": "متوقف مؤقتًا أثناء إخفاء علامة التبويب",
"staleSince": "لم يتم التحديث منذ {time}",
"unreachable": "تعذر الوصول إلى الخادم، إعادة المحاولة خلال {seconds} ث",
"hung": "الاستعلام متوقف، تم فرض التحديث",
"panelsFailing": "{count} لوحة فاشلة | {count} لوحات فاشلة",
"panelFailed": "فشل تحميل اللوحة",
"panelStale": "قديمة",
"queryError": "خطأ في الاستعلام: {detail}"
},
"playlist": {
"action": "قائمة تشغيل التلفاز",
"actionTooltip": "عرض لوحات المعلومات المحددة بالتناوب على التلفاز",
"title": "قائمة تشغيل التلفاز",
"dashboards": "لوحات المعلومات",
"countOfMax": "{count} من {max}",
"searchPlaceholder": "إضافة لوحات معلومات: البحث في كل المجلدات",
"fullPlaceholder": "قائمة التشغيل ممتلئة (الحد الأقصى {max})",
"searchFailed": "تعذر البحث عن لوحات المعلومات",
"noMatches": "لا توجد لوحات معلومات تطابق \"{query}\"",
"add": "إضافة",
"added": "تمت الإضافة",
"emptyTitle": "لا توجد لوحات معلومات في قائمة التشغيل",
"emptyDescription": "ابحث أعلاه لإضافة لوحة معلومات واحدة على الأقل",
"dragToReorder": "اسحب لإعادة الترتيب",
"moveUp": "تحريك لأعلى",
"moveDown": "تحريك لأسفل",
"remove": "إزالة",
"notFound": "غير موجودة",
"showEachFor": "عرض كل لوحة لمدة",
"timeRange": "النطاق الزمني",
"timeRangeHelp": "نطاق واحد لكل لوحات المعلومات",
"ownDefault": "الافتراضي الخاص بكل لوحة",
"variablesNote": "تفتح كل لوحة على علامة التبويب الأولى بقيم متغيراتها الافتراضية",
"refreshNote": "تُعاد تحميل البيانات عند ظهور كل لوحة وكل 5 دقائق ما دامت على الشاشة",
"linkCopied": "تم نسخ رابط قائمة التشغيل، افتحه في متصفح التلفاز",
"open": "فتح قائمة تشغيل التلفاز",
"position": "{current} من {total}",
"previous": "اللوحة السابقة",
"next": "اللوحة التالية",
"pause": "إيقاف التناوب مؤقتًا",
"resume": "استئناف التناوب",
"exit": "خروج",
"pausedSince": "التناوب متوقف مؤقتًا منذ {time}",
"held": "التناوب معلّق: تعذر الوصول إلى الخادم",
"waiting": "جارٍ تحميل اللوحة التالية",
"skippedNotFound": "تم تخطي {name}: لم يتم العثور على اللوحة",
"skippedForbidden": "تم تخطي {name}: لا توجد صلاحية وصول",
"slideName": "لوحة المعلومات {number}",
"loadingSlide": "جارٍ تحميل {current} من {total}",
"emptyStateTitle": "لا تتوفر أي من لوحات المعلومات في قائمة التشغيل",
"emptyStateDescription": "تم حذف كل لوحات المعلومات أو لم تعد لديك صلاحية الوصول. يبقى التلفاز هنا ويعيد المحاولة.",
"tryAgain": "إعادة المحاولة",
"invalidTitle": "رابط قائمة التشغيل هذا لا يتضمن لوحات معلومات",
"invalidDescription": "حدد لوحات المعلومات في القائمة ثم اختر قائمة تشغيل التلفاز",
"backToDashboards": "الانتقال إلى لوحات المعلومات",
"starting": "جارٍ البدء",
"loading": "جارٍ التحميل",
"intervalAdjusted": "تم تعديل الفاصل الزمني إلى {interval}",
"copied": "تم النسخ",
"addLabel": "إضافة لوحات معلومات",
"addHint": "يبحث في كل المجلدات؛ تعرض كل نتيجة مجلدها",
"copyLink": "نسخ الرابط",
"unreachable": "الخادم الخلفي غير قابل للوصول",
"ofTotal": "من {total}",
"nextAt": "التالي: {name} في {time}"
},
"generalSettings": "عام",
"variableSettings": "المتغيرات",
"tabSettings": "الألسنة",
@@ -19626,6 +19708,7 @@
"dashboardsList": "لوحات المعلومات",
"dashboards": "عرض لوحة المعلومات",
"dashboardsPanel": "لوحة المعلومات — لوحة (تحريك المؤشر على لوحة)",
"dashboardTvPlaylist": "قائمة تشغيل التلفاز",
"metrics": "المقاييس",
"traces": "التتبعات",
"alerts": "تنبيهات",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "التراجع عن تغيير التخطيط",
"dashboardLayoutRedo": "إعادة تغيير التخطيط",
"dashboardLayoutExit": "الخروج من تعديل التخطيط",
"dashboardKioskExit": "الخروج من وضع التلفاز",
"dashboardPlaylistPrev": "اللوحة السابقة",
"dashboardPlaylistNext": "اللوحة التالية",
"dashboardPlaylistPause": "إيقاف التناوب مؤقتًا أو استئنافه",
"dashboardPlaylistExit": "الخروج من قائمة تشغيل التلفاز",
"dashboardFullscreen": "تبديل ملء الشاشة",
"dashboardExport": "تصدير لوحة المعلومات",
"tableRowEdit": "تعديل: صف معلق",
@@ -21205,6 +21293,7 @@
"reportDetail": "تقرير",
"sharedChat": "محادثة مشتركة",
"passwordPolicy": "سياسة كلمة المرور",
"dashboardTvPlaylist": "قائمة تشغيل التلفاز",
"loginCallback": "إعادة الاتصال بتسجيل الدخول",
"slackOAuthCallback": "جارٍ ربط Slack",
"emptyStateDemo": "عرض الحالة الفارغة",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Dashboard exportieren",
"fullscreen": "Vollbildmodus aktivieren",
"exitFullscreen": "Vollbild beenden",
"kiosk": {
"tvMode": "TV-Modus",
"refresh": "Aktualisieren alle",
"refreshHelp": "Wie oft der Fernseher seine Daten neu lädt. Bei Aus bleibt der Bildschirm veraltet.",
"theme": "Design",
"themeCurrent": "Aktuell",
"themeLight": "Hell",
"themeDark": "Dunkel",
"fit": "Alle Panels auf einen Bildschirm einpassen",
"fitHelp": "Verkleinert die Panels auf die Bildschirmhöhe, sodass nichts scrollt.",
"open": "TV-Modus öffnen",
"exit": "TV-Modus beenden",
"refreshOff": "Automatische Aktualisierung aus",
"nextRefreshAt": "Nächste Aktualisierung um {time}",
"nextRetryAt": "Nächster Versuch um {time}",
"updatedAt": "Aktualisiert {time}",
"paused": "Pausiert, solange der Tab verborgen ist",
"staleSince": "Keine Aktualisierung seit {time}",
"unreachable": "Backend nicht erreichbar, neuer Versuch in {seconds} s",
"hung": "Abfrage hing, Aktualisierung erzwungen",
"panelsFailing": "{count} Panel fehlerhaft | {count} Panels fehlerhaft",
"panelFailed": "Panel konnte nicht geladen werden",
"panelStale": "Veraltet",
"queryError": "Abfragefehler: {detail}"
},
"playlist": {
"action": "TV-Playlist",
"actionTooltip": "Die ausgewählten Dashboards auf einem TV rotieren",
"title": "TV-Playlist",
"dashboards": "Dashboards",
"countOfMax": "{count} von {max}",
"searchPlaceholder": "Dashboards hinzufügen: in allen Ordnern suchen",
"fullPlaceholder": "Playlist ist voll (max. {max})",
"searchFailed": "Dashboards konnten nicht gesucht werden",
"noMatches": "Keine Dashboards passen zu \"{query}\"",
"add": "Hinzufügen",
"added": "Hinzugefügt",
"emptyTitle": "Keine Dashboards in der Playlist",
"emptyDescription": "Suchen Sie oben, um mindestens ein Dashboard hinzuzufügen",
"dragToReorder": "Zum Sortieren ziehen",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"remove": "Entfernen",
"notFound": "Nicht gefunden",
"showEachFor": "Anzeigen für je",
"timeRange": "Zeitraum",
"timeRangeHelp": "Ein Zeitraum für alle Dashboards",
"ownDefault": "Standard des jeweiligen Dashboards",
"variablesNote": "Jedes Dashboard öffnet auf dem ersten Tab mit seinen eigenen Variablen-Standards",
"refreshNote": "Daten laden neu, wenn ein Dashboard erscheint, und alle 5 Min., solange es angezeigt bleibt",
"linkCopied": "Playlist-Link kopiert, öffnen Sie ihn im Browser des TVs",
"open": "TV-Playlist öffnen",
"position": "{current} von {total}",
"previous": "Vorheriges Dashboard",
"next": "Nächstes Dashboard",
"pause": "Rotation pausieren",
"resume": "Rotation fortsetzen",
"exit": "Beenden",
"pausedSince": "Rotation pausiert seit {time}",
"held": "Rotation angehalten: Backend nicht erreichbar",
"waiting": "Nächstes Dashboard wird geladen",
"skippedNotFound": "{name} übersprungen: Dashboard nicht gefunden",
"skippedForbidden": "{name} übersprungen: kein Zugriff",
"slideName": "Dashboard {number}",
"loadingSlide": "Lade {current} von {total}",
"emptyStateTitle": "Keines der Dashboards der Playlist ist verfügbar",
"emptyStateDescription": "Alle Dashboards wurden gelöscht oder Sie haben keinen Zugriff mehr. Der TV bleibt hier und versucht es erneut.",
"tryAgain": "Erneut versuchen",
"invalidTitle": "Dieser Playlist-Link enthält keine Dashboards",
"invalidDescription": "Wählen Sie Dashboards in der Dashboard-Liste aus und wählen Sie TV-Playlist",
"backToDashboards": "Zu den Dashboards",
"starting": "Start",
"loading": "Wird geladen",
"intervalAdjusted": "Intervall angepasst auf {interval}",
"copied": "Kopiert",
"addLabel": "Dashboards hinzufügen",
"addHint": "Durchsucht alle Ordner; jedes Ergebnis zeigt seinen Ordner",
"copyLink": "Link kopieren",
"unreachable": "Backend nicht erreichbar",
"ofTotal": "von {total}",
"nextAt": "Als Nächstes: {name} um {time}"
},
"generalSettings": "Allgemeine Einstellungen",
"variableSettings": "Variable Einstellungen",
"tabSettings": "Tabs",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Dashboards",
"dashboards": "Dashboard-Ansicht",
"dashboardsPanel": "Dashboard — Panel (bewegen Sie den Mauszeiger über ein Panel)",
"dashboardTvPlaylist": "TV-Playlist",
"metrics": "Metriken",
"traces": "Traces",
"alerts": "Alarme",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Layoutänderung rückgängig machen",
"dashboardLayoutRedo": "Layoutänderung wiederholen",
"dashboardLayoutExit": "Layoutbearbeitung beenden",
"dashboardKioskExit": "TV-Modus beenden",
"dashboardPlaylistPrev": "Vorheriges Dashboard",
"dashboardPlaylistNext": "Nächstes Dashboard",
"dashboardPlaylistPause": "Rotation pausieren oder fortsetzen",
"dashboardPlaylistExit": "TV-Playlist beenden",
"dashboardFullscreen": "Vollbild umschalten",
"dashboardExport": "Dashboard exportieren",
"tableRowEdit": "Mit der Maus versehene Zeile bearbeiten",
@@ -21205,6 +21293,7 @@
"reportDetail": "Bericht",
"sharedChat": "Geteilter Chat",
"passwordPolicy": "Passwortrichtlinie",
"dashboardTvPlaylist": "TV-Playlist",
"loginCallback": "Anmelde-Rückruf",
"slackOAuthCallback": "Slack wird verbunden",
"emptyStateDemo": "Demo für leeren Zustand",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Export Dashboard",
"fullscreen": "Go Fullscreen",
"exitFullscreen": "Exit Fullscreen",
"kiosk": {
"tvMode": "TV mode",
"refresh": "Refresh every",
"refreshHelp": "How often the TV reloads its data. Off leaves the screen out of date.",
"theme": "Theme",
"themeCurrent": "Current",
"themeLight": "Light",
"themeDark": "Dark",
"fit": "Fit all panels on one screen",
"fitHelp": "Shrinks panels to the screen height so nothing scrolls.",
"open": "Open TV mode",
"exit": "Exit TV mode",
"refreshOff": "Auto-refresh off",
"nextRefreshAt": "Next refresh at {time}",
"nextRetryAt": "Next retry at {time}",
"updatedAt": "Updated {time}",
"paused": "Paused while the tab is hidden",
"staleSince": "Not updating since {time}",
"unreachable": "Backend unreachable, retry in {seconds}s",
"hung": "Query hung, refresh forced",
"panelsFailing": "{count} panel failing | {count} panels failing",
"panelFailed": "Panel failed to load",
"panelStale": "Stale",
"queryError": "Query error: {detail}"
},
"playlist": {
"action": "TV playlist",
"actionTooltip": "Rotate the selected dashboards on a TV",
"title": "TV playlist",
"dashboards": "Dashboards",
"countOfMax": "{count} of {max}",
"searchPlaceholder": "Add dashboards: search across all folders",
"fullPlaceholder": "Playlist is full ({max} max)",
"searchFailed": "Couldn't search dashboards",
"noMatches": "No dashboards match \"{query}\"",
"add": "Add",
"added": "Added",
"emptyTitle": "No dashboards in the playlist",
"emptyDescription": "Search above to add at least one dashboard",
"dragToReorder": "Drag to reorder",
"moveUp": "Move up",
"moveDown": "Move down",
"remove": "Remove",
"notFound": "Not found",
"showEachFor": "Show each for",
"timeRange": "Time range",
"timeRangeHelp": "One range for all dashboards",
"ownDefault": "Each dashboard's own default",
"variablesNote": "Each dashboard opens on its first tab with its own variable defaults",
"refreshNote": "Data reloads as each dashboard comes up, and every 5 min while one stays on screen",
"linkCopied": "Playlist link copied, open it in the TV's browser",
"open": "Open TV playlist",
"position": "{current} of {total}",
"previous": "Previous dashboard",
"next": "Next dashboard",
"pause": "Pause rotation",
"resume": "Resume rotation",
"exit": "Exit",
"pausedSince": "Rotation paused since {time}",
"held": "Rotation held: backend unreachable",
"waiting": "Loading the next dashboard",
"skippedNotFound": "Skipped {name}: dashboard not found",
"skippedForbidden": "Skipped {name}: no access",
"slideName": "Dashboard {number}",
"loadingSlide": "Loading {current} of {total}",
"emptyStateTitle": "None of the playlist's dashboards are available",
"emptyStateDescription": "Every dashboard was deleted or you no longer have access. The TV stays here and tries again.",
"tryAgain": "Try again",
"invalidTitle": "This playlist link names no dashboards",
"invalidDescription": "Select dashboards in the dashboard list and choose TV playlist",
"backToDashboards": "Go to dashboards",
"starting": "Starting",
"loading": "Loading",
"intervalAdjusted": "Interval adjusted to {interval}",
"copied": "Copied",
"addLabel": "Add dashboards",
"addHint": "Searches every folder; each result shows its folder",
"copyLink": "Copy link",
"unreachable": "Backend unreachable",
"ofTotal": "of {total}",
"nextAt": "Next: {name} at {time}"
},
"generalSettings": "General",
"variableSettings": "Variables",
"tabSettings": "Tabs",
@@ -19624,6 +19706,7 @@
"dashboardsList": "Dashboards",
"dashboards": "Dashboard View",
"dashboardsPanel": "Dashboard — Panel (hover a panel)",
"dashboardTvPlaylist": "TV playlist",
"metrics": "Metrics",
"traces": "Traces",
"alerts": "Alerts",
@@ -19691,6 +19774,11 @@
"dashboardLayoutUndo": "Undo layout change",
"dashboardLayoutRedo": "Redo layout change",
"dashboardLayoutExit": "Exit layout editing",
"dashboardKioskExit": "Exit TV mode",
"dashboardPlaylistPrev": "Previous dashboard",
"dashboardPlaylistNext": "Next dashboard",
"dashboardPlaylistPause": "Pause or resume rotation",
"dashboardPlaylistExit": "Exit TV playlist",
"dashboardFullscreen": "Toggle fullscreen",
"dashboardExport": "Export dashboard",
"tableRowEdit": "Edit hovered row",
@@ -21203,6 +21291,7 @@
"reportDetail": "Report",
"sharedChat": "Shared chat",
"passwordPolicy": "Password Policy",
"dashboardTvPlaylist": "TV playlist",
"loginCallback": "Login Callback",
"slackOAuthCallback": "Connecting Slack",
"emptyStateDemo": "Empty State Demo",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Exportar Dashboard",
"fullscreen": "Pantalla completa",
"exitFullscreen": "Salir de pantalla completa",
"kiosk": {
"tvMode": "Modo TV",
"refresh": "Actualizar cada",
"refreshHelp": "Con qué frecuencia la TV recarga sus datos. Desactivado deja la pantalla desactualizada.",
"theme": "Tema",
"themeCurrent": "Actual",
"themeLight": "Claro",
"themeDark": "Oscuro",
"fit": "Ajustar todos los paneles a una pantalla",
"fitHelp": "Reduce los paneles a la altura de la pantalla para que no haya que desplazarse.",
"open": "Abrir modo TV",
"exit": "Salir del modo TV",
"refreshOff": "Actualización automática desactivada",
"nextRefreshAt": "Próxima actualización a las {time}",
"nextRetryAt": "Próximo reintento a las {time}",
"updatedAt": "Actualizado {time}",
"paused": "En pausa mientras la pestaña está oculta",
"staleSince": "Sin actualizar desde {time}",
"unreachable": "Backend inaccesible, reintento en {seconds} s",
"hung": "Consulta bloqueada, actualización forzada",
"panelsFailing": "{count} panel con error | {count} paneles con error",
"panelFailed": "No se pudo cargar el panel",
"panelStale": "Desactualizado",
"queryError": "Error de consulta: {detail}"
},
"playlist": {
"action": "Lista de TV",
"actionTooltip": "Rotar los paneles seleccionados en una TV",
"title": "Lista de TV",
"dashboards": "Paneles",
"countOfMax": "{count} de {max}",
"searchPlaceholder": "Añadir paneles: buscar en todas las carpetas",
"fullPlaceholder": "La lista está llena (máx. {max})",
"searchFailed": "No se pudieron buscar los paneles",
"noMatches": "Ningún panel coincide con \"{query}\"",
"add": "Añadir",
"added": "Añadido",
"emptyTitle": "No hay paneles en la lista",
"emptyDescription": "Busca arriba para añadir al menos un panel",
"dragToReorder": "Arrastra para reordenar",
"moveUp": "Subir",
"moveDown": "Bajar",
"remove": "Quitar",
"notFound": "No encontrado",
"showEachFor": "Mostrar cada uno",
"timeRange": "Rango de tiempo",
"timeRangeHelp": "Un rango para todos los paneles",
"ownDefault": "El predeterminado de cada panel",
"variablesNote": "Cada panel se abre en su primera pestaña con sus propias variables predeterminadas",
"refreshNote": "Los datos se recargan al mostrar cada panel y cada 5 min mientras siga en pantalla",
"linkCopied": "Enlace de la lista copiado, ábrelo en el navegador de la TV",
"open": "Abrir lista de TV",
"position": "{current} de {total}",
"previous": "Panel anterior",
"next": "Panel siguiente",
"pause": "Pausar rotación",
"resume": "Reanudar rotación",
"exit": "Salir",
"pausedSince": "Rotación en pausa desde {time}",
"held": "Rotación detenida: backend inaccesible",
"waiting": "Cargando el siguiente panel",
"skippedNotFound": "Se omitió {name}: panel no encontrado",
"skippedForbidden": "Se omitió {name}: sin acceso",
"slideName": "Panel {number}",
"loadingSlide": "Cargando {current} de {total}",
"emptyStateTitle": "Ninguno de los paneles de la lista está disponible",
"emptyStateDescription": "Todos los paneles se eliminaron o ya no tienes acceso. La TV se queda aquí y lo vuelve a intentar.",
"tryAgain": "Reintentar",
"invalidTitle": "Este enlace de lista no incluye paneles",
"invalidDescription": "Selecciona paneles en la lista de paneles y elige Lista de TV",
"backToDashboards": "Ir a paneles",
"starting": "Iniciando",
"loading": "Cargando",
"intervalAdjusted": "Intervalo ajustado a {interval}",
"copied": "Copiado",
"addLabel": "Añadir paneles",
"addHint": "Busca en todas las carpetas; cada resultado muestra su carpeta",
"copyLink": "Copiar enlace",
"unreachable": "Backend inaccesible",
"ofTotal": "de {total}",
"nextAt": "Siguiente: {name} a las {time}"
},
"generalSettings": "Ajustes generales",
"variableSettings": "Ajustes de variables",
"tabSettings": "Pestañas",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Dashboards",
"dashboards": "Vista del Dashboard",
"dashboardsPanel": "Dashboard — Panel (coloca el cursor sobre un panel)",
"dashboardTvPlaylist": "Lista de TV",
"metrics": "Métricas",
"traces": "Trazas",
"alerts": "Alertas",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Deshacer cambio de diseño",
"dashboardLayoutRedo": "Rehacer cambio de diseño",
"dashboardLayoutExit": "Salir de la edición del diseño",
"dashboardKioskExit": "Salir del modo TV",
"dashboardPlaylistPrev": "Panel anterior",
"dashboardPlaylistNext": "Panel siguiente",
"dashboardPlaylistPause": "Pausar o reanudar la rotación",
"dashboardPlaylistExit": "Salir de la lista de TV",
"dashboardFullscreen": "Alternar pantalla completa",
"dashboardExport": "Exportar Dashboard",
"tableRowEdit": "Editar fila suspendida",
@@ -21205,6 +21293,7 @@
"reportDetail": "Informe",
"sharedChat": "Chat compartido",
"passwordPolicy": "Política de contraseñas",
"dashboardTvPlaylist": "Lista de TV",
"loginCallback": "Devolución de llamada de inicio de sesión",
"slackOAuthCallback": "Conectando con Slack",
"emptyStateDemo": "Demostración de estado vacío",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Exporter le tableau de bord",
"fullscreen": "Passez en mode plein écran",
"exitFullscreen": "Quitter le mode plein écran",
"kiosk": {
"tvMode": "Mode TV",
"refresh": "Actualiser toutes les",
"refreshHelp": "Fréquence à laquelle la TV recharge ses données. Désactivé laisse l'écran obsolète.",
"theme": "Thème",
"themeCurrent": "Actuel",
"themeLight": "Clair",
"themeDark": "Sombre",
"fit": "Ajuster tous les panneaux à un écran",
"fitHelp": "Réduit les panneaux à la hauteur de l'écran pour éviter tout défilement.",
"open": "Ouvrir le mode TV",
"exit": "Quitter le mode TV",
"refreshOff": "Actualisation automatique désactivée",
"nextRefreshAt": "Prochaine actualisation à {time}",
"nextRetryAt": "Prochaine tentative à {time}",
"updatedAt": "Mis à jour {time}",
"paused": "En pause tant que l'onglet est masqué",
"staleSince": "Pas de mise à jour depuis {time}",
"unreachable": "Backend injoignable, nouvel essai dans {seconds} s",
"hung": "Requête bloquée, actualisation forcée",
"panelsFailing": "{count} panneau en échec | {count} panneaux en échec",
"panelFailed": "Échec du chargement du panneau",
"panelStale": "Obsolète",
"queryError": "Erreur de requête : {detail}"
},
"playlist": {
"action": "Playlist TV",
"actionTooltip": "Faire défiler les tableaux de bord sélectionnés sur une TV",
"title": "Playlist TV",
"dashboards": "Tableaux de bord",
"countOfMax": "{count} sur {max}",
"searchPlaceholder": "Ajouter des tableaux de bord : rechercher dans tous les dossiers",
"fullPlaceholder": "La playlist est pleine ({max} max.)",
"searchFailed": "Impossible de rechercher les tableaux de bord",
"noMatches": "Aucun tableau de bord ne correspond à \"{query}\"",
"add": "Ajouter",
"added": "Ajouté",
"emptyTitle": "Aucun tableau de bord dans la playlist",
"emptyDescription": "Recherchez ci-dessus pour ajouter au moins un tableau de bord",
"dragToReorder": "Glisser pour réordonner",
"moveUp": "Monter",
"moveDown": "Descendre",
"remove": "Retirer",
"notFound": "Introuvable",
"showEachFor": "Afficher chacun",
"timeRange": "Période",
"timeRangeHelp": "Une période pour tous les tableaux de bord",
"ownDefault": "La valeur par défaut de chaque tableau de bord",
"variablesNote": "Chaque tableau de bord s'ouvre sur son premier onglet avec ses propres variables par défaut",
"refreshNote": "Les données se rechargent à l'affichage de chaque tableau de bord, puis toutes les 5 min s'il reste à l'écran",
"linkCopied": "Lien de la playlist copié, ouvrez-le dans le navigateur de la TV",
"open": "Ouvrir la playlist TV",
"position": "{current} sur {total}",
"previous": "Tableau de bord précédent",
"next": "Tableau de bord suivant",
"pause": "Mettre la rotation en pause",
"resume": "Reprendre la rotation",
"exit": "Quitter",
"pausedSince": "Rotation en pause depuis {time}",
"held": "Rotation suspendue : backend injoignable",
"waiting": "Chargement du tableau de bord suivant",
"skippedNotFound": "{name} ignoré : tableau de bord introuvable",
"skippedForbidden": "{name} ignoré : aucun accès",
"slideName": "Tableau de bord {number}",
"loadingSlide": "Chargement {current} sur {total}",
"emptyStateTitle": "Aucun tableau de bord de la playlist n'est disponible",
"emptyStateDescription": "Tous les tableaux de bord ont été supprimés ou vous n'y avez plus accès. La TV reste ici et réessaie.",
"tryAgain": "Réessayer",
"invalidTitle": "Ce lien de playlist ne contient aucun tableau de bord",
"invalidDescription": "Sélectionnez des tableaux de bord dans la liste et choisissez Playlist TV",
"backToDashboards": "Aller aux tableaux de bord",
"starting": "Démarrage",
"loading": "Chargement",
"intervalAdjusted": "Intervalle ajusté à {interval}",
"copied": "Copié",
"addLabel": "Ajouter des tableaux de bord",
"addHint": "Recherche dans tous les dossiers ; chaque résultat indique son dossier",
"copyLink": "Copier le lien",
"unreachable": "Backend injoignable",
"ofTotal": "sur {total}",
"nextAt": "Suivant : {name} à {time}"
},
"generalSettings": "Réglages généraux",
"variableSettings": "Réglages variables",
"tabSettings": "Onglets",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Tableaux de bord",
"dashboards": "Affichage du tableau de bord",
"dashboardsPanel": "Tableau de bord — Panneau (passez la souris sur un panneau)",
"dashboardTvPlaylist": "Playlist TV",
"metrics": "Métriques",
"traces": "Traces",
"alerts": "Alertes",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Annuler la modification de disposition",
"dashboardLayoutRedo": "Rétablir la modification de disposition",
"dashboardLayoutExit": "Quitter la modification de la disposition",
"dashboardKioskExit": "Quitter le mode TV",
"dashboardPlaylistPrev": "Tableau de bord précédent",
"dashboardPlaylistNext": "Tableau de bord suivant",
"dashboardPlaylistPause": "Mettre en pause ou reprendre la rotation",
"dashboardPlaylistExit": "Quitter la playlist TV",
"dashboardFullscreen": "Basculer en mode plein écran",
"dashboardExport": "Exporter le tableau de bord",
"tableRowEdit": "Modifier la ligne survolée",
@@ -21205,6 +21293,7 @@
"reportDetail": "Rapport",
"sharedChat": "Chat partagé",
"passwordPolicy": "Politique de mot de passe",
"dashboardTvPlaylist": "Playlist TV",
"loginCallback": "Rappel de connexion",
"slackOAuthCallback": "Connexion à Slack",
"emptyStateDemo": "Démo d'état vide",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Esporta dashboard",
"fullscreen": "Vai a schermo intero",
"exitFullscreen": "Esci da schermo intero",
"kiosk": {
"tvMode": "Modalità TV",
"refresh": "Aggiorna ogni",
"refreshHelp": "Ogni quanto la TV ricarica i dati. Disattivato lascia lo schermo non aggiornato.",
"theme": "Tema",
"themeCurrent": "Attuale",
"themeLight": "Chiaro",
"themeDark": "Scuro",
"fit": "Adatta tutti i pannelli a uno schermo",
"fitHelp": "Riduce i pannelli all'altezza dello schermo, così non serve scorrere.",
"open": "Apri modalità TV",
"exit": "Esci dalla modalità TV",
"refreshOff": "Aggiornamento automatico disattivato",
"nextRefreshAt": "Prossimo aggiornamento alle {time}",
"nextRetryAt": "Prossimo tentativo alle {time}",
"updatedAt": "Aggiornato {time}",
"paused": "In pausa mentre la scheda è nascosta",
"staleSince": "Nessun aggiornamento dalle {time}",
"unreachable": "Backend non raggiungibile, nuovo tentativo tra {seconds} s",
"hung": "Query bloccata, aggiornamento forzato",
"panelsFailing": "{count} pannello in errore | {count} pannelli in errore",
"panelFailed": "Impossibile caricare il pannello",
"panelStale": "Non aggiornato",
"queryError": "Errore di query: {detail}"
},
"playlist": {
"action": "Playlist TV",
"actionTooltip": "Ruota le dashboard selezionate su una TV",
"title": "Playlist TV",
"dashboards": "Dashboard",
"countOfMax": "{count} di {max}",
"searchPlaceholder": "Aggiungi dashboard: cerca in tutte le cartelle",
"fullPlaceholder": "La playlist è piena (max {max})",
"searchFailed": "Impossibile cercare le dashboard",
"noMatches": "Nessuna dashboard corrisponde a \"{query}\"",
"add": "Aggiungi",
"added": "Aggiunta",
"emptyTitle": "Nessuna dashboard nella playlist",
"emptyDescription": "Cerca sopra per aggiungere almeno una dashboard",
"dragToReorder": "Trascina per riordinare",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"remove": "Rimuovi",
"notFound": "Non trovata",
"showEachFor": "Mostra ciascuna per",
"timeRange": "Intervallo di tempo",
"timeRangeHelp": "Un intervallo per tutte le dashboard",
"ownDefault": "Il predefinito di ogni dashboard",
"variablesNote": "Ogni dashboard si apre sulla prima scheda con le proprie variabili predefinite",
"refreshNote": "I dati si ricaricano quando compare ogni dashboard e ogni 5 min finché resta sullo schermo",
"linkCopied": "Link della playlist copiato, aprilo nel browser della TV",
"open": "Apri playlist TV",
"position": "{current} di {total}",
"previous": "Dashboard precedente",
"next": "Dashboard successiva",
"pause": "Metti in pausa la rotazione",
"resume": "Riprendi la rotazione",
"exit": "Esci",
"pausedSince": "Rotazione in pausa dalle {time}",
"held": "Rotazione sospesa: backend non raggiungibile",
"waiting": "Caricamento della dashboard successiva",
"skippedNotFound": "{name} saltata: dashboard non trovata",
"skippedForbidden": "{name} saltata: nessun accesso",
"slideName": "Dashboard {number}",
"loadingSlide": "Caricamento {current} di {total}",
"emptyStateTitle": "Nessuna dashboard della playlist è disponibile",
"emptyStateDescription": "Tutte le dashboard sono state eliminate o non hai più accesso. La TV resta qui e riprova.",
"tryAgain": "Riprova",
"invalidTitle": "Questo link di playlist non contiene dashboard",
"invalidDescription": "Seleziona le dashboard nell'elenco e scegli Playlist TV",
"backToDashboards": "Vai alle dashboard",
"starting": "Avvio",
"loading": "Caricamento",
"intervalAdjusted": "Intervallo regolato a {interval}",
"copied": "Copiato",
"addLabel": "Aggiungi dashboard",
"addHint": "Cerca in tutte le cartelle; ogni risultato mostra la sua cartella",
"copyLink": "Copia link",
"unreachable": "Backend non raggiungibile",
"ofTotal": "di {total}",
"nextAt": "Prossimo: {name} alle {time}"
},
"generalSettings": "Impostazioni generali",
"variableSettings": "Variabili",
"tabSettings": "Schede",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Dashboard",
"dashboards": "Vista dashboard",
"dashboardsPanel": "Dashboard — Pannello (passa il mouse su un pannello)",
"dashboardTvPlaylist": "Playlist TV",
"metrics": "Metriche",
"traces": "Tracce",
"alerts": "Avvisi",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Annulla modifica al layout",
"dashboardLayoutRedo": "Ripeti modifica al layout",
"dashboardLayoutExit": "Esci dalla modifica del layout",
"dashboardKioskExit": "Esci dalla modalità TV",
"dashboardPlaylistPrev": "Dashboard precedente",
"dashboardPlaylistNext": "Dashboard successiva",
"dashboardPlaylistPause": "Metti in pausa o riprendi la rotazione",
"dashboardPlaylistExit": "Esci dalla playlist TV",
"dashboardFullscreen": "Attiva schermo intero",
"dashboardExport": "Esporta dashboard",
"tableRowEdit": "Modifica riga al passaggio del mouse",
@@ -21205,6 +21293,7 @@
"reportDetail": "Report",
"sharedChat": "Chat condivisa",
"passwordPolicy": "Criteri password",
"dashboardTvPlaylist": "Playlist TV",
"loginCallback": "Callback di accesso",
"slackOAuthCallback": "Connessione a Slack",
"emptyStateDemo": "Demo stato vuoto",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "ダッシュボードをエクスポート",
"fullscreen": "全画面表示",
"exitFullscreen": "フルスクリーンを終了",
"kiosk": {
"tvMode": "TV モード",
"refresh": "更新間隔",
"refreshHelp": "TV がデータを再読み込みする間隔です。オフにすると画面は更新されません。",
"theme": "テーマ",
"themeCurrent": "現在",
"themeLight": "ライト",
"themeDark": "ダーク",
"fit": "すべてのパネルを 1 画面に収める",
"fitHelp": "パネルを画面の高さに収め、スクロールが不要になります。",
"open": "TV モードを開く",
"exit": "TV モードを終了",
"refreshOff": "自動更新オフ",
"nextRefreshAt": "次の更新: {time}",
"nextRetryAt": "次の再試行: {time}",
"updatedAt": "{time} に更新",
"paused": "タブが非表示の間は一時停止中",
"staleSince": "{time} から更新されていません",
"unreachable": "バックエンドに接続できません。{seconds} 秒後に再試行",
"hung": "クエリが停止したため更新を強制しました",
"panelsFailing": "{count} 個のパネルでエラー | {count} 個のパネルでエラー",
"panelFailed": "パネルを読み込めませんでした",
"panelStale": "古いデータ",
"queryError": "クエリ エラー: {detail}"
},
"playlist": {
"action": "TV プレイリスト",
"actionTooltip": "選択したダッシュボードを TV で順番に表示",
"title": "TV プレイリスト",
"dashboards": "ダッシュボード",
"countOfMax": "{count} / {max}",
"searchPlaceholder": "ダッシュボードを追加: すべてのフォルダーを検索",
"fullPlaceholder": "プレイリストがいっぱいです (最大 {max})",
"searchFailed": "ダッシュボードを検索できませんでした",
"noMatches": "\"{query}\" に一致するダッシュボードはありません",
"add": "追加",
"added": "追加済み",
"emptyTitle": "プレイリストにダッシュボードがありません",
"emptyDescription": "上で検索して 1 つ以上のダッシュボードを追加してください",
"dragToReorder": "ドラッグして並べ替え",
"moveUp": "上へ移動",
"moveDown": "下へ移動",
"remove": "削除",
"notFound": "見つかりません",
"showEachFor": "各表示時間",
"timeRange": "時間範囲",
"timeRangeHelp": "すべてのダッシュボードで共通の範囲",
"ownDefault": "各ダッシュボードの既定値",
"variablesNote": "各ダッシュボードは最初のタブと既定の変数値で開きます",
"refreshNote": "各ダッシュボードの表示時にデータを再読み込みし、表示が続く間は 5 分ごとに再読み込みします",
"linkCopied": "プレイリストのリンクをコピーしました。TV のブラウザーで開いてください",
"open": "TV プレイリストを開く",
"position": "{current} / {total}",
"previous": "前のダッシュボード",
"next": "次のダッシュボード",
"pause": "ローテーションを一時停止",
"resume": "ローテーションを再開",
"exit": "終了",
"pausedSince": "{time} からローテーション一時停止中",
"held": "ローテーション保留中: バックエンドに接続できません",
"waiting": "次のダッシュボードを読み込み中",
"skippedNotFound": "{name} をスキップ: ダッシュボードが見つかりません",
"skippedForbidden": "{name} をスキップ: アクセス権がありません",
"slideName": "ダッシュボード {number}",
"loadingSlide": "{current} / {total} を読み込み中",
"emptyStateTitle": "プレイリストのダッシュボードがどれも利用できません",
"emptyStateDescription": "すべてのダッシュボードが削除されたか、アクセス権がなくなりました。TV はこの画面のまま再試行します。",
"tryAgain": "再試行",
"invalidTitle": "このプレイリストのリンクにはダッシュボードが含まれていません",
"invalidDescription": "ダッシュボード一覧でダッシュボードを選択し、TV プレイリストを選んでください",
"backToDashboards": "ダッシュボードへ移動",
"starting": "開始中",
"loading": "読み込み中",
"intervalAdjusted": "間隔を {interval} に調整しました",
"copied": "コピーしました",
"addLabel": "ダッシュボードを追加",
"addHint": "すべてのフォルダーを検索します。各結果にフォルダーが表示されます",
"copyLink": "リンクをコピー",
"unreachable": "バックエンドに接続できません",
"ofTotal": "/ {total}",
"nextAt": "次: {name}({time})"
},
"generalSettings": "一般設定",
"variableSettings": "変数設定",
"tabSettings": "タブ",
@@ -19626,6 +19708,7 @@
"dashboardsList": "ダッシュボード",
"dashboards": "ダッシュボードビュー",
"dashboardsPanel": "ダッシュボード — パネル (パネルにカーソルを合わせる)",
"dashboardTvPlaylist": "TV プレイリスト",
"metrics": "メトリクス",
"traces": "トレース",
"alerts": "アラート",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "レイアウトの変更を元に戻す",
"dashboardLayoutRedo": "レイアウトの変更をやり直す",
"dashboardLayoutExit": "レイアウト編集を終了",
"dashboardKioskExit": "TV モードを終了",
"dashboardPlaylistPrev": "前のダッシュボード",
"dashboardPlaylistNext": "次のダッシュボード",
"dashboardPlaylistPause": "ローテーションの一時停止/再開",
"dashboardPlaylistExit": "TV プレイリストを終了",
"dashboardFullscreen": "全画面表示に切り替え",
"dashboardExport": "ダッシュボードをエクスポート",
"tableRowEdit": "カーソルを合わせた行を編集",
@@ -21205,6 +21293,7 @@
"reportDetail": "レポート",
"sharedChat": "共有チャット",
"passwordPolicy": "パスワードポリシー",
"dashboardTvPlaylist": "TV プレイリスト",
"loginCallback": "ログインコールバック",
"slackOAuthCallback": "Slackに接続中",
"emptyStateDemo": "空状態デモ",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "대시보드 내보내기",
"fullscreen": "전체 화면으로 이동",
"exitFullscreen": "전체 화면 종료",
"kiosk": {
"tvMode": "TV 모드",
"refresh": "새로 고침 간격",
"refreshHelp": "TV가 데이터를 다시 불러오는 주기입니다. 끄면 화면이 갱신되지 않습니다.",
"theme": "테마",
"themeCurrent": "현재",
"themeLight": "라이트",
"themeDark": "다크",
"fit": "모든 패널을 한 화면에 맞추기",
"fitHelp": "패널을 화면 높이에 맞춰 스크롤이 필요 없게 합니다.",
"open": "TV 모드 열기",
"exit": "TV 모드 종료",
"refreshOff": "자동 새로 고침 꺼짐",
"nextRefreshAt": "다음 새로 고침: {time}",
"nextRetryAt": "다음 재시도: {time}",
"updatedAt": "{time}에 업데이트됨",
"paused": "탭이 숨겨진 동안 일시 중지됨",
"staleSince": "{time} 이후 업데이트 없음",
"unreachable": "백엔드에 연결할 수 없음, {seconds}초 후 재시도",
"hung": "쿼리가 멈춰 새로 고침을 강제함",
"panelsFailing": "{count}개 패널 오류 | {count}개 패널 오류",
"panelFailed": "패널을 불러오지 못했습니다",
"panelStale": "오래된 데이터",
"queryError": "쿼리 오류: {detail}"
},
"playlist": {
"action": "TV 재생 목록",
"actionTooltip": "선택한 대시보드를 TV에서 순환 표시",
"title": "TV 재생 목록",
"dashboards": "대시보드",
"countOfMax": "{count} / {max}",
"searchPlaceholder": "대시보드 추가: 모든 폴더에서 검색",
"fullPlaceholder": "재생 목록이 가득 찼습니다(최대 {max})",
"searchFailed": "대시보드를 검색할 수 없습니다",
"noMatches": "\"{query}\"와(과) 일치하는 대시보드가 없습니다",
"add": "추가",
"added": "추가됨",
"emptyTitle": "재생 목록에 대시보드가 없습니다",
"emptyDescription": "위에서 검색하여 대시보드를 하나 이상 추가하세요",
"dragToReorder": "끌어서 순서 변경",
"moveUp": "위로 이동",
"moveDown": "아래로 이동",
"remove": "제거",
"notFound": "찾을 수 없음",
"showEachFor": "각 표시 시간",
"timeRange": "시간 범위",
"timeRangeHelp": "모든 대시보드에 하나의 범위",
"ownDefault": "각 대시보드의 기본값",
"variablesNote": "각 대시보드는 첫 번째 탭과 자체 기본 변수로 열립니다",
"refreshNote": "각 대시보드가 표시될 때와 화면에 계속 있는 동안 5분마다 데이터를 다시 불러옵니다",
"linkCopied": "재생 목록 링크를 복사했습니다. TV 브라우저에서 여세요",
"open": "TV 재생 목록 열기",
"position": "{current} / {total}",
"previous": "이전 대시보드",
"next": "다음 대시보드",
"pause": "순환 일시 중지",
"resume": "순환 재개",
"exit": "종료",
"pausedSince": "{time}부터 순환 일시 중지됨",
"held": "순환 보류: 백엔드에 연결할 수 없음",
"waiting": "다음 대시보드 불러오는 중",
"skippedNotFound": "{name} 건너뜀: 대시보드를 찾을 수 없음",
"skippedForbidden": "{name} 건너뜀: 접근 권한 없음",
"slideName": "대시보드 {number}",
"loadingSlide": "{current} / {total} 불러오는 중",
"emptyStateTitle": "재생 목록의 대시보드를 하나도 사용할 수 없습니다",
"emptyStateDescription": "모든 대시보드가 삭제되었거나 더 이상 접근 권한이 없습니다. TV는 이 화면에 머물며 다시 시도합니다.",
"tryAgain": "다시 시도",
"invalidTitle": "이 재생 목록 링크에 대시보드가 없습니다",
"invalidDescription": "대시보드 목록에서 대시보드를 선택하고 TV 재생 목록을 선택하세요",
"backToDashboards": "대시보드로 이동",
"starting": "시작 중",
"loading": "불러오는 중",
"intervalAdjusted": "간격을 {interval}(으)로 조정했습니다",
"copied": "복사됨",
"addLabel": "대시보드 추가",
"addHint": "모든 폴더를 검색하며 각 결과에 폴더가 표시됩니다",
"copyLink": "링크 복사",
"unreachable": "백엔드에 연결할 수 없음",
"ofTotal": "/ {total}",
"nextAt": "다음: {name}, {time}"
},
"generalSettings": "일반 설정",
"variableSettings": "변수 설정",
"tabSettings": "탭",
@@ -19626,6 +19708,7 @@
"dashboardsList": "대시보드",
"dashboards": "대시보드 보기",
"dashboardsPanel": "대시보드 - 패널 (패널에 마우스 커서를 놓음)",
"dashboardTvPlaylist": "TV 재생 목록",
"metrics": "메트릭",
"traces": "트레이스",
"alerts": "알림",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "레이아웃 변경 실행 취소",
"dashboardLayoutRedo": "레이아웃 변경 다시 실행",
"dashboardLayoutExit": "레이아웃 편집 종료",
"dashboardKioskExit": "TV 모드 종료",
"dashboardPlaylistPrev": "이전 대시보드",
"dashboardPlaylistNext": "다음 대시보드",
"dashboardPlaylistPause": "순환 일시 중지 또는 재개",
"dashboardPlaylistExit": "TV 재생 목록 종료",
"dashboardFullscreen": "전체 화면 전환",
"dashboardExport": "대시보드 내보내기",
"tableRowEdit": "마우스로 가리킨 행 편집",
@@ -21205,6 +21293,7 @@
"reportDetail": "보고서",
"sharedChat": "공유 채팅",
"passwordPolicy": "비밀번호 정책",
"dashboardTvPlaylist": "TV 재생 목록",
"loginCallback": "로그인 콜백",
"slackOAuthCallback": "Slack 연결 중",
"emptyStateDemo": "빈 상태 데모",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Dashboard exporteren",
"fullscreen": "Ga naar volledig scherm",
"exitFullscreen": "Volledig scherm afsluiten",
"kiosk": {
"tvMode": "Tv-modus",
"refresh": "Vernieuwen elke",
"refreshHelp": "Hoe vaak de tv de gegevens opnieuw laadt. Uit laat het scherm verouderd.",
"theme": "Thema",
"themeCurrent": "Huidig",
"themeLight": "Licht",
"themeDark": "Donker",
"fit": "Alle panelen op één scherm passen",
"fitHelp": "Verkleint de panelen tot de schermhoogte, zodat er niets scrolt.",
"open": "Tv-modus openen",
"exit": "Tv-modus verlaten",
"refreshOff": "Automatisch vernieuwen uit",
"nextRefreshAt": "Volgende verversing om {time}",
"nextRetryAt": "Volgende poging om {time}",
"updatedAt": "Bijgewerkt {time}",
"paused": "Gepauzeerd zolang het tabblad verborgen is",
"staleSince": "Niet bijgewerkt sinds {time}",
"unreachable": "Backend onbereikbaar, nieuwe poging over {seconds} s",
"hung": "Query liep vast, vernieuwen geforceerd",
"panelsFailing": "{count} paneel mislukt | {count} panelen mislukt",
"panelFailed": "Paneel kon niet worden geladen",
"panelStale": "Verouderd",
"queryError": "Queryfout: {detail}"
},
"playlist": {
"action": "TV-afspeellijst",
"actionTooltip": "De geselecteerde dashboards op een TV laten rouleren",
"title": "TV-afspeellijst",
"dashboards": "Dashboards",
"countOfMax": "{count} van {max}",
"searchPlaceholder": "Dashboards toevoegen: zoeken in alle mappen",
"fullPlaceholder": "Afspeellijst is vol (max. {max})",
"searchFailed": "Kan dashboards niet zoeken",
"noMatches": "Geen dashboards gevonden voor \"{query}\"",
"add": "Toevoegen",
"added": "Toegevoegd",
"emptyTitle": "Geen dashboards in de afspeellijst",
"emptyDescription": "Zoek hierboven om minstens één dashboard toe te voegen",
"dragToReorder": "Sleep om te ordenen",
"moveUp": "Omhoog",
"moveDown": "Omlaag",
"remove": "Verwijderen",
"notFound": "Niet gevonden",
"showEachFor": "Elk tonen gedurende",
"timeRange": "Tijdsbereik",
"timeRangeHelp": "Eén bereik voor alle dashboards",
"ownDefault": "Standaard van elk dashboard",
"variablesNote": "Elk dashboard opent op het eerste tabblad met zijn eigen standaardvariabelen",
"refreshNote": "Gegevens laden opnieuw zodra een dashboard verschijnt, en elke 5 min zolang het op het scherm blijft",
"linkCopied": "Afspeellijstlink gekopieerd, open hem in de browser van de TV",
"open": "TV-afspeellijst openen",
"position": "{current} van {total}",
"previous": "Vorig dashboard",
"next": "Volgend dashboard",
"pause": "Rouleren pauzeren",
"resume": "Rouleren hervatten",
"exit": "Afsluiten",
"pausedSince": "Rouleren gepauzeerd sinds {time}",
"held": "Rouleren aangehouden: backend onbereikbaar",
"waiting": "Volgend dashboard laden",
"skippedNotFound": "{name} overgeslagen: dashboard niet gevonden",
"skippedForbidden": "{name} overgeslagen: geen toegang",
"slideName": "Dashboard {number}",
"loadingSlide": "{current} van {total} laden",
"emptyStateTitle": "Geen van de dashboards in de afspeellijst is beschikbaar",
"emptyStateDescription": "Alle dashboards zijn verwijderd of je hebt geen toegang meer. De TV blijft hier en probeert het opnieuw.",
"tryAgain": "Opnieuw proberen",
"invalidTitle": "Deze afspeellijstlink bevat geen dashboards",
"invalidDescription": "Selecteer dashboards in de dashboardlijst en kies TV-afspeellijst",
"backToDashboards": "Naar dashboards",
"starting": "Starten",
"loading": "Laden",
"intervalAdjusted": "Interval aangepast naar {interval}",
"copied": "Gekopieerd",
"addLabel": "Dashboards toevoegen",
"addHint": "Zoekt in alle mappen; elk resultaat toont zijn map",
"copyLink": "Link kopiëren",
"unreachable": "Backend onbereikbaar",
"ofTotal": "van {total}",
"nextAt": "Volgende: {name} om {time}"
},
"generalSettings": "Algemene instellingen",
"variableSettings": "Variabele instellingen",
"tabSettings": "Tabbladen",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Dashboards",
"dashboards": "Dashboard-weergave",
"dashboardsPanel": "Dashboard — Paneel (plaats de muisaanwijzer op een paneel)",
"dashboardTvPlaylist": "TV-afspeellijst",
"metrics": "Metrieken",
"traces": "Traces",
"alerts": "Alarmen",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Indelingswijziging ongedaan maken",
"dashboardLayoutRedo": "Indelingswijziging opnieuw uitvoeren",
"dashboardLayoutExit": "Indeling bewerken afsluiten",
"dashboardKioskExit": "Tv-modus verlaten",
"dashboardPlaylistPrev": "Vorig dashboard",
"dashboardPlaylistNext": "Volgend dashboard",
"dashboardPlaylistPause": "Rouleren pauzeren of hervatten",
"dashboardPlaylistExit": "TV-afspeellijst afsluiten",
"dashboardFullscreen": "Volledig scherm in- en uitschakelen",
"dashboardExport": "Dashboard exporteren",
"tableRowEdit": "Met de muisaanwijzer geplaatste rij bewerken",
@@ -21205,6 +21293,7 @@
"reportDetail": "Rapport",
"sharedChat": "Gedeelde chat",
"passwordPolicy": "Wachtwoordbeleid",
"dashboardTvPlaylist": "TV-afspeellijst",
"loginCallback": "Login-callback",
"slackOAuthCallback": "Slack verbinden",
"emptyStateDemo": "Lege-statusdemo",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Eksportuj pulpit",
"fullscreen": "Pełny ekran",
"exitFullscreen": "Wyjdź z pełnego ekranu",
"kiosk": {
"tvMode": "Tryb TV",
"refresh": "Odświeżaj co",
"refreshHelp": "Jak często telewizor ponownie wczytuje dane. Wyłączone pozostawia nieaktualny ekran.",
"theme": "Motyw",
"themeCurrent": "Bieżący",
"themeLight": "Jasny",
"themeDark": "Ciemny",
"fit": "Dopasuj wszystkie panele do jednego ekranu",
"fitHelp": "Zmniejsza panele do wysokości ekranu, aby nic nie trzeba było przewijać.",
"open": "Otwórz tryb TV",
"exit": "Zamknij tryb TV",
"refreshOff": "Automatyczne odświeżanie wyłączone",
"nextRefreshAt": "Następne odświeżenie o {time}",
"nextRetryAt": "Następna próba o {time}",
"updatedAt": "Zaktualizowano {time}",
"paused": "Wstrzymano, gdy karta jest ukryta",
"staleSince": "Brak aktualizacji od {time}",
"unreachable": "Backend nieosiągalny, ponowna próba za {seconds} s",
"hung": "Zapytanie zawiesiło się, wymuszono odświeżenie",
"panelsFailing": "{count} panel z błędem | {count} panele z błędem",
"panelFailed": "Nie udało się załadować panelu",
"panelStale": "Nieaktualne",
"queryError": "Błąd zapytania: {detail}"
},
"playlist": {
"action": "Playlista TV",
"actionTooltip": "Wyświetlaj wybrane pulpity na TV po kolei",
"title": "Playlista TV",
"dashboards": "Pulpity",
"countOfMax": "{count} z {max}",
"searchPlaceholder": "Dodaj pulpity: szukaj we wszystkich folderach",
"fullPlaceholder": "Playlista jest pełna (maks. {max})",
"searchFailed": "Nie udało się wyszukać pulpitów",
"noMatches": "Brak pulpitów pasujących do \"{query}\"",
"add": "Dodaj",
"added": "Dodano",
"emptyTitle": "Brak pulpitów na playliście",
"emptyDescription": "Wyszukaj powyżej, aby dodać co najmniej jeden pulpit",
"dragToReorder": "Przeciągnij, aby zmienić kolejność",
"moveUp": "W górę",
"moveDown": "W dół",
"remove": "Usuń",
"notFound": "Nie znaleziono",
"showEachFor": "Pokazuj każdy przez",
"timeRange": "Zakres czasu",
"timeRangeHelp": "Jeden zakres dla wszystkich pulpitów",
"ownDefault": "Domyślny zakres każdego pulpitu",
"variablesNote": "Każdy pulpit otwiera się na pierwszej karcie z własnymi domyślnymi zmiennymi",
"refreshNote": "Dane ładują się ponownie przy pokazaniu pulpitu i co 5 min, dopóki pozostaje na ekranie",
"linkCopied": "Skopiowano link do playlisty, otwórz go w przeglądarce TV",
"open": "Otwórz playlistę TV",
"position": "{current} z {total}",
"previous": "Poprzedni pulpit",
"next": "Następny pulpit",
"pause": "Wstrzymaj rotację",
"resume": "Wznów rotację",
"exit": "Zakończ",
"pausedSince": "Rotacja wstrzymana od {time}",
"held": "Rotacja zatrzymana: backend nieosiągalny",
"waiting": "Ładowanie następnego pulpitu",
"skippedNotFound": "Pominięto {name}: nie znaleziono pulpitu",
"skippedForbidden": "Pominięto {name}: brak dostępu",
"slideName": "Pulpit {number}",
"loadingSlide": "Ładowanie {current} z {total}",
"emptyStateTitle": "Żaden pulpit z playlisty nie jest dostępny",
"emptyStateDescription": "Wszystkie pulpity usunięto lub nie masz już dostępu. TV pozostaje tutaj i próbuje ponownie.",
"tryAgain": "Spróbuj ponownie",
"invalidTitle": "Ten link do playlisty nie zawiera pulpitów",
"invalidDescription": "Wybierz pulpity na liście pulpitów i wybierz Playlista TV",
"backToDashboards": "Przejdź do pulpitów",
"starting": "Uruchamianie",
"loading": "Ładowanie",
"intervalAdjusted": "Interwał zmieniony na {interval}",
"copied": "Skopiowano",
"addLabel": "Dodaj pulpity",
"addHint": "Przeszukuje wszystkie foldery; każdy wynik pokazuje swój folder",
"copyLink": "Kopiuj link",
"unreachable": "Backend nieosiągalny",
"ofTotal": "z {total}",
"nextAt": "Następny: {name} o {time}"
},
"generalSettings": "Ogólne",
"variableSettings": "Zmienne",
"tabSettings": "Zakładki",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Pulpity",
"dashboards": "Widok pulpitu",
"dashboardsPanel": "Pulpit — Panel (najedź na panel)",
"dashboardTvPlaylist": "Playlista TV",
"metrics": "Metryki",
"traces": "Trace",
"alerts": "Alerty",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Cofnij zmianę układu",
"dashboardLayoutRedo": "Ponów zmianę układu",
"dashboardLayoutExit": "Zakończ edycję układu",
"dashboardKioskExit": "Zamknij tryb TV",
"dashboardPlaylistPrev": "Poprzedni pulpit",
"dashboardPlaylistNext": "Następny pulpit",
"dashboardPlaylistPause": "Wstrzymaj lub wznów rotację",
"dashboardPlaylistExit": "Zakończ playlistę TV",
"dashboardFullscreen": "Przełącz pełny ekran",
"dashboardExport": "Eksportuj pulpit",
"tableRowEdit": "Edytuj najechany wiersz",
@@ -21205,6 +21293,7 @@
"reportDetail": "Raport",
"sharedChat": "Udostępniony czat",
"passwordPolicy": "Polityka haseł",
"dashboardTvPlaylist": "Playlista TV",
"loginCallback": "Callback logowania",
"slackOAuthCallback": "Łączenie ze Slackiem",
"emptyStateDemo": "Demo pustego stanu",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Exportar dashboard",
"fullscreen": "Mudar para ecrã inteiro",
"exitFullscreen": "Sair do ecrã inteiro",
"kiosk": {
"tvMode": "Modo TV",
"refresh": "Atualizar a cada",
"refreshHelp": "Com que frequência a TV recarrega os dados. Desligado deixa o ecrã desatualizado.",
"theme": "Tema",
"themeCurrent": "Atual",
"themeLight": "Claro",
"themeDark": "Escuro",
"fit": "Ajustar todos os painéis a um ecrã",
"fitHelp": "Reduz os painéis à altura do ecrã para que nada precise de deslocamento.",
"open": "Abrir modo TV",
"exit": "Sair do modo TV",
"refreshOff": "Atualização automática desligada",
"nextRefreshAt": "Próxima atualização às {time}",
"nextRetryAt": "Próxima tentativa às {time}",
"updatedAt": "Atualizado {time}",
"paused": "Em pausa enquanto o separador está oculto",
"staleSince": "Sem atualização desde {time}",
"unreachable": "Backend inacessível, nova tentativa em {seconds} s",
"hung": "Consulta bloqueada, atualização forçada",
"panelsFailing": "{count} painel com falha | {count} painéis com falha",
"panelFailed": "Falha ao carregar o painel",
"panelStale": "Desatualizado",
"queryError": "Erro de consulta: {detail}"
},
"playlist": {
"action": "Lista de TV",
"actionTooltip": "Rodar os painéis selecionados numa TV",
"title": "Lista de TV",
"dashboards": "Painéis",
"countOfMax": "{count} de {max}",
"searchPlaceholder": "Adicionar painéis: pesquisar em todas as pastas",
"fullPlaceholder": "A lista está cheia (máx. {max})",
"searchFailed": "Não foi possível pesquisar painéis",
"noMatches": "Nenhum painel corresponde a \"{query}\"",
"add": "Adicionar",
"added": "Adicionado",
"emptyTitle": "Sem painéis na lista",
"emptyDescription": "Pesquise acima para adicionar pelo menos um painel",
"dragToReorder": "Arrastar para reordenar",
"moveUp": "Mover para cima",
"moveDown": "Mover para baixo",
"remove": "Remover",
"notFound": "Não encontrado",
"showEachFor": "Mostrar cada um durante",
"timeRange": "Intervalo de tempo",
"timeRangeHelp": "Um intervalo para todos os painéis",
"ownDefault": "O predefinido de cada painel",
"variablesNote": "Cada painel abre no primeiro separador com as suas próprias variáveis predefinidas",
"refreshNote": "Os dados recarregam quando cada painel aparece e a cada 5 min enquanto se mantém no ecrã",
"linkCopied": "Ligação da lista copiada, abra-a no navegador da TV",
"open": "Abrir lista de TV",
"position": "{current} de {total}",
"previous": "Painel anterior",
"next": "Painel seguinte",
"pause": "Pausar rotação",
"resume": "Retomar rotação",
"exit": "Sair",
"pausedSince": "Rotação em pausa desde {time}",
"held": "Rotação suspensa: backend inacessível",
"waiting": "A carregar o painel seguinte",
"skippedNotFound": "{name} ignorado: painel não encontrado",
"skippedForbidden": "{name} ignorado: sem acesso",
"slideName": "Painel {number}",
"loadingSlide": "A carregar {current} de {total}",
"emptyStateTitle": "Nenhum dos painéis da lista está disponível",
"emptyStateDescription": "Todos os painéis foram eliminados ou já não tem acesso. A TV fica aqui e volta a tentar.",
"tryAgain": "Tentar novamente",
"invalidTitle": "Esta ligação de lista não inclui painéis",
"invalidDescription": "Selecione painéis na lista de painéis e escolha Lista de TV",
"backToDashboards": "Ir para painéis",
"starting": "A iniciar",
"loading": "A carregar",
"intervalAdjusted": "Intervalo ajustado para {interval}",
"copied": "Copiado",
"addLabel": "Adicionar painéis",
"addHint": "Pesquisa em todas as pastas; cada resultado mostra a sua pasta",
"copyLink": "Copiar ligação",
"unreachable": "Backend inacessível",
"ofTotal": "de {total}",
"nextAt": "Seguinte: {name} às {time}"
},
"generalSettings": "Geral",
"variableSettings": "Variáveis",
"tabSettings": "Separadores",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Dashboards",
"dashboards": "Visualização do dashboard",
"dashboardsPanel": "Dashboard — Painel (passe o rato sobre um painel)",
"dashboardTvPlaylist": "Lista de TV",
"metrics": "Métricas",
"traces": "Traces",
"alerts": "Alertas",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Anular alteração de esquema",
"dashboardLayoutRedo": "Refazer alteração de esquema",
"dashboardLayoutExit": "Sair da edição do esquema",
"dashboardKioskExit": "Sair do modo TV",
"dashboardPlaylistPrev": "Painel anterior",
"dashboardPlaylistNext": "Painel seguinte",
"dashboardPlaylistPause": "Pausar ou retomar a rotação",
"dashboardPlaylistExit": "Sair da lista de TV",
"dashboardFullscreen": "Alternar para ecrã inteiro",
"dashboardExport": "Exportar dashboard",
"tableRowEdit": "Editar a linha sob o cursor",
@@ -21205,6 +21293,7 @@
"reportDetail": "Relatório",
"sharedChat": "Chat partilhado",
"passwordPolicy": "Política de palavra-passe",
"dashboardTvPlaylist": "Lista de TV",
"loginCallback": "Callback de início de sessão",
"slackOAuthCallback": "A ligar ao Slack",
"emptyStateDemo": "Demonstração de estado vazio",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Экспорт дашборда",
"fullscreen": "На весь экран",
"exitFullscreen": "Выйти из полноэкранного режима",
"kiosk": {
"tvMode": "Режим ТВ",
"refresh": "Обновлять каждые",
"refreshHelp": "Как часто телевизор перезагружает данные. «Выкл.» оставляет экран устаревшим.",
"theme": "Тема",
"themeCurrent": "Текущая",
"themeLight": "Светлая",
"themeDark": "Тёмная",
"fit": "Уместить все панели на одном экране",
"fitHelp": "Уменьшает панели до высоты экрана, чтобы ничего не прокручивалось.",
"open": "Открыть режим ТВ",
"exit": "Выйти из режима ТВ",
"refreshOff": "Автообновление выключено",
"nextRefreshAt": "Следующее обновление в {time}",
"nextRetryAt": "Следующая попытка в {time}",
"updatedAt": "Обновлено {time}",
"paused": "Приостановлено, пока вкладка скрыта",
"staleSince": "Нет обновлений с {time}",
"unreachable": "Бэкенд недоступен, повтор через {seconds} с",
"hung": "Запрос завис, обновление принудительно",
"panelsFailing": "{count} панель с ошибкой | {count} панели с ошибкой",
"panelFailed": "Не удалось загрузить панель",
"panelStale": "Устарело",
"queryError": "Ошибка запроса: {detail}"
},
"playlist": {
"action": "ТВ-плейлист",
"actionTooltip": "Показывать выбранные дашборды на ТВ по очереди",
"title": "ТВ-плейлист",
"dashboards": "Дашборды",
"countOfMax": "{count} из {max}",
"searchPlaceholder": "Добавить дашборды: поиск во всех папках",
"fullPlaceholder": "Плейлист заполнен (макс. {max})",
"searchFailed": "Не удалось найти дашборды",
"noMatches": "Нет дашбордов по запросу \"{query}\"",
"add": "Добавить",
"added": "Добавлено",
"emptyTitle": "В плейлисте нет дашбордов",
"emptyDescription": "Найдите выше и добавьте хотя бы один дашборд",
"dragToReorder": "Перетащите для сортировки",
"moveUp": "Вверх",
"moveDown": "Вниз",
"remove": "Удалить",
"notFound": "Не найден",
"showEachFor": "Показывать каждый",
"timeRange": "Диапазон времени",
"timeRangeHelp": "Один диапазон для всех дашбордов",
"ownDefault": "Собственный по умолчанию у каждого дашборда",
"variablesNote": "Каждый дашборд открывается на первой вкладке со своими значениями переменных по умолчанию",
"refreshNote": "Данные загружаются заново при показе дашборда и каждые 5 мин, пока он остаётся на экране",
"linkCopied": "Ссылка на плейлист скопирована, откройте её в браузере ТВ",
"open": "Открыть ТВ-плейлист",
"position": "{current} из {total}",
"previous": "Предыдущий дашборд",
"next": "Следующий дашборд",
"pause": "Приостановить показ",
"resume": "Продолжить показ",
"exit": "Выйти",
"pausedSince": "Показ приостановлен с {time}",
"held": "Показ удерживается: бэкенд недоступен",
"waiting": "Загрузка следующего дашборда",
"skippedNotFound": "{name} пропущен: дашборд не найден",
"skippedForbidden": "{name} пропущен: нет доступа",
"slideName": "Дашборд {number}",
"loadingSlide": "Загрузка {current} из {total}",
"emptyStateTitle": "Ни один дашборд плейлиста недоступен",
"emptyStateDescription": "Все дашборды удалены или у вас больше нет доступа. ТВ остаётся на этом экране и пробует снова.",
"tryAgain": "Повторить",
"invalidTitle": "В этой ссылке на плейлист нет дашбордов",
"invalidDescription": "Выберите дашборды в списке дашбордов и нажмите «ТВ-плейлист»",
"backToDashboards": "К дашбордам",
"starting": "Запуск",
"loading": "Загрузка",
"intervalAdjusted": "Интервал изменён на {interval}",
"copied": "Скопировано",
"addLabel": "Добавить дашборды",
"addHint": "Поиск по всем папкам; у каждого результата указана папка",
"copyLink": "Копировать ссылку",
"unreachable": "Бэкенд недоступен",
"ofTotal": "из {total}",
"nextAt": "Далее: {name} в {time}"
},
"generalSettings": "Общие",
"variableSettings": "Переменные",
"tabSettings": "Вкладки",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Дашборды",
"dashboards": "Просмотр дашборда",
"dashboardsPanel": "Дашборд — панель (наведите курсор на панель)",
"dashboardTvPlaylist": "ТВ-плейлист",
"metrics": "Метрики",
"traces": "Трейсы",
"alerts": "Оповещения",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Отменить изменение макета",
"dashboardLayoutRedo": "Повторить изменение макета",
"dashboardLayoutExit": "Выйти из редактирования макета",
"dashboardKioskExit": "Выйти из режима ТВ",
"dashboardPlaylistPrev": "Предыдущий дашборд",
"dashboardPlaylistNext": "Следующий дашборд",
"dashboardPlaylistPause": "Приостановить или продолжить показ",
"dashboardPlaylistExit": "Выйти из ТВ-плейлиста",
"dashboardFullscreen": "Включить полноэкранный режим",
"dashboardExport": "Экспортировать дашборд",
"tableRowEdit": "Редактировать строку под курсором",
@@ -21205,6 +21293,7 @@
"reportDetail": "Отчёт",
"sharedChat": "Общий чат",
"passwordPolicy": "Политика паролей",
"dashboardTvPlaylist": "ТВ-плейлист",
"loginCallback": "Обратный вызов входа",
"slackOAuthCallback": "Подключение Slack",
"emptyStateDemo": "Демо пустого состояния",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Gösterge Panosunu Dışa Aktar",
"fullscreen": "Tam Ekrana Git",
"exitFullscreen": "Tam Ekrandan Çıkış",
"kiosk": {
"tvMode": "TV modu",
"refresh": "Yenileme sıklığı",
"refreshHelp": "TV'nin verilerini ne sıklıkla yeniden yüklediği. Kapalı, ekranı güncel olmayan halde bırakır.",
"theme": "Tema",
"themeCurrent": "Geçerli",
"themeLight": "Açık",
"themeDark": "Koyu",
"fit": "Tüm panelleri tek ekrana sığdır",
"fitHelp": "Panelleri ekran yüksekliğine sığdırır, böylece kaydırma gerekmez.",
"open": "TV modunu aç",
"exit": "TV modundan çık",
"refreshOff": "Otomatik yenileme kapalı",
"nextRefreshAt": "Sonraki yenileme: {time}",
"nextRetryAt": "Sonraki deneme: {time}",
"updatedAt": "{time} güncellendi",
"paused": "Sekme gizliyken duraklatıldı",
"staleSince": "{time} zamanından beri güncellenmiyor",
"unreachable": "Arka uca ulaşılamıyor, {seconds} sn sonra yeniden denenecek",
"hung": "Sorgu takıldı, yenileme zorlandı",
"panelsFailing": "{count} panel hatalı | {count} panel hatalı",
"panelFailed": "Panel yüklenemedi",
"panelStale": "Güncel değil",
"queryError": "Sorgu hatası: {detail}"
},
"playlist": {
"action": "TV oynatma listesi",
"actionTooltip": "Seçili panoları bir TV'de sırayla göster",
"title": "TV oynatma listesi",
"dashboards": "Panolar",
"countOfMax": "{count} / {max}",
"searchPlaceholder": "Pano ekle: tüm klasörlerde ara",
"fullPlaceholder": "Oynatma listesi dolu (en fazla {max})",
"searchFailed": "Panolar aranamadı",
"noMatches": "\"{query}\" ile eşleşen pano yok",
"add": "Ekle",
"added": "Eklendi",
"emptyTitle": "Oynatma listesinde pano yok",
"emptyDescription": "En az bir pano eklemek için yukarıda arayın",
"dragToReorder": "Sıralamak için sürükleyin",
"moveUp": "Yukarı taşı",
"moveDown": "Aşağı taşı",
"remove": "Kaldır",
"notFound": "Bulunamadı",
"showEachFor": "Her birini göster",
"timeRange": "Zaman aralığı",
"timeRangeHelp": "Tüm panolar için tek aralık",
"ownDefault": "Her panonun kendi varsayılanı",
"variablesNote": "Her pano kendi varsayılan değişkenleriyle ilk sekmesinde açılır",
"refreshNote": "Veriler her pano göründüğünde ve ekranda kaldığı sürece 5 dakikada bir yeniden yüklenir",
"linkCopied": "Oynatma listesi bağlantısı kopyalandı, TV'nin tarayıcısında açın",
"open": "TV oynatma listesini aç",
"position": "{current} / {total}",
"previous": "Önceki pano",
"next": "Sonraki pano",
"pause": "Döngüyü duraklat",
"resume": "Döngüyü sürdür",
"exit": "Çık",
"pausedSince": "Döngü {time} saatinden beri duraklatıldı",
"held": "Döngü bekletiliyor: arka uca ulaşılamıyor",
"waiting": "Sonraki pano yükleniyor",
"skippedNotFound": "{name} atlandı: pano bulunamadı",
"skippedForbidden": "{name} atlandı: erişim yok",
"slideName": "Pano {number}",
"loadingSlide": "{current} / {total} yükleniyor",
"emptyStateTitle": "Oynatma listesindeki panoların hiçbiri kullanılamıyor",
"emptyStateDescription": "Tüm panolar silindi veya artık erişiminiz yok. TV bu ekranda kalır ve yeniden dener.",
"tryAgain": "Tekrar dene",
"invalidTitle": "Bu oynatma listesi bağlantısında pano yok",
"invalidDescription": "Pano listesinde panoları seçip TV oynatma listesi'ni seçin",
"backToDashboards": "Panolara git",
"starting": "Başlatılıyor",
"loading": "Yükleniyor",
"intervalAdjusted": "Aralık {interval} olarak ayarlandı",
"copied": "Kopyalandı",
"addLabel": "Pano ekle",
"addHint": "Tüm klasörlerde arar; her sonuç kendi klasörünü gösterir",
"copyLink": "Bağlantıyı kopyala",
"unreachable": "Arka uca ulaşılamıyor",
"ofTotal": "/ {total}",
"nextAt": "Sıradaki: {name}, {time}"
},
"generalSettings": "Genel ayarlar",
"variableSettings": "Değişken ayarlar",
"tabSettings": "Sekmeler",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Gösterge panoları",
"dashboards": "Gösterge Panosu Görünümü",
"dashboardsPanel": "Dashboard — Panel (panele gelin)",
"dashboardTvPlaylist": "TV oynatma listesi",
"metrics": "Metrikler",
"traces": "İzler",
"alerts": "Uyarılar",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Düzen değişikliğini geri al",
"dashboardLayoutRedo": "Düzen değişikliğini yinele",
"dashboardLayoutExit": "Düzen düzenlemesinden çık",
"dashboardKioskExit": "TV modundan çık",
"dashboardPlaylistPrev": "Önceki pano",
"dashboardPlaylistNext": "Sonraki pano",
"dashboardPlaylistPause": "Döngüyü duraklat veya sürdür",
"dashboardPlaylistExit": "TV oynatma listesinden çık",
"dashboardFullscreen": "Tam ekranı aç/kapat",
"dashboardExport": "Gösterge panosunu dışa aktar",
"tableRowEdit": "Üzerine gelinen satırı düzenle",
@@ -21205,6 +21293,7 @@
"reportDetail": "Rapor",
"sharedChat": "Paylaşılan sohbet",
"passwordPolicy": "Parola Politikası",
"dashboardTvPlaylist": "TV oynatma listesi",
"loginCallback": "Giriş Geri Çağrısı",
"slackOAuthCallback": "Slack'e bağlanılıyor",
"emptyStateDemo": "Boş Durum Demosu",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "Xuất Bảng điều khiển",
"fullscreen": "Toàn màn hình",
"exitFullscreen": "Thoát toàn màn hình",
"kiosk": {
"tvMode": "Chế độ TV",
"refresh": "Làm mới mỗi",
"refreshHelp": "Tần suất TV tải lại dữ liệu. Tắt sẽ khiến màn hình không được cập nhật.",
"theme": "Giao diện",
"themeCurrent": "Hiện tại",
"themeLight": "Sáng",
"themeDark": "Tối",
"fit": "Vừa tất cả bảng trên một màn hình",
"fitHelp": "Thu nhỏ các bảng theo chiều cao màn hình để không cần cuộn.",
"open": "Mở chế độ TV",
"exit": "Thoát chế độ TV",
"refreshOff": "Tự động làm mới đã tắt",
"nextRefreshAt": "Làm mới tiếp theo lúc {time}",
"nextRetryAt": "Thử lại tiếp theo lúc {time}",
"updatedAt": "Đã cập nhật {time}",
"paused": "Tạm dừng khi tab bị ẩn",
"staleSince": "Không cập nhật từ {time}",
"unreachable": "Không kết nối được backend, thử lại sau {seconds} giây",
"hung": "Truy vấn bị treo, đã buộc làm mới",
"panelsFailing": "{count} bảng bị lỗi | {count} bảng bị lỗi",
"panelFailed": "Không tải được bảng",
"panelStale": "Đã cũ",
"queryError": "Lỗi truy vấn: {detail}"
},
"playlist": {
"action": "Danh sách phát TV",
"actionTooltip": "Xoay vòng các bảng điều khiển đã chọn trên TV",
"title": "Danh sách phát TV",
"dashboards": "Bảng điều khiển",
"countOfMax": "{count} / {max}",
"searchPlaceholder": "Thêm bảng điều khiển: tìm trong mọi thư mục",
"fullPlaceholder": "Danh sách phát đã đầy (tối đa {max})",
"searchFailed": "Không thể tìm bảng điều khiển",
"noMatches": "Không có bảng điều khiển nào khớp \"{query}\"",
"add": "Thêm",
"added": "Đã thêm",
"emptyTitle": "Danh sách phát chưa có bảng điều khiển",
"emptyDescription": "Tìm ở trên để thêm ít nhất một bảng điều khiển",
"dragToReorder": "Kéo để sắp xếp",
"moveUp": "Lên",
"moveDown": "Xuống",
"remove": "Xóa",
"notFound": "Không tìm thấy",
"showEachFor": "Hiển thị mỗi cái trong",
"timeRange": "Khoảng thời gian",
"timeRangeHelp": "Một khoảng cho mọi bảng điều khiển",
"ownDefault": "Mặc định riêng của từng bảng điều khiển",
"variablesNote": "Mỗi bảng điều khiển mở ở thẻ đầu tiên với giá trị biến mặc định riêng",
"refreshNote": "Dữ liệu tải lại khi mỗi bảng điều khiển xuất hiện và mỗi 5 phút khi nó còn trên màn hình",
"linkCopied": "Đã sao chép liên kết danh sách phát, hãy mở trong trình duyệt của TV",
"open": "Mở danh sách phát TV",
"position": "{current} / {total}",
"previous": "Bảng điều khiển trước",
"next": "Bảng điều khiển tiếp",
"pause": "Tạm dừng xoay vòng",
"resume": "Tiếp tục xoay vòng",
"exit": "Thoát",
"pausedSince": "Xoay vòng tạm dừng từ {time}",
"held": "Xoay vòng bị giữ: không kết nối được backend",
"waiting": "Đang tải bảng điều khiển tiếp theo",
"skippedNotFound": "Đã bỏ qua {name}: không tìm thấy bảng điều khiển",
"skippedForbidden": "Đã bỏ qua {name}: không có quyền truy cập",
"slideName": "Bảng điều khiển {number}",
"loadingSlide": "Đang tải {current} / {total}",
"emptyStateTitle": "Không bảng điều khiển nào trong danh sách phát khả dụng",
"emptyStateDescription": "Mọi bảng điều khiển đã bị xóa hoặc bạn không còn quyền truy cập. TV sẽ ở lại đây và thử lại.",
"tryAgain": "Thử lại",
"invalidTitle": "Liên kết danh sách phát này không có bảng điều khiển nào",
"invalidDescription": "Chọn bảng điều khiển trong danh sách và chọn Danh sách phát TV",
"backToDashboards": "Đến bảng điều khiển",
"starting": "Đang bắt đầu",
"loading": "Đang tải",
"intervalAdjusted": "Đã điều chỉnh khoảng thời gian thành {interval}",
"copied": "Đã sao chép",
"addLabel": "Thêm bảng điều khiển",
"addHint": "Tìm trong mọi thư mục; mỗi kết quả hiển thị thư mục của nó",
"copyLink": "Sao chép liên kết",
"unreachable": "Không kết nối được backend",
"ofTotal": "/ {total}",
"nextAt": "Tiếp theo: {name} lúc {time}"
},
"generalSettings": "Chung",
"variableSettings": "Biến",
"tabSettings": "Tab",
@@ -19626,6 +19708,7 @@
"dashboardsList": "Bảng điều khiển",
"dashboards": "Chế độ xem bảng điều khiển",
"dashboardsPanel": "Bảng điều khiển — Panel (di chuột qua panel)",
"dashboardTvPlaylist": "Danh sách phát TV",
"metrics": "Chỉ số",
"traces": "Trace",
"alerts": "Cảnh báo",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "Hoàn tác thay đổi bố cục",
"dashboardLayoutRedo": "Làm lại thay đổi bố cục",
"dashboardLayoutExit": "Thoát chỉnh sửa bố cục",
"dashboardKioskExit": "Thoát chế độ TV",
"dashboardPlaylistPrev": "Bảng điều khiển trước",
"dashboardPlaylistNext": "Bảng điều khiển tiếp",
"dashboardPlaylistPause": "Tạm dừng hoặc tiếp tục xoay vòng",
"dashboardPlaylistExit": "Thoát danh sách phát TV",
"dashboardFullscreen": "Chuyển đổi toàn màn hình",
"dashboardExport": "Xuất bảng điều khiển",
"tableRowEdit": "Chỉnh sửa hàng đang di chuột",
@@ -21205,6 +21293,7 @@
"reportDetail": "Báo cáo",
"sharedChat": "Cuộc trò chuyện được chia sẻ",
"passwordPolicy": "Chính sách mật khẩu",
"dashboardTvPlaylist": "Danh sách phát TV",
"loginCallback": "Callback đăng nhập",
"slackOAuthCallback": "Đang kết nối Slack",
"emptyStateDemo": "Demo trạng thái trống",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "导出仪表盘",
"fullscreen": "进入全屏模式",
"exitFullscreen": "退出全屏模式",
"kiosk": {
"tvMode": "电视模式",
"refresh": "刷新间隔",
"refreshHelp": "电视重新加载数据的频率。关闭后屏幕将不再更新。",
"theme": "主题",
"themeCurrent": "当前",
"themeLight": "浅色",
"themeDark": "深色",
"fit": "将所有面板放入一屏",
"fitHelp": "将面板缩放到屏幕高度,无需滚动。",
"open": "打开电视模式",
"exit": "退出电视模式",
"refreshOff": "自动刷新已关闭",
"nextRefreshAt": "下次刷新时间:{time}",
"nextRetryAt": "下次重试时间:{time}",
"updatedAt": "更新于 {time}",
"paused": "标签页隐藏时已暂停",
"staleSince": "自 {time} 起未更新",
"unreachable": "后端无法访问,{seconds} 秒后重试",
"hung": "查询卡住,已强制刷新",
"panelsFailing": "{count} 个面板失败 | {count} 个面板失败",
"panelFailed": "面板加载失败",
"panelStale": "数据过期",
"queryError": "查询错误:{detail}"
},
"playlist": {
"action": "电视轮播",
"actionTooltip": "在电视上轮流展示所选仪表板",
"title": "电视轮播",
"dashboards": "仪表板",
"countOfMax": "{count} / {max}",
"searchPlaceholder": "添加仪表板:在所有文件夹中搜索",
"fullPlaceholder": "轮播已满(最多 {max} 个)",
"searchFailed": "无法搜索仪表板",
"noMatches": "没有与“{query}”匹配的仪表板",
"add": "添加",
"added": "已添加",
"emptyTitle": "轮播中没有仪表板",
"emptyDescription": "在上方搜索并添加至少一个仪表板",
"dragToReorder": "拖动以排序",
"moveUp": "上移",
"moveDown": "下移",
"remove": "移除",
"notFound": "未找到",
"showEachFor": "每个显示",
"timeRange": "时间范围",
"timeRangeHelp": "所有仪表板使用同一范围",
"ownDefault": "各仪表板自己的默认值",
"variablesNote": "每个仪表板在第一个标签页打开,使用自己的变量默认值",
"refreshNote": "每个仪表板出现时重新加载数据,停留在屏幕上时每 5 分钟重新加载一次",
"linkCopied": "已复制轮播链接,请在电视浏览器中打开",
"open": "打开电视轮播",
"position": "{current} / {total}",
"previous": "上一个仪表板",
"next": "下一个仪表板",
"pause": "暂停轮播",
"resume": "继续轮播",
"exit": "退出",
"pausedSince": "轮播自 {time} 起已暂停",
"held": "轮播已保持:无法连接后端",
"waiting": "正在加载下一个仪表板",
"skippedNotFound": "已跳过 {name}:未找到仪表板",
"skippedForbidden": "已跳过 {name}:无访问权限",
"slideName": "仪表板 {number}",
"loadingSlide": "正在加载 {current} / {total}",
"emptyStateTitle": "轮播中的仪表板都不可用",
"emptyStateDescription": "所有仪表板都已删除,或您已无访问权限。电视将停留在此页面并重试。",
"tryAgain": "重试",
"invalidTitle": "此轮播链接未包含仪表板",
"invalidDescription": "在仪表板列表中选择仪表板,然后选择电视轮播",
"backToDashboards": "前往仪表板",
"starting": "正在启动",
"loading": "正在加载",
"intervalAdjusted": "间隔已调整为 {interval}",
"copied": "已复制",
"addLabel": "添加仪表板",
"addHint": "搜索所有文件夹;每个结果都会显示其文件夹",
"copyLink": "复制链接",
"unreachable": "后端不可达",
"ofTotal": "/ {total}",
"nextAt": "下一个:{name},{time}"
},
"generalSettings": "通用设置",
"variableSettings": "变量设置",
"tabSettings": "选项卡",
@@ -19626,6 +19708,7 @@
"dashboardsList": "仪表板",
"dashboards": "仪表板视图",
"dashboardsPanel": "仪表板-面板(将鼠标悬停在面板上)",
"dashboardTvPlaylist": "电视轮播",
"metrics": "指标",
"traces": "追踪",
"alerts": "告警",
@@ -19693,6 +19776,11 @@
"dashboardLayoutUndo": "撤销布局更改",
"dashboardLayoutRedo": "重做布局更改",
"dashboardLayoutExit": "退出布局编辑",
"dashboardKioskExit": "退出电视模式",
"dashboardPlaylistPrev": "上一个仪表板",
"dashboardPlaylistNext": "下一个仪表板",
"dashboardPlaylistPause": "暂停或继续轮播",
"dashboardPlaylistExit": "退出电视轮播",
"dashboardFullscreen": "切换全屏",
"dashboardExport": "导出仪表板",
"tableRowEdit": "编辑悬停的行",
@@ -21205,6 +21293,7 @@
"reportDetail": "报告",
"sharedChat": "共享对话",
"passwordPolicy": "密码策略",
"dashboardTvPlaylist": "电视轮播",
"loginCallback": "登录回调",
"slackOAuthCallback": "正在连接 Slack",
"emptyStateDemo": "空状态演示",
+89
View File
@@ -6402,6 +6402,88 @@
"export": "匯出儀表板",
"fullscreen": "全螢幕",
"exitFullscreen": "離開全螢幕",
"kiosk": {
"tvMode": "電視模式",
"refresh": "重新整理間隔",
"refreshHelp": "電視重新載入資料的頻率。關閉後螢幕將不再更新。",
"theme": "主題",
"themeCurrent": "目前",
"themeLight": "淺色",
"themeDark": "深色",
"fit": "將所有面板放入一個螢幕",
"fitHelp": "將面板縮放至螢幕高度,無需捲動。",
"open": "開啟電視模式",
"exit": "離開電視模式",
"refreshOff": "自動重新整理已關閉",
"nextRefreshAt": "下次重新整理時間:{time}",
"nextRetryAt": "下次重試時間:{time}",
"updatedAt": "更新於 {time}",
"paused": "分頁隱藏時已暫停",
"staleSince": "自 {time} 起未更新",
"unreachable": "後端無法連線,{seconds} 秒後重試",
"hung": "查詢卡住,已強制重新整理",
"panelsFailing": "{count} 個面板失敗 | {count} 個面板失敗",
"panelFailed": "面板載入失敗",
"panelStale": "資料過期",
"queryError": "查詢錯誤:{detail}"
},
"playlist": {
"action": "電視輪播",
"actionTooltip": "在電視上輪流顯示所選儀表板",
"title": "電視輪播",
"dashboards": "儀表板",
"countOfMax": "{count} / {max}",
"searchPlaceholder": "新增儀表板:在所有資料夾中搜尋",
"fullPlaceholder": "輪播已滿(最多 {max} 個)",
"searchFailed": "無法搜尋儀表板",
"noMatches": "沒有符合「{query}」的儀表板",
"add": "新增",
"added": "已新增",
"emptyTitle": "輪播中沒有儀表板",
"emptyDescription": "在上方搜尋並新增至少一個儀表板",
"dragToReorder": "拖曳以排序",
"moveUp": "上移",
"moveDown": "下移",
"remove": "移除",
"notFound": "找不到",
"showEachFor": "每個顯示",
"timeRange": "時間範圍",
"timeRangeHelp": "所有儀表板使用同一範圍",
"ownDefault": "各儀表板自己的預設值",
"variablesNote": "每個儀表板在第一個分頁開啟,使用自己的變數預設值",
"refreshNote": "每個儀表板出現時重新載入資料,停留在畫面上時每 5 分鐘重新載入一次",
"linkCopied": "已複製輪播連結,請在電視瀏覽器中開啟",
"open": "開啟電視輪播",
"position": "{current} / {total}",
"previous": "上一個儀表板",
"next": "下一個儀表板",
"pause": "暫停輪播",
"resume": "繼續輪播",
"exit": "結束",
"pausedSince": "輪播自 {time} 起已暫停",
"held": "輪播保持中:無法連線至後端",
"waiting": "正在載入下一個儀表板",
"skippedNotFound": "已略過 {name}:找不到儀表板",
"skippedForbidden": "已略過 {name}:沒有存取權",
"slideName": "儀表板 {number}",
"loadingSlide": "正在載入 {current} / {total}",
"emptyStateTitle": "輪播中的儀表板都無法使用",
"emptyStateDescription": "所有儀表板都已刪除,或您已沒有存取權。電視會停留在此畫面並重試。",
"tryAgain": "重試",
"invalidTitle": "此輪播連結未包含儀表板",
"invalidDescription": "在儀表板清單中選取儀表板,然後選擇電視輪播",
"backToDashboards": "前往儀表板",
"starting": "正在啟動",
"loading": "正在載入",
"intervalAdjusted": "間隔已調整為 {interval}",
"copied": "已複製",
"addLabel": "新增儀表板",
"addHint": "搜尋所有資料夾;每個結果都會顯示其資料夾",
"copyLink": "複製連結",
"unreachable": "後端無法連線",
"ofTotal": "/ {total}",
"nextAt": "下一個:{name},{time}"
},
"generalSettings": "一般",
"variableSettings": "變數",
"tabSettings": "分頁",
@@ -19621,6 +19703,7 @@
"dashboardsList": "儀表板",
"dashboards": "儀表板檢視",
"dashboardsPanel": "儀表板 — 面板(懸停面板)",
"dashboardTvPlaylist": "電視輪播",
"metrics": "指標",
"traces": "追蹤",
"alerts": "警示",
@@ -19688,6 +19771,11 @@
"dashboardLayoutUndo": "復原版面配置變更",
"dashboardLayoutRedo": "重做版面配置變更",
"dashboardLayoutExit": "結束版面配置編輯",
"dashboardKioskExit": "離開電視模式",
"dashboardPlaylistPrev": "上一個儀表板",
"dashboardPlaylistNext": "下一個儀表板",
"dashboardPlaylistPause": "暫停或繼續輪播",
"dashboardPlaylistExit": "結束電視輪播",
"dashboardFullscreen": "切換全螢幕",
"dashboardExport": "匯出儀表板",
"tableRowEdit": "編輯懸停的行",
@@ -21200,6 +21288,7 @@
"reportDetail": "報告",
"sharedChat": "已分享的對話",
"passwordPolicy": "密碼原則",
"dashboardTvPlaylist": "電視輪播",
"loginCallback": "登入回呼",
"slackOAuthCallback": "正在連線 Slack",
"emptyStateDemo": "空狀態示範",
@@ -205,7 +205,8 @@ describe("Index no-FTS recovery requests", { timeout: 30000 }, () => {
expect(wrapper.findComponent(OForm).vm.form.state.values.field).toBe("level");
const preview = wrapper.get('[data-test="logs-no-fts-preview"]').text();
await wrapper.get('[data-test="logs-no-fts-field-form"]').trigger("submit");
await vi.waitFor(() => expect(results()).toHaveLength(1));
// Switching to the UDS stream runs extractFields before the search, which outlasts the 1s default on loaded CI.
await vi.waitFor(() => expect(results()).toHaveLength(1), { timeout: 5000 });
expect(results()[0].queryReq.query.sql.replace(/"level"/g, "level")).toContain(preview);
});
+28
View File
@@ -27,6 +27,34 @@ export const dashboardsByFolderQuery = (org: string, folderId: string) =>
staleTime: NORMAL_STALE_TIME,
});
export interface DashboardSearchHit {
dashboardId: string;
title: string;
folderId: string;
folderName: string;
}
export const toSearchHit = (row: any): DashboardSearchHit => {
const body = row?.[`v${row?.version}`] ?? {};
return {
dashboardId: String(row?.dashboard_id ?? body.dashboardId ?? ""),
title: String(row?.title ?? body.title ?? ""),
folderId: String(row?.folder_id ?? "default"),
folderName: String(row?.folder_name ?? row?.folder_id ?? ""),
};
};
/** Dashboards across every folder whose title matches; the backend searches all folders when none is given. */
export const dashboardsSearchQuery = (org: string, title: string) =>
queryOptions({
queryKey: dashboardKeys.search(org, title),
queryFn: async (): Promise<DashboardSearchHit[]> =>
((await dashboards.list(0, 100, "name", false, "", org, "", title)).data?.dashboards ?? [])
.map(toSearchHit)
.filter((hit: DashboardSearchHit) => hit.dashboardId),
staleTime: NORMAL_STALE_TIME,
});
/**
* Reactive sugar for `setup()` consumers: the same cache entry and policy, only
* the consumption shape differs. A null folder disables the read rather than
+1
View File
@@ -25,4 +25,5 @@ import { orgKey } from "@/composables/query/keys";
export const dashboardKeys = {
all: (org: string) => orgKey(org, "dashboards"),
byFolder: (org: string, folderId: string) => orgKey(org, "dashboards", "list", folderId),
search: (org: string, title: string) => orgKey(org, "dashboards", "search", title),
};
+49
View File
@@ -0,0 +1,49 @@
// 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 <http://www.gnu.org/licenses/>.
import { describe, expect, it, vi } from "vitest";
const get = vi.fn();
vi.mock("./http", () => ({ default: () => ({ get }) }));
import { pingBackend } from "./health";
describe("pingBackend", () => {
it("asks /healthz with a short timeout and never lets a status reject", async () => {
get.mockResolvedValue({ status: 200 });
await expect(pingBackend()).resolves.toBe(true);
const [url, config] = get.mock.calls[0];
expect(url).toBe("/healthz");
expect(config.timeout).toBe(5000);
expect(config.validateStatus(503)).toBe(true);
});
it("counts any non-5xx answer as reachable", async () => {
get.mockResolvedValue({ status: 404 });
await expect(pingBackend()).resolves.toBe(true);
});
it("counts a 5xx as down", async () => {
get.mockResolvedValue({ status: 502 });
expect(await pingBackend()).toBe(false);
});
it("counts a network failure as down", async () => {
get.mockImplementation(async () => {
throw new TypeError("offline");
});
expect(await pingBackend()).toBe(false);
});
});
+33
View File
@@ -0,0 +1,33 @@
// 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 <http://www.gnu.org/licenses/>.
import http from "./http";
const PING_TIMEOUT_MS = 5000;
/** True when the backend answers at all; a 5xx from a proxy in front of it counts as down. */
export const pingBackend = async (): Promise<boolean> => {
try {
const response = await http().get("/healthz", {
timeout: PING_TIMEOUT_MS,
validateStatus: () => true,
});
return response.status > 0 && response.status < 500;
} catch {
return false;
}
};
export default { pingBackend };
+12
View File
@@ -103,6 +103,9 @@ export default createStore({
searchCollapsibleSection: 20,
theme: localStorage.getItem("theme") === "dark" ? "dark" : "light",
printMode: false,
kioskMode: false,
// A per-screen theme forced by a kiosk URL; never persisted as the user's preference.
themeOverride: null,
organizationData: JSON.parse(JSON.stringify(organizationObj)),
zoConfig: <{ [key: string]: any }>{},
timezone: useLocalTimezone()
@@ -297,6 +300,12 @@ export default createStore({
setPrintMode(state, payload) {
state.printMode = payload;
},
setKioskMode(state, payload) {
state.kioskMode = payload;
},
setThemeOverride(state, payload) {
state.themeOverride = payload;
},
setTimezone(state, payload) {
state.timezone = payload;
},
@@ -544,6 +553,9 @@ export default createStore({
setPrintMode(context, payload) {
context.commit("setPrintMode", payload);
},
setKioskMode(context, payload) {
context.commit("setKioskMode", payload);
},
setTimezone(context, payload) {
context.commit("setTimezone", payload);
},
+11
View File
@@ -56,6 +56,8 @@ const store = createStore({
organizations: [] as unknown[],
searchCollapsibleSection: 20,
printMode: false,
kioskMode: false,
themeOverride: null as string | null,
savedViewFlag: false,
savedFunctionDialog: false,
hiddenMenus: [] as unknown[],
@@ -327,6 +329,12 @@ const store = createStore({
setPrintMode(state, payload) {
state.printMode = payload;
},
setKioskMode(state, payload) {
state.kioskMode = payload;
},
setThemeOverride(state, payload) {
state.themeOverride = payload;
},
setTimezone(state, payload) {
state.timezone = payload;
},
@@ -573,6 +581,9 @@ const store = createStore({
setPrintMode(context, payload) {
context.commit("setPrintMode", payload);
},
setKioskMode(context, payload) {
context.commit("setKioskMode", payload);
},
setTimezone(context, payload) {
context.commit("setTimezone", payload);
},
+211
View File
@@ -0,0 +1,211 @@
// 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 <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
buildKioskQuery,
computeFitCellHeight,
formatAge,
formatClockTime,
formatRefreshLabel,
formatRelativePeriod,
hungAfterMs,
isFitEnabled,
isKioskEnabled,
parseKioskTheme,
pickKioskParams,
resolveKioskRefresh,
retryDelayMs,
staleAfterMs,
stripKioskParams,
summarizePanelError,
} from "./kiosk";
import type { TranslateFn } from "@/types/i18n";
const t = ((key: string, named?: Record<string, unknown>) =>
named ? `${key}:${JSON.stringify(named)}` : key) as unknown as TranslateFn;
describe("kiosk URL contract", () => {
it("accepts ?kiosk, ?kiosk=true and ?kiosk=1 and ignores anything else", () => {
expect(isKioskEnabled(null)).toBe(true);
expect(isKioskEnabled("")).toBe(true);
expect(isKioskEnabled("true")).toBe(true);
expect(isKioskEnabled("1")).toBe(true);
expect(isKioskEnabled(["true"])).toBe(true);
expect(isKioskEnabled(undefined)).toBe(false);
expect(isKioskEnabled("false")).toBe(false);
expect(isKioskEnabled("yes")).toBe(false);
});
it("only forces a known theme", () => {
expect(parseKioskTheme("dark")).toBe("dark");
expect(parseKioskTheme("light")).toBe("light");
expect(parseKioskTheme("blue")).toBeNull();
expect(parseKioskTheme(undefined)).toBeNull();
});
it("reads fit=true and fit=1 only", () => {
expect(isFitEnabled("true")).toBe(true);
expect(isFitEnabled("1")).toBe(true);
expect(isFitEnabled(null)).toBe(false);
expect(isFitEnabled("no")).toBe(false);
});
it("raises a too-low refresh to the server minimum instead of turning it off", () => {
expect(resolveKioskRefresh("1s", 10)).toBe(10);
expect(resolveKioskRefresh("1m", 10)).toBe(60);
expect(resolveKioskRefresh("2s", null)).toBe(5);
expect(resolveKioskRefresh("2s", 0)).toBe(5);
});
it("treats a missing, zero or garbage refresh as off", () => {
expect(resolveKioskRefresh(undefined, 5)).toBe(0);
expect(resolveKioskRefresh("0s", 5)).toBe(0);
expect(resolveKioskRefresh("Off", 5)).toBe(0);
expect(resolveKioskRefresh("soon", 5)).toBe(0);
});
it("carries the kiosk params over a URL rewrite, and nothing when kiosk is off", () => {
expect(
pickKioskParams({ kiosk: null, theme: "dark", fit: "1", timezone: "Asia/Kolkata" }),
).toEqual({ kiosk: "true", theme: "dark", fit: "true", timezone: "Asia/Kolkata" });
expect(pickKioskParams({ kiosk: "true", theme: "purple" })).toEqual({ kiosk: "true" });
expect(pickKioskParams({ theme: "dark", timezone: "UTC" })).toEqual({});
});
it("strips every kiosk-only param on exit and keeps the rest", () => {
expect(
stripKioskParams({
dashboard: "d1",
kiosk: "true",
theme: "dark",
fit: "true",
timezone: "UTC",
period: "15m",
}),
).toEqual({ dashboard: "d1", period: "15m" });
});
});
describe("buildKioskQuery", () => {
const base = {
org_identifier: "default",
dashboard: "d1",
folder: "default",
tab: "t2",
period: "1h",
refresh: "Off",
print: "false",
"var-region": "eu",
};
it("keeps the relative period, tab and variables, never converting to from/to", () => {
const q = buildKioskQuery(base, { refreshSeconds: 300, theme: "current", fit: false });
expect(q.period).toBe("1h");
expect(q.from).toBeUndefined();
expect(q.to).toBeUndefined();
expect(q.tab).toBe("t2");
expect(q["var-region"]).toBe("eu");
expect(q.kiosk).toBe("true");
expect(q.refresh).toBe("5m");
});
it("drops print and adds a theme only when one is forced", () => {
const current = buildKioskQuery(base, { refreshSeconds: 60, theme: "current", fit: false });
expect(current.print).toBeUndefined();
expect(current.theme).toBeUndefined();
expect(current.fit).toBeUndefined();
const dark = buildKioskQuery(base, { refreshSeconds: 60, theme: "dark", fit: true });
expect(dark.theme).toBe("dark");
expect(dark.fit).toBe("true");
});
it("keeps an absolute range and omits refresh when it is off", () => {
const q = buildKioskQuery(
{ dashboard: "d1", from: "100", to: "200", refresh: "1m", timezone: "UTC" },
{ refreshSeconds: 0, theme: "light", fit: false },
);
expect(q.from).toBe("100");
expect(q.to).toBe("200");
expect(q.refresh).toBeUndefined();
expect(q.timezone).toBe("UTC");
});
});
describe("kiosk helpers", () => {
it("formats a 24-hour clock time in the given timezone, with or without seconds", () => {
const at = Date.UTC(2026, 9, 14, 23, 16, 30);
expect(formatClockTime(at, "UTC")).toBe("23:16:30");
expect(formatClockTime(at, "Asia/Kolkata")).toBe("04:46:30");
expect(formatClockTime(at, "UTC", false)).toBe("23:16");
expect(formatClockTime(at, "Not/AZone")).toMatch(/^\d{2}:\d{2}:\d{2}$/);
});
it("words the age of the last update like the app's other refresh indicators", () => {
expect(formatAge(2000, t)).toBe("refreshButton.justNow");
expect(formatAge(42_000, t)).toBe('refreshButton.secondsAgo:{"sec":42}');
expect(formatAge(5 * 60_000 + 10_000, t)).toBe('refreshButton.minutesAgo:{"min":5}');
expect(formatAge(3 * 3_600_000, t)).toBe('refreshButton.hoursAgo:{"h":3}');
expect(formatAge(-500, t)).toBe("refreshButton.justNow");
});
it("backs off from 5s and never waits longer than the refresh interval", () => {
expect(retryDelayMs(1, 60)).toBe(5000);
expect(retryDelayMs(2, 60)).toBe(10000);
expect(retryDelayMs(3, 60)).toBe(20000);
expect(retryDelayMs(6, 60)).toBe(60000);
expect(retryDelayMs(10, 0)).toBe(60000);
// A slow refresh must not leave the screen stale for minutes after the backend is back.
expect(retryDelayMs(10, 300)).toBe(60000);
});
it("only calls a cycle hung after twice the interval, and never under 30s", () => {
expect(hungAfterMs(5)).toBe(30000);
expect(hungAfterMs(60)).toBe(120000);
expect(staleAfterMs(60)).toBe(120000);
});
it("sizes cells so every row fits", () => {
expect(computeFitCellHeight(900, 60)).toBe(15);
expect(computeFitCellHeight(100, 60)).toBe(4);
expect(computeFitCellHeight(900, 0)).toBeNull();
expect(computeFitCellHeight(0, 10)).toBeNull();
});
it("labels refresh intervals like the toolbar picker", () => {
expect(formatRefreshLabel(0, t)).toBe("common.off");
expect(formatRefreshLabel(30, t)).toBe('common.secShort:{"count":30}');
expect(formatRefreshLabel(300, t)).toBe('common.minShort:{"count":5}');
expect(formatRefreshLabel(3600, t)).toBe('common.hrShort:{"count":1}');
});
it("labels relative periods and rejects anything else", () => {
expect(formatRelativePeriod("15m", t)).toBe('common.pastMinute:{"count":15}');
expect(formatRelativePeriod("2d", t)).toBe('common.pastDay:{"count":2}');
expect(formatRelativePeriod("now-1h", t)).toBeNull();
expect(formatRelativePeriod(undefined, t)).toBeNull();
});
it("summarizes a panel error to its first meaningful line", () => {
expect(
summarizePanelError('Error# SQL error: ParserError("Expected: X at Line: 1")\nmore'),
).toBe("Expected: X at Line: 1");
expect(summarizePanelError("\n stream k8s_events not found\n")).toBe(
"stream k8s_events not found",
);
expect(summarizePanelError("")).toBe("");
});
});
+195
View File
@@ -0,0 +1,195 @@
// 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 <http://www.gnu.org/licenses/>.
import type { LocationQuery, LocationQueryValue } from "vue-router";
import { generateDurationLabel, parseDuration } from "@/utils/date";
import type { I18nKey, I18nText, TranslateFn } from "@/types/i18n";
export type KioskTheme = "light" | "dark";
export type KioskThemeChoice = "current" | KioskTheme;
export interface KioskLinkOptions {
refreshSeconds: number;
theme: KioskThemeChoice;
fit: boolean;
}
type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined;
/** Used when the server has not published `min_auto_refresh_interval`. */
export const DEFAULT_MIN_REFRESH_SECONDS = 5;
/** What the setup popover proposes when the dashboard's own refresh is off. */
export const KIOSK_DEFAULT_REFRESH_SECONDS = 300;
/** Refresh choices offered by the TV mode setup popover, in seconds (0 = off). */
export const KIOSK_REFRESH_CHOICES = [0, 30, 60, 300, 900, 1800, 3600] as const;
const RETRY_BASE_MS = 5000;
const RETRY_CAP_MS = 60000;
const HUNG_FLOOR_MS = 30000;
const MIN_FIT_CELL_PX = 4;
const PAST_PERIOD_KEYS: Record<string, I18nKey> = {
s: "common.pastSecond",
m: "common.pastMinute",
h: "common.pastHour",
d: "common.pastDay",
w: "common.pastWeek",
M: "common.pastMonth",
};
const first = (value: QueryValue): LocationQueryValue | undefined =>
Array.isArray(value) ? value[0] : value;
/** `?kiosk`, `?kiosk=true` and `?kiosk=1` turn kiosk on; anything else leaves it off. */
export const isKioskEnabled = (value: QueryValue): boolean => {
const v = first(value);
if (v === undefined) return false;
if (v === null || v === "") return true;
return v === "true" || v === "1";
};
export const parseKioskTheme = (value: QueryValue): KioskTheme | null => {
const v = first(value);
return v === "dark" || v === "light" ? v : null;
};
export const isFitEnabled = (value: QueryValue): boolean => {
const v = first(value);
return v === "true" || v === "1";
};
/** Kiosk refresh in seconds: garbage or zero is off, anything below the server minimum is raised to it. */
export const resolveKioskRefresh = (value: QueryValue, minSeconds?: number | null): number => {
const v = first(value);
if (!v) return 0;
const seconds = parseDuration(String(v));
if (!seconds) return 0;
const floor = minSeconds && minSeconds > 0 ? minSeconds : DEFAULT_MIN_REFRESH_SECONDS;
return Math.max(seconds, floor);
};
/** The kiosk-only params the dashboard's URL rewrite must carry over while kiosk is on. */
export const pickKioskParams = (query: LocationQuery): Record<string, string> => {
if (!isKioskEnabled(query.kiosk)) return {};
const out: Record<string, string> = { kiosk: "true" };
const theme = parseKioskTheme(query.theme);
if (theme) out.theme = theme;
if (isFitEnabled(query.fit)) out.fit = "true";
const timezone = first(query.timezone);
if (timezone) out.timezone = timezone;
return out;
};
export const stripKioskParams = (query: LocationQuery): LocationQuery => {
const next = { ...query };
delete next.kiosk;
delete next.theme;
delete next.fit;
delete next.timezone;
return next;
};
/** Query for a kiosk link: keeps the relative `period`, tab and variables exactly as they are. */
export const buildKioskQuery = (query: LocationQuery, options: KioskLinkOptions): LocationQuery => {
const next = stripKioskParams(query);
delete next.print;
if (options.refreshSeconds > 0) next.refresh = generateDurationLabel(options.refreshSeconds);
else delete next.refresh;
next.kiosk = "true";
const forcedTheme = parseKioskTheme(options.theme);
if (forcedTheme) next.theme = forcedTheme;
if (options.fit) next.fit = "true";
const timezone = first(query.timezone);
if (timezone) next.timezone = timezone;
return next;
};
/** Retry delay after `attempt` consecutive failed cycles; never longer than the refresh interval or a minute. */
export const retryDelayMs = (attempt: number, intervalSeconds: number): number => {
const cap = intervalSeconds > 0 ? Math.min(intervalSeconds * 1000, RETRY_CAP_MS) : RETRY_CAP_MS;
const delay = RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1);
return Math.min(delay, cap);
};
/** A cycle still loading after this long is treated as hung, so a slow query is not cancelled every tick. */
export const hungAfterMs = (intervalSeconds: number): number =>
Math.max(intervalSeconds * 2000, HUNG_FLOOR_MS);
export const staleAfterMs = (intervalSeconds: number): number => intervalSeconds * 2000;
/** Cell height that fits `rows` grid rows into `availablePx`, or null when there is nothing to fit. */
export const computeFitCellHeight = (availablePx: number, rows: number): number | null => {
if (!(rows > 0) || !(availablePx > 0)) return null;
return Math.max(MIN_FIT_CELL_PX, Math.floor(availablePx / rows));
};
/** Same wording as the toolbar refresh picker ("5 min", "Off"). */
export const formatRefreshLabel = (seconds: number, t: TranslateFn): I18nText => {
if (!seconds) return t("common.off");
if (seconds < 60) return t("common.secShort", { count: seconds });
if (seconds < 3600) return t("common.minShort", { count: Math.round(seconds / 60) });
if (seconds < 86400) return t("common.hrShort", { count: Math.round(seconds / 3600) });
return t("common.dayShort", { count: Math.round(seconds / 86400) });
};
/** 24-hour clock time in the given timezone ("23:16:30"); an unknown zone falls back to local time. */
export const formatClockTime = (ms: number, timezone?: string, seconds = true): string => {
const options: Intl.DateTimeFormatOptions = {
hour: "2-digit",
minute: "2-digit",
...(seconds ? { second: "2-digit" } : {}),
hourCycle: "h23",
};
try {
return new Intl.DateTimeFormat(undefined, { ...options, timeZone: timezone }).format(ms);
} catch {
return new Intl.DateTimeFormat(undefined, options).format(ms);
}
};
/** "just now", "42s ago", "5m ago": the same wording as the app's "Last refreshed" indicators. */
export const formatAge = (elapsedMs: number, t: TranslateFn): I18nText => {
const sec = Math.max(0, Math.floor(elapsedMs / 1000));
if (sec < 5) return t("refreshButton.justNow");
if (sec < 60) return t("refreshButton.secondsAgo", { sec });
const min = Math.floor(sec / 60);
if (min < 60) return t("refreshButton.minutesAgo", { min });
return t("refreshButton.hoursAgo", { h: Math.floor(min / 60) });
};
/** "Past 15 Minutes" for a relative period such as `15m`, or null when it is not one. */
export const formatRelativePeriod = (period: string | null | undefined, t: TranslateFn) => {
const match = /^(\d+)([smhdwM])$/.exec(period ?? "");
if (!match) return null;
const count = Number(match[1]);
return t(PAST_PERIOD_KEYS[match[2]], { count }, count);
};
/** First meaningful line of a panel error, without the transport prefixes a TV viewer cannot act on. */
export const summarizePanelError = (error: string): string => {
const line =
String(error ?? "")
.split("\n")
.find((part) => part.trim()) ?? "";
let text = line
.trim()
.replace(/^error#?\s*/i, "")
.replace(/^sql error:\s*/i, "");
const wrapped = /^\w+\("(.*)"\)$/.exec(text);
if (wrapped) text = wrapped[1];
return text.trim();
};
+304
View File
@@ -0,0 +1,304 @@
// 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 <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import {
PLAYLIST_MAX_ENTRIES,
buildPlaylistQuery,
buildSlideQuery,
isFramingBlocked,
isPlaylistFrameMessage,
isPlaylistIntervalAdjusted,
missingReasonFor,
parsePlaylistEntries,
parsePlaylistInterval,
parsePlaylistQuery,
parsePlaylistSlide,
pickPlaylistParams,
serializePlaylistEntries,
stripPlaylistParams,
type PlaylistConfig,
} from "./playlist";
const config = (over: Partial<PlaylistConfig> = {}): PlaylistConfig => ({
org: "acme",
entries: [
{ dashboardId: "d1", folderId: "default" },
{ dashboardId: "d 2", folderId: "ops:team" },
],
intervalSeconds: 60,
slideIndex: 0,
period: "1h",
theme: "dark",
fit: true,
timezone: null,
...over,
});
describe("playlist URL contract", () => {
describe("entries", () => {
it("round-trips ids that contain separators", () => {
const entries = [
{ dashboardId: "a,b", folderId: "x:y" },
{ dashboardId: "plain", folderId: "default" },
];
expect(parsePlaylistEntries(serializePlaylistEntries(entries))).toEqual(entries);
});
it("defaults a missing folder and drops empty, duplicate and malformed entries", () => {
expect(parsePlaylistEntries("d1,,d1:default,:f2,d3:,%E0%A4%A,d4:ops")).toEqual([
{ dashboardId: "d1", folderId: "default" },
{ dashboardId: "d3", folderId: "default" },
{ dashboardId: "d4", folderId: "ops" },
]);
});
it("treats a missing, empty or array value safely", () => {
expect(parsePlaylistEntries(undefined)).toEqual([]);
expect(parsePlaylistEntries("")).toEqual([]);
expect(parsePlaylistEntries(["d1:f1", "d2:f2"])).toEqual([
{ dashboardId: "d1", folderId: "f1" },
]);
});
it("caps the list at 50 entries on parse and on serialize", () => {
const many = Array.from({ length: 60 }, (_, i) => ({
dashboardId: `d${i}`,
folderId: "default",
}));
const parsed = parsePlaylistEntries(many.map((e) => `${e.dashboardId}:f`).join(","));
expect(parsed).toHaveLength(PLAYLIST_MAX_ENTRIES);
expect(parsed.at(-1)?.dashboardId).toBe("d49");
expect(serializePlaylistEntries(many).split(",")).toHaveLength(PLAYLIST_MAX_ENTRIES);
});
});
describe("interval", () => {
it.each([
["1m", 60],
["30s", 30],
["90", 90],
["2h", 3600],
["1s", 10],
[undefined, 60],
["abc", 60],
["0", 60],
["-5m", 60],
])("%s → %i seconds", (value, seconds) => {
expect(parsePlaylistInterval(value)).toBe(seconds);
});
it("reads m as minutes, not months", () => {
expect(parsePlaylistInterval("5m")).toBe(300);
});
});
describe("slide", () => {
it.each([
["1", 0],
["3", 2],
["9", 4],
["0", 0],
["-2", 0],
["x", 0],
[undefined, 0],
])("slide=%s of 5 → index %i", (value, index) => {
expect(parsePlaylistSlide(value, 5)).toBe(index);
});
});
it("parses a full player URL", () => {
expect(
parsePlaylistQuery({
org_identifier: "acme",
playlist: "d1:default,d%202:ops%3Ateam",
interval: "2m",
slide: "2",
period: "24h",
theme: "light",
fit: "true",
timezone: "UTC",
}),
).toEqual({
org: "acme",
entries: [
{ dashboardId: "d1", folderId: "default" },
{ dashboardId: "d 2", folderId: "ops:team" },
],
intervalSeconds: 120,
slideIndex: 1,
period: "24h",
theme: "light",
fit: true,
timezone: "UTC",
});
});
it("ignores garbage period and theme and returns null without dashboards", () => {
const parsed = parsePlaylistQuery({ playlist: "d1", period: "yesterday", theme: "neon" });
expect(parsed?.period).toBeNull();
expect(parsed?.theme).toBe("current");
expect(parsed?.fit).toBe(false);
expect(parsePlaylistQuery({ playlist: ",,," })).toBeNull();
expect(parsePlaylistQuery({})).toBeNull();
});
it("serializes the player query and parses it back to the same config", () => {
const query = buildPlaylistQuery(config({ slideIndex: 1 }));
expect(query).toEqual({
org_identifier: "acme",
playlist: "d1:default,d%202:ops%3Ateam",
interval: "1m",
slide: "2",
period: "1h",
theme: "dark",
fit: "true",
});
expect(parsePlaylistQuery(query)).toEqual(config({ slideIndex: 1 }));
});
it("leaves out period, theme and fit when they are each dashboard's own", () => {
const query = buildPlaylistQuery(config({ period: null, theme: "current", fit: false }));
expect(query.period).toBeUndefined();
expect(query.theme).toBeUndefined();
expect(query.fit).toBeUndefined();
});
describe("slide URLs", () => {
it("builds a frame URL in kiosk with the shared options and no variables or tab", () => {
expect(buildSlideQuery(config(), 1, "frame")).toEqual({
org_identifier: "acme",
dashboard: "d 2",
folder: "ops:team",
kiosk: "true",
refresh: "5m",
period: "1h",
theme: "dark",
fit: "true",
embed: "true",
});
});
it("builds a page-fallback URL that carries the playlist so the next page continues", () => {
const query = buildSlideQuery(config(), 1, "page", 2);
expect(query).toMatchObject({
dashboard: "d 2",
folder: "ops:team",
kiosk: "true",
playlist: "d1:default,d%202:ops%3Ateam",
interval: "1m",
slide: "2",
tvpage: "1",
miss: "2",
});
expect(query.embed).toBeUndefined();
});
});
it("picks and strips only the playlist params", () => {
const query = {
dashboard: "d1",
playlist: "d1",
interval: "1m",
slide: "1",
embed: "true",
tvpage: "1",
miss: "1",
kiosk: "true",
};
expect(pickPlaylistParams(query)).toEqual({
playlist: "d1",
interval: "1m",
slide: "1",
embed: "true",
tvpage: "1",
miss: "1",
});
expect(stripPlaylistParams(query)).toEqual({ dashboard: "d1", kiosk: "true" });
});
describe("frame messages", () => {
const source = "o2-tv-playlist";
it("accepts every well-formed message", () => {
expect(isPlaylistFrameMessage({ source, type: "hello" })).toBe(true);
expect(isPlaylistFrameMessage({ source, type: "ready", title: "A" })).toBe(true);
expect(isPlaylistFrameMessage({ source, type: "missing", reason: "forbidden" })).toBe(true);
expect(isPlaylistFrameMessage({ source, type: "error" })).toBe(true);
expect(
isPlaylistFrameMessage({
source,
type: "health",
health: { level: "warn", failing: 2, outage: false, lastSuccessAt: 1 },
}),
).toBe(true);
});
it("rejects foreign or malformed messages", () => {
expect(isPlaylistFrameMessage(null)).toBe(false);
expect(isPlaylistFrameMessage("ready")).toBe(false);
expect(isPlaylistFrameMessage({ type: "hello" })).toBe(false);
expect(isPlaylistFrameMessage({ source, type: "ready" })).toBe(false);
expect(isPlaylistFrameMessage({ source, type: "missing", reason: "gone" })).toBe(false);
expect(isPlaylistFrameMessage({ source, type: "health", health: { level: "x" } })).toBe(
false,
);
expect(isPlaylistFrameMessage({ source, type: "navigate" })).toBe(false);
expect(isPlaylistFrameMessage({ source, type: "context", context: {} })).toBe(false);
});
});
it("only treats 404 and 403 as a missing dashboard", () => {
expect(missingReasonFor(404)).toBe("notFound");
expect(missingReasonFor(403)).toBe("forbidden");
expect(missingReasonFor(500)).toBeNull();
expect(missingReasonFor(undefined)).toBeNull();
});
it.each([
[undefined, false],
["1m", false],
["90", false],
["1s", true],
["2h", true],
["abc", true],
])("interval=%s is reported as adjusted: %s", (value, adjusted) => {
expect(isPlaylistIntervalAdjusted(value)).toBe(adjusted);
});
describe("framing headers", () => {
const origin = "https://o2.example.com";
const blocked = (headers: Record<string, string>) =>
isFramingBlocked(new Headers(headers), origin);
it("allows no header, SAMEORIGIN and frame-ancestors that include this app", () => {
expect(blocked({})).toBe(false);
expect(blocked({ "X-Frame-Options": "SAMEORIGIN" })).toBe(false);
expect(
blocked({ "Content-Security-Policy": "default-src 'self'; frame-ancestors 'self'" }),
).toBe(false);
expect(blocked({ "Content-Security-Policy": `frame-ancestors ${origin}` })).toBe(false);
expect(blocked({ "Content-Security-Policy": "default-src 'self'" })).toBe(false);
});
it("blocks DENY and frame-ancestors that exclude this app", () => {
expect(blocked({ "X-Frame-Options": "DENY" })).toBe(true);
expect(blocked({ "Content-Security-Policy": "frame-ancestors 'none'" })).toBe(true);
expect(blocked({ "Content-Security-Policy": "frame-ancestors https://other.example" })).toBe(
true,
);
});
});
});
+294
View File
@@ -0,0 +1,294 @@
// 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 <http://www.gnu.org/licenses/>.
import type { LocationQuery, LocationQueryValue } from "vue-router";
import { generateDurationLabel, parseDuration } from "@/utils/date";
import { isFitEnabled, parseKioskTheme, type KioskThemeChoice } from "@/utils/dashboard/kiosk";
type QueryValue = LocationQueryValue | LocationQueryValue[] | undefined;
export interface PlaylistEntry {
dashboardId: string;
folderId: string;
}
/** A playlist row in the setup dialog; names are filled in when known. */
export interface PlaylistDialogEntry extends PlaylistEntry {
title?: string | null;
folderName?: string | null;
}
export interface PlaylistConfig {
org: string;
entries: PlaylistEntry[];
intervalSeconds: number;
/** 0-based slide to start on. */
slideIndex: number;
/** Relative range such as `1h`; null keeps each dashboard's own default range. */
period: string | null;
theme: KioskThemeChoice;
fit: boolean;
timezone: string | null;
}
export const PLAYLIST_MAX_ENTRIES = 50;
export const PLAYLIST_DEFAULT_INTERVAL_SECONDS = 60;
export const PLAYLIST_MIN_INTERVAL_SECONDS = 10;
export const PLAYLIST_MAX_INTERVAL_SECONDS = 3600;
/** Each frame refreshes itself at this pace, so a slide left on screen never goes stale. */
export const PLAYLIST_SLIDE_REFRESH_SECONDS = 300;
export const PLAYLIST_INTERVAL_CHOICES = [
10, 15, 20, 30, 60, 120, 300, 600, 900, 1800, 3600,
] as const;
export const PLAYLIST_PERIOD_CHOICES = ["15m", "1h", "6h", "24h", "7d"] as const;
/** Every param the playlist adds to a dashboard URL; carried by the URL rewrite and removed on exit. */
export const PLAYLIST_PARAMS = [
"playlist",
"interval",
"slide",
"embed",
"tvpage",
"miss",
] as const;
export const PLAYLIST_TV_PATH = "/dashboards/tv";
export const PLAYLIST_VIEW_PATH = "/dashboards/view";
const PERIOD_PATTERN = /^\d+[smhdwM]$/;
const first = (value: QueryValue): string | undefined => {
const v = Array.isArray(value) ? value[0] : value;
return typeof v === "string" ? v : undefined;
};
const decode = (part: string): string | null => {
try {
return decodeURIComponent(part).trim();
} catch {
return null;
}
};
/** `<dash>:<folder>,…`; malformed, empty and duplicate entries are dropped and the list is capped. */
export const parsePlaylistEntries = (value: QueryValue): PlaylistEntry[] => {
const raw = first(value);
if (!raw) return [];
const seen = new Set<string>();
const entries: PlaylistEntry[] = [];
for (const item of raw.split(",")) {
if (entries.length >= PLAYLIST_MAX_ENTRIES) break;
const at = item.indexOf(":");
const dashboardId = decode(at === -1 ? item : item.slice(0, at));
const folderId = at === -1 ? "default" : decode(item.slice(at + 1));
if (!dashboardId || folderId === null) continue;
const entry = { dashboardId, folderId: folderId || "default" };
const key = `${entry.dashboardId}\u0000${entry.folderId}`;
if (seen.has(key)) continue;
seen.add(key);
entries.push(entry);
}
return entries;
};
export const serializePlaylistEntries = (entries: PlaylistEntry[]): string =>
entries
.slice(0, PLAYLIST_MAX_ENTRIES)
.map((e) => `${encodeURIComponent(e.dashboardId)}:${encodeURIComponent(e.folderId)}`)
.join(",");
/** Seconds per slide, clamped to 10s–1h; missing or garbage falls back to 1m, never to "no rotation". */
export const parsePlaylistInterval = (value: QueryValue): number => {
const raw = first(value);
const seconds = raw ? parseDuration(raw) : 0;
if (!seconds) return PLAYLIST_DEFAULT_INTERVAL_SECONDS;
return Math.min(PLAYLIST_MAX_INTERVAL_SECONDS, Math.max(PLAYLIST_MIN_INTERVAL_SECONDS, seconds));
};
/** True when the link asked for an interval the player could not use as given. */
export const isPlaylistIntervalAdjusted = (value: QueryValue): boolean => {
const raw = first(value);
if (raw === undefined) return false;
return parseDuration(raw) !== parsePlaylistInterval(raw);
};
/** 1-based `slide` to a 0-based index clamped into the list. */
export const parsePlaylistSlide = (value: QueryValue, count: number): number => {
const n = Number.parseInt(first(value) ?? "", 10);
if (!Number.isFinite(n) || count <= 0) return 0;
return Math.min(count, Math.max(1, n)) - 1;
};
export const parsePlaylistPeriod = (value: QueryValue): string | null => {
const raw = first(value);
return raw && PERIOD_PATTERN.test(raw) ? raw : null;
};
/** The playlist a URL describes, or null when it names no dashboard. */
export const parsePlaylistQuery = (query: LocationQuery): PlaylistConfig | null => {
const entries = parsePlaylistEntries(query.playlist);
if (!entries.length) return null;
return {
org: first(query.org_identifier) ?? "",
entries,
intervalSeconds: parsePlaylistInterval(query.interval),
slideIndex: parsePlaylistSlide(query.slide, entries.length),
period: parsePlaylistPeriod(query.period),
theme: parseKioskTheme(query.theme) ?? "current",
fit: isFitEnabled(query.fit),
timezone: first(query.timezone) ?? null,
};
};
const sharedSlideParams = (config: PlaylistConfig): Record<string, string> => {
const out: Record<string, string> = {};
if (config.period) out.period = config.period;
const forcedTheme = parseKioskTheme(config.theme);
if (forcedTheme) out.theme = forcedTheme;
if (config.fit) out.fit = "true";
if (config.timezone) out.timezone = config.timezone;
return out;
};
export const buildPlaylistQuery = (config: PlaylistConfig): Record<string, string> => ({
...(config.org ? { org_identifier: config.org } : {}),
playlist: serializePlaylistEntries(config.entries),
interval: generateDurationLabel(config.intervalSeconds),
slide: String(config.slideIndex + 1),
...sharedSlideParams(config),
});
/** Kiosk URL of one slide; no `var-*` or `tab`, so each dashboard opens on its own defaults. */
export const buildSlideQuery = (
config: PlaylistConfig,
index: number,
mode: "frame" | "page",
misses = 0,
): Record<string, string> => {
const entry = config.entries[index];
const base: Record<string, string> = {
...(config.org ? { org_identifier: config.org } : {}),
dashboard: entry.dashboardId,
folder: entry.folderId,
kiosk: "true",
refresh: generateDurationLabel(PLAYLIST_SLIDE_REFRESH_SECONDS),
...sharedSlideParams(config),
};
if (mode === "frame") return { ...base, embed: "true" };
const page: Record<string, string> = {
...base,
...buildPlaylistQuery({ ...config, slideIndex: index }),
tvpage: "1",
};
if (misses > 0) page.miss = String(misses);
return page;
};
export const isPlaylistEmbed = (query: LocationQuery): boolean => first(query.embed) === "true";
export const isPlaylistPage = (query: LocationQuery): boolean => first(query.tvpage) === "1";
export const parsePlaylistMisses = (query: LocationQuery): number => {
const n = Number.parseInt(first(query.miss) ?? "", 10);
return Number.isFinite(n) && n > 0 ? n : 0;
};
export const pickPlaylistParams = (query: LocationQuery): Record<string, string> => {
const out: Record<string, string> = {};
for (const key of PLAYLIST_PARAMS) {
const value = first(query[key]);
if (value !== undefined) out[key] = value;
}
return out;
};
export const stripPlaylistParams = (query: LocationQuery): LocationQuery => {
const next = { ...query };
for (const key of PLAYLIST_PARAMS) delete next[key];
return next;
};
export const PLAYLIST_MESSAGE_SOURCE = "o2-tv-playlist";
export type SlideHealthLevel = "ok" | "warn" | "bad";
export interface SlideHealthReport {
level: SlideHealthLevel;
failing: number;
/** Backend unreachable: the player holds rotation instead of moving to another dead slide. */
outage: boolean;
lastSuccessAt: number | null;
}
export type PlaylistFrameMessage =
| { source: typeof PLAYLIST_MESSAGE_SOURCE; type: "hello" }
| { source: typeof PLAYLIST_MESSAGE_SOURCE; type: "ready"; title: string }
| { source: typeof PLAYLIST_MESSAGE_SOURCE; type: "health"; health: SlideHealthReport }
| { source: typeof PLAYLIST_MESSAGE_SOURCE; type: "missing"; reason: "notFound" | "forbidden" }
| { source: typeof PLAYLIST_MESSAGE_SOURCE; type: "error" };
/** Shape check for a message posted by a slide frame; the origin check is the caller's. */
export const isPlaylistFrameMessage = (data: unknown): data is PlaylistFrameMessage => {
if (!data || typeof data !== "object") return false;
const msg = data as Record<string, unknown>;
if (msg.source !== PLAYLIST_MESSAGE_SOURCE) return false;
switch (msg.type) {
case "hello":
case "error":
return true;
case "ready":
return typeof msg.title === "string";
case "missing":
return msg.reason === "notFound" || msg.reason === "forbidden";
case "health": {
const h = msg.health as Record<string, unknown> | null;
return (
!!h &&
(h.level === "ok" || h.level === "warn" || h.level === "bad") &&
typeof h.failing === "number" &&
typeof h.outage === "boolean"
);
}
default:
return false;
}
};
/** 404 is deleted, 403 is no access; anything else is a load failure worth retrying. */
export const missingReasonFor = (status: unknown): "notFound" | "forbidden" | null => {
if (status === 404) return "notFound";
if (status === 403) return "forbidden";
return null;
};
const directive = (csp: string, name: string): string[] | null => {
for (const part of csp.split(";")) {
const tokens = part.trim().split(/\s+/);
if (tokens[0]?.toLowerCase() === name) return tokens.slice(1).map((t) => t.toLowerCase());
}
return null;
};
/** Whether these response headers forbid this app from framing its own pages. */
export const isFramingBlocked = (headers: Headers, origin: string): boolean => {
const xfo = headers.get("x-frame-options")?.trim().toLowerCase();
if (xfo && xfo !== "sameorigin") return true;
const ancestors = directive(headers.get("content-security-policy") ?? "", "frame-ancestors");
if (!ancestors) return false;
if (ancestors.includes("'none'")) return true;
return !ancestors.some(
(source) => source === "'self'" || source === "*" || source === origin.toLowerCase(),
);
};
@@ -758,6 +758,69 @@ describe("Dashboards.vue", () => {
expect(columnIds).toContain("folder");
});
it("TV playlist takes the selected favorites from several folders, in the order they were ticked", async () => {
wrapper = shallowMount(Dashboards, {
global: buildGlobalConfig(storeWithTwo(), router, i18n),
});
await nextTick();
await nextTick();
useFavoriteDashboards().favorites.value = [
{ dashboardId: "remote1", folderId: "folder1", label: "Remote Dash" },
{ dashboardId: "dash1", folderId: "default", label: "Dashboard 1" },
];
wrapper.vm.updateActiveFolderId("__favorites__");
await nextTick();
wrapper.vm.selectedIds = ["dash1"];
wrapper.vm.selectedIds = ["remote1", "dash1"];
const button = wrapper
.findAllComponents({ name: "OButton" })
.find((b: any) => b.vm.$attrs["data-test"] === "dashboard-list-tv-playlist-btn");
button.vm.$emit("click");
await nextTick();
const dialog = wrapper.findComponent({ name: "PlaylistDialog" });
expect(dialog.props("open")).toBe(true);
expect(dialog.props("entries")).toEqual([
{ dashboardId: "dash1", folderId: "default", title: "Dashboard 1", folderName: "Default" },
{
dashboardId: "remote1",
folderId: "folder1",
title: "Remote Dash",
folderName: "Folder 1",
},
]);
// Opening the dialog leaves the bulk selection as it was.
expect(wrapper.vm.selectedIds).toEqual(["remote1", "dash1"]);
const open = vi.spyOn(window, "open").mockReturnValue(null);
const push = vi.spyOn(router, "push");
dialog.vm.$emit("open", {
org: "default",
entries: [{ dashboardId: "remote1", folderId: "folder1" }],
intervalSeconds: 60,
slideIndex: 0,
period: "1h",
theme: "current",
fit: true,
timezone: null,
});
const [href, target] = open.mock.calls[0];
expect(target).toBe("_blank");
const url = new URL(String(href), window.location.origin);
expect(url.pathname).toBe("/dashboards/tv");
expect(Object.fromEntries(url.searchParams)).toEqual({
org_identifier: "default",
playlist: "remote1:folder1",
interval: "1m",
slide: "1",
period: "1h",
fit: "true",
});
expect(push).not.toHaveBeenCalled();
open.mockRestore();
});
it("selecting a folder exits the favorites view and shows that folder", async () => {
wrapper = shallowMount(Dashboards, {
global: buildGlobalConfig(storeWithTwo(), router, i18n),
+73
View File
@@ -411,6 +411,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div>
</template>
<template #selection-actions>
<OButton
variant="outline"
size="sm"
icon-left="tv"
data-test="dashboard-list-tv-playlist-btn"
@click="openPlaylistDialog"
>
{{ t("dashboard.playlist.action") }}
<OTooltip :content="t('dashboard.playlist.actionTooltip')" />
</OButton>
<OButton
variant="outline"
size="sm"
@@ -524,6 +534,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@update:cancel="confirmBulkDelete = false"
v-model="confirmBulkDelete"
/>
<PlaylistDialog
v-model:open="showPlaylistDialog"
:entries="playlistEntries"
@open="openPlaylist"
/>
</OPageLayout>
</template>
@@ -598,6 +614,13 @@ import { useHomeDashboard } from "@/composables/useHomeDashboard";
import analytics from "@/services/product_analytics";
import { useFavoriteDashboards, FAVORITES_FOLDER_ID } from "@/composables/useFavoriteDashboards";
import OTruncatedText from "@/lib/core/Typography/OTruncatedText.vue";
import PlaylistDialog from "@/components/dashboards/kiosk/PlaylistDialog.vue";
import {
PLAYLIST_TV_PATH,
buildPlaylistQuery,
type PlaylistConfig,
type PlaylistDialogEntry,
} from "@/utils/dashboard/playlist";
const MoveDashboardToAnotherFolder = defineAsyncComponent(() => {
return import("@/components/dashboards/MoveDashboardToAnotherFolder.vue");
@@ -669,6 +692,7 @@ export default defineComponent({
FolderList,
OToggleGroup,
OToggleGroupItem,
PlaylistDialog,
},
setup() {
const store = useStore();
@@ -1663,6 +1687,51 @@ export default defineComponent({
const confirmBulkDelete = ref<boolean>(false);
const bulkDeleteLoading = ref(false);
const showPlaylistDialog = ref(false);
const playlistEntries = ref<PlaylistDialogEntry[]>([]);
// The table reports selection in row order; a playlist follows the order rows were ticked in.
const selectionOrder = ref<string[]>([]);
watch(
selectedIds,
(ids) => {
const selected = new Set(ids);
const kept = selectionOrder.value.filter((id) => selected.has(id));
const known = new Set(kept);
selectionOrder.value = [...kept, ...ids.filter((id) => !known.has(id))];
},
{ flush: "sync" },
);
const openPlaylistDialog = () => {
const folderNames = new Map<string, string>(
(store.state.organizationData?.folders ?? []).map((f: any) => [f.folderId, f.name]),
);
const rows = new Map<string, any>(dashboards.value.map((row: any) => [row.id, row]));
playlistEntries.value = selectionOrder.value
.filter((id) => rows.has(id))
.map((id) => {
const row = rows.get(id);
const folderId = row.folder_id || activeFolderId.value || "default";
return {
dashboardId: row.id,
folderId,
title: row.name,
folderName: row.folder || folderNames.get(folderId) || null,
};
});
showPlaylistDialog.value = true;
};
// A new tab keeps the list where it was; the TV tab is the one that goes full screen.
const openPlaylist = (config: PlaylistConfig) => {
const href = router.resolve({
path: PLAYLIST_TV_PATH,
query: buildPlaylistQuery(config),
}).href;
window.open(href, "_blank", "noopener");
};
const openBulkDeleteDialog = () => {
confirmBulkDelete.value = true;
};
@@ -1921,6 +1990,10 @@ export default defineComponent({
moveMultipleDashboards,
openBulkDeleteDialog,
bulkDeleteDashboards,
showPlaylistDialog,
playlistEntries,
openPlaylistDialog,
openPlaylist,
confirmBulkDelete,
bulkDeleteLoading,
isHome,
@@ -82,6 +82,9 @@ const mockGridStackInstance = {
setAnimation: vi.fn(),
setStatic: vi.fn(),
load: vi.fn(),
getRow: vi.fn().mockReturnValue(60),
getCellHeight: vi.fn().mockReturnValue(17),
cellHeight: vi.fn(),
engine: { nodes: [] as any[] },
};
@@ -965,6 +968,156 @@ describe("RenderDashboardCharts", () => {
});
});
describe("Kiosk", () => {
const kioskDashboard = () => ({
...defaultProps.dashboardData,
tabs: [
{
tabId: "default",
name: "Default Tab",
panels: [
{
id: "panel-1",
title: "Panel 1",
type: "line",
layout: { x: 0, y: 0, w: 96, h: 60, i: "panel-1" },
queries: [],
},
],
},
],
});
let clientHeight: PropertyDescriptor | undefined;
beforeEach(() => {
clientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get: () => 908,
});
mockGridStackInstance.cellHeight.mockClear();
});
afterEach(() => {
if (clientHeight) Object.defineProperty(HTMLElement.prototype, "clientHeight", clientHeight);
});
const twoTabs = () => ({
...kioskDashboard(),
tabs: [...kioskDashboard().tabs, { tabId: "beta", name: "Beta", panels: [] }],
});
const controls = () => wrapper.find('[data-test="dashboard-kiosk-controls"]');
it("puts the tab bar and the variables in one compact row on a wall screen", async () => {
wrapper = createWrapper({
showTabs: true,
kiosk: true,
viewOnly: true,
dashboardData: {
...twoTabs(),
variables: { list: [{ name: "region", type: "constant", value: "eu" }] },
},
});
await flushPromises();
expect(controls().classes()).toEqual(
expect.arrayContaining(["flex", "min-h-9", "items-center", "py-1"]),
);
expect(wrapper.find(".render-dashboard-charts-container").classes()).toContain("pt-0");
const tabs = wrapper.findComponent({ name: "TabList" });
expect(tabs.classes()).toEqual(expect.arrayContaining(["order-first", "flex-1"]));
expect(tabs.classes()).not.toContain("mt-2");
expect(tabs.props("viewOnly")).toBe(true);
const selector = wrapper.find('[data-test="global-variables-selector"]');
expect(selector.element.parentElement).toBe(controls().element);
expect(selector.classes()).toContain("mt-0!");
});
it("drops a lone tab from the row but keeps the variables", async () => {
wrapper = createWrapper({
showTabs: true,
kiosk: true,
dashboardData: {
...kioskDashboard(),
variables: { list: [{ name: "region", type: "constant", value: "eu" }] },
},
});
await flushPromises();
expect(controls().classes()).toContain("flex");
expect(wrapper.findComponent({ name: "TabList" }).classes()).toContain("hidden");
});
it("hides the row for a single-tab dashboard with no visible variables", async () => {
wrapper = createWrapper({
showTabs: true,
kiosk: true,
dashboardData: {
...kioskDashboard(),
variables: {
list: [{ name: "hidden", type: "constant", value: "x", hideOnDashboard: true }],
},
},
});
await flushPromises();
expect(controls().classes()).toEqual(["hidden"]);
});
it("leaves the normal layout as it was: a contents wrapper, spaced tab bar", async () => {
wrapper = createWrapper({ showTabs: true, dashboardData: twoTabs() });
await flushPromises();
expect(controls().classes()).toEqual(["contents"]);
expect(wrapper.findComponent({ name: "TabList" }).classes()).toContain("mt-2");
expect(wrapper.find(".render-dashboard-charts-container").classes()).toContain("pt-2");
});
it("shows the one-row bar with its values in a playlist slide, but out of interaction", async () => {
wrapper = createWrapper({
showTabs: true,
kiosk: true,
viewOnly: true,
readOnlySelectors: true,
dashboardData: {
...kioskDashboard(),
variables: { list: [{ name: "region", type: "constant", value: "eu" }] },
},
});
await flushPromises();
const row = wrapper.find('[data-test="dashboard-kiosk-controls"]');
expect(row.classes()).not.toContain("hidden");
expect(row.attributes("inert")).toBeDefined();
expect(row.attributes("data-read-only")).toBe("true");
expect(row.find('[data-test="global-variables-selector"]').exists()).toBe(true);
});
it("keeps the plain TV mode bar interactive", async () => {
wrapper = createWrapper({
kiosk: true,
dashboardData: {
...kioskDashboard(),
variables: { list: [{ name: "region", type: "constant", value: "eu" }] },
},
});
await flushPromises();
expect(wrapper.find('[data-test="dashboard-kiosk-controls"]').attributes("inert")).toBe(
undefined,
);
});
it("scales the cell height so every row fits when fit is on", async () => {
wrapper = createWrapper({ dashboardData: kioskDashboard(), kiosk: true, fitToScreen: true });
await flushPromises();
expect(mockGridStackInstance.cellHeight).toHaveBeenCalledWith(15);
expect(wrapper.classes()).toContain("overflow-hidden");
});
it("never resizes cells on a normal dashboard", async () => {
wrapper = createWrapper({ dashboardData: kioskDashboard() });
await flushPromises();
expect(mockGridStackInstance.cellHeight).not.toHaveBeenCalled();
expect(wrapper.classes()).toContain("overflow-y-auto");
});
});
describe("Tab Management", () => {
it("should render TabList when showTabs is true", () => {
wrapper = createWrapper({ showTabs: true });
@@ -18,13 +18,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- eslint-disable vue/attribute-hyphenation -->
<template>
<div
ref="dashboardScrollRef"
:class="[
'bg-surface-base',
frame ? 'border-border-default rounded-default border' : '',
store.state.printMode ? '' : 'h-full overflow-y-auto',
store.state.printMode
? ''
: fitToScreen
? 'h-full overflow-hidden'
: 'h-full overflow-y-auto',
]"
>
<div class="px-page-edge render-dashboard-charts-container pt-2">
<div class="px-page-edge render-dashboard-charts-container" :class="kiosk ? 'pt-0' : 'pt-2'">
<!-- flag to check if dashboardVariablesAndPanelsDataLoaded which is used while print mode-->
<span
class="hidden"
@@ -33,41 +38,57 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
>
</span>
<!-- Global variables sit above the tab bar: they apply dashboard-wide, not to the active tab. -->
<VariablesValueSelector
v-if="globalVariables.length > 0 || dashboardData?.variables?.showDynamicFilters"
:scope="'global'"
:tabId="selectedTabId"
:variablesConfig="{ list: globalVariables }"
:variablesManager="variablesManager"
:selectedTimeDate="currentTimeObj['__global']"
:initialVariableValues="initialVariableValues"
class="global-variables-selector"
data-test="global-variables-selector"
/>
<!-- Kiosk folds tabs and variables into one row; `contents` leaves the normal layout untouched. -->
<div
:class="
kiosk
? kioskControlsVisible
? 'flex min-h-9 min-w-0 flex-wrap items-center justify-end gap-x-4 gap-y-1 py-1'
: 'hidden'
: 'contents'
"
:inert="readOnlySelectors || undefined"
:data-read-only="readOnlySelectors || undefined"
data-test="dashboard-kiosk-controls"
>
<!-- Global variables sit above the tab bar: they apply dashboard-wide, not to the active tab. -->
<VariablesValueSelector
v-if="globalVariables.length > 0 || dashboardData?.variables?.showDynamicFilters"
:scope="'global'"
:tabId="selectedTabId"
:variablesConfig="{ list: globalVariables }"
:variablesManager="variablesManager"
:selectedTimeDate="currentTimeObj['__global']"
:initialVariableValues="initialVariableValues"
class="global-variables-selector"
:class="kiosk ? KIOSK_VARIABLES_CLASS : undefined"
data-test="global-variables-selector"
/>
<!-- Tab List -->
<TabList
v-if="showTabs && selectedTabId !== null"
class="mt-2"
:dashboardData="dashboardData"
:viewOnly="viewOnly"
:layoutMode="layoutMode"
@refresh="refreshDashboard"
@layoutChange="$emit('layoutChange')"
/>
<!-- Tab List -->
<TabList
v-if="showTabs && selectedTabId !== null"
:class="kiosk ? (kioskHasTabs ? 'order-first min-w-0 flex-1' : 'hidden') : 'mt-2'"
:dashboardData="dashboardData"
:viewOnly="viewOnly"
:layoutMode="layoutMode"
@refresh="refreshDashboard"
@layoutChange="$emit('layoutChange')"
/>
<!-- Tab-scoped Variables (for active tab, if using manager) -->
<VariablesValueSelector
v-if="variablesManager && currentTabVariables.length > 0 && selectedTabId"
:scope="'tabs'"
:tabId="selectedTabId"
:variablesConfig="{ list: currentTabVariables }"
:variablesManager="variablesManager"
:selectedTimeDate="currentTimeObj['__global']"
:initialVariableValues="initialVariableValues"
data-test="tab-variables-selector"
/>
<!-- Tab-scoped Variables (for active tab, if using manager) -->
<VariablesValueSelector
v-if="variablesManager && currentTabVariables.length > 0 && selectedTabId"
:scope="'tabs'"
:tabId="selectedTabId"
:variablesConfig="{ list: currentTabVariables }"
:variablesManager="variablesManager"
:selectedTimeDate="currentTimeObj['__global']"
:initialVariableValues="initialVariableValues"
:class="kiosk ? KIOSK_VARIABLES_CLASS : undefined"
data-test="tab-variables-selector"
/>
</div>
<slot name="before_panels" />
<div class="displayDiv clear-both mt-2 h-auto min-h-0">
@@ -93,6 +114,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@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 <http://www.gnu.org/licenses/>.
@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 <http://www.gnu.org/licenses/>.
"
:initialVariableValues="initialVariableValues"
class="panel-variables-margin mb-2"
:inert="readOnlySelectors || undefined"
data-test="panel-variables-selector"
/>
</div>
@@ -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<LayoutMode>,
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<HTMLElement | null>(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,
+145
View File
@@ -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 <http://www.gnu.org/licenses/>.
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<string, string> },
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<string, string> }) => ({
href: `/web${path}?${new URLSearchParams(query).toString()}`,
}),
}),
};
});
describe("TvPlaylist view", () => {
let wrapper: VueWrapper<any> | null = null;
let dispatch: ReturnType<typeof vi.spyOn>;
const mountView = async (query: Record<string, string>) => {
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);
});
});
+104
View File
@@ -0,0 +1,104 @@
<!-- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="bg-surface-base h-full" data-test="dashboard-tv-playlist">
<template v-if="config">
<PlaylistPlayer
:config="config"
:timezone="store.state.timezone"
:interval-adjusted="intervalAdjusted"
@slide="onSlide"
@fallback="onFallback"
@exit="exit"
/>
</template>
<div v-else class="flex h-full items-center justify-center" data-test="dashboard-tv-invalid">
<OEmptyState
icon="tv"
:title="t('dashboard.playlist.invalidTitle')"
:description="t('dashboard.playlist.invalidDescription')"
:action-label="t('dashboard.playlist.backToDashboards')"
@action="exit"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useStore } from "vuex";
import OEmptyState from "@/lib/core/EmptyState/OEmptyState.vue";
import PlaylistPlayer from "@/components/dashboards/kiosk/PlaylistPlayer.vue";
import { applyKioskTheme, clearKioskTheme } from "@/composables/dashboard/useKioskDisplay";
import { useI18nTyped } from "@/types/i18n";
import { parseKioskTheme } from "@/utils/dashboard/kiosk";
import {
PLAYLIST_VIEW_PATH,
buildPlaylistQuery,
buildSlideQuery,
isPlaylistIntervalAdjusted,
parsePlaylistQuery,
type PlaylistConfig,
} from "@/utils/dashboard/playlist";
const { t } = useI18nTyped();
const route = useRoute();
const router = useRouter();
const store = useStore();
const parsed = parsePlaylistQuery(route.query);
const config: PlaylistConfig | null = parsed
? { ...parsed, org: parsed.org || store.state.selectedOrganization?.identifier }
: null;
const intervalAdjusted = isPlaylistIntervalAdjusted(route.query.interval);
// Set before the first render so the app chrome never flashes on a TV.
if (config) {
store.dispatch("setKioskMode", true);
applyKioskTheme(store, parseKioskTheme(config.theme));
}
const replaceQuery = (next: PlaylistConfig) =>
router.replace({ path: route.path, query: buildPlaylistQuery(next) });
// Kept in the URL so a reload or a reboot resumes on the same slide.
const onSlide = (index: number) => {
if (config) void replaceQuery({ ...config, slideIndex: index });
};
const onFallback = (index: number) => {
if (!config) return;
const href = router.resolve({
path: PLAYLIST_VIEW_PATH,
query: buildSlideQuery(config, index, "page"),
}).href;
window.location.replace(window.location.origin + href);
};
const exit = () => {
router.replace({
path: "/dashboards",
query: { org_identifier: store.state.selectedOrganization?.identifier },
});
};
onBeforeUnmount(() => {
if (store.state.kioskMode) store.dispatch("setKioskMode", false);
clearKioskTheme(store);
});
</script>
+383 -54
View File
@@ -27,16 +27,20 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
},
isFullscreen ? 'bg-surface-base!' : '',
store.state.printMode === true ? 'pb-6' : '',
kioskMode && !kioskPointerActive ? 'cursor-none [&_*]:cursor-none!' : '',
]"
class="h-full"
:data-kiosk="kioskMode ? 'true' : undefined"
>
<OPageLayout
bleed
:main-panel="false"
:header-class="
isFullscreen || store.state.printMode === true
? 'stickyHeader fullscreenHeader bg-surface-panel'
: 'shrink-0'
kioskMode
? 'hidden'
: isFullscreen || store.state.printMode === true
? 'stickyHeader fullscreenHeader bg-surface-panel'
: 'shrink-0'
"
:subtitle="folderNameFromFolderId"
:icon="!isFullscreen && store.state.printMode !== true ? undefined : 'dashboard'"
@@ -108,12 +112,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@timezone-change="timeGate.flush"
data-test="dashboard-global-date-time-picker"
/>
<!-- Kiosk keeps this mounted on every width: it owns the refresh value and its URL sync. -->
<AutoRefreshInterval
v-if="!isMobile"
v-if="!isMobile || kioskMode"
v-model="refreshInterval"
trigger
:min-refresh-interval="store.state?.zoConfig?.min_auto_refresh_interval || 5"
@trigger="refreshData"
@trigger="onAutoRefreshTick"
class="dashboard-icons hideOnPrintMode h-7.5 [transition:all_0.2s_ease]"
size="sm"
/>
@@ -291,11 +296,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template #actions-overflow>
<AutoRefreshInterval
v-if="isMobile"
v-if="isMobile && !kioskMode"
v-model="refreshInterval"
trigger
:min-refresh-interval="store.state?.zoConfig?.min_auto_refresh_interval || 5"
@trigger="refreshData"
@trigger="onAutoRefreshTick"
class="dashboard-icons hideOnPrintMode h-7.5 [transition:all_0.2s_ease]"
size="sm"
/>
@@ -354,6 +359,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
shortcut-id="dashboardFullscreen"
/>
</OButton>
<KioskSetupPopover
v-if="!isFullscreen"
v-show="store.state.printMode !== true"
:current-refresh-seconds="refreshInterval"
:min-refresh-seconds="store.state?.zoConfig?.min_auto_refresh_interval || 5"
@open="enterKiosk"
/>
<OButton
v-if="!isFullscreen"
v-show="store.state.printMode !== true"
@@ -432,6 +444,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</template>
</template>
<KioskStrip
v-if="kioskMode && !playlistEmbed"
:title="currentDashboardData.data?.title"
:position="playlistPosition"
:time-range-label="kioskTimeRange"
:refresh-seconds="refreshInterval"
:health="kioskHealth"
:last-success-at="kioskLastSuccessAt"
:timezone="store.state.timezone"
:loading="arePanelsLoading"
:countdown="kioskCountdown"
@exit="exitKiosk"
/>
<div v-if="playlistHolding" class="fixed inset-0 z-50">
<PlaylistHoldingCard
:label="playlistHolding.label"
:title="playlistHolding.title"
:note="playlistHolding.note"
/>
</div>
<OBanner
v-if="hasUnsavedConflict"
variant="warning"
@@ -468,7 +500,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@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<string, any> = {};
@@ -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,
};
},
});
+392 -2
View File
@@ -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<string, string> = {};
// 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:
'<div><slot name="title" /><slot name="actions" /><slot name="actions-overflow" /><slot /></div>',
},
RenderDashboardCharts: {
name: "RenderDashboardCharts",
props: ["viewOnly", "kiosk", "fitToScreen", "showTabs", "readOnlySelectors"],
setup() {
return { applyLive: inject<Ref<boolean> | null>("dashboardAutoRun", null) };
},
template: '<div :data-apply-live="String(applyLive)" />',
},
},
},
});
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);
});
});
});
});