diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts index 3d5827aef..b659efc9c 100644 --- a/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts @@ -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", ); diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index cc505157b..848ea9ac2 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -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 & { char: string })[] = []; for (let yOffset = 0; yOffset < layout.length; yOffset++) { diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.ts index 9bb0b81c5..ba80f3ee2 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.ts @@ -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 })); }; diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts index e11e21086..ec667102e 100644 --- a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts +++ b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts @@ -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] }); diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css deleted file mode 100644 index fe141c3f7..000000000 --- a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.suppressTouchCallout { - user-select: none; - -webkit-touch-callout: none; -} diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx deleted file mode 100644 index ac1673369..000000000 --- a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx +++ /dev/null @@ -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 | DynamicSectionItem; - refs: UseGridstackRefs; -} - -export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren) => { - 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(null); - const longPressTimer = useRef | null>(null); - const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null); - const touchGuardElement = useRef(null); - const returnFocusRef = useRef(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) => { - 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) => { - 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) => { - const pointer = longPressPointer.current; - if ( - !pointer || - pointer.pointerId !== event.pointerId || - !hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY }) - ) { - return; - } - clearLongPress(); - }; - - const handlePointerEnd = (event: PointerEvent) => { - if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress(); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - 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 ( - <> - - {children} - - { - if (!opened) closeMenu(); - }} - withinPortal - position="bottom-start" - returnFocus={false} - > - - - - - } - 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")} - - - - - ); -}; diff --git a/apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts b/apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts deleted file mode 100644 index 9ab4461e3..000000000 --- a/apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/nextjs/src/components/board/sections/gridstack/long-press.ts b/apps/nextjs/src/components/board/sections/gridstack/long-press.ts deleted file mode 100644 index d7b94ad01..000000000 --- a/apps/nextjs/src/components/board/sections/gridstack/long-press.ts +++ /dev/null @@ -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; diff --git a/apps/nextjs/src/components/onboarding/board-tour-gate.tsx b/apps/nextjs/src/components/onboarding/board-tour-gate.tsx index 31365544f..454d20fa2 100644 --- a/apps/nextjs/src/components/onboarding/board-tour-gate.tsx +++ b/apps/nextjs/src/components/onboarding/board-tour-gate.tsx @@ -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 {children}; }; diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index ba8f892d9..b970eec28 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -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 = ''; @@ -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(); diff --git a/packages/api/src/router/test/custom-widget.spec.ts b/packages/api/src/router/test/custom-widget.spec.ts deleted file mode 100644 index b026b2f68..000000000 --- a/packages/api/src/router/test/custom-widget.spec.ts +++ /dev/null @@ -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(); - const fetchMock = vi.fn((_url: string, init: RequestInit) => { - started.resolve(); - return new Promise((_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/); - }); -}); diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 5894dd8ac..87c49fca0 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -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 diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 6b772a589..cd4e9d6e9 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -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); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index fb07be119..d5d9740f2 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -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:", diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 4f2e96d56..d776af04c 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -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", + ), ] : [], ); diff --git a/packages/widgets/src/beszel/_shared/stats-view.tsx b/packages/widgets/src/beszel/_shared/stats-view.tsx index c2042a4a9..57ff4b40b 100644 --- a/packages/widgets/src/beszel/_shared/stats-view.tsx +++ b/packages/widgets/src/beszel/_shared/stats-view.tsx @@ -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 = (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 !== "" }, diff --git a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx index bc13d498b..fda3146d2 100644 --- a/packages/widgets/src/beszel/_shared/system-stats-modal.tsx +++ b/packages/widgets/src/beszel/_shared/system-stats-modal.tsx @@ -50,7 +50,6 @@ export const BeszelSystemStatsModal = createModal(( timePeriod={timePeriod} visibility={allCharts} columns={2} - tooltipLayer="modal" onSwitchToHistorical={() => setTimePeriod("1h")} /> diff --git a/packages/widgets/src/downloads/helpers.ts b/packages/widgets/src/downloads/helpers.ts index 968c31f4b..ec77c9892 100644 --- a/packages/widgets/src/downloads/helpers.ts +++ b/packages/widgets/src/downloads/helpers.ts @@ -40,15 +40,3 @@ export function filterDownloadItemsByStatus( export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] { return [...new Set(items.map(({ state }) => state))]; } - -export function columnWidthsToRecord(columnsWidth: readonly Record[]): Record { - const result: Record = {}; - 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; -} diff --git a/packages/widgets/src/errors/component.spec.ts b/packages/widgets/src/errors/component.spec.ts index abe6ca0db..c1bc146c4 100644 --- a/packages/widgets/src/errors/component.spec.ts +++ b/packages/widgets/src/errors/component.spec.ts @@ -46,7 +46,6 @@ describe("WidgetError", () => { MantineProvider, null, createElement(WidgetError, { - kind: "app", error: new Error(secret), resetErrorBoundary: vi.fn(), }), diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx index 905135942..62a553c4f 100644 --- a/packages/widgets/src/firewall/component.tsx +++ b/packages/widgets/src/firewall/component.tsx @@ -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} /> - !error)} - selectedFirewall={activeFirewall} - isTiny={isTiny} - /> + + {activeFirewall && firewallHasError(activeFirewall) && ( + + + {t("common.error")} + + + )} + !error)} + selectedFirewall={activeFirewall} + isTiny={isTiny} + /> + )} @@ -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 ( diff --git a/packages/widgets/src/immich/albums-query.spec.ts b/packages/widgets/src/immich/albums-query.spec.ts index 377a22c1c..85faa58bc 100644 --- a/packages/widgets/src/immich/albums-query.spec.ts +++ b/packages/widgets/src/immich/albums-query.spec.ts @@ -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 }), + }, }, }, }, diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts index f80f4349c..013f207d8 100644 --- a/packages/widgets/src/manifest.spec.ts +++ b/packages/widgets/src/manifest.spec.ts @@ -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); diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index b1bf0e410..8f82fd265 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -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;