mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-04 08:09:27 +00:00
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:
co-authored by
github-actions[bot]
ktx-abhay
Abhay Padamani
parent
613a71d627
commit
1ea4ff6790
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "عرض الحالة الفارغة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "空状態デモ",
|
||||
|
||||
@@ -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": "빈 상태 데모",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Демо пустого состояния",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "空状态演示",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user