chore: reconcile widget layer after stack rebase

This commit is contained in:
ajnart committed 2026-08-14 11:07:09 +02:00
1 parent 09b82ba171
commit bd4bbcaca2
23 files changed
+101 -814

No files matched your search

@@ -83,7 +83,7 @@ describe("AdvancedFocusManualSurface", () => {
expect(dialog?.id).toBe("advanced-focus-test");
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(dialog?.closest("[data-portal]")).not.toBeNull();
expect(dialog?.closest(".grid-stack-item")).toBeNull();
expect(dialog?.closest("[data-grid-item-id]")).toBeNull();
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
"Test widget advanced view",
);
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
import { getFirstEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
@@ -106,39 +106,7 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
test("does not place a wide item beyond the right edge", () => {
const elements = createElementsFromLayout([["a", "a", "a", " "]]);
expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
xOffset: 0,
yOffset: 1,
});
});
});
describe("get nearest empty position", () => {
test("keeps the preferred cell when it fits", () => {
expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
xOffset: 2,
yOffset: 1,
});
});
test("chooses the nearest available cell when the preferred cell is occupied", () => {
const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
xOffset: 2,
yOffset: 0,
});
});
test("returns undefined when the item cannot fit within a bounded section", () => {
expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
});
});
const createElementsFromLayout = (layout: string[][]) => {
const elements: (Pick<Item["layouts"][number], "xOffset" | "yOffset" | "width" | "height"> & { char: string })[] = [];
for (let yOffset = 0; yOffset < layout.length; yOffset++) {
@@ -1,5 +1,5 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: QueryKey[]) => {
export const removeWidgetDataQueries = (queryClient: QueryClient, queryKeys: readonly QueryKey[]) => {
queryKeys.forEach((queryKey) => queryClient.removeQueries({ queryKey }));
};
@@ -172,7 +172,7 @@ describe("matchesWidgetItemQuery", () => {
integrationIds: ["beszel-1"],
systemId: "system-1",
timePeriod: "1h",
includeDocker: false,
includeDocker: true,
},
};
const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
@@ -1,4 +0,0 @@
.suppressTouchCallout {
user-select: none;
-webkit-touch-callout: none;
}
@@ -1,226 +0,0 @@
"use client";
import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
import { useEffect, useRef, useState } from "react";
import type { BoxProps } from "@mantine/core";
import { Box, Menu } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { ItemSelectModal } from "../../items/item-select-modal";
import { useBoardPermissions } from "../../permissions/client";
import type { BoardPlacementHint } from "../../items/actions/create-item";
import type { UseGridstackRefs } from "./use-gridstack";
import classes from "./board-empty-space-context-menu.module.css";
import { hasExceededLongPressMoveTolerance } from "./long-press";
const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
interface MenuState {
x: number;
y: number;
placement: BoardPlacementHint;
}
interface Props extends BoxProps {
section: Exclude<Section, { kind: "dynamic" }> | DynamicSectionItem;
refs: UseGridstackRefs;
}
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
const settings = useSettings();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
const { openModal } = useModalAction(ItemSelectModal);
const [isEditMode] = useEditMode();
const [menu, setMenu] = useState<MenuState | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
const touchGuardElement = useRef<HTMLDivElement | null>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
const closeMenu = (afterFocusRestore?: (returnFocus: HTMLElement | null) => void) => {
const returnFocus = returnFocusRef.current;
returnFocusRef.current = null;
setMenu(null);
requestAnimationFrame(() => {
returnFocus?.focus();
afterFocusRestore?.(returnFocus);
});
};
const clearLongPress = () => {
if (longPressTimer.current !== null) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
touchGuardElement.current = null;
longPressPointer.current = null;
};
useEffect(
() => () => {
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
},
[],
);
const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
const wrapper = refs.wrapperElement.current;
const gridstack = refs.gridstack.current;
if (!(target instanceof Element) || !wrapper || !gridstack) return null;
if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
const rowLimit = section.kind === "dynamic" ? section.height : undefined;
if (
cell.x < 0 ||
cell.y < 0 ||
cell.x >= gridstack.getColumn() ||
(rowLimit !== undefined && cell.y >= rowLimit) ||
!gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
) {
return null;
}
return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
};
const handleContextMenu = (event: MouseEvent<HTMLDivElement>) => {
if (!enabled) return;
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
event.preventDefault();
event.stopPropagation();
returnFocusRef.current = event.currentTarget;
setMenu({ x: event.clientX, y: event.clientY, placement });
};
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (!enabled || event.pointerType !== "touch") return;
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
const returnFocus = event.currentTarget;
clearLongPress();
longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
touchGuardElement.current = event.currentTarget;
touchGuardElement.current.classList.add(suppressTouchCalloutClass);
longPressTimer.current = setTimeout(() => {
returnFocusRef.current = returnFocus;
setMenu({ x: event.clientX, y: event.clientY, placement });
navigator.vibrate?.(20);
}, 500);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
const pointer = longPressPointer.current;
if (
!pointer ||
pointer.pointerId !== event.pointerId ||
!hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
) {
return;
}
clearLongPress();
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
!enabled ||
event.currentTarget !== event.target ||
(event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
) {
return;
}
const wrapper = event.currentTarget;
const gridstack = refs.gridstack.current;
if (!gridstack) return;
const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
for (let yOffset = 0; yOffset < rowCount; yOffset++) {
for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
const rect = wrapper.getBoundingClientRect();
event.preventDefault();
returnFocusRef.current = wrapper;
setMenu({
x: rect.left + 24,
y: rect.top + 24,
placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
});
return;
}
}
};
return (
<>
<Box
{...boxProps}
ref={refs.wrapper}
tabIndex={enabled ? 0 : undefined}
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onPointerLeave={handlePointerEnd}
>
{children}
</Box>
<Menu
opened={menu !== null}
withInitialFocusPlaceholder={false}
onChange={(opened) => {
if (!opened) closeMenu();
}}
withinPortal
position="bottom-start"
returnFocus={false}
>
<Menu.Target>
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
data-autofocus
leftSection={<IconPlus size={18} />}
onClick={() => {
const placement = menu?.placement;
closeMenu((returnFocus) => {
if (!placement) return;
openModal(
{ placement, board, persistImmediately: !isEditMode },
{
onClose: () => {
requestAnimationFrame(() => {
if (returnFocus?.isConnected) returnFocus.focus();
});
},
},
);
});
}}
>
{t("item.create.addAtPosition")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
};
@@ -1,17 +0,0 @@
import { describe, expect, test } from "vitest";
import { hasExceededLongPressMoveTolerance } from "./long-press";
describe("hasExceededLongPressMoveTolerance", () => {
test("allows small touch jitter", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 106 })).toBe(false);
});
test("allows movement exactly at the threshold", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 108 })).toBe(false);
});
test("cancels a deliberate scroll gesture", () => {
expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 100, y: 116 })).toBe(true);
});
});
@@ -1,7 +0,0 @@
export const longPressMoveTolerancePx = 10;
export const hasExceededLongPressMoveTolerance = (
start: { x: number; y: number },
current: { x: number; y: number },
tolerance = longPressMoveTolerancePx,
) => Math.hypot(current.x - start.x, current.y - start.y) > tolerance;
@@ -5,7 +5,7 @@ export const BoardTourGate = async ({ enabled, children }: PropsWithChildren<{ e
// Native server-side import keeps the completed-user client graph lean while
// giving first-time users one stable board tree instead of swapping a
// Suspense fallback after GridStack has mounted.
// Suspense fallback after the board grid has mounted.
const { BoardTourProvider } = await import("./board-tour");
return <BoardTourProvider>{children}</BoardTourProvider>;
};
+16 -77
View File
@@ -1,4 +1,4 @@
import type { Browser, BrowserContext, Locator, Page } from "@playwright/test";
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { chromium, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
@@ -26,7 +26,6 @@ const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
const boardName = "advanced-interactions-e2e";
const emptyCellCenterXRatio = 7.5 / 12;
describe("Board advanced interactions", () => {
let browser: Browser;
@@ -68,13 +67,15 @@ describe("Board advanced interactions", () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
const compactSurface = page.locator(".clock-wrapper").first();
const previewSurface = page.getByRole("region", { name: "Date and time advanced view" });
const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" });
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
const otherWidget = page
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
const header = page.locator("header[data-advanced-focus-background]");
const boardMain = page.locator("main[data-advanced-focus-background]");
@@ -146,7 +147,7 @@ describe("Board advanced interactions", () => {
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
await expect(header).toHaveAttribute("inert", "");
await expect(boardMain).toHaveAttribute("inert", "");
expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull();
expect(await manualSurface.evaluate((element) => element.closest("[data-grid-item-id]"))).toBeNull();
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
@@ -235,7 +236,7 @@ describe("Board advanced interactions", () => {
for (const compactOnlyKind of ["app", "iframe"]) {
const compactOnlyWidget = page
.locator(`.grid-stack-item[data-kind='${compactOnlyKind}'] > .grid-stack-item-content`)
.locator(`[data-grid-item-id][data-kind='${compactOnlyKind}'] [data-advanced-focus-source]`)
.first();
await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 });
@@ -256,7 +257,7 @@ describe("Board advanced interactions", () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
const iframeSlot = page.locator("[data-grid-item-id][data-kind='iframe'] [data-advanced-focus-source]").first();
const iframe = iframeSlot.locator("iframe");
await iframe.evaluate((element) => {
(element as HTMLIFrameElement).srcdoc = '<label>State <input id="state" value="initial"></label>';
@@ -275,13 +276,15 @@ describe("Board advanced interactions", () => {
await page.keyboard.up("Shift");
await expect(frame.locator("#state")).toHaveValue("changed");
const bookmarksSlot = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
const bookmarksSlot = page
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
await bookmarksSlot.focus();
await page.keyboard.press("Shift+Enter");
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible();
await page.setViewportSize({ width: 800, height: 900 });
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden();
await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
await expect(page.locator("[data-grid-item-id][data-kind='iframe'] iframe")).toBeVisible();
} finally {
await context.close();
}
@@ -291,7 +294,7 @@ describe("Board advanced interactions", () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 });
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.focus();
await page.keyboard.press("Shift+Enter");
const closeButton = page.getByRole("button", { name: "Close advanced view" });
@@ -321,70 +324,29 @@ describe("Board advanced interactions", () => {
}
}, 60_000);
test("opens the add-at-position chooser only when right click and change access are enabled", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const grid = page.locator(".grid-stack[data-kind='empty']");
const addHereMenuItem = page.getByRole("menuitem", { name: "Add item here" });
await expect(grid).toHaveAttribute("aria-label", "Add item here");
await grid.press("Shift+F10");
await expect(addHereMenuItem).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).toBeFocused();
await touchAndHoldEmptyGridSpaceAsync(grid);
await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 });
await page.keyboard.press("Escape");
await expect(addHereMenuItem).toBeHidden();
await expect(grid).toBeFocused();
await rightClickEmptyGridSpaceAsync(grid);
await expect(addHereMenuItem).toBeVisible();
await addHereMenuItem.click();
await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).toBeFocused();
} finally {
await context.close();
}
}, 60_000);
test("honors the user setting gate even when the user can modify the board", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials);
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const grid = page.locator(".grid-stack[data-kind='empty']");
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
await rightClickEmptyGridSpaceAsync(grid);
await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0);
} finally {
await context.close();
}
}, 60_000);
test("keeps read-only advanced viewing but blocks add-at-position without change access", async () => {
test("keeps read-only advanced viewing", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials);
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const grid = page.locator(".grid-stack[data-kind='empty']");
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
await rightClickEmptyGridSpaceAsync(grid);
await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0);
} finally {
await context.close();
}
@@ -408,29 +370,6 @@ const openBoardAsync = async (
return { context, page };
};
const rightClickEmptyGridSpaceAsync = async (grid: Locator) => {
const bounds = await grid.boundingBox();
expect(bounds).not.toBeNull();
if (!bounds) return;
await grid.click({
button: "right",
position: { x: bounds.width * emptyCellCenterXRatio, y: Math.min(24, bounds.height / 2) },
});
};
const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
const bounds = await grid.boundingBox();
expect(bounds).not.toBeNull();
if (!bounds) return;
const clientX = bounds.x + bounds.width * emptyCellCenterXRatio;
const clientY = bounds.y + Math.min(24, bounds.height / 2);
await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
await new Promise((resolve) => setTimeout(resolve, 800));
await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY });
};
const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => {
const boardId = createId();
const baseLayoutId = createId();
@@ -1,376 +0,0 @@
// @vitest-environment node
import { stringify } from "superjson";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
import { customApiRouter } from "../widgets/custom-api";
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
user: { id: userId, permissions, colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const createCustomApiCaller = (db: Database, session: Session | null) =>
customApiRouter.createCaller({ db, deviceType: undefined, session });
async function createFixtureAsync(db: Database) {
const ownerId = createId();
const viewerId = createId();
const modifierId = createId();
const unrelatedId = createId();
const adminId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values(
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
id,
name: id,
})),
);
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
await db.insert(boardUserPermissions).values([
{ boardId, userId: viewerId, permission: "view" },
{ boardId, userId: modifierId, permission: "modify" },
]);
await db.insert(customWidgetDefinitions).values({
id: definitionId,
name: "Restart service",
description: "Restarts a service",
url: "https://api.example.com/restart",
method: "POST",
displayType: "actionButton",
displayConfig: stringify({
type: "actionButton",
buttonLabel: "Restart",
confirmText: "Continue?",
}),
creatorId: adminId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringify({ definitionId, refreshInterval: 30 }),
});
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("custom widget definition authorization", () => {
it("restricts the full definition list to administrators", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
]);
});
it("returns only safe definition summaries to board modifiers", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
boardId: fixture.boardId,
});
expect(result).toEqual([
{
id: fixture.definitionId,
name: "Restart service",
description: "Restarts a service",
iconUrl: null,
displayType: "actionButton",
enabled: true,
},
]);
expect(JSON.stringify(result)).not.toContain("api.example.com");
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
await expect(
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}
});
});
describe("custom widget action authorization", () => {
it("requires an administrator even for users who can view or modify the board", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
});
it("executes for an administrator only when board, item, and definition are bound", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
await expect(
caller.execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
);
});
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const input = {
boardId: mismatch === "board" ? createId() : fixture.boardId,
itemId: mismatch === "item" ? createId() : fixture.itemId,
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
};
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects disabled definitions before sending a request", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("custom API outbound request hardening", () => {
it("rejects credential-bearing action URLs without exposing the credential", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ url: "https://operator:super-secret@private.example.com/restart" })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects redirects and redacts fetch errors", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi
.fn()
.mockRejectedValue(
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
);
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
});
it("aborts timed-out action requests and returns a stable failure", async () => {
vi.useFakeTimers();
const db = createDb();
const fixture = await createFixtureAsync(db);
const started = Promise.withResolvers<void>();
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
started.resolve();
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
});
});
});
vi.stubGlobal("fetch", fetchMock);
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
await started.promise;
await vi.advanceTimersByTimeAsync(10_000);
const result = await request;
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
});
it("bounds action responses before reporting success", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
});
it("binds data requests to a viewable item and never exposes backend error text", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData(input)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("bounds and disposes non-success data responses", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
});
it("redacts unsafe preview failures and rejects redirects", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
const result = await caller.preview({
url: "https://api.example.com/data",
method: "GET",
authType: "none",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
secrets: [],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/data",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({
success: false,
error: "Custom API preview failed",
responseInfo: null,
rawResponse: null,
});
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
});
});
+1 -1
View File
@@ -32,7 +32,7 @@ const feedsInput = z.object({
export const rssFeedRouter = createTRPCRouter({
getFeeds: publicProcedure.input(feedsInput).query(async ({ ctx, input }) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (env.NO_EXTERNAL_CONNECTION) return { entries: [], failedFeedCount: 0 };
const urls = (await canAccessAllFeedsAsync(ctx.db, ctx.session))
? input.urls
@@ -97,8 +97,8 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const isAudioDirect = isStreamDirect(transcodeElement?.$.audioDecision);
const containerChanged = Boolean(
transcodeElement?.$.container &&
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
mediaInfoElement?.$.container &&
transcodeElement.$.container !== mediaInfoElement.$.container,
);
const bitrateKbps =
parseOptionalNumber(sessionElement?.$.bandwidth) ?? parseOptionalNumber(mediaInfoElement?.$.bitrate);
+1
View File
@@ -81,6 +81,7 @@
"@tiptap/extension-underline": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@trpc/react-query": "catalog:",
"acorn": "catalog:",
"acorn-jsx": "catalog:",
"clsx": "catalog:",
@@ -3,6 +3,7 @@
import { useCallback, useMemo, useRef } from "react";
import { Box, Button, Center, Group, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -68,15 +69,16 @@ export default function BeszelSystemStatsWidget({
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
path: ["widget", "beszel", "getSystemStats"],
input: {
getQueryKey(
clientApi.widget.beszel.getSystemStats,
{
integrationIds: [selectedSystem.integrationId],
systemId: selectedSystem.systemId,
timePeriod: options.timePeriod,
timePeriod: options.timePeriod as BeszelTimePeriod,
includeDocker,
},
},
"query",
),
]
: [],
);
@@ -20,22 +20,17 @@ import {
useSystemChartData,
} from "./chart";
import { chartAxisFormatters, formatByteRate, formatGB, formatPercent, formatStorageBytes } from "./format";
import type { BeszelTooltipLayer } from "./tooltip";
import { makeTooltipProps } from "./tooltip";
import { useLiveStats } from "./use-live-stats";
const CHART_HEIGHT = 180;
const MB = 1024 * 1024;
const makeTooltipSet = (layer: BeszelTooltipLayer) => ({
percent: makeTooltipProps(formatPercent, false, layer),
gigabytes: makeTooltipProps(formatGB, true, layer),
rate: makeTooltipProps(formatByteRate, true, layer),
percentTotal: makeTooltipProps(formatPercent, true, layer),
bytesTotal: makeTooltipProps(formatStorageBytes, true, layer),
});
const tooltipSets = {
background: makeTooltipSet("background"),
modal: makeTooltipSet("modal"),
const tooltips = {
percent: makeTooltipProps(formatPercent),
gigabytes: makeTooltipProps(formatGB, true),
rate: makeTooltipProps(formatByteRate, true),
percentTotal: makeTooltipProps(formatPercent, true),
bytesTotal: makeTooltipProps(formatStorageBytes, true),
};
const ChartSkeleton = () => (
@@ -66,7 +61,6 @@ interface BeszelStatsViewProps {
visibility: BeszelStatsVisibility;
columns: 1 | 2;
onSwitchToHistorical?: () => void;
tooltipLayer?: BeszelTooltipLayer;
}
const whenVisible = <T,>(visible: boolean, data: T | undefined) => (visible ? data : undefined);
@@ -78,14 +72,11 @@ export function BeszelStatsView({
visibility,
columns,
onSwitchToHistorical,
tooltipLayer = "background",
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const tooltips = tooltipSets[tooltipLayer];
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
@@ -50,7 +50,6 @@ export const BeszelSystemStatsModal = createModal<BeszelSystemStatsModalProps>((
timePeriod={timePeriod}
visibility={allCharts}
columns={2}
tooltipLayer="modal"
onSwitchToHistorical={() => setTimePeriod("1h")}
/>
</Stack>
-12
View File
@@ -40,15 +40,3 @@ export function filterDownloadItemsByStatus<T extends { state: DownloadState }>(
export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
return [...new Set(items.map(({ state }) => state))];
}
export function columnWidthsToRecord(columnsWidth: readonly Record<string, string | number>[]): Record<string, number> {
const result: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const width = entry[key];
if (typeof width === "number") result[key] = width;
else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
}
return result;
}
@@ -46,7 +46,6 @@ describe("WidgetError", () => {
MantineProvider,
null,
createElement(WidgetError, {
kind: "app",
error: new Error(secret),
resetErrorBoundary: vi.fn(),
}),
+24 -9
View File
@@ -37,7 +37,7 @@ interface FirewallQueryState {
isPending: boolean;
isFetching: boolean;
isError: boolean;
data?: readonly { error?: string }[];
data?: readonly { error?: string; integration?: { id: string } }[];
}
export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[]) =>
@@ -48,6 +48,13 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
);
export const hasFirewallPartialFailure = (firewallId: string, queries: readonly FirewallQueryState[]) =>
queries.some(
(query) =>
query.isError ||
query.data?.some(({ error, integration }) => integration?.id === firewallId && Boolean(error)) === true,
);
export default function FirewallWidget({
integrationIds,
width,
@@ -101,6 +108,7 @@ export default function FirewallWidget({
const initialSelectedFirewall = firewallIds[0] ?? "";
const activeFirewall = firewallMetadata.has(selectedFirewall) ? selectedFirewall : initialSelectedFirewall;
const displayedFirewallIds = isAdvanced ? firewallIds : activeFirewall ? [activeFirewall] : [];
const firewallHasError = (firewallId: string) => hasFirewallPartialFailure(firewallId, queries);
const dropdownItems = firewallIds.map((firewallId) => ({
label: firewallMetadata.get(firewallId)?.name ?? firewallId,
value: firewallId,
@@ -128,11 +136,20 @@ export default function FirewallWidget({
dropdownItems={dropdownItems}
isTiny={isTiny}
/>
<FirewallVersion
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
<Group gap={4} wrap="nowrap">
{activeFirewall && firewallHasError(activeFirewall) && (
<Tooltip label={t("widget.firewall.error.internalServerError")}>
<Badge color="red" variant="light" size="xs">
{t("common.error")}
</Badge>
</Tooltip>
)}
<FirewallVersion
firewallsVersionData={firewallsVersionData.filter(({ error }) => !error)}
selectedFirewall={activeFirewall}
isTiny={isTiny}
/>
</Group>
</Group>
)}
@@ -142,9 +159,7 @@ export default function FirewallWidget({
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
const hasError = Boolean(
cpu?.error || memory?.error || version?.error || interfacesQuery.isError || interfaces?.error,
);
const hasError = firewallHasError(firewallId);
const metadata = firewallMetadata.get(firewallId);
return (
@@ -13,12 +13,19 @@ const mocks = vi.hoisted(() => ({
getAlbums: vi.fn(() => ({ data: [], isPending: false, isError: false })),
}));
vi.mock("@trpc/react-query", () => ({
getQueryKey: (procedure: { path: string[] }, input: unknown) => [procedure.path, { input, type: "query" }],
}));
vi.mock("@homarr/api/client", () => ({
clientApi: {
widget: {
immich: {
getAlbums: { useQuery: mocks.getAlbums },
getServerStats: { useQuery: () => ({ data: undefined }) },
getAlbums: { path: ["widget", "immich", "getAlbums"], useQuery: mocks.getAlbums },
getServerStats: {
path: ["widget", "immich", "getServerStats"],
useQuery: () => ({ data: undefined }),
},
},
},
},
+15 -13
View File
@@ -15,6 +15,7 @@ import {
} from "./manifest";
import { widgetQueryRefetchIntervals } from "./refetch-intervals";
import { widgetCatalogIcons } from "./catalog";
import { getWidgetQueryKeys } from "./definition";
describe("widget manifest promise stability", () => {
it("returns the same module promise for every render", () => {
@@ -111,11 +112,11 @@ describe("widget manifest promise stability", () => {
const definitions = await loadAllWidgetDefinitions();
const expectedQueryKeys = new Map([
["anchorNote", [["widget", "anchorNotes"]]],
["beszelAlerts", [["widget", "beszel"]]],
["clock", [["widget", "weather"]]],
["mediaMissing", [["widget", "mediaOrganizer"]]],
["mediaRequests-requestList", [["widget", "mediaRequests"]]],
["mediaRequests-requestStats", [["widget", "mediaRequests"]]],
["beszelAlerts", [["widget", "beszel", "getAlerts"]]],
["clock", [["widget", "weather", "atLocation"]]],
["mediaMissing", [["widget", "mediaOrganizer", "getData"]]],
["mediaRequests-requestList", [["widget", "mediaRequests", "getLatestRequests"]]],
["mediaRequests-requestStats", [["widget", "mediaRequests", "getStats"]]],
["smartHome-entityState", [["widget", "smartHome"]]],
] as const);
@@ -133,14 +134,15 @@ describe("widget manifest promise stability", () => {
const definition = definitions.get(kind);
if (definition?.refetchInterval === undefined) continue;
const entry = {
queryKey: definition.queryKey ?? [["widget", kind]],
intervalSeconds: definition.refetchInterval,
} as const;
const serializedQueryKey = JSON.stringify(entry.queryKey);
const existing = expectedByQueryKey.get(serializedQueryKey);
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
expectedByQueryKey.set(serializedQueryKey, entry);
for (const queryKey of getWidgetQueryKeys(definition, kind)) {
const path = queryKey[0];
if (!Array.isArray(path) || (path[0] !== "widget" && path[0] !== "docker")) continue;
const entry = { queryKey, intervalSeconds: definition.refetchInterval } as const;
const serializedQueryKey = JSON.stringify(entry.queryKey);
const existing = expectedByQueryKey.get(serializedQueryKey);
expect(existing?.intervalSeconds ?? entry.intervalSeconds).toBe(entry.intervalSeconds);
expectedByQueryKey.set(serializedQueryKey, entry);
}
}
const serializePolicy = (entry: PollingPolicy) => JSON.stringify(entry);
+15 -9
View File
@@ -8,23 +8,29 @@
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft", "getServerStatus"]], intervalSeconds: null },
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
{ queryKey: [["widget", "releases", "getLatest"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed", "getFeeds"]], intervalSeconds: null },
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice", "getPriceHistory"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getVisitorStats"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getActiveVisitors"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
] as const;