diff --git a/apps/docs/docs/advanced/development/widgets-and-integrations.mdx b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx index 2fff7e63f..0ee4dd453 100644 --- a/apps/docs/docs/advanced/development/widgets-and-integrations.mdx +++ b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx @@ -8,8 +8,8 @@ tags: sidebar_position: 2 --- -Homarr keeps feature registration explicit and typed. There is no generator or generated registry to run after a -change. A new feature is available when its typed metadata and lazy loader are registered. +Homarr keeps feature registration explicit and typed. Feature registration has no generator or generated registry. +A new feature is available when its typed metadata and lazy loader are registered. ## Add a widget @@ -46,10 +46,24 @@ Keep the HTTP client and its capability-specific types in `packages/integrations implementation out of the initial server bundle and makes the registration easy to find. 3. Implement only the interfaces required by the widgets that use the service. 4. Add canonical documentation in `apps/docs/docs/integrations//`. Its `index.ts` must satisfy - `IntegrationDefinition` so invalid metadata fails type checking. + `IntegrationDefinition` so invalid metadata fails type checking without widening the object. Use a type-only import + and `satisfies IntegrationDefinition`; do not cast the object. Beszel is a compact example with several widgets sharing one integration client. +## Write user documentation + +Write for technically capable self-hosters. Keep each page short and operational. + +- Start with one sentence describing what the integration or widget adds to Homarr. +- Include prerequisites, credentials, permissions, destructive actions, and non-obvious limits only when they matter. +- Use numbered steps only when order matters. Prefer the exact UI labels a user must select. +- Do not add marketing, implementation history, architecture internals, or explanations of basic self-hosting concepts. +- End when the user can configure and verify the feature; do not repeat the same information in a summary. + +The usual page needs only an introduction and a `Configuration` section. Add `Notes` or `Troubleshooting` only for +real caveats. + ## Data and errors - TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching. diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index f919b7b2e..371826598 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -110,6 +110,9 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Set desktop and mobile home boards for the current user - Configure global board defaults such as public home boards and status behavior +Creating, duplicating, or reconfiguring a widget validates its integration selection on the server. The caller needs +use access for each newly linked integration; unchanged links remain editable through normal board permissions. + Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx index 225aa4a3a..ef32bfa2a 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.tsx @@ -9,7 +9,7 @@ import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; -import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { widgetCatalogIcons } from "@homarr/ui/widget-icons"; import type { Board } from "../../_types"; import classes from "./_layout-preview.module.css"; diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx index 9cdf91241..101ef0ec5 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/layout.tsx @@ -1,7 +1,7 @@ import type { PropsWithChildren } from "react"; import { widgetKinds } from "@homarr/definitions"; -import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { widgetCatalogIcons } from "@homarr/ui/widget-icons"; import { MainNavigation } from "~/components/layout/navigation"; import { ClientShell } from "~/components/layout/shell"; diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index c73482aac..472dad981 100644 --- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx +++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx @@ -4,7 +4,7 @@ import { IconLayoutGrid } from "@tabler/icons-react"; import type { BoardPreviewData } from "@homarr/boards/layout-preview"; import { projectBoardLayout } from "@homarr/boards/layout-preview"; import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; -import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { widgetCatalogIcons } from "@homarr/ui/widget-icons"; import classes from "./board-layout-thumbnail.module.css"; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 24dee4e5e..4ea1d6562 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -42,7 +42,7 @@ import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectableCard } from "@homarr/ui"; import type { TablerIcon } from "@homarr/ui"; -import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { widgetCatalogIcons } from "@homarr/ui/widget-icons"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { EmptySection } from "~/app/[locale]/boards/_types"; diff --git a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx index c377ae166..3e83f0715 100644 --- a/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx +++ b/apps/nextjs/src/components/board/selection/board-selection-toolbar.tsx @@ -22,7 +22,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; -import { widgetCatalogIcons } from "@homarr/widgets/catalog"; +import { widgetCatalogIcons } from "@homarr/ui/widget-icons"; import type { EmptySection } from "~/app/[locale]/boards/_types"; import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry"; diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 67448945c..c4929bf30 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -73,6 +73,7 @@ import { throwIfCustomWidgetBoardDuplicationForbidden, throwIfCustomWidgetPlacementChangeForbidden, } from "./board/custom-widget-placement-access"; +import { validateTimetableOptionsChangeAsync } from "./widgets/timetable"; interface BoardItemPlacementRectangle { xOffset: number; @@ -1558,7 +1559,6 @@ export const boardRouter = createTRPCRouter({ const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id); let previousOptions: Record | undefined; if (previousItem?.kind === "timetable") previousOptions = previousItem.options; - const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); await validateTimetableOptionsChangeAsync(item.options, previousOptions); } @@ -2144,7 +2144,6 @@ export const boardRouter = createTRPCRouter({ }); if (input.kind === "timetable") { - const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); await validateTimetableOptionsChangeAsync(input.options); } diff --git a/packages/api/src/router/timetable-options-validation.ts b/packages/api/src/router/timetable-options-validation.ts deleted file mode 100644 index 5e9180553..000000000 --- a/packages/api/src/router/timetable-options-validation.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { lookup } from "node:dns/promises"; -import { BlockList, isIP } from "node:net"; -import { TRPCError } from "@trpc/server"; -import { parse } from "superjson"; -import z from "zod"; - -import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable"; -import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; - -const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); - -const blockedTimetableIpv4Addresses = new BlockList(); -const blockedTimetableIpv4Subnets = [ - ["0.0.0.0", 8], - ["10.0.0.0", 8], - ["100.64.0.0", 10], - ["127.0.0.0", 8], - ["169.254.0.0", 16], - ["172.16.0.0", 12], - ["192.0.0.0", 24], - ["192.0.2.0", 24], - ["192.88.99.0", 24], - ["192.168.0.0", 16], - ["198.18.0.0", 15], - ["198.51.100.0", 24], - ["203.0.113.0", 24], - ["224.0.0.0", 4], - ["240.0.0.0", 4], -] as const; - -const blockedTimetableIpv6Addresses = new BlockList(); -const blockedTimetableIpv6Subnets = [ - ["::", 96], - ["::ffff:0:0", 96], - ["::ffff:0:0:0", 96], - ["64:ff9b::", 96], - ["64:ff9b:1::", 48], - ["100::", 64], - ["2001::", 32], - ["2001:2::", 48], - ["2001:10::", 28], - ["2001:db8::", 32], - ["2002::", 16], - ["fc00::", 7], - ["fe80::", 10], - ["fec0::", 10], - ["ff00::", 8], -] as const; - -for (const [network, prefix] of blockedTimetableIpv4Subnets) { - blockedTimetableIpv4Addresses.addSubnet(network, prefix, "ipv4"); -} - -for (const [network, prefix] of blockedTimetableIpv6Subnets) { - blockedTimetableIpv6Addresses.addSubnet(network, prefix, "ipv6"); -} - -export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => { - try { - return normalizeTimetableBaseUrl(baseUrl); - } catch (cause) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: cause instanceof Error ? cause.message : "Invalid timetable base URL", - cause, - }); - } -}; - -export const getSavedTimetableBaseUrl = (serializedOptions: string) => { - try { - const options = timetableOptionsSchema.parse(parse(serializedOptions)); - return normalizeTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); - } catch (cause) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Timetable widget configuration is invalid", - cause, - }); - } -}; - -const normalizeHostname = (hostname: string) => { - let withoutBrackets = hostname; - if (hostname.startsWith("[") && hostname.endsWith("]")) withoutBrackets = hostname.slice(1, -1); - return withoutBrackets.toLowerCase().replace(/\.$/, ""); -}; - -const isBlockedTimetableAddress = (address: string, family: number) => { - if (family === 6) return blockedTimetableIpv6Addresses.check(address, "ipv6"); - return blockedTimetableIpv4Addresses.check(address, "ipv4"); -}; - -export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { - const hostname = normalizeHostname(new URL(baseUrl).hostname); - const hostnameFamily = isIP(hostname); - const isInternalHostname = - hostnameFamily === 0 && - (!hostname.includes(".") || - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname.endsWith(".local") || - hostname.endsWith(".internal") || - hostname.endsWith(".lan") || - hostname.endsWith(".home.arpa")); - - if (isInternalHostname) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); - } - - let addresses: TimetableResolvedAddress[]; - try { - let resolvedAddresses = [{ address: hostname, family: hostnameFamily }]; - if (hostnameFamily === 0) resolvedAddresses = await lookup(hostname, { all: true, verbatim: true }); - addresses = resolvedAddresses.filter( - (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, - ); - } catch (cause) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Timetable base URL host could not be resolved", - cause, - }); - } - - if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Timetable base URL must not target a private or internal address", - }); - } - - return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); -}; - -const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { - const parsedOptions = timetableOptionsSchema.safeParse(options); - if (!parsedOptions.success) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); - } - return normalizeTimetableBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); -}; - -export const validateTimetableOptionsChangeAsync = async ( - nextOptions: Record, - previousOptions?: Record, -) => { - const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); - if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; - - let previousBaseUrl: string | undefined; - if (previousOptions) { - try { - previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); - } catch { - // Invalid legacy options must not prevent replacing them with a valid configuration. - } - } - if (nextBaseUrl === previousBaseUrl) return; - - await resolvePublicTimetableAddressesAsync(nextBaseUrl); -}; diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index aa18fdb31..05df408c1 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,23 +1,12 @@ import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { SystemHealthMonitoring } from "@homarr/integrations"; +import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -// This endpoint uses the generic system-info handler, so it intentionally excludes -// health-monitoring integrations with specialized data models such as Proxmox and Coolify. -const healthMonitoringIntegrationKinds = [ - "openmediavault", - "dashDot", - "truenas", - "unraid", - "glances", - "synology", - "mock", -] as const; - interface SystemHealthQueryResult { integrationId: string; integrationName: string; @@ -35,7 +24,7 @@ export const healthMonitoringRouter = createTRPCRouter({ "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)", }, }) - .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) + .concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds)) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>( ctx.integrations, @@ -80,7 +69,7 @@ export const healthMonitoringRouter = createTRPCRouter({ "Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)", }, }) - .concat(createOneIntegrationMiddleware("query", "proxmox", "mock")) + .concat(createOneIntegrationMiddleware("query", ...healthMonitoringClusterIntegrationKinds)) .query(async ({ ctx }) => { const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {}); const { data } = await innerHandler.getDataAsync(); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 08a8fcb0f..e8f7d11e3 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -50,5 +50,6 @@ export const indexerManagerRouter = createTRPCRouter({ }); }), ); + await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id)); }), }); diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts index c548e039e..e39e5f1dc 100644 --- a/packages/api/src/router/widgets/options.ts +++ b/packages/api/src/router/widgets/options.ts @@ -9,6 +9,7 @@ import { boards, items } from "@homarr/db/schema"; import type { WidgetOptionsSettings } from "../../../../widgets/src"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; +import { validateTimetableOptionsChangeAsync } from "./timetable"; import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access"; export const optionsRouter = createTRPCRouter({ @@ -49,7 +50,6 @@ export const optionsRouter = createTRPCRouter({ const previousOptions = SuperJSON.parse>(item.options); const updatedOptions = { ...previousOptions, ...input.newOptions }; if (item.kind === "timetable") { - const { validateTimetableOptionsChangeAsync } = await import("../timetable-options-validation"); await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions); } throwIfCustomWidgetPlacementChangeForbidden({ diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index 9b77ab76b..ced16645e 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,4 +1,7 @@ +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; import z from "zod"; import type { Session } from "@homarr/auth"; @@ -10,15 +13,10 @@ import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; -import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-url"; +import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; -import { - getSavedTimetableBaseUrl, - normalizeTimetableBaseUrlOrThrowBadRequest, - resolvePublicTimetableAddressesAsync, -} from "../timetable-options-validation"; const baseUrlSchema = z.string().url().max(2_048); const timetableSourceSchema = z.object({ @@ -26,6 +24,172 @@ const timetableSourceSchema = z.object({ itemId: z.string().max(100).optional(), boardId: z.string().max(100).optional(), }); +const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); + +const blockedTimetableIpv4Addresses = new BlockList(); +const blockedTimetableIpv6Addresses = new BlockList(); +const blockedTimetableSubnets = [ + ["0.0.0.0", 8, "ipv4"], + ["10.0.0.0", 8, "ipv4"], + ["100.64.0.0", 10, "ipv4"], + ["127.0.0.0", 8, "ipv4"], + ["169.254.0.0", 16, "ipv4"], + ["172.16.0.0", 12, "ipv4"], + ["192.0.0.0", 24, "ipv4"], + ["192.0.2.0", 24, "ipv4"], + ["192.88.99.0", 24, "ipv4"], + ["192.168.0.0", 16, "ipv4"], + ["198.18.0.0", 15, "ipv4"], + ["198.51.100.0", 24, "ipv4"], + ["203.0.113.0", 24, "ipv4"], + ["224.0.0.0", 4, "ipv4"], + ["240.0.0.0", 4, "ipv4"], + ["::", 96, "ipv6"], + ["::ffff:0:0", 96, "ipv6"], + ["::ffff:0:0:0", 96, "ipv6"], + ["64:ff9b::", 96, "ipv6"], + ["64:ff9b:1::", 48, "ipv6"], + ["100::", 64, "ipv6"], + ["2001::", 32, "ipv6"], + ["2001:2::", 48, "ipv6"], + ["2001:10::", 28, "ipv6"], + ["2001:db8::", 32, "ipv6"], + ["2002::", 16, "ipv6"], + ["fc00::", 7, "ipv6"], + ["fe80::", 10, "ipv6"], + ["fec0::", 10, "ipv6"], + ["ff00::", 8, "ipv6"], +] as const; + +for (const [network, prefix, family] of blockedTimetableSubnets) { + const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses; + blockList.addSubnet(network, prefix, family); +} + +const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: cause instanceof Error ? cause.message : "Invalid timetable base URL", + cause, + }); + } +}; + +const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Timetable widget configuration is invalid", + cause, + }); +}; + +const parseSavedTimetableOptions = (serializedOptions: string) => { + try { + return timetableOptionsSchema.parse(parse(serializedOptions)); + } catch (cause) { + return throwInvalidSavedTimetableConfiguration(cause); + } +}; + +const normalizeSavedTimetableBaseUrl = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + return throwInvalidSavedTimetableConfiguration(cause); + } +}; + +const normalizeHostname = (hostname: string) => { + const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + return withoutBrackets.toLowerCase().replace(/\.$/, ""); +}; + +const isBlockedTimetableAddress = (address: string, family: number) => { + return family === 6 + ? blockedTimetableIpv6Addresses.check(address, "ipv6") + : blockedTimetableIpv4Addresses.check(address, "ipv4"); +}; + +const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { + const hostname = normalizeHostname(new URL(baseUrl).hostname); + const hostnameFamily = isIP(hostname); + const isInternalHostname = + hostnameFamily === 0 && + (!hostname.includes(".") || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + hostname.endsWith(".internal") || + hostname.endsWith(".lan") || + hostname.endsWith(".home.arpa")); + + if (isInternalHostname) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); + } + + let addresses: TimetableResolvedAddress[]; + try { + const resolvedAddresses = + hostnameFamily === 0 + ? await lookup(hostname, { all: true, verbatim: true }) + : [{ address: hostname, family: hostnameFamily }]; + addresses = resolvedAddresses.filter( + (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, + ); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL host could not be resolved", + cause, + }); + } + + if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL must not target a private or internal address", + }); + } + + return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); +}; + +const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { + const parsedOptions = timetableOptionsSchema.safeParse(options); + if (!parsedOptions.success) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); + } + return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); +}; + +export const validateTimetableOptionsChangeAsync = async ( + nextOptions: Record, + previousOptions?: Record, +) => { + const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); + if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; + + let previousBaseUrl: string | undefined; + if (previousOptions) { + try { + previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); + } catch { + // Invalid legacy options must not prevent replacing them with a valid configuration. + } + } + if (nextBaseUrl === previousBaseUrl) return; + + await resolvePublicTimetableAddressesAsync(nextBaseUrl); +}; + +const getSavedTimetableBaseUrl = (serializedOptions: string) => { + const options = parseSavedTimetableOptions(serializedOptions); + return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); +}; + interface ResolvedTimetableSource { baseUrl: string; pinnedAddresses?: TimetableResolvedAddress[]; @@ -36,7 +200,7 @@ const resolveTimetableBaseUrlAsync = async ( input: z.infer, allowBoardConfiguration = false, ): Promise => { - const requestedBaseUrl = normalizeTimetableBaseUrlOrThrowBadRequest(input.baseUrl); + const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl); if (allowBoardConfiguration) { if (input.boardId) { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); diff --git a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts index 7b11e6ac3..f2b2cfe86 100644 --- a/packages/custom-widgets/scripts/ai-assistant-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-assistant-evaluation.ts @@ -144,8 +144,7 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] = type: "function", function: { name: "customWidget_getComponent", - description: - "Get installed documentation and safety rules for one named Custom JSX component. Fetch only a selected component, one tool call at a time, after any optional example.", + description: "Get installed documentation and safety rules for one named Custom JSX component.", parameters: objectSchema({ name: { type: "string" } }, ["name"]), }, }, @@ -163,8 +162,7 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] = type: "function", function: { name: "customWidget_getExample", - description: - "Optionally get one installed Custom JSX example by catalog ID before requesting any component documentation.", + description: "Get one installed Custom JSX example by catalog ID.", parameters: objectSchema({ name: { type: "string" } }, ["name"]), }, }, @@ -253,24 +251,11 @@ export function executeAssistantEvaluationTool( name: string, input: Record, ): unknown { - const componentDocumentCount = state.calledTools.filter( - (toolName) => toolName === "customWidget_getComponent", - ).length; - if (name === "customWidget_getExample") { - if (componentDocumentCount > 0) { - return { - error: - "An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.", - }; - } - const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null; - if (!example) return { error: "Custom JSX example not found" }; - state.calledTools.push(name); - const { template, ...widget } = example.widget; - return { ...example, widget: { ...widget, templateLines: template.split("\n") } }; - } const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8; - if (name === "customWidget_getComponent" && componentDocumentCount >= componentDocumentLimit) { + if ( + name === "customWidget_getComponent" && + state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit + ) { return { error: "The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.", @@ -292,6 +277,12 @@ export function executeAssistantEvaluationTool( ? getCustomWidgetSharedProps(names) : { error: "At least one shared prop name is required" }; } + if (name === "customWidget_getExample") { + const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null; + if (!example) return { error: "Custom JSX example not found" }; + const { template, ...widget } = example.widget; + return { ...example, widget: { ...widget, templateLines: template.split("\n") } }; + } if (name === "customWidget_validate") { const parsed = parseDefinition(input.widget); if (!parsed.success) return { valid: false, issues: parsed.issues }; @@ -585,11 +576,11 @@ async function runAssistantAttempt(args: { process.stdout.write(` tool: ${toolCall.function.name}\n`); let output: unknown; try { - const parsed: unknown = JSON.parse(toolCall.function.arguments); - let input: Record = {}; - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { - input = Object.fromEntries(Object.entries(parsed)); - } + const parsed = JSON.parse(toolCall.function.arguments) as unknown; + const input = + parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input); } catch (error) { output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" }; diff --git a/packages/custom-widgets/scripts/ai-evaluation.ts b/packages/custom-widgets/scripts/ai-evaluation.ts index f5a6b5246..35db9c72b 100644 --- a/packages/custom-widgets/scripts/ai-evaluation.ts +++ b/packages/custom-widgets/scripts/ai-evaluation.ts @@ -219,7 +219,7 @@ export function buildRepairPrompt( export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string { return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates. -The installed Homarr skill and runtime references below are authoritative. The verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes unless the validated manifest contradicts them. Do not invent external endpoint or authentication objections from outside assumptions. Decorative icons paired with equivalent adjacent visible status text need no separate aria-label. A Badge containing explicit visible status text is not color-only. A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper. In particular, request state is exposed as status. with loading/ok/status/error fields while successful payloads are exposed as data.. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility. +The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status. with loading/ok/status/error fields while successful payloads are exposed as data.. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility. Installed Homarr Custom Widget authoring contract: ${getCustomWidgetSkillContent()} @@ -254,14 +254,16 @@ Required review behavior: - Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems. - Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array. -Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`; +Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`; } export function judgePasses(result: CustomWidgetJudgeResult): boolean { return ( - result.total >= 85 && + result.total >= 90 && Object.values(result.categories).every((score) => score >= 75) && result.categories.goalFulfillment >= 85 && + result.categories.visualQuality >= 85 && + result.categories.dailyUsefulness >= 85 && result.categories.complexityDiscipline >= 80 && result.dailyUseDecision === "would-use-daily" && result.fatalProblems.length === 0 diff --git a/packages/custom-widgets/scripts/evaluate-ai-authoring.ts b/packages/custom-widgets/scripts/evaluate-ai-authoring.ts index 3c7138a39..9a9841379 100644 --- a/packages/custom-widgets/scripts/evaluate-ai-authoring.ts +++ b/packages/custom-widgets/scripts/evaluate-ai-authoring.ts @@ -15,55 +15,58 @@ if (!apiKey) { const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length); const assistantMode = process.argv.includes("--assistant"); const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10); -const maxLoops = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10; -let selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES; -if (assistantMode) { - selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter( - (testCase) => - (testCase.sampleResponse !== undefined || testCase.previewResponses?.length) && - testCase.expectations !== undefined, - ); -} -let selectedCases = selectableCases; -if (requestedCase) - selectedCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase); +const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10; +const selectableCases = assistantMode + ? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter( + (testCase) => + (testCase.sampleResponse !== undefined || testCase.previewResponses?.length) && + testCase.expectations !== undefined, + ) + : CUSTOM_WIDGET_AI_EVALUATION_CASES; +const selectedCases = requestedCase + ? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase) + : selectableCases; if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`); +if (totalLoopBudget < selectedCases.length) { + throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`); +} const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-"); const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId); await mkdir(outputRoot, { recursive: true }); const results: Array = []; +let remainingLoopBudget = totalLoopBudget; for (const testCase of selectedCases) { + const remainingCases = selectedCases.length - results.length; + const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases)); process.stdout.write(`Evaluating ${testCase.id}...\n`); - let result: AiEvaluationResult | CustomWidgetAssistantEvaluationResult; - if (assistantMode) { - result = await evaluateCustomWidgetAssistantCase({ - testCase, - apiKey, - baseUrl: providerBaseUrl, - outputRoot, - maxLoops, - generatorModel, - judgeModel, - }); - } else { - result = await evaluateCustomWidgetCase({ - testCase, - apiKey, - baseUrl: providerBaseUrl, - outputRoot, - maxLoops, - generatorModel, - judgeModel, - }); - } + const result = assistantMode + ? await evaluateCustomWidgetAssistantCase({ + testCase, + apiKey, + baseUrl: providerBaseUrl, + outputRoot, + maxLoops, + generatorModel, + judgeModel, + }) + : await evaluateCustomWidgetCase({ + testCase, + apiKey, + baseUrl: providerBaseUrl, + outputRoot, + maxLoops, + generatorModel, + judgeModel, + }); results.push(result); - if (result.judge) { - process.stdout.write(` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`); - } else { - process.stdout.write(` failed after ${result.attempts} attempt(s)\n`); - } + remainingLoopBudget -= result.attempts; + process.stdout.write( + result.judge + ? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n` + : ` failed after ${result.attempts} attempt(s)\n`, + ); } const summary = { diff --git a/packages/custom-widgets/src/core/ai-prompt.ts b/packages/custom-widgets/src/core/ai-prompt.ts index 0576d90c1..612ef143e 100644 --- a/packages/custom-widgets/src/core/ai-prompt.ts +++ b/packages/custom-widgets/src/core/ai-prompt.ts @@ -1,11 +1,5 @@ import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema"; import { customJsxAuthoringCatalog } from "./component-catalog"; -import { - redactCustomWidgetAiContext, - redactCustomWidgetAiResponse, - redactCustomWidgetAiText, - redactCustomWidgetAiUrl, -} from "./definition-security"; import { customJsxExamples } from "./examples"; export interface CustomWidgetAiDraft { @@ -112,28 +106,19 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT; export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time. -Mandatory sequence — follow it exactly: -1. First call customWidget_getSkill and follow the complete installed skill and bundled references. -2. Call customWidget_schema, then customWidget_getComponentCatalog. -3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call. Then choose at most four component names total and never request a fifth. Fetch only those named docs and, once, any named shared props with customWidget_getSharedProps. -4. Before validation, quickly reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span; use catalog components instead. Immediately call customWidget_validate with the complete definition and repair every issue. -5. Call customWidget_previewCreate with that exact validated definition. -6. Call customWidget_previewQuery once for every query returned by the preview and inspect every response. -7. Call customWidget_createFromPreview with the exact fully tested preview session ID. - -Make one documentation tool call at a time. Never issue batched customWidget_getComponent calls, speculate about components, or walk the catalog. If preview evidence requires a definition change, restart at step 4 with the revised complete definition. The live resources are authoritative for this Homarr release. +First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release. Keep the documentation phase bounded. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only to resolve a concrete validation issue or missing interaction; never walk the catalog component-by-component. Move promptly from the smallest sufficient documentation set to a complete customWidget_validate call. -Construct one credential-free definition. Supply required manual parameters to preview queries, inspect the real response shape and HTTP status, and open or provide the returned previewPath for visual review. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs. +Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs. Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions. -Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show timestamps as concise human-readable absolute dates and times; do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper. Give recoverable load errors and empty states a clear refresh or retry path. +Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the response supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path. Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware. -Create distinctive polish with one restrained accent for decorative chrome, while operational, degraded, and down states retain distinct semantic colors. Use clear surface contrast and purposeful typography rather than decorative gradients or excessive nested cards. Do not wrap every metric or service row in Paper or Card. Prefer one dominant surface with clearly separated service rows, a one-column compact narrow layout, and an intentionally more spacious wide layout. Label standalone icons for assistive technology or pair them with visible text. +Create distinctive polish with one semantic accent, clear surface contrast, purposeful typography, and compact narrow-tile rows rather than decorative gradients or excessive nested cards. Label standalone icons for assistive technology or pair them with visible text. Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state. @@ -144,7 +129,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio if (documentationUrl) { const documentationSection = formatBudgetedContextSection( "API documentation URL (reference only)", - redactCustomWidgetAiUrl(documentationUrl), + documentationUrl, 700, "data", ); @@ -153,7 +138,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio if (request?.trim()) { const requestSection = formatBudgetedContextSection( "User-authored widget request (product intent only)", - redactCustomWidgetAiText(request.trim()), + request.trim(), 1_700, "request", ); @@ -216,7 +201,7 @@ function buildCustomWidgetPromptSections( const sections = [CUSTOM_WIDGET_CONTEXT_BOUNDARY_INSTRUCTION]; const requestSection = formatBudgetedContextSection( "User-authored widget request (product intent only)", - redactCustomWidgetAiText(request?.trim() || "Describe the widget you want to create."), + request?.trim() || "Describe the widget you want to create.", 1_700, "request", ); @@ -224,7 +209,7 @@ function buildCustomWidgetPromptSections( if (documentationUrl) { const documentationSection = formatBudgetedContextSection( "API documentation URL (reference only)", - redactCustomWidgetAiUrl(documentationUrl), + documentationUrl, 700, "data", ); @@ -233,7 +218,7 @@ function buildCustomWidgetPromptSections( if (currentConfig) { const draftSection = formatBudgetedContextSection( "Current raw widget draft", - JSON.stringify(redactCustomWidgetAiContext(currentConfig), null, 2), + JSON.stringify(currentConfig, null, 2), 4_000, "data", ); @@ -242,19 +227,14 @@ function buildCustomWidgetPromptSections( if (diagnostics?.length) { const diagnosticSection = formatBudgetedContextSection( "Current normalized diagnostics", - JSON.stringify(redactCustomWidgetAiContext(diagnostics)), + JSON.stringify(diagnostics), 1_500, "data", ); if (diagnosticSection) sections.push(diagnosticSection); } if (rawResponse) { - const responseSection = formatBudgetedContextSection( - "Sample API response", - redactCustomWidgetAiResponse(rawResponse), - 1_000, - "data", - ); + const responseSection = formatBudgetedContextSection("Sample API response", rawResponse, 1_000, "data"); if (responseSection) sections.push(responseSection); } const authoringBudget = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - footerLength - 8; diff --git a/packages/custom-widgets/src/core/definition-security.ts b/packages/custom-widgets/src/core/definition-security.ts index 1aa39f350..c5c215fcd 100644 --- a/packages/custom-widgets/src/core/definition-security.ts +++ b/packages/custom-widgets/src/core/definition-security.ts @@ -191,70 +191,6 @@ export function redactCustomWidgetCredentialLiterals(value: string): string { ); } -export function redactCustomWidgetAiContext(value: unknown, path: DefinitionPath = []): unknown { - if (Array.isArray(value)) return value.map((entry, index) => redactCustomWidgetAiContext(entry, [...path, index])); - if (value !== null && typeof value === "object") { - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => { - const entryPath = [...path, key]; - const risk = getCustomWidgetCredentialKeyRisk(key); - if (risk === "strong" && !isSchemaAuthenticationControl(entryPath)) { - if (!isHarmlessCustomWidgetCredentialSetting(entry)) return [key, "[REDACTED]"]; - } - if (risk === "ambiguous" && typeof entry === "string") { - if (redactCustomWidgetCredentialLiterals(entry) !== entry) return [key, "[REDACTED]"]; - } - return [key, redactCustomWidgetAiContext(entry, entryPath)]; - }), - ); - } - if (typeof value !== "string") return value; - - const lastPathPart = path.at(-1); - let key = ""; - if (typeof lastPathPart === "string") key = lastPathPart; - if (["sources", "requests", "options"].includes(key)) { - try { - const parsed: unknown = JSON.parse(value); - return redactCustomWidgetAiContext(parsed, path); - } catch { - /* Invalid editor JSON is still useful prompt context. */ - } - } - if (key === "baseUrl" || key === "iconUrl") { - return redactCustomWidgetCredentialLiterals(redactCustomWidgetAiUrl(value)); - } - return redactCustomWidgetAiText(value); -} - -export function redactCustomWidgetAiResponse(value: string): string { - try { - const parsed: unknown = JSON.parse(value); - return JSON.stringify(redactCustomWidgetAiContext(parsed), null, 2); - } catch { - return redactCustomWidgetAiText(value); - } -} - -export function redactCustomWidgetAiUrl(value: string): string { - try { - const url = new URL(value); - url.username = ""; - url.password = ""; - url.search = ""; - url.hash = ""; - return url.toString(); - } catch { - return "[REDACTED_URL]"; - } -} - -export function redactCustomWidgetAiText(value: string): string { - return redactCustomWidgetCredentialLiterals( - value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactCustomWidgetAiUrl(candidate)), - ); -} - function isSchemaAuthenticationControl(path: DefinitionPath): boolean { return ( path.length === 3 && diff --git a/packages/custom-widgets/src/core/examples.ts b/packages/custom-widgets/src/core/examples.ts index b3d1b30d6..30d90a473 100644 --- a/packages/custom-widgets/src/core/examples.ts +++ b/packages/custom-widgets/src/core/examples.ts @@ -36,8 +36,8 @@ export const customJsxExamples: readonly CustomJsxExample[] = [ requests: { overview: { path: "/api/overview", cacheSeconds: 30 } }, options: {}, template: ` - System overviewLive service health - {status.overview?.loading ? : status.overview?.error ? Service health is unavailable. Use Refresh service health to try again. {status.overview.error} : Services{data.overview?.services?.length ?? 0}Online{(data.overview?.services ?? []).filter(service => service.online).length}Offline{(data.overview?.services ?? []).filter(service => !service.online).length}Last checked{(data.overview?.checkedAt ?? data.overview?.lastCheckedAt) ? Date.toLocaleDateString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") + " " + Date.toLocaleTimeString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") : "Not reported"}{(data.overview?.services?.length ?? 0) > 0 ? {data.overview.services.map(service => {service.name}{service.latency != null ? service.latency + " ms" : "Latency unavailable"}{service.online ? "Online" : "Offline"})} : No services were returned. Use Refresh service health to try again.}} + System overviewLive service health + {status.overview?.loading ? : status.overview?.error ? {status.overview.error} : {(data.overview?.services ?? []).map(service => {service.name}{service.online ? "Online" : "Offline"}{service.latency ?? "—"} ms)}} `, }, }, diff --git a/packages/custom-widgets/src/jsx/analyzer-jsx.ts b/packages/custom-widgets/src/jsx/analyzer-jsx.ts index 03d1da519..a68f045f7 100644 --- a/packages/custom-widgets/src/jsx/analyzer-jsx.ts +++ b/packages/custom-widgets/src/jsx/analyzer-jsx.ts @@ -1,4 +1,3 @@ -import { customJsxCatalogComponentByName } from "../core/component-catalog"; import { customJsxBindableComponentNames, customJsxComponentByName, @@ -32,20 +31,6 @@ export function analyzeCustomJsxElement( const name = nameNode ? customJsxTagName(nameNode) : null; const resolvedName = name ? resolveCustomJsxComponentName(name) : null; const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined; - const attributes = nodesOf(opening.attributes); - if (resolvedName === "TablerIcon") { - const attributeNames = new Set(); - for (const attribute of attributes) { - if (attribute.type !== "JSXAttribute") continue; - const attributeName = nodeOf(attribute.name); - if (attributeName?.type === "JSXIdentifier") attributeNames.add(String(attributeName.name)); - } - for (const prop of customJsxCatalogComponentByName.get(resolvedName)?.props ?? []) { - if (prop.required && !attributeNames.has(prop.name)) { - context.add(opening, `MISSING_REQUIRED_PROP: '${prop.name}' on ${name} is required`); - } - } - } if (descriptor?.safety === "denied") { context.add( opening, @@ -61,7 +46,7 @@ export function analyzeCustomJsxElement( ); } - for (const attribute of attributes) { + for (const attribute of nodesOf(opening.attributes)) { if (attribute.type === "JSXSpreadAttribute") { const argument = nodeOf(attribute.argument); if (argument) context.visit(argument, depth + 1, bindings); @@ -86,11 +71,7 @@ export function analyzeCustomJsxElement( attributeName !== "bind" && !customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName) ) { - if (resolvedName === "TablerIcon") { - context.add(attribute, `UNKNOWN_COMPONENT_PROP: '${attributeName}' on ${name} is not supported`); - } else { - context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning"); - } + context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning"); } else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) { context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning"); } diff --git a/packages/custom-widgets/src/jsx/runtime-components.tsx b/packages/custom-widgets/src/jsx/runtime-components.tsx index 463482d2b..66ed0a843 100644 --- a/packages/custom-widgets/src/jsx/runtime-components.tsx +++ b/packages/custom-widgets/src/jsx/runtime-components.tsx @@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy"; import { sanitizeCustomJsxProps } from "./safe-properties"; -type Namespace = object; +type Namespace = Readonly>; function resolveExport(namespace: Namespace, name: string): ComponentType | undefined { let value: unknown = namespace; @@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) { return undefined; } - value = Reflect.get(value, segment); + value = (value as Record)[segment]; } return typeof value === "function" || (typeof value === "object" && value !== null) ? (value as ComponentType) @@ -233,9 +233,9 @@ export interface CustomJsxComponentAdapters { export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record> { const namespaces: Record = { - "@mantine/core": Core, - "@mantine/charts": Charts, - "@mantine/dates": Dates, + "@mantine/core": Core as unknown as Namespace, + "@mantine/charts": Charts as unknown as Namespace, + "@mantine/dates": Dates as unknown as Namespace, }; const components: Record> = {}; for (const descriptor of enabledCustomJsxComponents) { @@ -253,7 +253,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): components[descriptor.name] = wrap(descriptor.name, component, additions); } } - const core: Namespace = Core; + const core = Core as unknown as Namespace; for (const name of [ "Notification", "LoadingOverlay", diff --git a/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts b/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts index 8bee1553d..03ec6cab5 100644 --- a/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts +++ b/packages/custom-widgets/src/test/ai-assistant-evaluation.spec.ts @@ -146,24 +146,6 @@ describe("Custom Widget assistant live evaluation harness", () => { }); }); - it("rejects a late example without reducing the existing component-document budget", () => { - const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health"); - if (!testCase) throw new Error("Fake service-health case is missing"); - const state = createAssistantEvaluationState(); - - for (let index = 0; index < 8; index += 1) { - executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" }); - } - expect( - executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }), - ).toEqual({ - error: - "An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.", - }); - expect(state.calledTools).not.toContain("customWidget_getExample"); - expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8); - }); - it("enforces explicit multi-iteration preview requests before persistence", () => { const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health"); if (!baseCase) throw new Error("Fake service-health case is missing"); diff --git a/packages/custom-widgets/src/test/ai-evaluation.spec.ts b/packages/custom-widgets/src/test/ai-evaluation.spec.ts index 550abb466..f20c923d0 100644 --- a/packages/custom-widgets/src/test/ai-evaluation.spec.ts +++ b/packages/custom-widgets/src/test/ai-evaluation.spec.ts @@ -226,38 +226,14 @@ describe("AI authoring evaluation", () => { }); }); - it("requires an excellent result without weak categories", () => { - expect( - judgePasses({ - ...makeJudgeResult(85), - categories: { - ...makeCategories(85), - visualQuality: 76, - dailyUsefulness: 76, - complexityDiscipline: 80, - }, - }), - ).toBe(true); - expect(judgePasses(makeJudgeResult(84))).toBe(false); - expect( - judgePasses({ - ...makeJudgeResult(90), - categories: { ...makeCategories(90), accessibility: 74 }, - }), - ).toBe(false); + it("requires exceptional goal, design, practicality, and complexity scores", () => { + expect(judgePasses(makeJudgeResult(90))).toBe(true); expect( judgePasses({ ...makeJudgeResult(92), - categories: { ...makeCategories(92), goalFulfillment: 84 }, + categories: { ...makeCategories(92), dailyUsefulness: 84 }, }), ).toBe(false); - expect( - judgePasses({ - ...makeJudgeResult(92), - categories: { ...makeCategories(92), complexityDiscipline: 79 }, - }), - ).toBe(false); - expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false); expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false); }); @@ -281,20 +257,6 @@ describe("AI authoring evaluation", () => { expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2)); expect(prompt).toContain("# Bundled file: references/runtime.md"); expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility"); - expect(prompt).toContain( - "verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes", - ); - expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions"); - expect(prompt).toContain( - "Decorative icons paired with equivalent adjacent visible status text need no separate aria-label", - ); - expect(prompt).toContain("A Badge containing explicit visible status text is not color-only"); - expect(prompt).toContain( - "A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper", - ); - expect(prompt).toContain( - 'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"', - ); }); it("computes the weighted score and refuses inflated advisory verdicts", () => { diff --git a/packages/custom-widgets/src/test/ai-prompt.spec.ts b/packages/custom-widgets/src/test/ai-prompt.spec.ts index 76ee47d65..0859b58a3 100644 --- a/packages/custom-widgets/src/test/ai-prompt.spec.ts +++ b/packages/custom-widgets/src/test/ai-prompt.spec.ts @@ -53,46 +53,11 @@ describe("AI prompt", () => { expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable"); + expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons"); expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( - "choose at most four component names total and never request a fifth", - ); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( - "reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span", - ); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( - "do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper", - ); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( - "operational, degraded, and down states retain distinct semantic colors", - ); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows"); - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain( - "a one-column compact narrow layout, and an intentionally more spacious wide layout", - ); - const orderedSteps = [ - "1. First call customWidget_getSkill", - "2. Call customWidget_schema", - "3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call", - "4. Before validation", - "5. Call customWidget_previewCreate", - "6. Call customWidget_previewQuery once for every query", - "7. Call customWidget_createFromPreview", - ]; - for (const [index, step] of orderedSteps.entries()) { - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step); - if (index === 0) continue; - expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan( - CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step), - ); - } expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan( CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"), ); @@ -109,7 +74,7 @@ describe("AI prompt", () => { it("preserves raw context and the final instruction when optional context is large", () => { const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it"); - expect(prompt).toContain('"data": "'); + expect(prompt).toContain('"data":"'); expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true); expect(prompt.length).toBeLessThanOrEqual(12_000); }); @@ -137,25 +102,4 @@ describe("AI prompt", () => { expect(prompt).toContain("Use compact cards and show the current latency"); }); - - it("redacts credentials from widget context", () => { - const prompt = buildCustomWidgetAiPrompt( - undefined, - JSON.stringify({ token: "sensitive" }), - { - requests: { - status: { - path: "/status?credential=Bearer-sk-secret-123456", - headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" }, - }, - }, - }, - "Use Authorization: Bearer sk-secret-123456", - ); - - expect(prompt).not.toContain("sensitive"); - expect(prompt).not.toContain("sk-secret-123456"); - expect(prompt).toContain('"X-Auth": "[REDACTED]"'); - expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"'); - }); }); diff --git a/packages/custom-widgets/src/test/authoring-resources.spec.ts b/packages/custom-widgets/src/test/authoring-resources.spec.ts index 658613952..5dace960e 100644 --- a/packages/custom-widgets/src/test/authoring-resources.spec.ts +++ b/packages/custom-widgets/src/test/authoring-resources.spec.ts @@ -10,7 +10,6 @@ import { getCustomWidgetSkill, getCustomWidgetSkillContent, } from "../core/authoring-resources"; -import { validateCustomJsxTemplate } from "../jsx/analyzer"; const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget"); @@ -56,11 +55,6 @@ describe("authoring resources", () => { sources: { default: { baseUrl: "https://pokeapi.co" } }, }, }); - const serviceDashboard = getCustomWidgetExample("service-dashboard"); - if (!serviceDashboard) throw new Error("Service dashboard example is missing"); - expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]); - expect(serviceDashboard.widget.template.match(/ { expect(validateCustomJsxTemplate('')).toEqual([]); }); - test("rejects TablerIcon without its required name prop", () => { - expect(validateCustomJsxTemplate('')).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - severity: "error", - message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"), - }), - expect.objectContaining({ - severity: "error", - message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"), - }), - ]), - ); - }); - test("accepts common bounded formatting and collection operations", () => { expect( validateCustomJsxTemplate( diff --git a/packages/custom-widgets/src/workbench/code-editor.tsx b/packages/custom-widgets/src/workbench/code-editor.tsx index efb3219c7..16f34839a 100644 --- a/packages/custom-widgets/src/workbench/code-editor.tsx +++ b/packages/custom-widgets/src/workbench/code-editor.tsx @@ -277,8 +277,7 @@ function EditorLoader() { function formatCode(value: string, language: "jsx" | "json" | "css") { if (language === "json") { try { - const parsed: unknown = JSON.parse(value); - return `${JSON.stringify(parsed, null, 2)}\n`; + return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`; } catch { return value; } diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index bc91628f1..a5acc731d 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -13,6 +13,25 @@ export interface WidgetIntegrationConfig { maxIntegrations?: number; } +const healthMonitoringGeneralIntegrationKinds = [ + "openmediavault", + "dashDot", + "truenas", + "unraid", + "glances", + "synology", +] as const satisfies readonly IntegrationKind[]; + +export const healthMonitoringSystemIntegrationKinds = [ + ...healthMonitoringGeneralIntegrationKinds, + "mock", +] as const satisfies readonly IntegrationKind[]; + +export const healthMonitoringClusterIntegrationKinds = [ + "proxmox", + "mock", +] as const satisfies readonly IntegrationKind[]; + export const widgetIntegrationConfigs = { dnsHoleSummary: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") }, dnsHoleControls: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") }, @@ -38,15 +57,13 @@ export const widgetIntegrationConfigs = { networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") }, healthMonitoring: { - supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter( - (kind) => kind !== "patchmon" && kind !== "wud", - ), + supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds, ...healthMonitoringClusterIntegrationKinds], }, firewall: { supportedIntegrations: getIntegrationKindsByCategory("firewall") }, notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") }, mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] }, systemResources: { - supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], + supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds], }, systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] }, beszelAlerts: { supportedIntegrations: ["beszel", "mock"] }, diff --git a/packages/redis/src/lib/channel.spec.ts b/packages/redis/src/lib/channel.spec.ts index 0ec39a2a5..48925c79b 100644 --- a/packages/redis/src/lib/channel.spec.ts +++ b/packages/redis/src/lib/channel.spec.ts @@ -16,6 +16,7 @@ describe("createLockChannel", () => { expect(token).toEqual(expect.any(String)); if (!token) throw new Error("Expected the fallback lock to return a token"); + await expect(lock.renewAsync(token, 60)).resolves.toBe(true); await expect(lock.releaseAsync(token)).resolves.toBeUndefined(); }); }); diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 15f5405ae..4bff11b19 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -155,6 +155,19 @@ export const createLockChannel = (name: string) => { const result = await client.set(name, token, "EX", ttlSeconds, "NX"); return result === "OK" ? token : null; }, + renewAsync: async (token: string, ttlSeconds: number) => { + const client = getSetClient; + if (!client) return true; + + const result = await client.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end", + 1, + name, + token, + ttlSeconds, + ); + return result === 1; + }, releaseAsync: async (token: string) => { const client = getSetClient; if (!client) return; diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index f0804601b..fdfcd8d68 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -67,6 +67,7 @@ describe("createRequestHandler", () => { })), setAsync: vi.fn(async () => undefined), acquireRefreshLockAsync: vi.fn(async () => "lock"), + renewRefreshLockAsync: vi.fn(async () => true), releaseRefreshLockAsync: vi.fn(async () => undefined), }; const handler = createRequestHandler({ @@ -80,6 +81,35 @@ describe("createRequestHandler", () => { expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled(); }); + it("renews the refresh lock while the upstream request is still running", async () => { + vi.useFakeTimers(); + const upstreamRequest = deferred(); + const renewRefreshLockAsync = vi.fn(async () => true); + const releaseRefreshLockAsync = vi.fn(async () => undefined); + const sharedCache: SharedCacheAdapter = { + generation: "shared-1", + isShared: true, + getAsync: async () => null, + setAsync: async () => undefined, + acquireRefreshLockAsync: async () => "lock", + renewRefreshLockAsync, + releaseRefreshLockAsync, + }; + const handler = createRequestHandler({ + cacheTtlMs: 1_000, + requestAsync: async () => await upstreamRequest.promise, + getSharedCacheAsync: async () => sharedCache, + }); + + const pending = handler.handler({ id: "same" }).getDataAsync(); + await vi.advanceTimersByTimeAsync(5_000); + expect(renewRefreshLockAsync).toHaveBeenCalledTimes(1); + + upstreamRequest.resolve("upstream"); + await expect(pending).resolves.toMatchObject({ data: "upstream" }); + expect(releaseRefreshLockAsync).toHaveBeenCalledWith("lock"); + }); + it("waits out a refresh lock held by another process instead of stampeding the upstream", async () => { vi.useFakeTimers(); const timestamp = new Date(); @@ -92,6 +122,7 @@ describe("createRequestHandler", () => { setAsync: vi.fn(async () => undefined), // A different process/pod already holds the refresh lock. acquireRefreshLockAsync: vi.fn(async () => null), + renewRefreshLockAsync: vi.fn(async () => true), releaseRefreshLockAsync: vi.fn(async () => undefined), }; const handler = createRequestHandler({ @@ -104,7 +135,7 @@ describe("createRequestHandler", () => { // Poll well past the old fixed wait window while the lock is still held and the // shared cache is empty: the waiter must keep waiting, not fetch upstream itself. - await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(20_000); expect(requestAsync).not.toHaveBeenCalled(); // The lock holder finishes and publishes the entry to the shared cache. @@ -141,6 +172,7 @@ describe("createRequestHandler", () => { getAsync: async () => undefined, setAsync: async () => undefined, acquireRefreshLockAsync: async () => undefined, + renewRefreshLockAsync: async () => undefined, releaseRefreshLockAsync: async () => undefined, }); await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1)); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index 672a7c89a..f371d257b 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -20,25 +20,57 @@ export interface SharedCacheAdapter { getAsync: () => Promise | null | undefined>; setAsync: (entry: CacheEntry) => Promise; acquireRefreshLockAsync: () => Promise; + renewRefreshLockAsync: (token: string) => Promise; releaseRefreshLockAsync: (token: string) => Promise; } const MAX_CACHE_SIZE = 1000; const DEFAULT_TTL_MS = 10_000; const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000; -// A waiter that loses the refresh lock polls the shared cache until the lock holder -// publishes the entry (the holder writes it before releasing the lock). This window -// must be >= the shared-cache refresh lock TTL (REFRESH_LOCK_TTL_SECONDS in -// shared-cache.ts, currently 15s); otherwise every waiting pod gives up early and -// stampedes the upstream on a cold cache while the holder is still refreshing. -const SHARED_REFRESH_WAIT_MS = 15_000; -const SHARED_REFRESH_POLL_MS = 75; +const SHARED_REFRESH_POLL_MS = 100; +const SHARED_REFRESH_LOCK_RETRY_MS = 1_000; +const SHARED_REFRESH_STALE_WAIT_MS = 15_000; +// Must remain comfortably below REFRESH_LOCK_TTL_SECONDS in shared-cache.ts. +const SHARED_REFRESH_LOCK_RENEW_MS = 5_000; const delayAsync = async (durationMs: number) => await new Promise((resolve) => { setTimeout(resolve, durationMs); }); +const startRefreshLockRenewal = (sharedCache: SharedCacheAdapter, token: string) => { + let isOwned = true; + let isStopped = false; + let timer: ReturnType | undefined; + + const schedule = () => { + timer = setTimeout(() => { + void sharedCache.renewRefreshLockAsync(token).then( + (renewed) => { + if (renewed !== true) { + isOwned = false; + return; + } + if (!isStopped) schedule(); + }, + () => { + isOwned = false; + }, + ); + }, SHARED_REFRESH_LOCK_RENEW_MS); + timer.unref?.(); + }; + + schedule(); + return { + isOwned: () => isOwned, + stop: () => { + isStopped = true; + if (timer) clearTimeout(timer); + }, + }; +}; + const evictExpired = (cache: Map>) => { const now = Date.now(); for (const [key, entry] of cache) { @@ -134,8 +166,9 @@ export const createRequestHandler = = returnStaleAt && options.fallbackToStaleOnError && stale) { + storeInMemory(key, stale, requestGeneration); + return stale; + } + if (Date.now() >= retryLockAt) { + lockToken = await sharedCache.acquireRefreshLockAsync(); + retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS; + } } } } + const refreshLockRenewal = + lockToken && sharedCache ? startRefreshLockRenewal(sharedCache, lockToken) : undefined; try { const data = await options.requestAsync(input); const completedAt = Date.now(); @@ -167,7 +204,9 @@ export const createRequestHandler = 0) { storeInMemory(key, entry, requestGeneration); - if (sharedCache?.isShared) await sharedCache.setAsync(entry); + if (sharedCache?.isShared && refreshLockRenewal?.isOwned() !== false) { + await sharedCache.setAsync(entry); + } } return entry; } catch (error) { @@ -182,6 +221,7 @@ export const createRequestHandler = { diff --git a/packages/request-handler/src/lib/shared-cache.ts b/packages/request-handler/src/lib/shared-cache.ts index 99e734570..06df2a89a 100644 --- a/packages/request-handler/src/lib/shared-cache.ts +++ b/packages/request-handler/src/lib/shared-cache.ts @@ -144,6 +144,12 @@ const createRedisSharedCache = ({ () => lock.acquireAsync(REFRESH_LOCK_TTL_SECONDS), undefined, ), + renewRefreshLockAsync: async (token) => + await runRedisOperationAsync( + "refresh-lock-renew", + () => lock.renewAsync(token, REFRESH_LOCK_TTL_SECONDS), + undefined, + ), releaseRefreshLockAsync: async (token) => { await runRedisOperationAsync("refresh-lock-release", () => lock.releaseAsync(token), undefined); }, diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 2daffc1b5..7836f5a35 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -20,7 +20,6 @@ "./custom-api/custom-jsx-display": "./src/custom-api/custom-jsx-display.tsx", "./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts", "./assistant/context": "./src/assistant/context.tsx", - "./catalog": "./src/catalog.ts", "./definition": "./src/definition.ts", "./errors": "./src/errors/component.tsx", "./manifest": "./src/manifest.ts", diff --git a/packages/widgets/src/app/prefetch.ts b/packages/widgets/src/app/prefetch.ts index 8e99f7082..22c811cb6 100644 --- a/packages/widgets/src/app/prefetch.ts +++ b/packages/widgets/src/app/prefetch.ts @@ -7,8 +7,11 @@ import { createTrpcQueryKey } from "../trpc-query-key"; const logger = createLogger({ module: "appWidgetPrefetch" }); -const prefetchAllAsync: Prefetch<"app"> = async (queryClient, items) => { - const appIds = items.map((item) => item.options.appId); +const prefetchAllAsync: Prefetch = async (queryClient, items) => { + const appIds: string[] = []; + for (const item of items) { + if (typeof item.options.appId === "string") appIds.push(item.options.appId); + } const distinctAppIds = [...new Set(appIds)]; const dbApps = await db.query.apps.findMany({ diff --git a/packages/widgets/src/bookmarks/prefetch.ts b/packages/widgets/src/bookmarks/prefetch.ts index 75f9373a4..6ec1bab54 100644 --- a/packages/widgets/src/bookmarks/prefetch.ts +++ b/packages/widgets/src/bookmarks/prefetch.ts @@ -8,8 +8,13 @@ import { getDirectBookmarkUrl } from "./bookmark-item"; const logger = createLogger({ module: "bookmarksWidgetPrefetch" }); -const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => { - const appIds = items.flatMap((item) => item.options.items.filter((value) => !getDirectBookmarkUrl(value))); +const getAppIds = (options: Record) => { + if (!Array.isArray(options.items)) return []; + return options.items.filter((value): value is string => typeof value === "string" && !getDirectBookmarkUrl(value)); +}; + +const prefetchAllAsync: Prefetch = async (queryClient, items) => { + const appIds = items.flatMap((item) => getAppIds(item.options)); const distinctAppIds = [...new Set(appIds)]; const dbApps = await db.query.apps.findMany({ @@ -17,7 +22,7 @@ const prefetchAllAsync: Prefetch<"bookmarks"> = async (queryClient, items) => { }); for (const item of items) { - const itemAppIds = item.options.items.filter((value) => !getDirectBookmarkUrl(value)); + const itemAppIds = getAppIds(item.options); if (itemAppIds.length === 0) { continue; } diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts deleted file mode 100644 index 31eb37e9e..000000000 --- a/packages/widgets/src/catalog.ts +++ /dev/null @@ -1 +0,0 @@ -export { widgetCatalogIcons } from "@homarr/ui/widget-icons"; diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 6a308254b..f7947f7b4 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -83,11 +83,11 @@ const createWithDynamicImport = componentLoader, }); -export type PrefetchLoader = () => Promise<{ default: Prefetch }>; -export type Prefetch = ( +export type PrefetchLoader = () => Promise<{ default: Prefetch }>; +export type Prefetch = ( queryClient: QueryClient, items: { - options: inferOptionsFromCreator>; + options: Record; integrationIds: string[]; }[], ) => Promise; diff --git a/packages/widgets/src/health-monitoring/integration-selection.ts b/packages/widgets/src/health-monitoring/integration-selection.ts index ee2f679df..2ab8baf34 100644 --- a/packages/widgets/src/health-monitoring/integration-selection.ts +++ b/packages/widgets/src/health-monitoring/integration-selection.ts @@ -1,3 +1,4 @@ +import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions"; interface HealthMonitoringIntegration { @@ -5,8 +6,11 @@ interface HealthMonitoringIntegration { kind: IntegrationKind; } +const systemIntegrationKinds = new Set(healthMonitoringSystemIntegrationKinds); +const clusterIntegrationKinds = new Set(healthMonitoringClusterIntegrationKinds); + export const partitionHealthMonitoringIntegrations = (integrations: HealthMonitoringIntegration[]) => ({ - clusterIntegrationIds: integrations.filter(({ kind }) => kind === "proxmox" || kind === "mock").map(({ id }) => id), + clusterIntegrationIds: integrations.filter(({ kind }) => clusterIntegrationKinds.has(kind)).map(({ id }) => id), // Mock implements both contracts and remains visible in both views. - systemIntegrationIds: integrations.filter(({ kind }) => kind !== "proxmox").map(({ id }) => id), + systemIntegrationIds: integrations.filter(({ kind }) => systemIntegrationKinds.has(kind)).map(({ id }) => id), }); diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts index f15fa4b0f..7685f935a 100644 --- a/packages/widgets/src/manifest.spec.ts +++ b/packages/widgets/src/manifest.spec.ts @@ -14,7 +14,7 @@ import { widgetKinds, } from "./manifest"; import { widgetQueryRefetchIntervals } from "./refetch-intervals"; -import { widgetCatalogIcons } from "./catalog"; +import { widgetCatalogIcons } from "@homarr/ui/widget-icons"; import { getWidgetQueryKeys } from "./definition"; const serializePollingPolicy = ({ diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index bd5f00e00..954971ce3 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -154,12 +154,10 @@ export const reduceWidgetOptionsWithDefinition = ( settings: Pick, currentValue: Record = {}, ) => { - const options = definition.createOptions(settings) as Record; - return objectEntries(options).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: currentValue[key] ?? value.defaultValue, - }), - {} as Record, - ); + const options: Record = definition.createOptions(settings); + const result: Record = {}; + for (const [key, value] of objectEntries(options)) { + result[key] = currentValue[key] ?? value.defaultValue; + } + return result; }; diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts index 1f0220893..528874c50 100644 --- a/packages/widgets/src/prefetch.ts +++ b/packages/widgets/src/prefetch.ts @@ -5,12 +5,10 @@ import type { WidgetKind } from "@homarr/definitions"; import { createSettings } from "@homarr/settings/creator"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest"; -import type { PrefetchLoader, WidgetOptionsRecordOf } from "./definition"; -import type { inferOptionsFromCreator } from "./options"; +import type { PrefetchLoader } from "./definition"; -const definePrefetchLoaders = }>>( - loaders: TLoaders, -) => loaders; +const definePrefetchLoaders = >>(loaders: TLoaders) => + loaders; // Keep these imports explicit so Next.js can trace each optional prefetch // module without loading its database-specific implementation eagerly. @@ -24,11 +22,11 @@ type PrefetchWidgetKind = keyof typeof prefetchLoaders; const hasPrefetchLoader = (kind: WidgetKind): kind is PrefetchWidgetKind => Object.prototype.hasOwnProperty.call(prefetchLoaders, kind); -export const prefetchForKindAsync = async ( - kind: TKind, +export const prefetchForKindAsync = async ( + kind: WidgetKind, queryClient: QueryClient, items: { - options: inferOptionsFromCreator>; + options: Record; integrationIds: string[]; }[], ) => { @@ -51,5 +49,5 @@ export const prefetchForKindAsync = async ( ), })); - await callback(queryClient, itemsWithDefaultOptions as never[]); + await callback(queryClient, itemsWithDefaultOptions); };