From 396b78aa3e03731a7f477d01c85adc173ae2b41b Mon Sep 17 00:00:00 2001 From: ajnart Date: Sat, 22 Aug 2026 09:00:57 +0200 Subject: [PATCH] revert: keep only minimal review fixes --- .../after-the-installation.mdx | 9 +- .../management/custom-widgets/custom-jsx.mdx | 4 +- .../custom-widgets/requests-and-security.mdx | 4 +- apps/nextjs/next.config.ts | 2 +- .../_custom-widget-form-utils.ts | 23 +- .../configuration-request/[token]/route.ts | 4 +- .../board/advanced-focus/context.tsx | 155 +++----- .../components/board/items/item-content.tsx | 17 +- .../board/items/widget-context-menu.tsx | 214 +++++------ .../layout/scaled-board-canvas.module.css | 14 +- .../board/layout/scaled-board-canvas.tsx | 17 +- .../board/sections/grid/grid-editor.css | 11 - .../api/src/custom-widget-configuration.ts | 1 - .../preview-action-procedures.ts | 2 +- .../custom-widget/preview-query-procedures.ts | 2 +- .../router/custom-widget/request-executor.ts | 15 +- .../router/custom-widget/response-cache.ts | 214 ----------- .../router/test/widgets/custom-api-v2.spec.ts | 4 +- packages/api/src/router/widgets/custom-api.ts | 20 +- packages/api/src/trpc.ts | 5 +- packages/common/src/request.ts | 22 -- packages/custom-widgets/src/core/index.ts | 1 - .../custom-widgets/src/core/request-limits.ts | 1 - .../custom-widgets/src/jsx/interpreter.tsx | 20 +- .../custom-widgets/src/jsx/jsx-emitter.tsx | 2 +- .../src/runtime/custom-jsx-renderer.tsx | 46 +-- .../src/runtime/refresh-button.tsx | 2 +- packages/custom-widgets/src/runtime/types.ts | 1 - packages/custom-widgets/src/server/index.ts | 1 + .../src/server/request-executor.ts | 59 ++- .../src/server/request-limits.ts | 2 +- .../src/server/request-timeout.ts | 9 - .../custom-widgets/src/server/response.ts | 10 - packages/onboarding/src/setup-studio.tsx | 350 ++++++++---------- packages/translation/src/lang/en.json | 1 - .../ui/src/components/integration-marquee.tsx | 33 +- .../src/custom-api/custom-jsx-display.tsx | 35 +- .../custom-api/widget-definition-context.tsx | 2 - scripts/entrypoint.sh | 45 +-- 39 files changed, 421 insertions(+), 958 deletions(-) delete mode 100644 packages/api/src/router/custom-widget/response-cache.ts delete mode 100644 packages/custom-widgets/src/core/request-limits.ts delete mode 100644 packages/custom-widgets/src/server/request-timeout.ts diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 3fda726e3..bcf9b92fa 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -53,7 +53,7 @@ The studio keeps related choices together: 1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, domain, or reverse-proxy path used by your services. URL suggestions can use separate ports, subdomains, or one path per service. 2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup. -3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it. Opening Review with an incomplete selected integration asks you to confirm that Homarr should skip it; the same confirmation applies to the timeline, keyboard navigation, and **Build my board**. +3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it. 4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets. 5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint. 6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct. @@ -213,11 +213,8 @@ otherwise the collision chain reflows neighboring tiles automatically. Every `1 × 1` tile has a logical content size of `200 × 200` pixels. Larger tiles use multiples of that fixed unit, with a consistent gap between cells. Homarr scales the complete board uniformly, like zooming a page. -The complete canvas always fits the available width without horizontal board scrolling. -Down to 50%, Homarr preserves the widget's readable typography and control density instead of shrinking each widget -independently. Below 50% it stops compensating and scales text and controls together with the board, like zooming out a -page, so tile content keeps its designed proportions instead of overlapping neighboring tiles. Add a narrow responsive -layout — Homarr already creates and reflows the protected Mobile layout — when a board has to stay readable on small screens. +The complete canvas always fits the available width without horizontal board scrolling. Below 100%, Homarr preserves the +widget's readable typography and control density instead of shrinking each widget independently. It does not squeeze or reflow the content of individual widgets, so a widget keeps the same design and proportions across screen sizes. On a narrow screen, Homarr uses the **Mobile layout** for the selected board and stacks the board content vertically while diff --git a/apps/docs/docs/management/custom-widgets/custom-jsx.mdx b/apps/docs/docs/management/custom-widgets/custom-jsx.mdx index 653112bdf..b1f8e6aff 100644 --- a/apps/docs/docs/management/custom-widgets/custom-jsx.mdx +++ b/apps/docs/docs/management/custom-widgets/custom-jsx.mdx @@ -61,8 +61,8 @@ than one variable quantifier are rejected. - `ActionButton` and `ToggleSwitch` run actions only when the widget is mounted outside edit mode. A toggle needs both enabled and disabled request parameters and rolls back its visual state if the action fails. - `RefreshButton` renders a compact, accessible icon control that invalidates the client's load-query cache so active load - queries can refetch; it does not execute a request itself. It is disabled for anonymous viewers, when the widget has no - active item or preview session, or while the board is in edit mode. + queries can refetch; it does not execute a request itself. It is disabled when the widget has no active item or preview + session, or while the board is in edit mode. - `Icon`/`TablerIcon` resolves installed safe icons. Always include responsive loading, empty, error, and success states. Runtime failures remain inside the widget tile. diff --git a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx index 35d5415be..f06362c8e 100644 --- a/apps/docs/docs/management/custom-widgets/requests-and-security.mdx +++ b/apps/docs/docs/management/custom-widgets/requests-and-security.mdx @@ -60,8 +60,8 @@ Queries default to view permission and actions to modify. DELETE always requires Management and runtime are intentionally separate. A Homarr administrator with board-modify permission places and configures the widget. After placement, query and action authorization follows the request's board permission. Public boards may execute queries for anonymous viewers, but mutations stay authenticated. This includes shared-cache -invalidation: `RefreshButton` is disabled for anonymous viewers, without an active item or preview session, and in edit -mode, while ordinary scheduled query rendering remains available to anonymous viewers. +invalidation: `RefreshButton` is disabled only without an active item or preview session, or in edit mode, while ordinary +scheduled query rendering remains available to anonymous viewers. Responses use bounded, versioned shared-Redis cache keys. Action invalidation advances the shared version so every Homarr replica stops serving the previous response without scanning or deleting a key prefix. diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 812596cd3..71e31402c 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -46,7 +46,7 @@ const nextConfig: NextConfig = { serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], - preloadEntriesOnStart: false, + preloadEntriesOnStart: true, turbopackFileSystemCacheForBuild: true, useTypeScriptCli: true, }, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index 1e90557b6..eba48aa67 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -1,8 +1,6 @@ import { fetchApi } from "@homarr/api/client"; -import { mapWithConcurrency } from "@homarr/common"; import { customWidgetDefinitionSchema, - CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, getCustomWidgetDefaultOptions, getCustomWidgetRequiredSecretKinds, parseCustomWidgetAiResponse, @@ -104,26 +102,25 @@ export function isRuntimeParams( } export async function loadPreviewQueries(definition: HomarrCustomWidgetV2, sessionId: string) { - const requests = Object.entries(definition.requests).filter( + const data: Record = {}; + const status: Record = {}; + for (const [requestId] of Object.entries(definition.requests).filter( ([, entry]) => entry.kind === "query" && entry.trigger === "load", - ); - const entries = await mapWithConcurrency(requests, CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, async ([requestId]) => { + )) { try { const result = await fetchApi.customWidget.previewQuery.query({ sessionId, requestId, params: {} }); - return [requestId, result.data, { loading: false, ...result }] as const; + data[requestId] = result.data; + status[requestId] = { loading: false, ...result }; } catch (error) { - const failedStatus = { + data[requestId] = null; + status[requestId] = { loading: false, ok: false, error: error instanceof Error ? error.message : "Request failed", }; - return [requestId, null, failedStatus] as const; } - }); - return { - data: Object.fromEntries(entries.map(([requestId, data]) => [requestId, data])), - status: Object.fromEntries(entries.map(([requestId, , status]) => [requestId, status])), - }; + } + return { data, status }; } export function getDefinitionDefaults(definition: HomarrCustomWidgetV2) { diff --git a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts index ca1ac8a29..2e60d4b33 100644 --- a/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts +++ b/apps/nextjs/src/app/api/custom-widgets/configuration-request/[token]/route.ts @@ -7,10 +7,10 @@ import { configureCustomWidgetSourceFromRequest, configurePreviewSessionSource, getCustomWidgetConfigurationRequest, - invalidateCustomWidgetResponseCache, releaseCustomWidgetConfigurationRequest, } from "@homarr/api/custom-widget-configuration"; import { customWidgetSourceSchema } from "@homarr/custom-widgets/core"; +import { invalidateCustomWidgetResponseCache } from "@homarr/custom-widgets/server"; import { db } from "@homarr/db"; import { adminRoute } from "../../admin"; @@ -90,7 +90,7 @@ const completeConfigurationRequest = async (request: NextRequest, context: Route sourceResult.data, secrets, ); - await invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]); + invalidateCustomWidgetResponseCache([`custom-jsx:preview:${claimed.target.id}:`]); } else { const result = await configureCustomWidgetSourceFromRequest(db, { definitionId: claimed.target.id, diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 1fb4e1369..cc218e4c4 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -1,16 +1,7 @@ "use client"; import type { CSSProperties, PropsWithChildren } from "react"; -import { - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Overlay, Portal } from "@mantine/core"; import { useIsomorphicEffect, useViewportSize } from "@mantine/hooks"; @@ -37,12 +28,9 @@ interface OpenAdvancedFocusOptions { restoreFocusTarget?: HTMLElement; } -interface AdvancedFocusState { +interface AdvancedFocusContextValue { active: ActiveFocus | null; viewportSize: { width: number; height: number }; -} - -interface AdvancedFocusActions { open: (itemId: string, source: HTMLElement, options?: OpenAdvancedFocusOptions) => void; close: (restoreFocus?: boolean) => void; dismiss: (itemId: string) => void; @@ -50,17 +38,7 @@ interface AdvancedFocusActions { leave: (itemId: string) => void; } -interface AdvancedFocusStore { - getSnapshot: () => AdvancedFocusState; - subscribe: (listener: () => void) => () => void; -} - -const AdvancedFocusActionsContext = createContext(null); -const AdvancedFocusStoreContext = createContext(null); -const INACTIVE_FOCUS_STATE: AdvancedFocusState = { - active: null, - viewportSize: { width: 0, height: 0 }, -}; +const AdvancedFocusContext = createContext(null); const HOVER_DELAY_MS = 500; const POINTER_TRANSITION_GRACE_MS = 80; const CLOSE_DURATION_MS = 180; @@ -84,15 +62,6 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { const holdIndicatorRef = useRef(null); const pointerPositionRef = useRef({ x: -100, y: -100 }); const pointerFrameRef = useRef(null); - const focusStateRef = useRef({ active: null, viewportSize }); - const focusListenersRef = useRef(new Set<() => void>()); - const [focusStore] = useState(() => ({ - getSnapshot: () => focusStateRef.current, - subscribe: (listener) => { - focusListenersRef.current.add(listener); - return () => focusListenersRef.current.delete(listener); - }, - })); const cancelHoverTimer = useCallback(() => { if (hoverTimerRef.current === null) return; @@ -394,83 +363,55 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { }; }, [active?.activation]); - useIsomorphicEffect(() => { - focusStateRef.current = { active, viewportSize }; - for (const listener of focusListenersRef.current) listener(); - }, [active, viewportSize]); - - const actions = useMemo(() => ({ open, close, dismiss, hover, leave }), [close, dismiss, hover, leave, open]); + const value = useMemo( + () => ({ active, viewportSize, open, close, dismiss, hover, leave }), + [active, close, dismiss, hover, leave, open, viewportSize], + ); return ( - - - {children} - {isHoldPending && ( - -
- - - - - ⇧ -
-
- )} - {active?.activation === "preview" && ( - - - - )} -
-
+ + {children} + {isHoldPending && ( + +
+ + + + + ⇧ +
+
+ )} + {active?.activation === "preview" && ( + + + + )} +
); }; -const useAdvancedFocusStore = () => { - const store = useContext(AdvancedFocusStoreContext); - if (!store) throw new Error("Advanced focus state must be used within BoardAdvancedFocusProvider"); - return store; -}; - -export const useAdvancedFocusActions = () => { - const actions = useContext(AdvancedFocusActionsContext); - if (!actions) throw new Error("Advanced focus actions must be used within BoardAdvancedFocusProvider"); - return actions; -}; - -export const useAdvancedFocusItem = (itemId: string | null) => { - const store = useAdvancedFocusStore(); - const getItemSnapshot = useCallback(() => { - const snapshot = store.getSnapshot(); - if (itemId && snapshot.active?.itemId === itemId) return snapshot; - return INACTIVE_FOCUS_STATE; - }, [itemId, store]); - - return useSyncExternalStore(store.subscribe, getItemSnapshot, () => INACTIVE_FOCUS_STATE); -}; - export const useAdvancedFocus = () => { - const store = useAdvancedFocusStore(); - const state = useSyncExternalStore(store.subscribe, store.getSnapshot, () => INACTIVE_FOCUS_STATE); - const actions = useAdvancedFocusActions(); - return useMemo(() => ({ ...state, ...actions }), [actions, state]); + const context = useContext(AdvancedFocusContext); + if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider"); + return context; }; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 882b0a094..a8a394b92 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -28,7 +28,7 @@ import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/ import type { SectionItem } from "~/app/[locale]/boards/_types"; import { getLogicalTrackSize } from "~/components/board/layout"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; -import { useAdvancedFocusActions, useAdvancedFocusItem } from "../advanced-focus/context"; +import { useAdvancedFocus } from "../advanced-focus/context"; import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; @@ -176,14 +176,13 @@ const LoadedBoardItemContent = ({ ); const [manualSurface, setManualSurface] = useState(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState(null); - const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); - const focusItemId = supportsAdvancedFocus ? item.id : null; - const { active: activeFocus, viewportSize } = useAdvancedFocusItem(focusItemId); - const { open, close, dismiss, hover, leave } = useAdvancedFocusActions(); + const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus(); const { width: viewportWidth, height: viewportHeight } = viewportSize; + const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); const widgetName = getWidgetName(item.kind, t); const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); const advancedViewId = `advanced-focus-${item.id}`; + const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; const isAdvanced = activeFocus !== null; const advancedRect = activeFocus ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) @@ -352,7 +351,13 @@ const LoadedBoardItemContent = ({ return ( <> {isEditMode && } - + | null>; widgetRuntimeRef: WidgetRuntimeRef; sourceRef: RefObject; children: ReactNode; @@ -47,44 +48,6 @@ export const WidgetContextMenu = ({ sourceRef, children, }: WidgetContextMenuProps) => { - const { data: session } = useSession(); - const [isEditMode] = useEditMode(); - const settings = useSettings(); - const [menuOpened, setMenuOpened] = useState(false); - - if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}; - - return ( - - {children} - {menuOpened && ( - - )} - - ); -}; - -type WidgetContextMenuDropdownProps = Omit; - -const WidgetContextMenuDropdown = ({ - item, - definition, - widgetRuntimeRef, - sourceRef, -}: WidgetContextMenuDropdownProps) => { const { data: session } = useSession(); const [isEditMode] = useEditMode(); const board = useRequiredBoard(); @@ -104,8 +67,9 @@ const WidgetContextMenuDropdown = ({ hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false)); const canOpenAdvancedFocus = supportsAdvancedFocus(definition); const queryClient = useQueryClient(); - const { open: openAdvancedFocus } = useAdvancedFocusActions(); + const { open: openAdvancedFocus } = useAdvancedFocus(); const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); + const [menuOpened, setMenuOpened] = useState(false); const persistBoard = useCallback( (updater: (previous: typeof board) => typeof board) => { @@ -250,89 +214,103 @@ const WidgetContextMenuDropdown = ({ [setItemOptions], ); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}; + const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden); return ( - - {canOpenAdvancedFocus && ( - <> - } - onClick={() => { - if (sourceRef.current) - openAdvancedFocus(item.id, sourceRef.current, { - restoreFocusTarget: - sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? sourceRef.current, - }); - }} - > - {t("item.advancedFocus.open")} - - - - )} + + {children} + + {canOpenAdvancedFocus && ( + <> + } + onClick={() => { + if (sourceRef.current) + openAdvancedFocus(item.id, sourceRef.current, { + restoreFocusTarget: + sourceRef.current.querySelector("[data-advanced-focus-trigger]") ?? + sourceRef.current, + }); + }} + > + {t("item.advancedFocus.open")} + + + + )} - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - - {t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)} - - ))} - - )} + {toggleOptions.length > 0 && ( + <> + {tMenu("options")} + {toggleOptions.map(([key]) => ( + + {t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)} + + ))} + + )} - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {translateIfNecessary(t, action.label)} - - ); - })} - - )} + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {translateIfNecessary(t, action.label)} + + ); + })} + + )} - {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } - : } - onClick={handleRefetch} - disabled={isWidgetFetching} - > - - {tCommon("refresh")} - - - - } - onClick={openEditModal} - onFocus={preloadWidgetEditModal} - onPointerEnter={preloadWidgetEditModal} - disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)} - > - {tMenu("settings")} - - + {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } + : } + onClick={handleRefetch} + disabled={isWidgetFetching} + > + + {tCommon("refresh")} + + + + } + onClick={openEditModal} + onFocus={preloadWidgetEditModal} + onPointerEnter={preloadWidgetEditModal} + disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)} + > + {tMenu("settings")} + + + ); }; diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css index 2f771122f..feb2a8ac5 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css @@ -2,12 +2,10 @@ width: 100%; max-width: 100%; min-width: 0; + overflow: clip; +} - /* - * The canvas always scales to fit, so board content must never widen the - * document or scroll sideways. `clip` guarantees that without creating a - * scroll container. - */ +.viewport[data-board-hydrated="false"] { overflow: clip; } @@ -21,10 +19,8 @@ /* * Keep dashboard UI at its normal readable density while the fixed canvas - * is fitted below 100%, until the compensation is capped (see - * MAX_BOARD_UI_COMPENSATION) and the board zooms out as a whole instead. - * Above 100%, the canvas behaves like browser zoom and enlarges everything - * together. + * is fitted below 100%. Above 100%, the canvas still behaves like browser + * zoom and enlarges everything together. */ --mantine-scale: var(--board-canvas-ui-scale, 1); --mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1)); diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx index 14bb1a59f..d9bc3a636 100644 --- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx +++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx @@ -8,18 +8,6 @@ import { useElementSize } from "@mantine/hooks"; import classes from "./scaled-board-canvas.module.css"; const BoardCanvasScaleContext = createContext(1); -/** - * Below 100% the board keeps dashboard UI at its normal physical size by - * inverse-scaling it against the canvas. That compensation cannot continue - * forever: at 0.14 a `200 x 200` logical tile still has to host controls sized - * for a desktop, so labels, tables and buttons overlap their neighbours. - * - * Stop compensating at 50% and let the remaining reduction behave like zooming - * out a page. Tile content then keeps the proportions it was designed with - * instead of overflowing its tile, and the canvas can still always fit the - * viewport - the board never scrolls sideways. - */ -const MAX_BOARD_UI_COMPENSATION = 2; export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext); @@ -32,9 +20,8 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: export const calculateBoardUiScale = (canvasScale: number) => { if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1; - if (canvasScale >= 1) return 1; - return Math.min(1 / canvasScale, MAX_BOARD_UI_COMPENSATION); + return canvasScale < 1 ? 1 / canvasScale : 1; }; interface ScaledBoardCanvasProps { @@ -73,6 +60,7 @@ export const ScaledBoardCanvas = ({ const uiScale = calculateBoardUiScale(scale); const inverseScale = scale > 0 ? 1 / scale : 1; const visualWidth = logicalWidth * scale; + const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5; return ( `custom-jsx:preview:${session.id}:${requestId}:`), ); } diff --git a/packages/api/src/router/custom-widget/preview-query-procedures.ts b/packages/api/src/router/custom-widget/preview-query-procedures.ts index 1c549d01b..4bb841c13 100644 --- a/packages/api/src/router/custom-widget/preview-query-procedures.ts +++ b/packages/api/src/router/custom-widget/preview-query-procedures.ts @@ -19,7 +19,7 @@ export const previewQueryProcedures = { .input(z.object({ sessionId: z.string().min(1) })) .mutation(async ({ ctx, input }) => { const session = await getPreviewSession(input.sessionId, ctx.session.user.id); - await invalidateCustomWidgetResponseCache([`custom-jsx:preview:${session.id}:`]); + invalidateCustomWidgetResponseCache([`custom-jsx:preview:${session.id}:`]); }), previewQuery: permissionRequiredProcedure diff --git a/packages/api/src/router/custom-widget/request-executor.ts b/packages/api/src/router/custom-widget/request-executor.ts index 0a6440c03..a00946eb8 100644 --- a/packages/api/src/router/custom-widget/request-executor.ts +++ b/packages/api/src/router/custom-widget/request-executor.ts @@ -16,7 +16,6 @@ import { import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server"; import { toTrpcError } from "./domain-error"; -import { executeWithCustomWidgetResponseCache, invalidateSharedCustomWidgetResponseCache } from "./response-cache"; export type { CustomWidgetAuthConfig, @@ -75,18 +74,14 @@ export async function resolveAndValidateHost(hostname: string, scope: CustomWidg export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest): Promise { try { - return await executeWithCustomWidgetResponseCache(input, (request) => - executeDomainRequest({ - ...request, - logError: (event) => logger.error("Custom widget request failed", event), - }), - ); + return await executeDomainRequest({ + ...input, + logError: (event) => logger.error("Custom widget request failed", event), + }); } catch (error) { toTrpcError(error); } } -export async function invalidateCustomWidgetResponseCache(prefixes: readonly string[]) { +export const invalidateCustomWidgetResponseCache = (prefixes: readonly string[]) => invalidateDomainResponseCache(prefixes); - await invalidateSharedCustomWidgetResponseCache(prefixes); -} diff --git a/packages/api/src/router/custom-widget/response-cache.ts b/packages/api/src/router/custom-widget/response-cache.ts deleted file mode 100644 index 146bd32ea..000000000 --- a/packages/api/src/router/custom-widget/response-cache.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Buffer } from "node:buffer"; -import { createHash } from "node:crypto"; - -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createRedisClient } from "@homarr/core/infrastructure/redis"; -import type { RedisClient } from "@homarr/core/infrastructure/redis"; -import type { CustomWidgetHttpRequest, CustomWidgetHttpResponse } from "@homarr/custom-widgets/server"; - -const ENTRY_PREFIX = "custom-widget:response-cache:v2:entry:"; -const GENERATION_PREFIX = "custom-widget:response-cache:v2:generation:"; -const LRU_KEY = "custom-widget:response-cache:v2:lru"; -const SIZE_KEY = "custom-widget:response-cache:v2:sizes"; -const TOTAL_BYTES_KEY = "custom-widget:response-cache:v2:bytes"; -// Outlive the one-hour response TTL and 45-second request deadline so a -// generation cannot reset while an entry from the previous one is reachable. -const GENERATION_RETENTION_SECONDS = 2 * 60 * 60; -const MAX_ENTRIES = 1_000; -const MAX_BYTES = 64 * 1024 * 1024; - -const INVALIDATE_GENERATIONS_SCRIPT = ` -for _, key in ipairs(KEYS) do - redis.call('INCR', key) - redis.call('EXPIRE', key, ARGV[1]) -end -return #KEYS -`; - -// Reading has to touch the LRU on a hit and reclaim the byte/LRU/size -// bookkeeping on a miss: entry keys carry their own PX expiry, so a TTL-expired -// response would otherwise keep occupying a slot and its bytes forever and -// shrink the usable cache to nothing over a long uptime. -const READ_SCRIPT = ` -local value = redis.call('GET', KEYS[1]) -if value then - redis.call('ZADD', KEYS[2], ARGV[1], KEYS[1]) - return value -end -local size = tonumber(redis.call('HGET', KEYS[3], KEYS[1]) or '0') -if size > 0 then redis.call('DECRBY', KEYS[4], size) end -redis.call('ZREM', KEYS[2], KEYS[1]) -redis.call('HDEL', KEYS[3], KEYS[1]) -return false -`; - -const FORGET_SCRIPT = ` -local size = tonumber(redis.call('HGET', KEYS[3], KEYS[1]) or '0') -if size > 0 then redis.call('DECRBY', KEYS[4], size) end -redis.call('DEL', KEYS[1]) -redis.call('ZREM', KEYS[2], KEYS[1]) -redis.call('HDEL', KEYS[3], KEYS[1]) -return 1 -`; - -const STORE_SCRIPT = ` -local previousSize = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0') -if previousSize > 0 then redis.call('DECRBY', KEYS[3], previousSize) end -redis.call('SET', ARGV[1], ARGV[2], 'PX', ARGV[3]) -redis.call('ZADD', KEYS[1], ARGV[4], ARGV[1]) -redis.call('HSET', KEYS[2], ARGV[1], ARGV[5]) -local totalBytes = redis.call('INCRBY', KEYS[3], ARGV[5]) -while redis.call('ZCARD', KEYS[1]) > tonumber(ARGV[6]) or totalBytes > tonumber(ARGV[7]) do - local evicted = redis.call('ZPOPMIN', KEYS[1], 1) - if #evicted == 0 then break end - local evictedKey = evicted[1] - local evictedSize = tonumber(redis.call('HGET', KEYS[2], evictedKey) or '0') - redis.call('DEL', evictedKey) - redis.call('HDEL', KEYS[2], evictedKey) - if evictedSize > 0 then totalBytes = redis.call('DECRBY', KEYS[3], evictedSize) end -end -return totalBytes -`; - -const logger = createLogger({ module: "custom-widget:response-cache" }); -const inFlight = new Map }>(); -let redis: RedisClient | undefined; - -const useSharedCache = () => process.env.NODE_ENV !== "test"; - -const getRedis = () => { - redis ??= createRedisClient(); - return redis; -}; - -const getGenerationKey = (prefix: string) => `${GENERATION_PREFIX}${createHash("sha256").update(prefix).digest("hex")}`; - -const getCacheKeyPrefixes = (cacheKey: string) => { - const prefixes: string[] = []; - let separatorIndex = cacheKey.indexOf(":"); - while (separatorIndex >= 0) { - prefixes.push(cacheKey.slice(0, separatorIndex + 1)); - separatorIndex = cacheKey.indexOf(":", separatorIndex + 1); - } - prefixes.push(cacheKey); - return prefixes; -}; - -const getGeneration = async (client: RedisClient, cacheKey: string) => { - const keys = getCacheKeyPrefixes(cacheKey).map(getGenerationKey); - const generations = await client.mget(...keys); - return generations.map((generation) => generation ?? "0").join(":"); -}; - -const getEntryKey = (generation: string, cacheKey: string) => { - const digest = createHash("sha256").update(cacheKey).digest("hex"); - return `${ENTRY_PREFIX}${generation}:${digest}`; -}; - -const readCachedResponse = async (client: RedisClient, entryKey: string) => { - const serialized = (await client.eval(READ_SCRIPT, 4, entryKey, LRU_KEY, SIZE_KEY, TOTAL_BYTES_KEY, Date.now())) as - | string - | null; - if (serialized === null) return undefined; - try { - return JSON.parse(serialized) as CustomWidgetHttpResponse; - } catch { - await client.eval(FORGET_SCRIPT, 4, entryKey, LRU_KEY, SIZE_KEY, TOTAL_BYTES_KEY); - return undefined; - } -}; - -const storeCachedResponse = async ( - client: RedisClient, - entryKey: string, - response: CustomWidgetHttpResponse, - ttlSeconds: number, -) => { - const serialized = JSON.stringify(response); - const size = Buffer.byteLength(serialized, "utf8"); - await client.eval( - STORE_SCRIPT, - 3, - LRU_KEY, - SIZE_KEY, - TOTAL_BYTES_KEY, - entryKey, - serialized, - ttlSeconds * 1_000, - Date.now(), - size, - MAX_ENTRIES, - MAX_BYTES, - ); -}; - -export async function executeWithCustomWidgetResponseCache( - input: CustomWidgetHttpRequest, - execute: (input: CustomWidgetHttpRequest) => Promise, -): Promise { - const cacheKey = input.kind === "query" ? input.cacheKey : undefined; - if (!cacheKey) return execute(input); - if (!useSharedCache()) return execute(input); - - let client: RedisClient; - let generation: string; - try { - client = getRedis(); - generation = await getGeneration(client, cacheKey); - if ((input.cacheTtlSeconds ?? 0) > 0) { - const cached = await readCachedResponse(client, getEntryKey(generation, cacheKey)); - if (cached) return cached; - } - } catch (error) { - logger.error("Shared custom widget response cache is unavailable", { - errorName: error instanceof Error ? error.name : "UnknownError", - }); - // Keep the cache key so the caller's process-local cache still absorbs - // polling while Redis is down. Dropping it would send every refresh of - // every placed widget straight to the upstream API. - return execute(input); - } - - const pendingKey = `${generation}:${cacheKey}`; - const pending = inFlight.get(pendingKey); - if (pending) return pending.request; - - let request: Promise; - request = execute({ ...input, cacheKey: undefined }) - .then(async (response) => { - const ttlSeconds = input.cacheTtlSeconds ?? 0; - if (!response.ok || ttlSeconds <= 0) return response; - try { - const currentGeneration = await getGeneration(client, cacheKey); - if (currentGeneration === generation) { - await storeCachedResponse(client, getEntryKey(generation, cacheKey), response, ttlSeconds); - } - } catch (error) { - logger.error("Failed to store a custom widget response", { - errorName: error instanceof Error ? error.name : "UnknownError", - }); - } - return response; - }) - .finally(() => { - if (inFlight.get(pendingKey)?.request === request) inFlight.delete(pendingKey); - }); - inFlight.set(pendingKey, { cacheKey, request }); - return request; -} - -export async function invalidateSharedCustomWidgetResponseCache(prefixes: readonly string[]) { - if (prefixes.length === 0 || !useSharedCache()) return; - const uniquePrefixes = [...new Set(prefixes)]; - for (const [key, pending] of inFlight) { - if (uniquePrefixes.some((prefix) => pending.cacheKey.startsWith(prefix))) inFlight.delete(key); - } - try { - const keys = uniquePrefixes.map(getGenerationKey); - await getRedis().eval(INVALIDATE_GENERATIONS_SCRIPT, keys.length, ...keys, GENERATION_RETENTION_SECONDS); - } catch (error) { - logger.error("Failed to invalidate the shared custom widget response cache", { - errorName: error instanceof Error ? error.name : "UnknownError", - }); - } -} diff --git a/packages/api/src/router/test/widgets/custom-api-v2.spec.ts b/packages/api/src/router/test/widgets/custom-api-v2.spec.ts index 680193193..4d85250b5 100644 --- a/packages/api/src/router/test/widgets/custom-api-v2.spec.ts +++ b/packages/api/src/router/test/widgets/custom-api-v2.spec.ts @@ -118,8 +118,8 @@ describe("Custom JSX v2 board router", () => { }); test("refreshes the server response cache for a placed widget", async () => { - const { db, itemId, ownerId } = await setup(); - const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: createSession(ownerId) }); + const { db, itemId } = await setup(); + const caller = customApiRouter.createCaller({ db, deviceType: undefined, session: null }); await caller.refresh({ itemId }); diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 43abce16d..7f3994fbd 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -2,7 +2,6 @@ import { TRPCError } from "@trpc/server"; import { parse as parseSuperJson } from "superjson"; import { z } from "zod/v4"; -import { mapWithConcurrency } from "@homarr/common"; import { decryptSecret } from "@homarr/common/server"; import { eq } from "@homarr/db"; import { boards, customWidgetDefinitions, items, legacyCustomWidgetDefinitions } from "@homarr/db/schema"; @@ -14,7 +13,6 @@ import { validateCustomWidgetOptions, } from "@homarr/custom-widgets/core"; import type { CustomJsxRequest, CustomWidgetSource } from "@homarr/custom-widgets/core"; -import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "@homarr/custom-widgets/core"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; @@ -42,7 +40,7 @@ interface CustomWidgetItemOptions { refreshInterval?: number; } -type RouterContext = Parameters[0] & { ipAddress?: string }; +type RouterContext = Parameters[0]; type ResolvedDefinition = Awaited>; const parseItemOptions = (raw: string): CustomWidgetItemOptions => { @@ -156,7 +154,6 @@ const withRequestLimit = async ( const release = await acquireCustomWidgetRequestLimit({ category: request.kind === "query" ? "query" : request.method === "DELETE" ? "delete" : "action", userId: ctx.session?.user.id, - anonymousId: ctx.ipAddress, itemId: resolved.item.id, definitionId: resolved.stored.id, }); @@ -197,9 +194,9 @@ const executeRequest = async ( }; export const customApiRouter = createTRPCRouter({ - refresh: protectedProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => { + refresh: publicProcedure.input(itemInputSchema).mutation(async ({ ctx, input }) => { const resolved = await resolvePlacedDefinitionAsync(ctx, input.itemId); - await invalidateCustomWidgetResponseCache([ + invalidateCustomWidgetResponseCache([ `custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:`, ]); }), @@ -209,10 +206,8 @@ export const customApiRouter = createTRPCRouter({ const loadRequests = Object.entries(resolved.definition.requests).filter( ([, request]) => request.kind === "query" && request.trigger === "load", ); - const entries = await mapWithConcurrency( - loadRequests, - CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, - async ([requestId, request]) => { + const entries = await Promise.all( + loadRequests.map(async ([requestId, request]) => { try { const response = await executeRequest(ctx, resolved, { id: requestId, ...request }, {}); return [ @@ -242,13 +237,12 @@ export const customApiRouter = createTRPCRouter({ }, ] as const; } - }, + }), ); return { type: "customJsx" as const, template: resolved.definition.template, - canRefresh: ctx.session !== null, data: Object.fromEntries(entries.map(([id, result]) => [id, result.data])), status: Object.fromEntries(entries.map(([id, result]) => [id, result.status])), options: resolved.configuration, @@ -291,7 +285,7 @@ export const customApiRouter = createTRPCRouter({ } const response = await executeRequest(ctx, resolved, request, input.params); if (response.ok && request.invalidates?.length) { - await invalidateCustomWidgetResponseCache( + invalidateCustomWidgetResponseCache( request.invalidates.flatMap((requestId) => [ `custom-jsx:${resolved.item.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`, `custom-widget:options:${resolved.stored.id}:${getCustomWidgetCacheVersion(resolved.stored)}:${requestId}:`, diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 4bff80e4a..2c0936dbb 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -13,7 +13,7 @@ import { ZodError } from "zod/v4"; import type { Session } from "@homarr/auth"; import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common"; -import { trustedIpAddressFromHeaders, userAgent } from "@homarr/common/server"; +import { userAgent } from "@homarr/common/server"; import type { DeviceType } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { db } from "@homarr/db"; @@ -43,8 +43,6 @@ interface ApiContext { deviceType: DeviceType; baseUrl?: `${string}://${string}`; onboardingClaimToken?: string; - /** Trusted transport peer, safe to key rate limits and quotas on. */ - ipAddress?: string; db: typeof db; } @@ -62,7 +60,6 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n session, deviceType: userAgent(opts.headers).device.type, baseUrl: extractBaseUrlFromHeaders(opts.headers), - ipAddress: trustedIpAddressFromHeaders(opts.headers) ?? undefined, onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")), db, }; diff --git a/packages/common/src/request.ts b/packages/common/src/request.ts index a0224c75d..f90d91945 100644 --- a/packages/common/src/request.ts +++ b/packages/common/src/request.ts @@ -10,28 +10,6 @@ export const userAgent = (headers: Headers) => { export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined; -/** - * The client-reported forwarding chain, verbatim. Behind an additional reverse - * proxy this still contains the originating client, so it is the useful value - * for audit logging and diagnostics. A caller can put anything in front of the - * chain, so never key a limit or quota on it. - */ export const ipAddressFromHeaders = (headers: Headers): string | null => { return headers.get("x-forwarded-for"); }; - -/** - * The last hop of the forwarding chain. The bundled nginx proxy appends its own - * transport peer, so this is the only entry a caller cannot choose, which makes - * it the right identity for rate limits and concurrency budgets. - * - * Behind an additional reverse proxy the last hop is that proxy, so every client - * behind it shares one identity. Distinguishing them needs an explicit - * trusted-proxy configuration, which Homarr does not have yet. - */ -export const trustedIpAddressFromHeaders = (headers: Headers): string | null => { - const forwardedFor = headers.get("x-forwarded-for"); - if (!forwardedFor) return null; - - return forwardedFor.split(",").at(-1)?.trim() || null; -}; diff --git a/packages/custom-widgets/src/core/index.ts b/packages/custom-widgets/src/core/index.ts index 78347d551..05f371794 100644 --- a/packages/custom-widgets/src/core/index.ts +++ b/packages/custom-widgets/src/core/index.ts @@ -2,7 +2,6 @@ export * from "./bundled-widgets"; export * from "./examples"; export * from "./import"; export * from "./options"; -export * from "./request-limits"; export * from "./schema"; export * from "./schema-references"; export * from "./secret-requirements"; diff --git a/packages/custom-widgets/src/core/request-limits.ts b/packages/custom-widgets/src/core/request-limits.ts deleted file mode 100644 index ac36ad2b1..000000000 --- a/packages/custom-widgets/src/core/request-limits.ts +++ /dev/null @@ -1 +0,0 @@ -export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4; diff --git a/packages/custom-widgets/src/jsx/interpreter.tsx b/packages/custom-widgets/src/jsx/interpreter.tsx index 92f7e7bc0..29e01e931 100644 --- a/packages/custom-widgets/src/jsx/interpreter.tsx +++ b/packages/custom-widgets/src/jsx/interpreter.tsx @@ -24,10 +24,6 @@ export interface RenderSafeJsxOptions { budgets?: SafeJsxBudgets; } -export interface RenderCompiledSafeJsxOptions extends Omit { - root: AstNode; -} - export interface SafeJsxRenderResult { node: ReactNode; warnings: string[]; @@ -355,23 +351,11 @@ class Interpreter { } } -export function compileSafeJsx(template: string): AstNode { - return parseCustomJsxTemplate(template); -} - -export function renderCompiledSafeJsx({ - root, - components, - bindings, - budgets, -}: RenderCompiledSafeJsxOptions): SafeJsxRenderResult { +export function renderSafeJsx({ template, components, bindings, budgets }: RenderSafeJsxOptions): SafeJsxRenderResult { const limits: EvaluationBudgets = { ...DEFAULT_BUDGETS, ...budgets }; for (const [name, value] of Object.entries(limits)) { if (!Number.isSafeInteger(value) || value <= 0) throw new SafeJsxError(`Invalid interpreter budget: ${name}`); } + const root = parseCustomJsxTemplate(template); return new Interpreter(components, new Environment(bindings), limits).render(root); } - -export function renderSafeJsx({ template, ...options }: RenderSafeJsxOptions): SafeJsxRenderResult { - return renderCompiledSafeJsx({ ...options, root: compileSafeJsx(template) }); -} diff --git a/packages/custom-widgets/src/jsx/jsx-emitter.tsx b/packages/custom-widgets/src/jsx/jsx-emitter.tsx index 7a821acd5..8191b72f9 100644 --- a/packages/custom-widgets/src/jsx/jsx-emitter.tsx +++ b/packages/custom-widgets/src/jsx/jsx-emitter.tsx @@ -68,7 +68,7 @@ export function emitJsxElement( child.type === "JSXExpressionContainer" ? asNode(child.expression, "JSX child expression") : null; if (resolvedTag === "SubFetch" && expression?.type === "ArrowFunctionExpression") { const callback = context.createCallback(expression, environment); - return (value: unknown, metadata: unknown) => context.renderCallback(callback, [value, metadata], depth + 1); + return (value: unknown) => context.renderCallback(callback, [value], depth + 1); } return context.evaluate(child, environment, depth + 1); }); diff --git a/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx b/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx index e09656734..4ff277810 100644 --- a/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx +++ b/packages/custom-widgets/src/runtime/custom-jsx-renderer.tsx @@ -3,14 +3,12 @@ import { Component, useCallback, useEffect, useId, useMemo, useState } from "rea import { Alert, Box, Stack, Text } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; -import { compileSafeJsx, renderCompiledSafeJsx } from "../jsx/interpreter"; +import { renderSafeJsx } from "../jsx/interpreter"; import { CustomJsxInputsProvider } from "../jsx/runtime-components"; import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components"; import type { CustomJsxRequestCapability } from "./types"; const EMPTY_RECORD: Record = {}; -const referenceIds = new WeakMap(); -let nextReferenceId = 1; const methodColors: Readonly> = { GET: "blue", @@ -164,27 +162,12 @@ function CustomJsxRendererSession({ }, [inputTypes], ); - const compiled = useMemo(() => { - try { - return { root: compileSafeJsx(template), error: null }; - } catch (error) { - return { root: null, error: error instanceof Error ? error : new Error(String(error)) }; - } - }, [template]); const rendered = useMemo(() => { - if (compiled.error || !compiled.root) { - return { - node: null, - warnings: [], - boundaryKey: `${template.length}:compile-error`, - error: compiled.error ?? new Error("Template compilation failed"), - }; - } try { const bindings = { ...createBindings(data), status, options, inputs }; return { - ...renderCompiledSafeJsx({ root: compiled.root, components, bindings }), - boundaryKey: createBoundaryKey(template, [data, status, options, inputs]), + ...renderSafeJsx({ template, components, bindings }), + boundaryKey: createBoundaryKey(template, bindings), error: null, }; } catch (error) { @@ -195,7 +178,7 @@ function CustomJsxRendererSession({ error: error instanceof Error ? error : new Error(String(error)), }; } - }, [compiled, components, createBindings, data, inputs, options, status, template]); + }, [components, createBindings, data, inputs, options, status, template]); useEffect(() => setParseErrors([]), [rendered.boundaryKey, template]); const handleError = useCallback( (error: Error) => @@ -245,20 +228,9 @@ function CustomJsxRendererSession({ ); } -function createBoundaryKey(template: string, values: readonly unknown[]) { - return `${template.length}:${values.map(referenceKey).join(":")}`; -} - -function referenceKey(value: unknown): string { - if ((typeof value === "object" && value !== null) || typeof value === "function") { - const reference = value as object; - let id = referenceIds.get(reference); - if (!id) { - id = nextReferenceId; - nextReferenceId += 1; - referenceIds.set(reference, id); - } - return `r${id}`; - } - return `${typeof value}:${String(value)}`; +function createBoundaryKey(template: string, bindings: Readonly>) { + let hash = 0; + const value = `${template}\0${JSON.stringify(bindings)}`; + for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) | 0; + return `${template.length}:${hash}`; } diff --git a/packages/custom-widgets/src/runtime/refresh-button.tsx b/packages/custom-widgets/src/runtime/refresh-button.tsx index af331fe2c..69a973177 100644 --- a/packages/custom-widgets/src/runtime/refresh-button.tsx +++ b/packages/custom-widgets/src/runtime/refresh-button.tsx @@ -37,7 +37,7 @@ export function RefreshButton({ label, color = "gray", variant = "subtle", size size={size as never} onClick={() => void refresh()} loading={loading} - disabled={!runtime.canRefresh || (!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode} + disabled={(!runtime.itemId && !runtime.previewSessionId) || runtime.isEditMode} > diff --git a/packages/custom-widgets/src/runtime/types.ts b/packages/custom-widgets/src/runtime/types.ts index 0cb00e111..a72ba836b 100644 --- a/packages/custom-widgets/src/runtime/types.ts +++ b/packages/custom-widgets/src/runtime/types.ts @@ -82,7 +82,6 @@ export interface CustomWidgetRuntimeValue { previewSessionId?: string; previewLiveActions?: boolean; queriesDisabled?: boolean; - canRefresh?: boolean; isEditMode: boolean; requestCapabilities: readonly CustomJsxRequestCapability[]; port: CustomWidgetRuntimePort; diff --git a/packages/custom-widgets/src/server/index.ts b/packages/custom-widgets/src/server/index.ts index 5d4d15b63..227aa28fa 100644 --- a/packages/custom-widgets/src/server/index.ts +++ b/packages/custom-widgets/src/server/index.ts @@ -19,6 +19,7 @@ export type { CustomWidgetAuthConfig, CustomWidgetHttpRequest, CustomWidgetHttpR export { CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT, CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS, + CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT, CustomWidgetRequestLimiter, } from "./request-limits"; export type { RequestLimitInput, RequestLimiterOptions, RequestLimitStore } from "./request-limits"; diff --git a/packages/custom-widgets/src/server/request-executor.ts b/packages/custom-widgets/src/server/request-executor.ts index 82f660e36..db7976761 100644 --- a/packages/custom-widgets/src/server/request-executor.ts +++ b/packages/custom-widgets/src/server/request-executor.ts @@ -13,8 +13,7 @@ import { validateCustomWidgetUrl, } from "./network-policy"; import { closeDispatcher } from "./request-dispatcher-lifecycle"; -import { isCustomWidgetRequestTimeoutError } from "./request-timeout"; -import { normalizeResponseHeaders, parseResponseBody } from "./response"; +import { parseResponseBody } from "./response"; export { assertSafeStaticHeaders, @@ -29,14 +28,13 @@ export { MAX_RESPONSE_JSON_DEPTH, MAX_RESPONSE_JSON_NODES, } from "./response"; -export { isCustomWidgetRequestTimeoutError } from "./request-timeout"; export const MAX_REQUEST_BODY_BYTES = 10 * 1024; const REQUEST_TIMEOUT_MS = 10_000; const MAX_QUERY_REDIRECTS = 3; const MAX_RESPONSE_CACHE_ENTRIES = 1_000; -const MAX_RESPONSE_CACHE_BYTES = 64 * 1024 * 1024; export const MAX_REQUEST_DURATION_MS = 45_000; +const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]); const RESPONSE_TOO_LARGE_ERROR_CODE = "UND_ERR_RES_EXCEEDED_MAX_SIZE"; export interface CustomWidgetAuthConfig { @@ -65,10 +63,9 @@ export interface CustomWidgetHttpResponse { statusText: string; data: unknown; } -const cache = new Map(); +const cache = new Map(); const inFlight = new Map>(); let cacheEpoch = 0; -let cacheBytes = 0; type RequestHopResult = | { kind: "response"; response: CustomWidgetHttpResponse } @@ -220,6 +217,23 @@ async function performRequestWithinDeadline( } } +export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean { + if (signals.some((signal) => signal.aborted)) return true; + if (!(error instanceof Error)) return false; + if (error.name.includes("Timeout")) return true; + const code = "code" in error && typeof error.code === "string" ? error.code : undefined; + return code !== undefined && TIMEOUT_ERROR_CODES.has(code); +} + +function normalizeResponseHeaders(values: Record) { + const headers = new Headers(); + for (const [name, value] of Object.entries(values)) { + if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry)); + else if (value !== undefined) headers.set(name, value); + } + return headers; +} + function assertRequest(input: CustomWidgetHttpRequest): void { if (input.body !== undefined && Buffer.byteLength(input.body, "utf8") > MAX_REQUEST_BODY_BYTES) throw new CustomWidgetDomainError({ code: "PAYLOAD_TOO_LARGE", message: "Request body exceeds the 10 KiB limit" }); @@ -242,12 +256,8 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest) const key = input.kind === "query" ? input.cacheKey : undefined; if (!key) return performRequest(input); const cached = cache.get(key); - if (cached && cached.expiresAt > Date.now()) { - cache.delete(key); - cache.set(key, cached); - return cached.response; - } - if (cached) deleteCachedResponse(key); + if (cached && cached.expiresAt > Date.now()) return cached.response; + if (cached) cache.delete(key); const pending = inFlight.get(key); if (pending) return pending; const requestEpoch = cacheEpoch; @@ -255,12 +265,8 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest) request = performRequest(input) .then((response) => { if (response.ok && (input.cacheTtlSeconds ?? 0) > 0 && requestEpoch === cacheEpoch) { - const size = Buffer.byteLength(JSON.stringify(response), "utf8"); - if (size <= MAX_RESPONSE_CACHE_BYTES) { - pruneCache(size); - cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response, size }); - cacheBytes += size; - } + pruneCache(); + cache.set(key, { expiresAt: Date.now() + (input.cacheTtlSeconds ?? 0) * 1000, response }); } return response; }) @@ -274,22 +280,15 @@ export async function executeCustomWidgetRequest(input: CustomWidgetHttpRequest) export function invalidateCustomWidgetResponseCache(prefixes: readonly string[]): void { if (prefixes.length === 0) return; cacheEpoch += 1; - for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) deleteCachedResponse(key); + for (const key of cache.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) cache.delete(key); for (const key of inFlight.keys()) if (prefixes.some((prefix) => key.startsWith(prefix))) inFlight.delete(key); } -function pruneCache(incomingSize: number): void { - for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) deleteCachedResponse(key); - while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES || cacheBytes + incomingSize > MAX_RESPONSE_CACHE_BYTES) { +function pruneCache(): void { + for (const [key, entry] of cache) if (entry.expiresAt <= Date.now()) cache.delete(key); + while (cache.size >= MAX_RESPONSE_CACHE_ENTRIES) { const key = cache.keys().next().value as string | undefined; if (!key) return; - deleteCachedResponse(key); + cache.delete(key); } } - -function deleteCachedResponse(key: string): void { - const cached = cache.get(key); - if (!cached) return; - cache.delete(key); - cacheBytes = Math.max(0, cacheBytes - cached.size); -} diff --git a/packages/custom-widgets/src/server/request-limits.ts b/packages/custom-widgets/src/server/request-limits.ts index 768cc1d84..565d8ee84 100644 --- a/packages/custom-widgets/src/server/request-limits.ts +++ b/packages/custom-widgets/src/server/request-limits.ts @@ -1,6 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT } from "../core/request-limits"; import { CustomWidgetDomainError } from "./errors"; const WINDOW_MS = 60_000; @@ -8,6 +7,7 @@ const WINDOW_MS = 60_000; // Keep the lease above that total and identify every acquisition so a late // release can never remove capacity owned by a newer request. export const CUSTOM_WIDGET_REQUEST_CONCURRENCY_TTL_MS = 60_000; +export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4; export const CUSTOM_WIDGET_DEFINITION_CONCURRENCY_LIMIT = 8; const CATEGORY_LIMITS = { query: 60, action: 10, delete: 3 } as const; const DEFINITION_CATEGORY_LIMITS = { query: 240, action: 40, delete: 12 } as const; diff --git a/packages/custom-widgets/src/server/request-timeout.ts b/packages/custom-widgets/src/server/request-timeout.ts deleted file mode 100644 index ed1989d4c..000000000 --- a/packages/custom-widgets/src/server/request-timeout.ts +++ /dev/null @@ -1,9 +0,0 @@ -const TIMEOUT_ERROR_CODES = new Set(["UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT"]); - -export function isCustomWidgetRequestTimeoutError(error: unknown, ...signals: readonly AbortSignal[]): boolean { - if (signals.some((signal) => signal.aborted)) return true; - if (!(error instanceof Error)) return false; - if (error.name.includes("Timeout")) return true; - const code = "code" in error && typeof error.code === "string" ? error.code : undefined; - return code !== undefined && TIMEOUT_ERROR_CODES.has(code); -} diff --git a/packages/custom-widgets/src/server/response.ts b/packages/custom-widgets/src/server/response.ts index 8d5a52ee1..dec549d17 100644 --- a/packages/custom-widgets/src/server/response.ts +++ b/packages/custom-widgets/src/server/response.ts @@ -1,4 +1,3 @@ -import { Headers } from "undici"; import type { Response } from "undici"; import { CustomWidgetDomainError } from "./errors"; @@ -6,15 +5,6 @@ export const MAX_RESPONSE_BODY_BYTES = 1024 * 1024; export const MAX_RESPONSE_JSON_DEPTH = 32; export const MAX_RESPONSE_JSON_NODES = 50_000; -export function normalizeResponseHeaders(values: Record) { - const headers = new Headers(); - for (const [name, value] of Object.entries(values)) { - if (Array.isArray(value)) value.forEach((entry) => headers.append(name, entry)); - else if (value !== undefined) headers.set(name, value); - } - return headers; -} - export function assertJsonBudget(value: unknown): void { let nodes = 0; const visit = (current: unknown, depth: number): void => { diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index e12e91a7c..75c44d090 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -16,9 +16,9 @@ import { CopyButton, Fieldset, Group, - Modal, Paper, PasswordInput, + Popover, Progress, Select, SimpleGrid, @@ -110,7 +110,6 @@ import classes from "./onboarding-studio.module.css"; type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review"; type LayoutPreset = "balanced" | "wide" | "focused"; -type IncompleteIntegrationConfirmationIntent = "navigate" | "apply"; const getLayoutPresetForColumnCount = (columnCount: number): LayoutPreset => columnCount <= 8 ? "focused" : columnCount <= 10 ? "balanced" : "wide"; @@ -161,9 +160,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS const sounds = useOnboardingSounds(); const [activeSection, setActiveSection] = useState("essentials"); const [incompleteIntegrationConfirmationOpened, setIncompleteIntegrationConfirmationOpened] = useState(false); - const [confirmedIncompleteIntegrationSignature, setConfirmedIncompleteIntegrationSignature] = useState( - null, - ); const [selectedLocale, setSelectedLocale] = useState(currentLocale); const [serverOrigin, setServerOrigin] = useState(environment.serverOrigin); const [urlMode, setUrlMode] = useState("hostPort"); @@ -188,7 +184,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS const [isApplying, setIsApplying] = useState(false); const [applyProgress, setApplyProgress] = useState(0); const [applyMessage, setApplyMessage] = useState(""); - const [applyError, setApplyError] = useState(null); const [appError, setAppError] = useState(null); const sectionHeadingRef = useRef(null); const sectionButtonRefs = useRef<(HTMLButtonElement | null)[]>([]); @@ -196,7 +191,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS const focusSectionHeading = useRef(true); const seenIntegrationSourceIds = useRef(new Set()); const seenAppSourceIds = useRef(new Set()); - const incompleteIntegrationConfirmationIntent = useRef(null); const docker = clientApi.onboard.discoverDockerServices.useQuery(undefined, { enabled: environment.dockerConfigured, @@ -209,11 +203,7 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS }); const complete = clientApi.onboard.completeSetup.useMutation({ onError(error) { - let message = t("common.unknownError"); - if (error.message.trim().length > 0) { - message = error.message; - } - showErrorNotification({ title: t("review.errorTitle"), message }); + showErrorNotification({ title: t("review.errorTitle"), message: error.message }); }, }); const dockerData = docker.data; @@ -244,21 +234,6 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS () => new Set(discoveredIntegrations.map((integration) => integration.kind)), [discoveredIntegrations], ); - const incompleteDrafts = useMemo(() => drafts.filter((draft) => !isIntegrationDraftComplete(draft)), [drafts]); - const incompleteIntegrationSignature = useMemo( - () => - JSON.stringify( - incompleteDrafts.map((draft) => ({ - id: draft.id, - hasUrl: draft.url.trim().length > 0, - secrets: draft.secrets.map((secret) => ({ kind: secret.kind, hasValue: secret.value.trim().length > 0 })), - })), - ), - [incompleteDrafts], - ); - const hasIncompleteIntegrations = incompleteDrafts.length > 0; - const incompleteIntegrationsConfirmed = - hasIncompleteIntegrations && confirmedIncompleteIntegrationSignature === incompleteIntegrationSignature; useEffect(() => { if (!docker.data) return; @@ -351,30 +326,11 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS if (next) selectSection(next.id); }; - const changeSection = (section: StudioSection) => { + const selectSection = (section: StudioSection) => { if (section !== activeSection) sounds.swoosh(); setActiveSection(section); }; - const selectSection = (section: StudioSection) => { - if ( - section === "review" && - activeSection !== "review" && - hasIncompleteIntegrations && - !incompleteIntegrationsConfirmed - ) { - incompleteIntegrationConfirmationIntent.current = "navigate"; - setIncompleteIntegrationConfirmationOpened(true); - return; - } - changeSection(section); - }; - - const closeIncompleteIntegrationConfirmation = () => { - incompleteIntegrationConfirmationIntent.current = null; - setIncompleteIntegrationConfirmationOpened(false); - }; - const handleSectionKeyDown = (event: KeyboardEvent, index: number) => { const nextIndex = event.key === "Home" @@ -410,15 +366,9 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS }); }; - const applySetupAsync = async (skipIncompleteIntegrationConfirmation = false) => { + const applySetupAsync = async () => { if (isApplying) return; - if (!skipIncompleteIntegrationConfirmation && hasIncompleteIntegrations && !incompleteIntegrationsConfirmed) { - incompleteIntegrationConfirmationIntent.current = "apply"; - setIncompleteIntegrationConfirmationOpened(true); - return; - } setIsApplying(true); - setApplyError(null); setAppError(null); setAppErrorSourceId(null); setApplyProgress(5); @@ -533,32 +483,13 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS setApplyMessage(t("review.progress.done")); setApplyProgress(100); await revalidatePathActionAsync("/init"); - } catch (error) { - let message = t("common.unknownError"); - if (error instanceof Error && error.message.trim().length > 0) { - message = error.message; - } + } catch { sounds.error(); - setApplyError(message); setApplyProgress(0); setIsApplying(false); } }; - const confirmIncompleteIntegrations = () => { - const intent = incompleteIntegrationConfirmationIntent.current; - if (!intent) return; - - incompleteIntegrationConfirmationIntent.current = null; - setConfirmedIncompleteIntegrationSignature(incompleteIntegrationSignature); - setIncompleteIntegrationConfirmationOpened(false); - if (intent === "apply") { - void applySetupAsync(true); - return; - } - changeSection("review"); - }; - const sectionProps: StudioSectionProps = { headingRef: sectionHeadingRef, environment, @@ -639,10 +570,10 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS }, applyProgress, applyMessage, - applyError, appError, appErrorSourceId, }; + const hasIncompleteIntegrations = drafts.some((draft) => !isIntegrationDraftComplete(draft)); const selectedAppWithoutAddress = discoveredApps.find( (app) => selectedAppIds.includes(app.sourceId) && (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0, ); @@ -659,122 +590,153 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS } setAppError(null); setAppErrorSourceId(null); - move(1); + if (hasIncompleteIntegrations) { + setIncompleteIntegrationConfirmationOpened(true); + } else { + move(1); + } } else { move(1); } }; return ( - <> - - - {t("connect.incompleteConfirmation")} - - - - - - -
- -
- - - +
+ +
+ + + - -
- - -
+
+ - - + {activeSection === "review" ? ( + - {activeSection === "review" ? ( - - ) : ( - + ) : ( + + {activeSection === "connect" && hasIncompleteIntegrations ? ( + setIncompleteIntegrationConfirmationOpened(false)} + transitionProps={{ duration: 0 }} + position="top-end" + width={320} + shadow="md" + withArrow + trapFocus + > + + + + + + {t("connect.incompleteConfirmation")} + + + + + + + + ) : ( - - )} - -
-
-
-
-
- + )} + + )} + + +
+ + +
); }; @@ -845,7 +807,6 @@ interface StudioSectionProps { setRightSidebar: (value: boolean) => void; applyProgress: number; applyMessage: string; - applyError: string | null; appError: string | null; appErrorSourceId: string | null; } @@ -1960,11 +1921,6 @@ const Review = (props: StudioSectionProps) => { ) : null} - {props.applyError ? ( - } title={t("errorTitle")} role="alert"> - {props.applyError} - - ) : null} ); }; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index df66ba613..1957636a0 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -126,7 +126,6 @@ "needsConfiguration": "Needs configuration", "tested": "Connection tested", "testFailed": "Connection test failed", - "incompleteConfirmationTitle": "Skip unfinished integrations?", "incompleteConfirmation": "Unfinished integrations will be skipped. Continue without them?" }, "board": { diff --git a/packages/ui/src/components/integration-marquee.tsx b/packages/ui/src/components/integration-marquee.tsx index 3c895cf96..f413b298a 100644 --- a/packages/ui/src/components/integration-marquee.tsx +++ b/packages/ui/src/components/integration-marquee.tsx @@ -1,24 +1,16 @@ import { Image, Marquee, SimpleGrid } from "@mantine/core"; import { splitToNChunks } from "@homarr/common"; +import { integrationDefs } from "@homarr/definitions"; import classes from "./integration-marquee.module.css"; -const localIconPaths = [ - "/images/apps/sonarr.svg", - "/images/apps/radarr.svg", - "/images/apps/lidarr.svg", - "/images/apps/readarr.svg", - "/images/apps/nextcloud.svg", - "/images/apps/truenas.svg", - "/images/apps/unraid-alt.svg", - "/images/apps/imdb.svg", - "/images/apps/lastfm.svg", - "/images/apps/tmdb.svg", - "/images/apps/the-tvdb.svg", - "/images/apps/vgmdb.svg", -]; -const iconGroups = splitToNChunks(localIconPaths, 3); +const iconGroups = splitToNChunks( + Object.values(integrationDefs) + .filter((integration) => integration.name !== "Mock") + .map((integration) => integration.iconUrl), + 3, +); const animationDuration = iconGroups.flat().length * 2; export const IntegrationMarquee = ({ className }: { className?: string }) => ( @@ -44,16 +36,7 @@ export const IntegrationMarquee = ({ className }: { className?: string }) => ( classNames={{ content: classes.content }} > {icons.map((icon) => ( - + ))} ))} diff --git a/packages/widgets/src/custom-api/custom-jsx-display.tsx b/packages/widgets/src/custom-api/custom-jsx-display.tsx index 4f80ce0ac..97117086a 100644 --- a/packages/widgets/src/custom-api/custom-jsx-display.tsx +++ b/packages/widgets/src/custom-api/custom-jsx-display.tsx @@ -11,8 +11,6 @@ import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./wi export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime"; -const EMPTY_RECORD: Record = {}; - export default function CustomJsxDisplay({ data }: { data: Record }) { const t = useI18n("widget.customApi.customJsx"); const actionT = useI18n("common.action"); @@ -39,33 +37,17 @@ export default function CustomJsxDisplay({ data }: { data: Record - Object.keys(queryState).length === 0 - ? baseData - : { ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.data])) }, - [baseData, queryState], - ); - const mergedStatus = useMemo( - () => - Object.keys(queryState).length === 0 - ? baseStatus - : { ...baseStatus, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])) }, - [baseStatus, queryState], - ); + const baseData = isRecord(data.data) ? data.data : {}; + const baseStatus = isRecord(data.status) ? data.status : {}; const renderer = ( [id, value.data])) }} + status={{ + ...baseStatus, + ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [id, value.status])), + }} + options={isRecord(data.options) ? data.options : {}} components={components} createBindings={SAFE_BINDINGS} messages={{ @@ -91,7 +73,6 @@ export default function CustomJsxDisplay({ data }: { data: Record