diff --git a/scripts/translations/.translation_state.json b/scripts/translations/.translation_state.json index 1b6e2a5502..8986deb4d7 100644 --- a/scripts/translations/.translation_state.json +++ b/scripts/translations/.translation_state.json @@ -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", diff --git a/web/src/components/ThemeSwitcher.spec.ts b/web/src/components/ThemeSwitcher.spec.ts index 9b07c9caba..ed6267c205 100644 --- a/web/src/components/ThemeSwitcher.spec.ts +++ b/web/src/components/ThemeSwitcher.spec.ts @@ -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"); + }); + }); }); diff --git a/web/src/components/ThemeSwitcher.vue b/web/src/components/ThemeSwitcher.vue index 4dae01909e..3898e0b1c6 100644 --- a/web/src/components/ThemeSwitcher.vue +++ b/web/src/components/ThemeSwitcher.vue @@ -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); diff --git a/web/src/components/dashboards/PanelContainer.spec.ts b/web/src/components/dashboards/PanelContainer.spec.ts index 0299599073..abfe02066c 100644 --- a/web/src/components/dashboards/PanelContainer.spec.ts +++ b/web/src/components/dashboards/PanelContainer.spec.ts @@ -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: '
' }, @@ -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(); + }); }); }); diff --git a/web/src/components/dashboards/PanelContainer.vue b/web/src/components/dashboards/PanelContainer.vue index 24e076434e..bef9ee4268 100644 --- a/web/src/components/dashboards/PanelContainer.vue +++ b/web/src/components/dashboards/PanelContainer.vue @@ -17,6 +17,8 @@ along with this program. If not, see .