revert: keep only minimal review fixes

This commit is contained in:
ajnart committed 2026-08-22 09:00:57 +02:00
1 parent caf1f6621a
commit 396b78aa3e
39 files changed
+421 -958

No files matched your search

@@ -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
@@ -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.
@@ -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.
+1 -1
View File
@@ -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,
},
@@ -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<string, unknown> = {};
const status: Record<string, unknown> = {};
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) {
@@ -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,
@@ -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<AdvancedFocusActions | null>(null);
const AdvancedFocusStoreContext = createContext<AdvancedFocusStore | null>(null);
const INACTIVE_FOCUS_STATE: AdvancedFocusState = {
active: null,
viewportSize: { width: 0, height: 0 },
};
const AdvancedFocusContext = createContext<AdvancedFocusContextValue | null>(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<HTMLDivElement | null>(null);
const pointerPositionRef = useRef({ x: -100, y: -100 });
const pointerFrameRef = useRef<number | null>(null);
const focusStateRef = useRef<AdvancedFocusState>({ active: null, viewportSize });
const focusListenersRef = useRef(new Set<() => void>());
const [focusStore] = useState<AdvancedFocusStore>(() => ({
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 (
<AdvancedFocusActionsContext.Provider value={actions}>
<AdvancedFocusStoreContext.Provider value={focusStore}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusStoreContext.Provider>
</AdvancedFocusActionsContext.Provider>
<AdvancedFocusContext.Provider value={value}>
{children}
{isHoldPending && (
<Portal>
<div
ref={holdIndicatorRef}
data-advanced-focus-hold-indicator
className={classes.holdIndicator}
style={
{
"--advanced-focus-pointer-x": `${pointerPositionRef.current.x}px`,
"--advanced-focus-pointer-y": `${pointerPositionRef.current.y}px`,
} as CSSProperties
}
aria-hidden
>
<svg className={classes.holdIndicatorRing} viewBox="0 0 36 36">
<circle className={classes.holdIndicatorTrack} cx="18" cy="18" r="15" pathLength="1" />
<circle className={classes.holdIndicatorProgress} cx="18" cy="18" r="15" pathLength="1" />
</svg>
<span className={classes.holdIndicatorLabel}>&#8679;</span>
</div>
</Portal>
)}
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
zIndex="var(--homarr-z-index-widget-preview-backdrop)"
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusContext.Provider>
);
};
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;
};
@@ -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<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(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 && <BoardItemMenu item={menuItem} definition={definition} />}
<WidgetContextMenu item={item} definition={definition} widgetRuntimeRef={widgetRuntimeRef} sourceRef={sourceRef}>
<WidgetContextMenu
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
>
<Box
ref={sourceRef}
w="100%"
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode, RefObject } from "react";
import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
@@ -26,7 +26,7 @@ import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import { getWidgetOptionTranslationNamespace } from "@homarr/widgets/option-translation";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocusActions } from "../advanced-focus/context";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
@@ -35,6 +35,7 @@ import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
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 (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
{menuOpened && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
/>
)}
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children">;
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 (
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ?? sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={menuOpened}
onChange={setMenuOpened}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current)
openAdvancedFocus(item.id, sourceRef.current, {
restoreFocusTarget:
sourceRef.current.querySelector<HTMLElement>("[data-advanced-focus-trigger]") ??
sourceRef.current,
});
}}
>
{t("item.advancedFocus.open")}
</Menu.Item>
<Menu.Divider />
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)}
</Menu.CheckboxItem>
))}
</>
)}
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
{t(`${getWidgetOptionTranslationNamespace(item.kind, key)}.label` as never)}
</Menu.CheckboxItem>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tCommon("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
onClick={handleRefetch}
disabled={isWidgetFetching}
>
<Group justify="space-between" wrap="nowrap" gap="sm">
{tCommon("refresh")}
<WidgetQueryStatus
queryClient={queryClient}
matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching}
t={t}
/>
</Group>
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
onFocus={preloadWidgetEditModal}
onPointerEnter={preloadWidgetEditModal}
disabled={!canConfigureWidget || (hasSupportedIntegrations && isPending)}
>
{tMenu("settings")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -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));
@@ -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 (
<Box
@@ -82,6 +70,7 @@ export const ScaledBoardCanvas = ({
data-testid="board-canvas"
data-board-hydrated={isHydrated ? "true" : "false"}
data-canvas-scale={scale}
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
data-canvas-initial-height={initialLogicalHeight}
data-canvas-initial-width={initialAvailableWidth}
aria-label={label}
@@ -9,17 +9,6 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
/*
* This element is the dnd-kit draggable and its own drag handle. The touch
* activation constraint only fires after a 200 ms hold, but the browser
* decides who owns a touch gesture when it begins: without this rule the
* board's horizontal scrollport (or the page) claims the pan, emits
* `pointercancel`, and cancels the grid transaction instead of moving the
* widget. Only edit mode renders this element, so ordinary view-mode
* scrolling over widgets is unaffected.
*/
touch-action: none;
}
.board-grid-entry:has([data-board-widget-header]):hover,
@@ -5,4 +5,3 @@ export {
serializeCustomWidgetDefinition,
} from "./router/custom-widget/stored-definition";
export { configureCustomWidgetSourceFromRequest } from "./router/custom-widget/secret-persistence";
export { invalidateCustomWidgetResponseCache } from "./router/custom-widget/request-executor";
@@ -64,7 +64,7 @@ export const previewActionProcedures = {
kind: "action",
});
if (response.ok && request.invalidates?.length) {
await invalidateCustomWidgetResponseCache(
invalidateCustomWidgetResponseCache(
request.invalidates.map((requestId) => `custom-jsx:preview:${session.id}:${requestId}:`),
);
}
@@ -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
@@ -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<CustomWidgetHttpResponse> {
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);
}
@@ -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<string, { cacheKey: string; request: Promise<CustomWidgetHttpResponse> }>();
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<CustomWidgetHttpResponse>,
): Promise<CustomWidgetHttpResponse> {
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<CustomWidgetHttpResponse>;
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",
});
}
}
@@ -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 });
+7 -13
View File
@@ -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<typeof throwIfActionForbiddenAsync>[0] & { ipAddress?: string };
type RouterContext = Parameters<typeof throwIfActionForbiddenAsync>[0];
type ResolvedDefinition = Awaited<ReturnType<typeof resolvePlacedDefinitionAsync>>;
const parseItemOptions = (raw: string): CustomWidgetItemOptions => {
@@ -156,7 +154,6 @@ const withRequestLimit = async <T>(
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}:`,
+1 -4
View File
@@ -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,
};
-22
View File
@@ -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;
};
@@ -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";
@@ -1 +0,0 @@
export const CUSTOM_WIDGET_USER_ITEM_CONCURRENCY_LIMIT = 4;
@@ -24,10 +24,6 @@ export interface RenderSafeJsxOptions {
budgets?: SafeJsxBudgets;
}
export interface RenderCompiledSafeJsxOptions extends Omit<RenderSafeJsxOptions, "template"> {
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) });
}
@@ -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);
});
@@ -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<string, never> = {};
const referenceIds = new WeakMap<object, number>();
let nextReferenceId = 1;
const methodColors: Readonly<Record<string, string>> = {
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<Record<string, unknown>>) {
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}`;
}
@@ -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}
>
<IconRefresh size={16} />
</ActionIcon>
@@ -82,7 +82,6 @@ export interface CustomWidgetRuntimeValue {
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
canRefresh?: boolean;
isEditMode: boolean;
requestCapabilities: readonly CustomJsxRequestCapability[];
port: CustomWidgetRuntimePort;
@@ -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";
@@ -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<string, { expiresAt: number; response: CustomWidgetHttpResponse; size: number }>();
const cache = new Map<string, { expiresAt: number; response: CustomWidgetHttpResponse }>();
const inFlight = new Map<string, Promise<CustomWidgetHttpResponse>>();
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<string, string | string[] | undefined>) {
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);
}
@@ -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;
@@ -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);
}
@@ -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<string, string | string[] | undefined>) {
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 => {
+153 -197
View File
@@ -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<StudioSection>("essentials");
const [incompleteIntegrationConfirmationOpened, setIncompleteIntegrationConfirmationOpened] = useState(false);
const [confirmedIncompleteIntegrationSignature, setConfirmedIncompleteIntegrationSignature] = useState<string | null>(
null,
);
const [selectedLocale, setSelectedLocale] = useState(currentLocale);
const [serverOrigin, setServerOrigin] = useState(environment.serverOrigin);
const [urlMode, setUrlMode] = useState<UrlTemplateMode>("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<string | null>(null);
const [appError, setAppError] = useState<string | null>(null);
const sectionHeadingRef = useRef<HTMLHeadingElement>(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<string>());
const seenAppSourceIds = useRef(new Set<string>());
const incompleteIntegrationConfirmationIntent = useRef<IncompleteIntegrationConfirmationIntent | null>(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<HTMLButtonElement>, 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 (
<>
<Modal
opened={incompleteIntegrationConfirmationOpened}
onClose={closeIncompleteIntegrationConfirmation}
title={t("connect.incompleteConfirmationTitle")}
centered
>
<Stack gap="md">
<Text size="sm">{t("connect.incompleteConfirmation")}</Text>
<Group justify="flex-end" gap="xs">
<Button variant="default" onClick={closeIncompleteIntegrationConfirmation}>
{tCommon("cancel")}
</Button>
<Button onClick={confirmIncompleteIntegrations}>{tCommon("confirm")}</Button>
</Group>
</Stack>
</Modal>
<main
className={classes.page}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
} as CSSProperties
}
>
<OnboardingBackdrop />
<div className={classes.shell}>
<Group className={classes.topbar} justify="center" mb="lg">
<OnboardingWordmark
primaryColor={primaryColor}
secondaryColor={secondaryColor.toLowerCase() === initialSecondaryColor ? undefined : secondaryColor}
/>
</Group>
<main
className={classes.page}
style={
{
"--studio-glow-color": primaryColor,
"--studio-secondary-glow-color": secondaryColor,
} as CSSProperties
}
>
<OnboardingBackdrop />
<div className={classes.shell}>
<Group className={classes.topbar} justify="center" mb="lg">
<OnboardingWordmark
primaryColor={primaryColor}
secondaryColor={secondaryColor.toLowerCase() === initialSecondaryColor ? undefined : secondaryColor}
/>
</Group>
<Paper className={classes.studio} radius="lg">
<div className={classes.studioGrid}>
<nav className={classes.rail} aria-label={t("navigationLabel")}>
<Timeline
active={activeIndex}
bulletSize={32}
lineWidth={2}
color={primaryColor}
className={classes.timeline}
classNames={{ itemBullet: classes.timelineBullet }}
>
{sectionDefinitions.map((section, index) => {
const Icon = section.icon;
const active = section.id === activeSection;
return (
<Timeline.Item
key={section.id}
bullet={<Icon size={16} aria-hidden />}
title={
<UnstyledButton
ref={(node) => {
sectionButtonRefs.current[index] = node;
}}
className={classes.timelineButton}
data-active={active}
onClick={() => selectSection(section.id)}
onKeyDown={(event) => handleSectionKeyDown(event, index)}
aria-label={`${t(`navigation.${section.id}`)} (${index + 1}/${sectionDefinitions.length})`}
aria-current={active ? "step" : undefined}
aria-controls="onboarding-studio-section"
tabIndex={active ? 0 : -1}
>
<Stack gap={0} align="flex-start">
<Text className={classes.timelineLabel} size="sm" fw={active ? 650 : 500}>
{t(`navigation.${section.id}`)}
</Text>
<Text className={classes.timelineLabel} size="xs" c="dimmed">
{index + 1} / {sectionDefinitions.length}
</Text>
</Stack>
</UnstyledButton>
}
/>
);
})}
</Timeline>
</nav>
<section
id="onboarding-studio-section"
className={classes.content}
aria-labelledby="studio-section-title"
<Paper className={classes.studio} radius="lg">
<div className={classes.studioGrid}>
<nav className={classes.rail} aria-label={t("navigationLabel")}>
<Timeline
active={activeIndex}
bulletSize={32}
lineWidth={2}
color={primaryColor}
className={classes.timeline}
classNames={{ itemBullet: classes.timelineBullet }}
>
<div key={activeSection}>
<StudioSectionContent section={activeSection} {...sectionProps} />
</div>
{sectionDefinitions.map((section, index) => {
const Icon = section.icon;
const active = section.id === activeSection;
return (
<Timeline.Item
key={section.id}
bullet={<Icon size={16} aria-hidden />}
title={
<UnstyledButton
ref={(node) => {
sectionButtonRefs.current[index] = node;
}}
className={classes.timelineButton}
data-active={active}
onClick={() => selectSection(section.id)}
onKeyDown={(event) => handleSectionKeyDown(event, index)}
aria-label={`${t(`navigation.${section.id}`)} (${index + 1}/${sectionDefinitions.length})`}
aria-current={active ? "step" : undefined}
aria-controls="onboarding-studio-section"
tabIndex={active ? 0 : -1}
>
<Stack gap={0} align="flex-start">
<Text className={classes.timelineLabel} size="sm" fw={active ? 650 : 500}>
{t(`navigation.${section.id}`)}
</Text>
<Text className={classes.timelineLabel} size="xs" c="dimmed">
{index + 1} / {sectionDefinitions.length}
</Text>
</Stack>
</UnstyledButton>
}
/>
);
})}
</Timeline>
</nav>
<Group className={classes.stickyActions} justify="space-between" wrap="nowrap">
<Button
className={classes.backAction}
size="sm"
variant="default"
leftSection={<IconArrowLeft size={16} />}
aria-label={t("back")}
disabled={activeIndex === 0 || isApplying}
onClick={() => move(-1)}
>
{t("back")}
<section id="onboarding-studio-section" className={classes.content} aria-labelledby="studio-section-title">
<div key={activeSection}>
<StudioSectionContent section={activeSection} {...sectionProps} />
</div>
<Group className={classes.stickyActions} justify="space-between" wrap="nowrap">
<Button
className={classes.backAction}
size="sm"
variant="default"
leftSection={<IconArrowLeft size={16} />}
aria-label={t("back")}
disabled={activeIndex === 0 || isApplying}
onClick={() => move(-1)}
>
{t("back")}
</Button>
{activeSection === "review" ? (
<Button size="sm" loading={isApplying} onClick={continueOnboarding}>
{t("buildBoard")}
</Button>
{activeSection === "review" ? (
<Button size="sm" loading={isApplying} onClick={continueOnboarding}>
{t("buildBoard")}
</Button>
) : (
<Group className={classes.primaryActions} gap="xs" wrap="nowrap">
) : (
<Group className={classes.primaryActions} gap="xs" wrap="nowrap">
{activeSection === "connect" && hasIncompleteIntegrations ? (
<Popover
opened={incompleteIntegrationConfirmationOpened}
onDismiss={() => setIncompleteIntegrationConfirmationOpened(false)}
transitionProps={{ duration: 0 }}
position="top-end"
width={320}
shadow="md"
withArrow
trapFocus
>
<Popover.Target>
<Button
size="sm"
disabled={continueDisabled}
rightSection={<IconArrowRight size={16} />}
aria-haspopup="dialog"
aria-expanded={incompleteIntegrationConfirmationOpened}
onClick={() => setIncompleteIntegrationConfirmationOpened((opened) => !opened)}
>
{tCommon("continue")}
</Button>
</Popover.Target>
<Popover.Dropdown role="dialog" aria-label={t("connect.incompleteConfirmation")}>
<Stack gap="sm">
<Text size="sm">{t("connect.incompleteConfirmation")}</Text>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="default"
onClick={() => setIncompleteIntegrationConfirmationOpened(false)}
>
{tCommon("cancel")}
</Button>
<Button
size="xs"
onClick={() => {
setIncompleteIntegrationConfirmationOpened(false);
move(1);
}}
>
{tCommon("confirm")}
</Button>
</Group>
</Stack>
</Popover.Dropdown>
</Popover>
) : (
<Button
size="sm"
disabled={continueDisabled}
@@ -783,15 +745,15 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
>
{tCommon("continue")}
</Button>
</Group>
)}
</Group>
</section>
</div>
</Paper>
</div>
</main>
</>
)}
</Group>
)}
</Group>
</section>
</div>
</Paper>
</div>
</main>
);
};
@@ -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) => {
</Stack>
</Paper>
) : null}
{props.applyError ? (
<Alert color="red" icon={<IconAlertCircle size={18} />} title={t("errorTitle")} role="alert">
{props.applyError}
</Alert>
) : null}
</Stack>
);
};
-1
View File
@@ -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": {
@@ -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) => (
<Image
key={`${icon}-${columnIndex}`}
src={icon}
alt=""
fit="contain"
w={52}
h={52}
loading="lazy"
decoding="async"
/>
<Image key={`${icon}-${columnIndex}`} src={icon} alt="" fit="contain" w={52} h={52} />
))}
</Marquee>
))}
@@ -11,8 +11,6 @@ import { InactiveWidgetDefinitionProvider, WidgetDefinitionProvider } from "./wi
export { CUSTOM_JSX_METHOD_COLORS } from "@homarr/custom-widgets/runtime";
const EMPTY_RECORD: Record<string, unknown> = {};
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const t = useI18n("widget.customApi.customJsx");
const actionT = useI18n("common.action");
@@ -39,33 +37,17 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
return { ...current, [requestId]: value };
});
}, []);
const baseData = isRecord(data.data) ? data.data : EMPTY_RECORD;
const baseStatus = isRecord(data.status) ? data.status : EMPTY_RECORD;
const options = isRecord(data.options) ? data.options : EMPTY_RECORD;
// The renderer memoizes the interpreted template and derives its error-boundary
// reset key from these prop identities. Merging into a fresh object on every
// render would re-run the interpreter and reset the boundary even when the
// response is byte-identical, so keep the merged records referentially stable.
const mergedData = useMemo(
() =>
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 = (
<CustomJsxRenderer
template={String(data.template ?? "")}
data={mergedData}
status={mergedStatus}
options={options}
data={{ ...baseData, ...Object.fromEntries(Object.entries(queryState).map(([id, value]) => [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<string, unknow
previewSessionId={previewSessionId}
previewLiveActions={data.previewLiveActions === true}
queriesDisabled={data.queriesDisabled === true}
canRefresh={data.canRefresh === true}
isEditMode={data.isEditMode === true}
requestCapabilities={capabilities}
setQueryState={publishQueryState}
@@ -21,7 +21,6 @@ interface WidgetDefinitionProviderProps {
previewSessionId?: string;
previewLiveActions?: boolean;
queriesDisabled?: boolean;
canRefresh?: boolean;
isEditMode?: boolean;
requestCapabilities?: readonly CustomJsxRequestCapability[];
setQueryState?(requestId: string, value: CustomWidgetPublishedQueryState | null): void;
@@ -172,7 +171,6 @@ export function WidgetDefinitionProvider(props: WidgetDefinitionProviderProps) {
previewSessionId={props.previewSessionId}
previewLiveActions={props.previewLiveActions}
queriesDisabled={props.queriesDisabled}
canRefresh={Boolean(props.previewSessionId) || props.canRefresh === true}
isEditMode={props.isEditMode ?? false}
requestCapabilities={props.requestCapabilities ?? []}
port={port}
+8 -37
View File
@@ -7,43 +7,14 @@ export PGID=${PGID:-0}
echo "Starting with UID='$PUID', GID='$PGID'"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# Only chown the paths the non-root user must actually write to at runtime.
# Recursively chowning the whole /app tree is extremely slow on network- or
# HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
# bundle and its node_modules contain tens of thousands of tiny files. This
# turned a ~10s step into 15+ minutes for some users (#2190). Application
# code, migrations and native modules are only read/executed, so they stay
# owned by root and do not need chowning.
echo "Changing owner to $PUID:$PGID..."
# Application data volume (db, redis dumps, trusted certificates).
# Ensure the known subdirectories exist and migrate ownership of any
# existing content, so a persistent volume with stale or root-owned
# entries stays writable after a PUID/PGID change. These trees are small
# (a single sqlite db, a redis dump, a few certs), unlike the /app tree.
#
# /appdata is user-supplied storage and was never chowned before, so it can
# be a CIFS/SMB or NFS mount that rejects chown outright. Those mounts
# already assign ownership themselves, so a failure here is informational:
# `set -e` must not turn it into a container that refuses to start.
mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates
appdata_chown_failed=0
chown "${PUID}:${PGID}" /appdata || appdata_chown_failed=1
chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates || appdata_chown_failed=1
if [ "${appdata_chown_failed}" = "1" ]; then
echo "Warning: could not change ownership of /appdata. This is expected on mounts that assign ownership themselves (CIFS/SMB, NFS with a fixed uid). Continuing."
fi
# Next.js runtime cache (image optimization, fetch cache, ...)
mkdir -p /app/apps/nextjs/.next/cache
chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache
# nginx runtime directories and the rendered configuration
chown -R "${PUID}:${PGID}" /var/cache/nginx
chown -R "${PUID}:${PGID}" /var/log/nginx
chown -R "${PUID}:${PGID}" /var/lib/nginx
mkdir -p /run/nginx
touch /run/nginx/nginx.pid
chown -R "${PUID}:${PGID}" /run/nginx
touch /etc/nginx/nginx.conf
chown "${PUID}:${PGID}" /etc/nginx/nginx.conf
# The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
chown -R $PUID:$PGID /var/cache/nginx
chown -R $PUID:$PGID /var/log/nginx
chown -R $PUID:$PGID /var/lib/nginx
chown -R $PUID:$PGID /run/nginx/nginx.pid
chown -R $PUID:$PGID /etc/nginx
echo "Changing owner to $PUID:$PGID, done."
fi