diff --git a/.cursor/rules/codebase-context.mdc b/.cursor/rules/codebase-context.mdc index 48528cbe9..349673637 100644 --- a/.cursor/rules/codebase-context.mdc +++ b/.cursor/rules/codebase-context.mdc @@ -40,7 +40,7 @@ homarr/ │ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries │ ├── core/ # Env validation, DB/Redis driver factories, logging │ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions -│ ├── widgets/ # All 39 dashboard widgets (definitions + components) +│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry │ ├── integrations/ # Integration classes (HTTP clients to external apps) │ ├── redis/ # Redis pub/sub channels, caching abstractions │ ├── translation/ # next-intl setup, locale configs, lang JSON files @@ -118,11 +118,15 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos ### Widget System -Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record`). Each widget: -- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` -- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options -- Loads dynamically via `next/dynamic` -- Fetches data via tRPC client hooks (backed by cron job caches) +Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in +`packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and +`componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`. + +Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal +module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so +`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while +keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected +entries so later loads can retry. Do not restore an eager root `widgetImports` map. ### Integration System diff --git a/apps/docs/docs/advanced/development/widgets-and-integrations.mdx b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx new file mode 100644 index 000000000..6e481bdfa --- /dev/null +++ b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx @@ -0,0 +1,102 @@ +--- +title: Widgets and integrations +tags: + - Developer + - Technical documentation + - Widgets + - Integrations +sidebar_position: 2 +--- + +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 + +Keep the UI, options, helpers, and tests together in `packages/widgets/src//`. The usual folder only needs an +`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed. + +1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in + `packages/definitions/src/docs/widget-doc-slugs.ts`. +2. Export `definition` and `componentLoader` from `packages/widgets/src//index.ts` with + `createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and + errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard. +3. For an integration-backed widget, add its integration kinds and selection rules once to + `packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local + definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without + duplicating widget metadata. +4. Register the widget in both explicit literal loader maps and the type-only module map in + `packages/widgets/src/registry.ts`, then add its icon to `packages/ui/src/widget-icons.ts`. The module and component + loaders must both use literal import paths. Keeping them separate lets a cold widget load request its definition + and component chunks in parallel; the type-only map preserves exact option and component inference. Keep all three + maps in the same order so drift is obvious in review. +5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit + lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in + `@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures. +6. Add canonical documentation in `apps/docs/docs/widgets//`. + +Weather is the small integration-free example. Downloads shows integration selection, polling, server caching, +mutations, partial upstream failures, and a larger options surface. + +## Add an integration + +Keep the HTTP client and its capability-specific types in `packages/integrations/src//`. + +1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives, + categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant. +2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the + 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 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 for bounded key + spaces. Public handlers whose keys come from arbitrary URLs, domains, symbols, or coordinates stay in the bounded + process-local cache. +- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys + include keyed credential fingerprints and the complete integration identity, and are invalidated after mutations. + The fingerprints change with credentials without exposing their values in Redis keys. +- Give upstream requests a finite deadline and pass the request handler's `AbortSignal` into clients that support it. + Admission is bounded per handler. Request deduplication and distributed lock ownership remain active until the + upstream settles or a bounded cleanup grace expires, while unresolved upstream work stays separately capped. +- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards. +- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log + secrets or full upstream payloads. +- Multi-integration reads should return successful providers when one service fails and surface a clear error when all + providers fail. + +Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact +TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable. + +## Registry imports + +Use `@homarr/widgets/manifest` to load registered widgets. `loadWidgetDefinition` is the metadata-only path; +`loadWidgetComponent` loads only the UI module; and `loadWidgetResources` requests both paths in parallel. These +loaders cache in-flight and successful promises and remove rejected promises so a later request can retry. + +The former root `widgetImports` and `loadWidgetDynamic` exports are intentionally not compatibility aliases. +`widgetImports` synchronously imported every widget module, and `loadWidgetDynamic` depended on that eager map. +Restoring them at the package root would defeat lazy definition chunks or add client-only runtime dependencies to +every root consumer. Migrate metadata consumers to `loadWidgetDefinition`, renderers to `loadWidgetResources`, and +type-only registry consumers from the removed internal `WidgetImportRecord` helper to the root `WidgetImports` type. + +`@homarr/widgets/catalog` remains as a deprecated compatibility subpath for `widgetCatalogIcons`. New imports should +use `@homarr/ui/widget-icons`, which is the canonical server-safe icon catalog. diff --git a/apps/docs/docs/integrations/beszel/index.ts b/apps/docs/docs/integrations/beszel/index.ts index bcb8065e8..5b7db0d8c 100644 --- a/apps/docs/docs/integrations/beszel/index.ts +++ b/apps/docs/docs/integrations/beszel/index.ts @@ -1,9 +1,9 @@ -import { IntegrationDefinition } from "@site/src/types"; +import type { IntegrationDefinition } from "@site/src/types"; -export const beszelIntegration: IntegrationDefinition = { +export const beszelIntegration = { name: "Beszel", description: "Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.", iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", path: "../../integrations/beszel", -}; +} satisfies IntegrationDefinition; diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 3165867d4..8b72e3bd6 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -113,7 +113,10 @@ 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 -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`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. 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. +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. The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum. diff --git a/apps/docs/plugins/validate-docs-coverage.ts b/apps/docs/plugins/validate-docs-coverage.ts index a3e077fea..d677a22f7 100644 --- a/apps/docs/plugins/validate-docs-coverage.ts +++ b/apps/docs/plugins/validate-docs-coverage.ts @@ -2,13 +2,17 @@ import type { LoadContext, Plugin } from "@docusaurus/types"; import fs from "node:fs"; import path from "node:path"; -import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs"; import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs"; +import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration"; -const slugMaps = { - Integration: { slugs: integrationDocSlugs, folder: "integrations" }, - Widget: { slugs: widgetDocSlugs, folder: "widgets" }, -} as const; +const documentationEntries = [ + { + label: "Integration", + folder: "integrations", + entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const), + }, + { label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) }, +] as const; export default function validateDocsCoveragePlugin(context: LoadContext): Plugin { return { @@ -17,8 +21,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin const docsDir = path.resolve(context.siteDir, "docs"); const missing: string[] = []; - for (const [label, { slugs, folder }] of Object.entries(slugMaps)) { - for (const [kind, slug] of Object.entries(slugs)) { + for (const { label, folder, entries } of documentationEntries) { + for (const [kind, slug] of entries) { if (!slug) continue; if (!fs.existsSync(path.join(docsDir, folder, slug))) { missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`); diff --git a/apps/docs/src/types.ts b/apps/docs/src/types.ts index 04b3848b9..48ba6b02b 100644 --- a/apps/docs/src/types.ts +++ b/apps/docs/src/types.ts @@ -1,5 +1,5 @@ -import { TablerIcon } from "@tabler/icons-react"; -import { ReactNode } from "react"; +import type { TablerIcon } from "@tabler/icons-react"; +import type { ReactNode } from "react"; export interface IntegrationDefinition { name: string; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 5b6621679..7da75639d 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -145,10 +145,14 @@ const ScopedTRPCReactProvider = ({ refetchOnWindowFocus: false, refetchOnReconnect: false, }); - for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { - client.setQueryDefaults(queryKey, { - refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000, - }); + for (const queryDefaults of widgetQueryRefetchIntervals) { + const policy: { refetchInterval?: number | false; staleTime?: number } = {}; + if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false; + if (typeof queryDefaults.intervalSeconds === "number") { + policy.refetchInterval = queryDefaults.intervalSeconds * 1000; + } + if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000; + client.setQueryDefaults(queryDefaults.queryKey, policy); } for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { client.setQueryDefaults(queryKey, policy); 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]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index bbce84cce..884e56989 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -114,12 +114,15 @@ export default async function Layout(props: { }) : null, ); - const assistantAvailabilityPromise: Promise = sessionPromise.then((session) => { + const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise => { if (!session) return "unauthenticated"; - return api.assistant - .getAvailability() - .then((availability) => (availability.enabled ? "enabled" : "unconfigured")) - .catch(() => "error"); + + try { + const availability = await api.assistant.getAvailability(); + return availability.enabled ? "enabled" : "unconfigured"; + } catch { + return "error"; + } }); const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([ sessionPromise, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx index 200952d69..bba1a4cf4 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-advanced-manifest.tsx @@ -50,7 +50,8 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm onChange={(next) => { setDraft(next); try { - const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown); + const value: unknown = JSON.parse(next); + const parsed = customWidgetDefinitionSchema.safeParse(value); if (!parsed.success) { setError(parsed.error.issues[0]?.message ?? t("invalidWidget")); return; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts index c0303010e..e7c64cea2 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form-utils.ts @@ -76,7 +76,8 @@ export function parseSources(value: string): Array props.setMobilePane(value as MobilePane)} + onChange={(value) => { + if (value === "configure" || value === "preview") props.setMobilePane(value); + }} data={[ { value: "configure", label: w("configure") }, { value: "preview", label: w("section.preview") }, diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx index eab1e9076..462994f85 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-request-value-editors.tsx @@ -173,7 +173,9 @@ export function RequestBodyEditor({ onChange={(next) => { setDraft(next); try { - onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined); + let parsed: unknown; + if (next.trim()) parsed = JSON.parse(next); + onChange(parsed); setError(undefined); } catch (cause) { setError(cause instanceof Error ? cause.message : t("invalidJson")); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index f6e85e0ff..215dee98f 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -2,7 +2,6 @@ import { headers } from "next/headers"; import { notFound } from "next/navigation"; import { Stack } from "@mantine/core"; -import { mcpRouter } from "@homarr/api/mcp"; import { openApiDocument } from "@homarr/api/open-api"; import { api } from "@homarr/api/server"; import { auth } from "@homarr/auth/next"; @@ -16,40 +15,24 @@ import { ScalarApiReference } from "./components/scalar-api-reference"; import type { McpToolGroup } from "./components/api-page-tabs"; -let cachedToolGroups: McpToolGroup[] | null = null; - function getMcpToolGroups(): McpToolGroup[] { - if (cachedToolGroups) return cachedToolGroups; - const tools = extractMcpTools(); - - const procedures = (mcpRouter as any)._def.procedures as Record; - const procedureTypeMap = new Map(); - for (const [key, proc] of Object.entries(procedures)) { - const type = proc?._def?.type; - if (type === "query" || type === "mutation") { - procedureTypeMap.set(key, type); - } - } - const groups = new Map(); for (const tool of tools) { const namespace = tool.pathInRouter[0] ?? "other"; if (!groups.has(namespace)) { groups.set(namespace, []); } - const procedureKey = tool.pathInRouter.join("."); groups.get(namespace)?.push({ name: tool.name, description: tool.description, - type: procedureTypeMap.get(procedureKey) ?? "query", + type: tool.type, }); } - cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({ + return Array.from(groups.entries()).map(([namespace, items]) => ({ namespace, tools: items, })); - return cachedToolGroups; } export async function generateMetadata() { @@ -70,12 +53,14 @@ export default async function ApiPage() { if (!session?.user.permissions.includes("admin")) { notFound(); } - const requestHeaders = await headers(); + const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([ + headers(), + api.apiKeys.getAll(), + getI18n("management.page.tool.api.tab"), + getMcpToolGroups(), + ]); const baseUrl = extractBaseUrlFromHeaders(requestHeaders); const document = openApiDocument(baseUrl); - const apiKeys = await api.apiKeys.getAll(); - const t = await getI18n("management.page.tool.api.tab"); - const toolGroups = getMcpToolGroups(); return ( <> 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/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index c48fdab0a..78e2fef0e 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -327,20 +327,6 @@ const sumCompleteStepMetric = ( return (values as number[]).reduce((sum, value) => sum + value, 0); }; -const getProcedureTypeMap = () => { - const procedures = ( - mcpRouter as unknown as { - ["_def"]: { procedures: Record }; - } - )["_def"].procedures; - return new Map( - Object.entries(procedures).flatMap(([path, procedure]) => { - const type = procedure["_def"]?.type; - return type === "query" || type === "mutation" ? [[path, type] as const] : []; - }), - ); -}; - const waitForDemoChunkAsync = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const createDemoAssistantResponse = (request: z.infer) => { @@ -385,7 +371,8 @@ export async function POST(request: Request) { const parsed = requestSchema.safeParse( (() => { try { - return JSON.parse(requestBody) as unknown; + const value: unknown = JSON.parse(requestBody); + return value; } catch { return null; } @@ -516,14 +503,13 @@ export async function POST(request: Request) { const canAuthorCustomWidgets = session.user.permissions.includes("admin"); const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets); const caller = mcpRouter.createCaller(context); - const procedureTypes = getProcedureTypeMap(); const omittedCustomWidgetTools = new Set(customWidgetAuthoringContext.omittedToolNames); const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name)); const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget(); const homarrTools = Object.fromEntries( mcpTools.map((mcpTool) => { - const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"; + const requiresApproval = mcpTool.type === "mutation"; return [ mcpTool.name, tool({ @@ -571,11 +557,9 @@ export async function POST(request: Request) { }), ) satisfies ToolSet; const toolApproval = Object.fromEntries( - mcpTools.flatMap((mcpTool) => - procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation" - ? [[mcpTool.name, "user-approval" as const]] - : [], - ), + mcpTools + .filter((mcpTool) => mcpTool.type === "mutation") + .map((mcpTool) => [mcpTool.name, "user-approval" as const]), ); const frontendTools = Object.fromEntries( diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index a5f4f9b09..70bcfa4a4 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -46,18 +46,21 @@ vi.mock("../_extract-tools", () => ({ { name: "board_getAllBoards", description: "List boards", + type: "query", pathInRouter: ["board", "getAllBoards"], inputSchema: { type: "object", properties: {} }, }, { name: "customWidget_list", description: "List Custom Widgets", + type: "query", pathInRouter: ["customWidget", "list"], inputSchema: { type: "object", properties: {} }, }, { name: "customWidget_workshopSearch", description: "Search Workshop", + type: "query", pathInRouter: ["customWidget", "workshopSearch"], inputSchema: { type: "object", properties: {} }, }, diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts index d1fe6f5ac..d736c1baf 100644 --- a/apps/nextjs/src/app/api/mcp/_handler.ts +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -80,18 +80,10 @@ function clearAuthFailures(ip: string | null) { authFailures.delete(ip ?? "unknown"); } -let toolsCache: McpTool[] | null = null; - -function getTools() { - if (!toolsCache) { - toolsCache = extractMcpTools(); - logger.info(`Extracted ${toolsCache.length} MCP tools`); - } - return toolsCache; -} - -const getVisibleTools = (isAdmin: boolean) => - isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_")); +const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => { + if (isAdmin) return tools; + return tools.filter((tool) => !tool.name.startsWith("customWidget_")); +}; function registerCustomWidgetAuthoring(server: McpServer) { server.registerPrompt( @@ -336,7 +328,7 @@ export const handleMcpRequest = async (req: NextRequest) => { const isAdmin = session.user.permissions.includes("admin"); const protocolHandler = createMcpProtocolHandler({ caller, - tools: getVisibleTools(isAdmin), + tools: getVisibleTools(extractMcpTools(), isAdmin), version: getPackageVersion(), instructions: SERVER_INSTRUCTIONS, configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined, diff --git a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts index e29baf6c8..b5c8882f8 100644 --- a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts +++ b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts @@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() }); const echoTool: McpTool = { name: "echo", description: "Echo a message", + type: "query", pathInRouter: ["echo"], inputValidator: echoValidator, inputSchema: z.toJSONSchema(echoValidator), diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts index 2d199d91d..427958058 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts @@ -2,25 +2,32 @@ import { describe, expect, test } from "vitest"; import type { WidgetKind } from "@homarr/definitions"; import { widgetIntegrationSupport } from "@homarr/definitions"; -import { widgetImports } from "@homarr/widgets"; +import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest"; import { getAssistantWidgetConfiguration } from "./assistant-widget-tool"; const settings = { enableStatusByDefault: true, forceDisableStatus: false }; +const widgetDefinitions = await loadAllWidgetDefinitions(); + +const getWidgetDefinition = (kind: WidgetKind) => { + const definition = widgetDefinitions.get(kind); + if (!definition) throw new Error(`No definition is registered for ${kind}`); + return definition; +}; describe("getAssistantWidgetConfiguration", () => { test("keeps server-side integration support synchronized with widget definitions", () => { - for (const [kind, widgetImport] of Object.entries(widgetImports)) { - const definition = widgetImport.definition; + for (const [kind, definition] of widgetDefinitions) { const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined; + const supportedIntegrations = widgetIntegrationSupport[kind]; - expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected); + expect(supportedIntegrations, kind).toEqual(expected); } }); test("preserves generated notebook content and fills its hidden defaults", () => { const result = getAssistantWidgetConfiguration( - widgetImports.notebook.definition, + getWidgetDefinition("notebook"), { boardId: "board-1", boardName: "Home", @@ -42,7 +49,7 @@ describe("getAssistantWidgetConfiguration", () => { test("keeps only accessible, compatible integrations and respects the widget maximum", () => { const result = getAssistantWidgetConfiguration( - widgetImports.audioStats.definition, + getWidgetDefinition("audioStats"), { boardId: "board-1", boardName: "Home", @@ -90,13 +97,13 @@ describe("getAssistantWidgetConfiguration", () => { test("treats integration-backed widgets as required unless explicitly optional", () => { const mediaServer = getAssistantWidgetConfiguration( - widgetImports.mediaServer.definition, + getWidgetDefinition("mediaServer"), { boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" }, settings, [], ); const calendar = getAssistantWidgetConfiguration( - widgetImports.calendar.definition, + getWidgetDefinition("calendar"), { boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" }, settings, [], diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx index 55462c3e4..bf87766a8 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/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index e38dd0ccc..ad753f8d1 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -63,14 +63,14 @@ describe("item actions create-item", () => { { layoutId, height: 1, - width: 1, + width: 2, ...emptyPosition, sectionId: firstSectionId, }, ], }), ); - expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 }); + expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 }); }); test("should correctly pass containers and items to getFirstEmptyPosition", () => { // Arrange @@ -118,14 +118,14 @@ describe("item actions create-item", () => { expect(createdItem).toEqual( expect.objectContaining({ kind: itemKind, - layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }], + layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }], }), ); expect(spy).toHaveBeenCalledWith( [expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)], layout.columnCount, undefined, - { height: 1, width: 1 }, + { height: 1, width: 2 }, ); }); diff --git a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts index cd2b848a9..78ab1ef84 100644 --- a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts @@ -1,5 +1,5 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions"; +import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions"; export interface BoardRecipeRecommendation { widgetKind: WidgetKind; @@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({ return widgetKinds .filter((widgetKind) => !existingKinds.has(widgetKind)) .flatMap((widgetKind) => { - const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities]; - if (!capability) return []; + const supportedIntegrations = widgetIntegrationSupport[widgetKind]; + if (!supportedIntegrations) return []; - const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind)); + const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind)); if (matches.length === 0) return []; const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind); @@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({ widgetKind, integrationKind, isNewlyAvailable: Boolean(preferredMatch), - score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length, + score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length, }, ]; }) - .sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) + .toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) .slice(0, Math.max(0, limit)); }; 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/items/widget-query-scope.spec.ts b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts index ec667102e..5b7cfe52b 100644 --- a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts +++ b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts @@ -1,12 +1,14 @@ import type { QueryKey } from "@tanstack/react-query"; import { describe, expect, test } from "vitest"; -import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets"; -import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets"; +import type { WidgetKind } from "@homarr/definitions"; +import type { NormalizedWidgetQuery, WidgetQueryMatcherScope } from "@homarr/widgets"; +import { getWidgetQueryKeys } from "@homarr/widgets"; +import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest"; import { matchesWidgetItemQuery } from "./widget-query-scope"; -type WidgetKind = keyof typeof widgetImports; +const widgetDefinitions = await loadAllWidgetDefinitions(); const allPhotosAlbumId = "all"; const createScope = (overrides: Partial = {}): WidgetQueryMatcherScope => ({ @@ -19,7 +21,9 @@ const createScope = (overrides: Partial = {}): WidgetQu }); const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => { - const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string }; + const definition = widgetDefinitions.get(kind); + if (!definition) throw new Error(`No definition is registered for ${kind}`); + const queryKey: QueryKey = [path, { input, type: "query" }]; return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher); }; 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/mcp-tools.ts b/packages/api/src/mcp-tools.ts index b3e2d058e..d9ade36a0 100644 --- a/packages/api/src/mcp-tools.ts +++ b/packages/api/src/mcp-tools.ts @@ -1,4 +1,4 @@ -import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import"; +import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import"; import { z } from "zod/v4"; export interface McpMeta { @@ -12,6 +12,7 @@ export interface McpMeta { export interface McpTool { name: string; description: string; + type: "query" | "mutation"; pathInRouter: string[]; inputSchema: z.core.JSONSchema.JSONSchema; inputValidator: z.ZodObject; @@ -19,13 +20,54 @@ export interface McpTool { const emptyInput = z.object({}); +const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function"; + const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput); +const getObjectInput = (input: z.ZodType) => { + if (input instanceof z.ZodObject) return input; + + if (input instanceof z.ZodOptional) { + const unwrappedInput = input.unwrap(); + if (unwrappedInput instanceof z.ZodObject) return unwrappedInput; + } + + return null; +}; + +const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => { + if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined; + + const metadata = meta.mcp; + if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined; + if (typeof metadata.enabled !== "boolean") return undefined; + + const result: NonNullable = { enabled: metadata.enabled }; + if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name; + if ("description" in metadata && typeof metadata.description === "string") { + result.description = metadata.description; + } + return result; +}; + +const getProcedureInput = (inputs: z.ZodType[]) => { + if (inputs.length === 0) return emptyInput; + if (inputs.length === 1) { + const input = inputs[0] ?? emptyInput; + return getObjectInput(input) ?? input; + } + + const objectInputs = inputs.map(getObjectInput); + if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs); + return null; +}; + const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => { - const properties = (schema.properties ?? {}) as Record>; - const required = ((schema.required as string[] | undefined) ?? []).filter( - (key) => !("default" in (properties[key] ?? {})), - ); + const properties = schema.properties ?? {}; + const required = (schema.required ?? []).filter((key) => { + const property = properties[key]; + return !property || typeof property !== "object" || !("default" in property); + }); return { ...schema, @@ -41,22 +83,15 @@ export function extractMcpToolsFromProcedures input !== undefined); - const procedureInput = - inputs.length === 0 - ? emptyInput - : inputs.length === 1 - ? (inputs[0] ?? emptyInput) - : inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject) - ? mergeInputs(inputs) - : null; + const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType); + const procedureInput = getProcedureInput(inputs); if ( procedureInput === null || @@ -69,6 +104,7 @@ export function extractMcpToolsFromProcedures( +const logger = createLogger({ module: "integrationMiddleware" }); + +const getIntegrationKindForLog = (integration: object) => { + if ("kind" in integration && typeof integration.kind === "string") return integration.kind; + return "unknown"; +}; + +interface IntegrationAccessMetadata { + action: IntegrationAction; + cardinality: "one" | "many"; + userId: string | undefined; + requestedIntegrationIds: readonly string[]; + allowedIntegrationKinds: readonly IntegrationKind[]; +} + +const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => { + logger.error("Database returned an integration outside the requested kinds", { + integrationKind: kind, + allowedIntegrationKinds: allowedKinds, + }); + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" }); +}; + +const createOneIntegrationMiddlewareForKinds = ( action: IntegrationAction, - ...kinds: AtLeastOneOf // Ensure at least one kind is provided + kinds: readonly TKind[], ) => { return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { + const accessMetadata: IntegrationAccessMetadata = { + action, + cardinality: "one", + userId: ctx.session?.user.id, + requestedIntegrationIds: [input.integrationId], + allowedIntegrationKinds: kinds, + }; + logger.debug("Resolving integration access", accessMetadata); + const integration = await ctx.db.query.integrations.findFirst({ where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)), with: { @@ -45,6 +70,7 @@ export const createOneIntegrationMiddleware = ( }); if (!integration) { + logger.warn("Integration access target was not found", accessMetadata); throw new TRPCError({ code: "NOT_FOUND", message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`, @@ -52,22 +78,24 @@ export const createOneIntegrationMiddleware = ( } await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session); + logger.debug("Integration access granted", { + ...accessMetadata, + resolvedIntegrationKinds: [integration.kind], + }); - const { - secrets, - kind, - items: _ignore1, - groupPermissions: _ignore2, - userPermissions: _ignore3, - ...rest - } = integration; + const narrowedKind = kinds.find((kind) => kind === integration.kind); + if (!narrowedKind) { + return throwUnexpectedIntegrationKind(integration.kind, kinds); + } + + const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration; return next({ ctx: { integration: { ...rest, externalUrl: rest.app?.href ?? null, - kind: kind as TKind, + kind: narrowedKind, decryptedSecrets: secrets.map((secret) => ({ ...secret, value: decryptSecret(secret.value), @@ -78,6 +106,101 @@ export const createOneIntegrationMiddleware = ( }); }; +/** + * Creates a middleware that provides the integration in the context that is of the specified kinds + * @param action query for showing data or interact for mutating data + * @param kinds kinds of integrations that are supported + * @returns middleware that can be used with trpc + * @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...) + * @throws TRPCError NOT_FOUND if the integration was not found + * @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration + */ +export const createOneIntegrationMiddleware = ( + action: IntegrationAction, + ...kinds: AtLeastOneOf // Ensure at least one kind is provided +) => { + return createOneIntegrationMiddlewareForKinds(action, kinds); +}; + +const createManyIntegrationMiddlewareForKinds = ( + action: IntegrationAction, + kinds: readonly TKind[], +) => { + return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => { + const accessMetadata: IntegrationAccessMetadata = { + action, + cardinality: "many", + userId: ctx.session?.user.id, + requestedIntegrationIds: input.integrationIds, + allowedIntegrationKinds: kinds, + }; + logger.debug("Resolving integration access", accessMetadata); + + const findIntegrations = async () => { + if (input.integrationIds.length === 0) return []; + + return await ctx.db.query.integrations.findMany({ + where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, + }, + }, + userPermissions: true, + groupPermissions: true, + }, + }); + }; + const dbIntegrations = await findIntegrations(); + + const offset = input.integrationIds.length - dbIntegrations.length; + if (offset !== 0) { + logger.warn("Integration access targets were not found", { + ...accessMetadata, + missingIntegrationCount: offset, + resolvedIntegrationIds: dbIntegrations.map(({ id }) => id), + resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind), + }); + throw new TRPCError({ + code: "NOT_FOUND", + message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`, + }); + } + + if (dbIntegrations.length >= 1) { + await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session); + } + + logger.debug("Integration access granted", { + ...accessMetadata, + resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind), + }); + + return next({ + ctx: { + integrations: dbIntegrations.map( + ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => { + const narrowedKind = kinds.find((allowedKind) => allowedKind === kind); + if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds); + return { + ...rest, + externalUrl: rest.app?.href ?? null, + kind: narrowedKind, + decryptedSecrets: secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }; + }, + ), + }, + }); + }); +}; + /** * Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item * It also ensures that the user has permission to perform the specified action on the integrations @@ -92,55 +215,27 @@ export const createManyIntegrationMiddleware = ( action: IntegrationAction, ...kinds: AtLeastOneOf // Ensure at least one kind is provided ) => { - return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => { - const dbIntegrations = - input.integrationIds.length >= 1 - ? await ctx.db.query.integrations.findMany({ - where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)), - with: { - app: true, - secrets: true, - items: { - with: { - item: true, - }, - }, - userPermissions: true, - groupPermissions: true, - }, - }) - : []; - - const offset = input.integrationIds.length - dbIntegrations.length; - if (offset !== 0) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`, - }); - } - - if (dbIntegrations.length >= 1) { - await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session); - } - - return next({ - ctx: { - integrations: dbIntegrations.map( - ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({ - ...rest, - externalUrl: rest.app?.href ?? null, - kind: kind as TKind, - decryptedSecrets: secrets.map((secret) => ({ - ...secret, - value: decryptSecret(secret.value), - })), - }), - ), - }, - }); - }); + return createManyIntegrationMiddlewareForKinds(action, kinds); }; +export const createOneWidgetIntegrationMiddleware = ( + action: IntegrationAction, + widgetKind: TWidgetKind, +): ReturnType>> => + createOneIntegrationMiddlewareForKinds>( + action, + getWidgetIntegrationConfig(widgetKind).supportedIntegrations, + ); + +export const createManyWidgetIntegrationMiddleware = ( + action: IntegrationAction, + widgetKind: TWidgetKind, +): ReturnType>> => + createManyIntegrationMiddlewareForKinds>( + action, + getWidgetIntegrationConfig(widgetKind).supportedIntegrations, + ); + /** * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations * @param action action to perform @@ -152,25 +247,39 @@ export const createManyIntegrationMiddleware = ( const throwIfActionIsNotAllowedAsync = async ( action: IntegrationAction, db: Database, - integrations: Parameters[1], + integrationRecords: Parameters[1], session: Session | null, ) => { if (action === "interact") { - const haveAllInteractAccess = integrations + const haveAllInteractAccess = integrationRecords .map((integration) => constructIntegrationPermissions(integration, session)) .every(({ hasInteractAccess }) => hasInteractAccess); if (haveAllInteractAccess) return; + logger.warn("Integration access denied", { + action, + userId: session?.user.id, + integrationIds: integrationRecords.map(({ id }) => id), + integrationKinds: integrationRecords.map(getIntegrationKindForLog), + }); + throw new TRPCError({ code: "FORBIDDEN", message: "User does not have permission to interact with at least one of the specified integrations", }); } - const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session); + const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session); if (hasQueryAccess) return; + logger.warn("Integration access denied", { + action, + userId: session?.user.id, + integrationIds: integrationRecords.map(({ id }) => id), + integrationKinds: integrationRecords.map(getIntegrationKindForLog), + }); + throw new TRPCError({ code: "FORBIDDEN", message: "User does not have permission to query at least one of the specified integration", diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts index c3605bb78..8d9a21efe 100644 --- a/packages/api/src/router/app.ts +++ b/packages/api/src/router/app.ts @@ -5,9 +5,10 @@ import type { Session } from "@homarr/auth"; import { createId } from "@homarr/common"; import type { Database, InferSelectModel } from "@homarr/db"; import { asc, eq, inArray, like } from "@homarr/db"; -import { apps } from "@homarr/db/schema"; +import { apps, integrations } from "@homarr/db/schema"; import { selectAppSchema } from "@homarr/db/validationSchemas"; import { getIconForName } from "@homarr/icons"; +import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis"; import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app"; import { byIdSchema, paginatedSchema } from "@homarr/validation/common"; @@ -256,6 +257,14 @@ export const appRouter = createTRPCRouter({ pingUrl: input.pingUrl === "" ? null : input.pingUrl, }) .where(eq(apps.id, input.id)); + + if (app.href !== input.href) { + const linkedIntegrations = await ctx.db.query.integrations.findMany({ + columns: { id: true }, + where: eq(integrations.appId, input.id), + }); + await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id))); + } }), delete: permissionRequiredProcedure .requiresPermission("app-full-all") @@ -271,7 +280,12 @@ export const appRouter = createTRPCRouter({ }) .input(byIdSchema) .mutation(async ({ ctx, input }) => { + const linkedIntegrations = await ctx.db.query.integrations.findMany({ + columns: { id: true }, + where: eq(integrations.appId, input.id), + }); await ctx.db.delete(apps).where(eq(apps.id, input.id)); + await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id))); }), }); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 93526dc72..e98d9dd4c 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -20,9 +20,7 @@ import { groupMembers, groupPermissions, groups, - integrationGroupPermissions, integrationItems, - integrationUserPermissions, integrations, apps, itemLayouts, @@ -71,7 +69,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc"; import { throwIfActionForbiddenAsync } from "./board/board-access"; -import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access"; +import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access"; import { throwIfCustomWidgetBoardDuplicationForbidden, throwIfCustomWidgetPlacementChangeForbidden, @@ -131,6 +129,75 @@ const findFirstAvailableBoardItemPosition = ( return null; }; +interface WidgetConfiguration { + id: string; + kind: WidgetKind; + integrationIds: readonly string[]; +} + +const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => { + if (left.length !== right.length) return false; + const sortedRight = right.toSorted(); + return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]); +}; + +const validateWidgetConfigurationsAsync = async ( + ctx: Parameters[0], + submittedItems: readonly WidgetConfiguration[], + storedItems: readonly WidgetConfiguration[] = [], +) => { + const storedItemsById = new Map(storedItems.map((item) => [item.id, item])); + const changedItems = submittedItems.filter((item) => { + const storedItem = storedItemsById.get(item.id); + if (!storedItem || storedItem.kind !== item.kind) return true; + return !haveSameIntegrationIds(item.integrationIds, storedItem.integrationIds); + }); + if (changedItems.length === 0) return; + + const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))]; + let integrationRecords: { id: string; kind: IntegrationKind }[] = []; + if (selectedIntegrationIds.length > 0) { + integrationRecords = await ctx.db.query.integrations.findMany({ + columns: { id: true, kind: true }, + where: inArray(integrations.id, selectedIntegrationIds), + }); + } + const integrationRecordsById = new Map(integrationRecords.map((integration) => [integration.id, integration])); + const invalidIntegrationIds = selectedIntegrationIds.filter( + (integrationId) => !integrationRecordsById.has(integrationId), + ); + if (invalidIntegrationIds.length > 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Integration not found", + }); + } + + const addedOrReconfiguredIntegrationIds = [ + ...new Set( + changedItems.flatMap((item) => { + const storedItem = storedItemsById.get(item.id); + if (!storedItem || storedItem.kind !== item.kind) return item.integrationIds; + return item.integrationIds.filter((integrationId) => !storedItem.integrationIds.includes(integrationId)); + }), + ), + ]; + await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use"); + + for (const item of changedItems) { + const selectedIntegrationKinds = item.integrationIds.flatMap( + (integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [], + ); + const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds); + if (!integrationIssue) continue; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue), + }); + } +}; + const searchAccessibleBoardsAsync = async ( ctx: { db: Database; session: Session | null }, query: string, @@ -169,11 +236,7 @@ const searchAccessibleBoardsAsync = async ( like(boards.name, `%${query}%`), ctx.session?.user.permissions.includes("board-view-all") ? undefined - : or( - eq(boards.isPublic, true), - eq(boards.creatorId, ctx.session?.user.id ?? ""), - inArray(boards.id, boardIds), - ), + : or(eq(boards.isPublic, true), eq(boards.creatorId, ctx.session?.user.id ?? ""), inArray(boards.id, boardIds)), ), limit, orderBy: asc(boards.name), @@ -659,7 +722,7 @@ export const boardRouter = createTRPCRouter({ mcp: { enabled: true, description: - "Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }", + "Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }", }, }) .input(boardDuplicateSchema) @@ -696,6 +759,14 @@ export const boardRouter = createTRPCRouter({ const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board; throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems); + await validateWidgetConfigurationsAsync( + ctx, + boardItems.map((item) => ({ + id: item.id, + kind: item.kind, + integrationIds: item.integrations.map(({ integrationId }) => integrationId), + })), + ); const newBoardId = createId(); @@ -776,17 +847,19 @@ export const boardRouter = createTRPCRouter({ const sectionLayoutsToInsert: InferInsertModel[] = boardSections .flatMap((section) => - section.layouts.map((layoutSection): InferInsertModel => ({ - ...layoutSection, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - layoutId: layoutsMap.get(layoutSection.layoutId)!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sectionId: sectionMap.get(layoutSection.sectionId)!, - parentSectionId: layoutSection.parentSectionId - ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sectionMap.get(layoutSection.parentSectionId)! - : layoutSection.parentSectionId, - })), + section.layouts.map( + (layoutSection): InferInsertModel => ({ + ...layoutSection, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + layoutId: layoutsMap.get(layoutSection.layoutId)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionId: sectionMap.get(layoutSection.sectionId)!, + parentSectionId: layoutSection.parentSectionId + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionMap.get(layoutSection.parentSectionId)! + : layoutSection.parentSectionId, + }), + ), ) .concat( (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({ @@ -803,11 +876,13 @@ export const boardRouter = createTRPCRouter({ ); const sectionCollapseStatesToInsert: InferInsertModel[] = boardSections.flatMap( (section) => - section.collapseStates.map((collapseState): InferInsertModel => ({ - ...collapseState, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sectionId: sectionMap.get(collapseState.sectionId)!, - })), + section.collapseStates.map( + (collapseState): InferInsertModel => ({ + ...collapseState, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionId: sectionMap.get(collapseState.sectionId)!, + }), + ), ); const itemMap = new Map(boardItems.map((item) => [item.id, createId()])); @@ -822,15 +897,17 @@ export const boardRouter = createTRPCRouter({ const itemLayoutsToInsert: InferInsertModel[] = boardItems .flatMap((item) => - item.layouts.map((layoutSection): InferInsertModel => ({ - ...layoutSection, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - sectionId: sectionMap.get(layoutSection.sectionId)!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - itemId: itemMap.get(layoutSection.itemId)!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - layoutId: layoutsMap.get(layoutSection.layoutId)!, - })), + item.layouts.map( + (layoutSection): InferInsertModel => ({ + ...layoutSection, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sectionId: sectionMap.get(layoutSection.sectionId)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemId: itemMap.get(layoutSection.itemId)!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + layoutId: layoutsMap.get(layoutSection.layoutId)!, + }), + ), ) .concat( (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({ @@ -844,34 +921,12 @@ export const boardRouter = createTRPCRouter({ })), ); - // Creates a list with all integration ids the user has access to - const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all"); - const integrationIdsWithAccess = hasAccessForAll - ? [] - : await ctx.db - .selectDistinct({ - id: integrationGroupPermissions.integrationId, - }) - .from(integrationGroupPermissions) - .leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId)) - .where(eq(groupMembers.userId, ctx.session.user.id)) - .union( - ctx.db - .selectDistinct({ id: integrationUserPermissions.integrationId }) - .from(integrationUserPermissions) - .where(eq(integrationUserPermissions.userId, ctx.session.user.id)), - ) - .then((result) => result.map((row) => row.id)); - const itemIntegrationsToInsert = boardItems.flatMap((item) => - item.integrations - // Restrict integrations to only those the user has access to - .filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll) - .map((integration) => ({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - itemId: itemMap.get(item.id)!, - integrationId: integration.integrationId, - })), + item.integrations.map((integration) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemId: itemMap.get(item.id)!, + integrationId: integration.integrationId, + })), ); await handleTransactionsAsync(ctx.db, { @@ -1514,6 +1569,7 @@ export const boardRouter = createTRPCRouter({ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify"); const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id); + await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items); throwIfCustomWidgetPlacementChangeForbidden({ isAdmin: ctx.session.user.permissions.includes("admin"), submittedItems: input.items, @@ -1523,10 +1579,9 @@ export const boardRouter = createTRPCRouter({ for (const item of input.items) { if (item.kind !== "timetable") continue; const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id); - await validateTimetableOptionsChangeAsync( - item.options, - previousItem?.kind === "timetable" ? previousItem.options : undefined, - ); + let previousOptions: Record | undefined; + if (previousItem?.kind === "timetable") previousOptions = previousItem.options; + await validateTimetableOptionsChangeAsync(item.options, previousOptions); } await handleTransactionsAsync(ctx.db, { @@ -1550,15 +1605,17 @@ export const boardRouter = createTRPCRouter({ const sectionLayoutsToInsert = addedSections .filter((section) => section.kind === "container") .flatMap((section) => - section.layouts.map((sectionLayout): InferInsertModel => ({ - layoutId: sectionLayout.layoutId, - sectionId: section.id, - parentSectionId: sectionLayout.parentSectionId, - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - })), + section.layouts.map( + (sectionLayout): InferInsertModel => ({ + layoutId: sectionLayout.layoutId, + sectionId: section.id, + parentSectionId: sectionLayout.parentSectionId, + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + }), + ), ); if (sectionLayoutsToInsert.length > 0) { @@ -1580,15 +1637,17 @@ export const boardRouter = createTRPCRouter({ ); await transaction.insert(schema.itemLayouts).values( addedItems.flatMap((item) => - item.layouts.map((layoutSection): InferInsertModel => ({ - layoutId: layoutSection.layoutId, - sectionId: layoutSection.sectionId, - itemId: item.id, - height: layoutSection.height, - width: layoutSection.width, - xOffset: layoutSection.xOffset, - yOffset: layoutSection.yOffset, - })), + item.layouts.map( + (layoutSection): InferInsertModel => ({ + layoutId: layoutSection.layoutId, + sectionId: layoutSection.sectionId, + itemId: item.id, + height: layoutSection.height, + width: layoutSection.width, + xOffset: layoutSection.xOffset, + yOffset: layoutSection.yOffset, + }), + ), ), ); } @@ -1746,15 +1805,17 @@ export const boardRouter = createTRPCRouter({ const sectionLayoutsToInsert = addedSections .filter((section) => section.kind === "container") .flatMap((section) => - section.layouts.map((sectionLayout): InferInsertModel => ({ - layoutId: sectionLayout.layoutId, - sectionId: section.id, - parentSectionId: sectionLayout.parentSectionId, - height: sectionLayout.height, - width: sectionLayout.width, - xOffset: sectionLayout.xOffset, - yOffset: sectionLayout.yOffset, - })), + section.layouts.map( + (sectionLayout): InferInsertModel => ({ + layoutId: sectionLayout.layoutId, + sectionId: section.id, + parentSectionId: sectionLayout.parentSectionId, + height: sectionLayout.height, + width: sectionLayout.width, + xOffset: sectionLayout.xOffset, + yOffset: sectionLayout.yOffset, + }), + ), ); if (sectionLayoutsToInsert.length > 0) { @@ -1781,15 +1842,17 @@ export const boardRouter = createTRPCRouter({ .insert(itemLayouts) .values( addedItems.flatMap((item) => - item.layouts.map((layoutSection): InferInsertModel => ({ - layoutId: layoutSection.layoutId, - sectionId: layoutSection.sectionId, - itemId: item.id, - height: layoutSection.height, - width: layoutSection.width, - xOffset: layoutSection.xOffset, - yOffset: layoutSection.yOffset, - })), + item.layouts.map( + (layoutSection): InferInsertModel => ({ + layoutId: layoutSection.layoutId, + sectionId: layoutSection.sectionId, + itemId: item.id, + height: layoutSection.height, + width: layoutSection.width, + xOffset: layoutSection.xOffset, + yOffset: layoutSection.yOffset, + }), + ), ), ) .run(); @@ -2092,6 +2155,10 @@ export const boardRouter = createTRPCRouter({ .output(z.object({ itemId: z.string() })) .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + + await validateWidgetConfigurationsAsync(ctx, [ + { id: "", kind: input.kind, integrationIds: input.integrationIds }, + ]); throwIfCustomWidgetPlacementChangeForbidden({ isAdmin: ctx.session.user.permissions.includes("admin"), submittedItems: [{ id: "", kind: input.kind, options: input.options }], @@ -2102,35 +2169,6 @@ export const boardRouter = createTRPCRouter({ await validateTimetableOptionsChangeAsync(input.options); } - let selectedIntegrationKinds: IntegrationKind[] = []; - if (input.integrationIds.length > 0) { - const existing = await ctx.db.query.integrations.findMany({ - columns: { id: true, kind: true }, - where: inArray(integrations.id, input.integrationIds), - }); - const validIds = new Set(existing.map((row) => row.id)); - const invalid = input.integrationIds.filter((id) => !validIds.has(id)); - if (invalid.length > 0) { - throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` }); - } - - selectedIntegrationKinds = existing.map((integration) => integration.kind); - - await Promise.all( - input.integrationIds.map((integrationId) => - throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"), - ), - ); - } - - const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds); - if (integrationIssue) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue), - }); - } - return await serializeBoardItemPlacementAsync(input.boardId, async () => { const board = await ctx.db.query.boards.findFirst({ where: eq(boards.id, input.boardId), diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 910d16131..a9cf5f1b8 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove"; export const performDockerContainerActionsAsync = async ( targets: DockerContainerTarget[], action: DockerContainerAction, -) => - await Promise.all( +) => { + const results = await Promise.all( targets.map(async (target) => { try { const capability = action === "remove" ? "remove" : "lifecycle"; @@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async ( }), ); + if (results.some(({ success }) => success)) { + dockerContainersRequestHandler.invalidateCache(); + } + + return results; +}; + const assertDockerEndpointCapability = ( endpointId: string, capability: "inventory" | "logs" | "lifecycle" | "remove", diff --git a/packages/api/src/router/integration/integration-access.ts b/packages/api/src/router/integration/integration-access.ts index 78f8961e4..a44f982fb 100644 --- a/packages/api/src/router/integration/integration-access.ts +++ b/packages/api/src/router/integration/integration-access.ts @@ -1,12 +1,59 @@ import { TRPCError } from "@trpc/server"; import type { Session } from "@homarr/auth"; +import type { IntegrationPermissionsProps } from "@homarr/auth/shared"; import { constructIntegrationPermissions } from "@homarr/auth/shared"; import type { Database, SQL } from "@homarr/db"; import { eq, inArray } from "@homarr/db"; -import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema"; +import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema"; import type { IntegrationPermission } from "@homarr/definitions"; +const hasIntegrationPermission = ( + integration: IntegrationPermissionsProps, + session: Session | null, + permission: IntegrationPermission, +) => { + const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session); + if (hasFullAccess) return true; + if (permission === "interact") return hasInteractAccess; + if (permission === "use") return hasUseAccess || hasInteractAccess; + return false; +}; + +const throwIfActionsForbiddenAsync = async ( + ctx: { db: Database; session: Session | null }, + integrationWhere: SQL, + expectedCount: number, + permission: IntegrationPermission, +) => { + const { db, session } = ctx; + const groupsOfCurrentUser = await db.query.groupMembers.findMany({ + where: eq(groupMembers.userId, session?.user.id ?? ""), + }); + const integrationRecords = await db.query.integrations.findMany({ + where: integrationWhere, + columns: { id: true }, + with: { + userPermissions: { + where: eq(integrationUserPermissions.userId, session?.user.id ?? ""), + }, + groupPermissions: { + where: inArray( + integrationGroupPermissions.groupId, + groupsOfCurrentUser.map(({ groupId }) => groupId).concat(""), + ), + }, + }, + }); + + if ( + integrationRecords.length !== expectedCount || + !integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission)) + ) { + notAllowed(); + } +}; + /** * Throws NOT_FOUND if user is not allowed to perform action on integration * @param ctx trpc router context @@ -18,47 +65,23 @@ export const throwIfActionForbiddenAsync = async ( integrationWhere: SQL, permission: IntegrationPermission, ) => { - const { db, session } = ctx; - const groupsOfCurrentUser = await db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, session?.user.id ?? ""), - }); - const integration = await db.query.integrations.findFirst({ - where: integrationWhere, - columns: { - id: true, - }, - with: { - userPermissions: { - where: eq(integrationUserPermissions.userId, session?.user.id ?? ""), - }, - groupPermissions: { - where: inArray( - integrationGroupPermissions.groupId, - groupsOfCurrentUser.map((group) => group.groupId).concat(""), - ), - }, - }, - }); + await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission); +}; - if (!integration) { - notAllowed(); - } +export const throwIfIntegrationActionsForbiddenAsync = async ( + ctx: { db: Database; session: Session | null }, + integrationIds: readonly string[], + permission: IntegrationPermission, +) => { + const uniqueIntegrationIds = [...new Set(integrationIds)]; + if (uniqueIntegrationIds.length === 0) return; - const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session); - - if (hasFullAccess) { - return; // As full access is required and user has full access, allow - } - - if (["interact", "use"].includes(permission) && hasInteractAccess) { - return; // As interact access is required and user has interact access, allow - } - - if (permission === "use" && hasUseAccess) { - return; // As use access is required and user has use access, allow - } - - notAllowed(); + await throwIfActionsForbiddenAsync( + ctx, + inArray(integrations.id, uniqueIntegrationIds), + uniqueIntegrationIds.length, + permission, + ); }; /** diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index a3a20f6e9..a36ff5c6e 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -27,8 +27,10 @@ import { integrationKinds, integrationSecretKindObject, } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { invalidateIntegrationCacheAsync } from "@homarr/redis"; +import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; +import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { byIdSchema } from "@homarr/validation/common"; import { integrationCreateSchema, @@ -156,9 +158,21 @@ export const integrationRouter = createTRPCRouter({ }) .input(byIdSchema) .query(async ({ ctx, input }) => { - await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full"); + const integrationWhere = eq(integrations.id, input.id); + + await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + logger.warn("Integration lookup refused", { + integrationId: input.id, + errorCode: error.code, + reason: "integration-not-found-or-forbidden", + }); + } + throw error; + }); + const integration = await ctx.db.query.integrations.findFirst({ - where: eq(integrations.id, input.id), + where: integrationWhere, with: { secrets: { columns: { @@ -179,6 +193,11 @@ export const integrationRouter = createTRPCRouter({ }); if (!integration) { + logger.warn("Integration lookup refused", { + integrationId: input.id, + errorCode: "NOT_FOUND", + reason: "integration-not-found", + }); throw new TRPCError({ code: "NOT_FOUND", message: "Integration not found", @@ -695,7 +714,17 @@ export const integrationRouter = createTRPCRouter({ .input(mediaRequestRequestSchema) .mutation(async ({ ctx, input }) => { const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + try { + mediaRequestListRequestHandler.invalidateCache(); + await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]); + } catch (error) { + logger.warn("Failed to invalidate media request caches after a successful request", { + integrationId: ctx.integration.id, + error: String(error), + }); + } + return result; }), }); diff --git a/packages/api/src/router/integration/integration-test-connection.ts b/packages/api/src/router/integration/integration-test-connection.ts index a1c735f25..79ac09898 100644 --- a/packages/api/src/router/integration/integration-test-connection.ts +++ b/packages/api/src/router/integration/integration-test-connection.ts @@ -4,7 +4,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { Integration } from "@homarr/db/schema"; import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { getAllSecretKindOptions } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; const logger = createLogger({ module: "integrationTestConnection" }); diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 855c63705..1a41f5755 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -218,6 +218,7 @@ export const onboardRouter = createTRPCRouter({ const discoveredApps: DiscoveredApp[] = []; for (const service of discovery.services) { + const widgetKind = service.widgetKind; if (service.integrationKind) { discoveredIntegrations.push({ sourceId: service.sourceId, @@ -231,7 +232,7 @@ export const onboardRouter = createTRPCRouter({ group: service.group, boardName: service.boardName, host: service.host, - widgetKind: service.widgetKind, + widgetKind, description: service.description, pingUrl: service.pingUrl, }); @@ -249,7 +250,7 @@ export const onboardRouter = createTRPCRouter({ description: service.description, pingUrl: service.pingUrl, host: service.host, - widgetKind: service.widgetKind, + widgetKind, }); } } diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index b8411ca1f..21d9cdd1b 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -41,6 +41,14 @@ const defaultSession = { expires: new Date().toISOString(), } satisfies Session; +const integrationUseSession = { + ...defaultSession, + user: { + ...defaultSession.user, + permissions: ["integration-use-all"], + }, +} satisfies Session; + // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); @@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => { }, }; const caller = boardRouter.createCaller({ db, deviceType: undefined, session }); - const { boardId } = await createFullBoardAsync(db, "source"); + const { boardId, integrationId } = await createFullBoardAsync(db, "source"); + await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId)); const result = await caller.duplicateBoard({ id: boardId, name: "copy" }); @@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => { it("should remove integration reference when not present in input", async () => { const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession }); const anotherIntegration = { id: createId(), kind: "adGuardHome", @@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => { items: [ { id: itemId, - kind: "clock", - options: { is24HourFormat: true }, + kind: "dnsHoleSummary", + options: { usePiHoleColors: true, layout: "grid" }, integrationIds: [anotherIntegration.id], layouts: [ { @@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => { it("should add integration reference when present in input", async () => { const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession }); const integration = { id: createId(), kind: "plex", @@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => { items: [ { id: itemId, - kind: "clock", - options: { is24HourFormat: true }, + kind: "mediaServer", + options: { showOnlyPlaying: true, showBitrate: true, showLocation: true }, integrationIds: [integration.id], layouts: [ { diff --git a/packages/api/src/router/test/integration/integration-test-connection.spec.ts b/packages/api/src/router/test/integration/integration-test-connection.spec.ts index 0da8356c4..5cc8ea3ea 100644 --- a/packages/api/src/router/test/integration/integration-test-connection.spec.ts +++ b/packages/api/src/router/test/integration/integration-test-connection.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, test, vi } from "vitest"; import * as homarrDefinitions from "@homarr/definitions"; -import * as homarrIntegrations from "@homarr/integrations"; +import type * as homarrIntegrations from "@homarr/integrations"; +import * as homarrIntegrationFactory from "@homarr/integrations/factory"; import { testConnectionAsync } from "../../integration/integration-test-connection"; @@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => { describe("testConnectionAsync should run test connection of integration", () => { test("with input of only form secrets matching api key kind it should use form apiKey", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); + const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ @@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); + const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ @@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of form and db secrets matching api key kind it should use form apiKey", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); + const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ @@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); + const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ @@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () => test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => { // Arrange - const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync"); + const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync"); const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions"); factorySpy.mockReturnValue( Promise.resolve({ diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts index 790c957c5..74af58dc8 100644 --- a/packages/api/src/router/widgets/anchor-notes.ts +++ b/packages/api/src/router/widgets/anchor-notes.ts @@ -3,15 +3,12 @@ import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { - anchorNotesListInputSchema, - anchorNoteUpdateInputSchema, - createIntegrationAsync, - mockWidgetData, -} from "@homarr/integrations"; +import { mockWidgetData } from "@homarr/integrations"; +import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const noteIdInput = z.object({ @@ -51,7 +48,7 @@ const normalizeAnchorContent = (content: string | undefined) => { export const anchorNotesRouter = createTRPCRouter({ listNotes: publicProcedure - .concat(createOneIntegrationMiddleware("query", "anchor", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "anchorNote")) .input(anchorNotesListInputSchema) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") { @@ -73,7 +70,7 @@ export const anchorNotesRouter = createTRPCRouter({ return data; }), getNote: publicProcedure - .concat(createOneIntegrationMiddleware("query", "anchor", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "anchorNote")) .input(noteIdInput) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") { @@ -91,7 +88,7 @@ export const anchorNotesRouter = createTRPCRouter({ return data; }), updateNote: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "anchor", "mock")) + .concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote")) .input(anchorNoteUpdateInputSchema) .mutation(async ({ ctx, input }) => { const normalizedContent = normalizeAnchorContent(input.content); @@ -115,6 +112,8 @@ export const anchorNotesRouter = createTRPCRouter({ ...(input.title !== undefined ? { title: input.title } : {}), ...(normalizedContent !== undefined ? { content: normalizedContent } : {}), }); + anchorNotesListRequestHandler.invalidateCache(); + await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]); return updatedNote; } catch (error) { diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts index 9fa96b553..d8114c72e 100644 --- a/packages/api/src/router/widgets/archive-team-warrior.ts +++ b/packages/api/src/router/widgets/archive-team-warrior.ts @@ -1,12 +1,12 @@ import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior"; import { mockWidgetData } from "@homarr/integrations"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const archiveTeamWarriorRouter = createTRPCRouter({ getStatus: publicProcedure - .concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior")) .query(async ({ ctx }) => { if (ctx.integration.kind === "mock") { return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) }; diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts index cc3e25ddd..8661af3c4 100644 --- a/packages/api/src/router/widgets/audio-stats.ts +++ b/packages/api/src/router/widgets/audio-stats.ts @@ -2,12 +2,12 @@ import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobooks import { navidromeRequestHandler } from "@homarr/request-handler/navidrome"; import { mockWidgetData } from "@homarr/integrations"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const audioStatsRouter = createTRPCRouter({ getStats: publicProcedure - .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "audioStats")) .query(async ({ ctx }) => { const { kind } = ctx.integration; if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats }; diff --git a/packages/api/src/router/widgets/bazarr.ts b/packages/api/src/router/widgets/bazarr.ts index e9f8093b5..53554a460 100644 --- a/packages/api/src/router/widgets/bazarr.ts +++ b/packages/api/src/router/widgets/bazarr.ts @@ -1,7 +1,7 @@ import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr"; import { mockWidgetData } from "@homarr/integrations"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const bazarrRouter = createTRPCRouter({ @@ -13,7 +13,7 @@ export const bazarrRouter = createTRPCRouter({ "Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all", }, }) - .concat(createOneIntegrationMiddleware("query", "bazarr", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "bazarr")) .query(async ({ ctx }) => { if (ctx.integration.kind === "mock") return mockWidgetData.bazarr; diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index c52ca1717..b61e9843f 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { LiveStatsEvent } from "@homarr/integrations/types"; import { beszelAlertsRequestHandler, @@ -11,7 +11,7 @@ import { } from "@homarr/request-handler/beszel"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -27,7 +27,7 @@ export const beszelRouter = createTRPCRouter({ "Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)", }, }) - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid")) .query(async ({ ctx }) => { const integrationIds = ctx.integrations.map((i) => i.id); logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds }); @@ -71,7 +71,7 @@ export const beszelRouter = createTRPCRouter({ "Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)", }, }) - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts")) .input( z.object({ includeHistory: z.boolean().default(true), @@ -145,7 +145,7 @@ export const beszelRouter = createTRPCRouter({ "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", }, }) - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats")) .input( z.object({ systemId: z.string(), @@ -196,7 +196,7 @@ export const beszelRouter = createTRPCRouter({ }), subscribeSystemStats: publicProcedure - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats")) .input( z.object({ systemId: z.string(), diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index bbe5b247d..9d843d9b1 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,10 +1,9 @@ import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -25,7 +24,7 @@ export const calendarRouter = createTRPCRouter({ showUnmonitored: z.boolean(), }), ) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) + .concat(createManyWidgetIntegrationMiddleware("query", "calendar")) .query(async ({ ctx, input }) => { return await settleIntegrationQueries( ctx.integrations, @@ -44,7 +43,7 @@ export const calendarRouter = createTRPCRouter({ name: integration.name, kind: integration.kind, }, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts index d0d8b8355..7b22f62ee 100644 --- a/packages/api/src/router/widgets/coolify.ts +++ b/packages/api/src/router/widgets/coolify.ts @@ -2,7 +2,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; import { mockWidgetData } from "@homarr/integrations"; import { coolifyRequestHandler } from "@homarr/request-handler/coolify"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -17,7 +17,7 @@ interface CoolifyInstanceResult { export const coolifyRouter = createTRPCRouter({ getInstancesInfo: publicProcedure - .concat(createManyIntegrationMiddleware("query", "coolify", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "coolify")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 4dffb9f2f..ef8a9f683 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -1,12 +1,14 @@ import { z } from "zod/v4"; import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { DnsHoleSummary } from "@homarr/integrations/types"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; -import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { + createManyWidgetIntegrationMiddleware, + createOneWidgetIntegrationMiddleware, +} from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; @@ -32,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({ "Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)", }, }) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole"))) + .concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary")) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>( ctx.integrations, @@ -66,10 +68,11 @@ export const dnsHoleRouter = createTRPCRouter({ "Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)", }, }) - .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) + .concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls")) .mutation(async ({ ctx: { integration } }) => { const client = await createIntegrationAsync(integration); await client.enableAsync(); + await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]); }), disable: protectedProcedure @@ -85,9 +88,10 @@ export const dnsHoleRouter = createTRPCRouter({ duration: z.number().optional(), }), ) - .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole"))) + .concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls")) .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); await client.disableAsync(input.duration); + await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 261d3ba46..d7e0825fc 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -1,19 +1,15 @@ import { z } from "zod/v4"; import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; -import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations"; +import { downloadClientItemSchema } from "@homarr/integrations/downloads"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; -const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => - createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient")); - interface DownloadClientQueryResultBase { integrationId: string; integrationName: string; @@ -42,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase & } ); +const runDownloadMutationsAsync = async (mutations: Promise[], integrationIds: string[]) => { + const results = await Promise.allSettled(mutations); + await downloadClientRequestHandler.invalidateCacheAsync(integrationIds); + + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +}; + export const downloadsRouter = createTRPCRouter({ getJobsAndStatuses: publicProcedure .meta({ @@ -51,7 +55,7 @@ export const downloadsRouter = createTRPCRouter({ "Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)", }, }) - .concat(createDownloadClientIntegrationMiddleware("query")) + .concat(createManyWidgetIntegrationMiddleware("query", "downloads")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>( @@ -86,27 +90,27 @@ export const downloadsRouter = createTRPCRouter({ "Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)", }, }) - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .mutation(async ({ ctx }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.pauseQueueAsync(); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), pauseItem: protectedProcedure - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema })) .mutation(async ({ ctx, input }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.pauseItemAsync(input.item); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), resume: protectedProcedure .meta({ @@ -116,38 +120,38 @@ export const downloadsRouter = createTRPCRouter({ "Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)", }, }) - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .mutation(async ({ ctx }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.resumeQueueAsync(); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), resumeItem: protectedProcedure - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema })) .mutation(async ({ ctx, input }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.resumeItemAsync(input.item); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), deleteItem: protectedProcedure - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() })) .mutation(async ({ ctx, input }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.deleteItemAsync(input.item, input.fromDisk); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), }); diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 28888649f..4b141a609 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -1,5 +1,3 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import type { IntegrationKind } from "@homarr/definitions"; import { mockWidgetData } from "@homarr/integrations"; import { firewallCpuRequestHandler, @@ -8,36 +6,37 @@ import { firewallVersionRequestHandler, } from "@homarr/request-handler/firewall"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const firewallMiddleware = createManyIntegrationMiddleware( - "query", - ...getIntegrationKindsByCategory("firewall"), - "mock", -); +const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall"); -interface FirewallResult { - integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date }; - summary: TSummary; - error?: string; +type FirewallIntegration = Parameters[0]; + +interface FirewallHandler { + handler: ( + integration: FirewallIntegration, + input: Record, + ) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> }; } -const queryFirewall = ( - handler: { - handler: ( - integration: any, - input: Record, - ) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> }; - }, - createFallback: () => TSummary, - getMockData: () => TSummary, -) => +interface FirewallQueryResult { + integration: { + id: string; + name: string; + kind: FirewallIntegration["kind"] | "mock"; + updatedAt: Date; + }; + summary: TData; + error: string | undefined; +} + +const queryFirewall = (handler: FirewallHandler, createFallback: () => TData, getMockData: () => TData) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => settleIntegrationQueries( ctx.integrations, - async (integration): Promise> => { + async (integration): Promise> => { if (integration.kind === "mock") { return { integration: { @@ -58,7 +57,7 @@ const queryFirewall = ( }; }, { - fallback: (integration, error): FirewallResult => ({ + fallback: (integration, error): FirewallQueryResult => ({ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) }, summary: createFallback(), error: toPublicIntegrationError(error), diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 5c123ecb1..05df408c1 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,22 +1,12 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { SystemHealthMonitoring } from "@homarr/integrations"; -import { SynologyIntegration } 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"; -const healthMonitoringIntegrationKinds = [ - "openmediavault", - "dashDot", - "truenas", - "unraid", - "glances", - "synology", - "mock", -] as const; - interface SystemHealthQueryResult { integrationId: string; integrationName: string; @@ -34,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, @@ -69,10 +59,6 @@ export const healthMonitoringRouter = createTRPCRouter({ .concat(createOneIntegrationMiddleware("query", "synology")) .query(async ({ ctx }) => { const integrationInstance = await createIntegrationAsync(ctx.integration); - if (!(integrationInstance instanceof SynologyIntegration)) { - throw new Error("Expected Synology integration"); - } - return await integrationInstance.listStorageVolumesAsync(); }), getClusterHealthStatus: publicProcedure @@ -83,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/immich.ts b/packages/api/src/router/widgets/immich.ts index 835f96996..09f582dfa 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -1,6 +1,5 @@ import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mockWidgetData } from "@homarr/integrations"; import { immichAlbumRequestHandler, @@ -8,23 +7,21 @@ import { immichStatsRequestHandler, } from "@homarr/request-handler/immich"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const createImmichIntegrationMiddleware = (action: IntegrationAction) => - createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock"); - export const immichRouter = createTRPCRouter({ - getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { - if (ctx.integration.kind === "mock") return mockWidgetData.immichStats; - const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {}); - const data = await innerHandler.getDataAsync(); - return data.data; - }), + getServerStats: publicProcedure + .concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats")) + .query(async ({ ctx }) => { + if (ctx.integration.kind === "mock") return mockWidgetData.immichStats; + const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {}); + const data = await innerHandler.getDataAsync(); + return data.data; + }), getAlbum: publicProcedure - .concat(createImmichIntegrationMiddleware("query")) + .concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel")) .input( z.object({ albumId: z.string().optional(), @@ -48,7 +45,7 @@ export const immichRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500).optional(), }), ) - .concat(createImmichIntegrationMiddleware("query")) + .concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel")) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") { if (input.limit === undefined) return [...mockWidgetData.immichAlbums]; diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 4d1d893d8..7e982e4d9 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -1,20 +1,15 @@ import { TRPCError } from "@trpc/server"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; -const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => - createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager")); - export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure - .concat(createIndexerManagerIntegrationMiddleware("query")) + .concat(createManyWidgetIntegrationMiddleware("query", "indexerManager")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, @@ -26,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, indexers, - error: undefined as string | undefined, + error: undefined, }; }, { @@ -41,9 +36,9 @@ export const indexerManagerRouter = createTRPCRouter({ ); }), testAllIndexers: protectedProcedure - .concat(createIndexerManagerIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager")) .mutation(async ({ ctx }) => { - await Promise.all( + const results = await Promise.allSettled( ctx.integrations.map(async (integration) => { const client = await createIntegrationAsync(integration); await client.testAllAsync().catch((err) => { @@ -55,5 +50,9 @@ export const indexerManagerRouter = createTRPCRouter({ }); }), ); + await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id)); + + const firstFailure = results.find((result) => result.status === "rejected"); + if (firstFailure?.status === "rejected") throw firstFailure.reason; }), }); diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index 91738b4a8..2577b332e 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -1,9 +1,8 @@ import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -16,7 +15,7 @@ export const mediaOrganizerRouter = createTRPCRouter({ "Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)", }, }) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer"))) + .concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing")) .input(z.object({ pageSize: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { return await settleIntegrationQueries( @@ -28,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, ...data, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index e1acb95b6..f4b715d46 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -1,13 +1,12 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { getMediaReleaseResponse } from "./media-release-result"; export const mediaReleaseRouter = createTRPCRouter({ getMediaReleases: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) + .concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases")) .query(async ({ ctx }) => { return await getMediaReleaseResponse(ctx.integrations, async (integration) => { const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 28d0cb15a..554dcbb99 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -1,8 +1,7 @@ import { z } from "zod/v4"; import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { @@ -11,7 +10,10 @@ import { } from "@homarr/request-handler/media-request-list"; import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; -import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { + createManyWidgetIntegrationMiddleware, + createOneWidgetIntegrationMiddleware, +} from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; @@ -30,7 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({ "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).", }, }) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList")) .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { const results: IntegrationQueryResult[] = await settleIntegrationQueries( @@ -40,7 +42,7 @@ export const mediaRequestsRouter = createTRPCRouter({ return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data, - error: undefined as string | undefined, + error: undefined, }; }, { @@ -85,7 +87,7 @@ export const mediaRequestsRouter = createTRPCRouter({ "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)", }, }) - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) + .concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats")) .query(async ({ ctx }) => { const results: IntegrationQueryResult<{ stats: MediaRequestStats["stats"] | null; @@ -96,8 +98,8 @@ export const mediaRequestsRouter = createTRPCRouter({ const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, - data: { ...data, stats: data.stats as typeof data.stats | null }, - error: undefined as string | undefined, + data, + error: undefined, }; }, { @@ -134,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({ "Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')", }, }) - .concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest"))) + .concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList")) .input( z.object({ requestId: z.number(), @@ -150,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); + mediaRequestListRequestHandler.invalidateCache(); + await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]); return { success: true }; }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 77e00204b..7b7b7d294 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -1,16 +1,11 @@ import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => - createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService")); - export const mediaServerRouter = createTRPCRouter({ getCurrentStreams: publicProcedure .meta({ @@ -20,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({ "Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)", }, }) - .concat(createMediaServerIntegrationMiddleware("query")) + .concat(createManyWidgetIntegrationMiddleware("query", "mediaServer")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { return await settleIntegrationQueries( @@ -34,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({ integrationName: integration.name, integrationKind: integration.kind, sessions: data, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/media-transcoding.ts b/packages/api/src/router/widgets/media-transcoding.ts index 88bd91704..4313e3aae 100644 --- a/packages/api/src/router/widgets/media-transcoding.ts +++ b/packages/api/src/router/widgets/media-transcoding.ts @@ -1,17 +1,12 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding"; import { paginatedSchema } from "@homarr/validation/common"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => - createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding")); - export const mediaTranscodingRouter = createTRPCRouter({ getDataAsync: publicProcedure - .concat(createIndexerManagerIntegrationMiddleware("query")) + .concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding")) .input(paginatedSchema.pick({ page: true, pageSize: true })) .query(async ({ ctx, input }) => { const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, { diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts index 25c974da3..2a39f4b42 100644 --- a/packages/api/src/router/widgets/network-controller.ts +++ b/packages/api/src/router/widgets/network-controller.ts @@ -1,9 +1,8 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { NetworkControllerSummary } from "@homarr/integrations/types"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -22,7 +21,7 @@ interface NetworkControllerQueryResult { export const networkControllerRouter = createTRPCRouter({ summary: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) + .concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary")) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>( ctx.integrations, diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index 3453b47a6..eacd66015 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -1,17 +1,12 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { notificationsRequestHandler } from "@homarr/request-handler/notifications"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => - createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications")); - export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure - .unstable_concat(createNotificationsIntegrationMiddleware("query")) + .unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, @@ -27,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({ updatedAt: timestamp, }, data, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts index 0adcb2fc5..2c12fdb0f 100644 --- a/packages/api/src/router/widgets/paperless-ngx.ts +++ b/packages/api/src/router/widgets/paperless-ngx.ts @@ -1,12 +1,12 @@ import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx"; import { mockWidgetData } from "@homarr/integrations"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const paperlessNgxRouter = createTRPCRouter({ getStats: publicProcedure - .concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx")) .query(async ({ ctx }) => { if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx; const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {}); diff --git a/packages/api/src/router/widgets/patchmon.ts b/packages/api/src/router/widgets/patchmon.ts index 2ea98bb10..4fd0554b3 100644 --- a/packages/api/src/router/widgets/patchmon.ts +++ b/packages/api/src/router/widgets/patchmon.ts @@ -1,7 +1,7 @@ import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon"; import { mockWidgetData } from "@homarr/integrations"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; export const patchmonRouter = createTRPCRouter({ @@ -13,7 +13,7 @@ export const patchmonRouter = createTRPCRouter({ "Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.", }, }) - .concat(createOneIntegrationMiddleware("query", "patchmon", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "patchmon")) .query(async ({ ctx }) => { if (ctx.integration.kind === "mock") return mockWidgetData.patchmon; const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {}); diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index a0f38c7fa..e9a83d890 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -1,20 +1,15 @@ import { z } from "zod/v4"; import { TRPCError } from "@trpc/server"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { smartHomeEntityStateRequestHandler, toSafeEntityDetails, } from "@homarr/request-handler/smart-home-entity-state"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; -const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) => - createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer")); - export const smartHomeRouter = createTRPCRouter({ entityState: publicProcedure .meta({ @@ -25,7 +20,7 @@ export const smartHomeRouter = createTRPCRouter({ }, }) .input(z.object({ entityId: z.string() })) - .concat(createSmartHomeIntegrationMiddleware("query")) + .concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState")) .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const { data } = await innerHandler.getDataAsync(); @@ -40,7 +35,7 @@ export const smartHomeRouter = createTRPCRouter({ }, }) .input(z.object({ entityId: z.string() })) - .concat(createSmartHomeIntegrationMiddleware("query")) + .concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState")) .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const { data } = await innerHandler.getDataAsync(); @@ -54,7 +49,7 @@ export const smartHomeRouter = createTRPCRouter({ "Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')", }, }) - .concat(createSmartHomeIntegrationMiddleware("interact")) + .concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState")) .input(z.object({ entityId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); @@ -62,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({ if (!success) { throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" }); } + await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]); return true; }), executeAutomation: protectedProcedure @@ -72,7 +68,7 @@ export const smartHomeRouter = createTRPCRouter({ "Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)", }, }) - .concat(createSmartHomeIntegrationMiddleware("interact")) + .concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation")) .input(z.object({ automationId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); @@ -80,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({ if (!success) { throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" }); } + await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]); return true; }), }); diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts index 7770ed051..5c8b0b587 100644 --- a/packages/api/src/router/widgets/speedtest-tracker.ts +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -1,13 +1,13 @@ import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker"; import { mockWidgetData } from "@homarr/integrations"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const speedtestTrackerRouter = createTRPCRouter({ getDashboard: publicProcedure - .concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, @@ -29,9 +29,9 @@ export const speedtestTrackerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index f8960d1cf..6f1730aac 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -1,13 +1,13 @@ import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; import { mockWidgetData } from "@homarr/integrations"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const tracearrRouter = createTRPCRouter({ getDashboard: publicProcedure - .concat(createManyIntegrationMiddleware("query", "tracearr", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "tracearr")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts index a80891fd7..a5751c7da 100644 --- a/packages/api/src/router/widgets/traefik.ts +++ b/packages/api/src/router/widgets/traefik.ts @@ -1,7 +1,7 @@ import { traefikRequestHandler } from "@homarr/request-handler/traefik"; import { mockWidgetData } from "@homarr/integrations"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -14,7 +14,7 @@ export const traefikRouter = createTRPCRouter({ "Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.", }, }) - .concat(createManyIntegrationMiddleware("query", "traefik", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "traefik")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, @@ -36,9 +36,9 @@ export const traefikRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts index c5736be53..639169982 100644 --- a/packages/api/src/router/widgets/umami.ts +++ b/packages/api/src/router/widgets/umami.ts @@ -2,7 +2,8 @@ import { z } from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations"; +import { mockWidgetData } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { umamiActiveVisitorsRequestHandler, umamiEventNamesRequestHandler, @@ -12,25 +13,26 @@ import { umamiTopReferrersRequestHandler, } from "@homarr/request-handler/umami"; -import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { + createManyWidgetIntegrationMiddleware, + createOneWidgetIntegrationMiddleware, +} from "../../middlewares/integration"; import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const logger = createLogger({ module: "umami-router" }); export const umamiRouter = createTRPCRouter({ - getWebsites: publicProcedure - .concat(createOneIntegrationMiddleware("query", "umami", "mock")) - .query(async ({ ctx }) => { - if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites; - try { - const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" }); - return await instance.getWebsitesAsync(); - } catch (error) { - logger.warn(new Error("Failed to load websites", { cause: error })); - return []; - } - }), + getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => { + if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites; + try { + const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" }); + return await instance.getWebsitesAsync(); + } catch (error) { + logger.warn(new Error("Failed to load websites", { cause: error })); + return []; + } + }), getVisitorStats: publicProcedure .input( @@ -40,7 +42,7 @@ export const umamiRouter = createTRPCRouter({ eventName: z.string().optional(), }), ) - .concat(createManyIntegrationMiddleware("query", "umami", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { return await settleIntegrationQueries(ctx.integrations, async (integration) => { if (integration.kind === "mock") { @@ -74,7 +76,7 @@ export const umamiRouter = createTRPCRouter({ getEventNames: publicProcedure .input(z.object({ websiteId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "umami", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames; try { @@ -102,7 +104,7 @@ export const umamiRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500), }), ) - .concat(createOneIntegrationMiddleware("query", "umami", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit); try { @@ -136,7 +138,7 @@ export const umamiRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500), }), ) - .concat(createOneIntegrationMiddleware("query", "umami", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit); try { @@ -170,7 +172,7 @@ export const umamiRouter = createTRPCRouter({ eventNames: z.array(z.string()), }), ) - .concat(createOneIntegrationMiddleware("query", "umami", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") { return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName)); @@ -201,7 +203,7 @@ export const umamiRouter = createTRPCRouter({ getActiveVisitors: publicProcedure .input(z.object({ websiteId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "umami", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { if (ctx.integration.kind === "mock") return 7; try { diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts index bc8481690..0eec9a943 100644 --- a/packages/api/src/router/widgets/ups.ts +++ b/packages/api/src/router/widgets/ups.ts @@ -1,40 +1,37 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { upsSummariesRequestHandler } from "@homarr/request-handler/ups"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const upsRouter = createTRPCRouter({ - getSummaries: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups"))) - .query(async ({ ctx }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = upsSummariesRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = upsSummariesRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - summaries: data, - updatedAt: timestamp, - error: undefined as string | undefined, - }; - }, - { - fallback: (integration, error) => ({ - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - summaries: [], - updatedAt: new Date(0), - error: toPublicIntegrationError(error), - }), - throwOnAllFailures: true, - }, - ); - }), + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: data, + updatedAt: timestamp, + error: undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + summaries: [], + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); + }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 68104158d..9e41f2da4 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,13 +1,13 @@ import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { mockWidgetData } from "@homarr/integrations"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const uptimeKumaRouter = createTRPCRouter({ getDashboard: publicProcedure - .concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock")) + .concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, @@ -29,9 +29,9 @@ export const uptimeKumaRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, integrationUrl: integration.url, - dashboard: data as typeof data | null, + dashboard: data, updatedAt: timestamp, - error: undefined as string | undefined, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 0d1ff146e..908d693b6 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -1,10 +1,9 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import type { IntegrationKind } from "@homarr/definitions"; import { mockWidgetData } from "@homarr/integrations"; import type { VpnSummary } from "@homarr/integrations/types"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -16,7 +15,7 @@ interface VpnResult { export const vpnRouter = createTRPCRouter({ getSummaries: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock")) + .unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, @@ -43,8 +42,8 @@ export const vpnRouter = createTRPCRouter({ kind: integration.kind, updatedAt: timestamp, }, - summary: data as typeof data | null, - error: undefined as string | undefined, + summary: data, + error: undefined, }; }, { diff --git a/packages/api/src/router/widgets/wud.ts b/packages/api/src/router/widgets/wud.ts index bb7ed3f3f..e5b88ac60 100644 --- a/packages/api/src/router/widgets/wud.ts +++ b/packages/api/src/router/widgets/wud.ts @@ -1,7 +1,7 @@ import { wudStatsRequestHandler } from "@homarr/request-handler/wud"; import { mockWidgetData } from "@homarr/integrations"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const wudRouter = createTRPCRouter({ @@ -13,7 +13,7 @@ export const wudRouter = createTRPCRouter({ "Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.", }, }) - .concat(createOneIntegrationMiddleware("query", "wud", "mock")) + .concat(createOneWidgetIntegrationMiddleware("query", "wud")) .query(async ({ ctx }) => { if (ctx.integration.kind === "mock") { return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) }; diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index c095c7715..9042f8e96 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -15,18 +15,18 @@ interface IntegrationLike { kind: string; } -interface Options { - fallback?: (integration: TIntegration, error: unknown) => TResult; +interface Options { + fallback?: (integration: TIntegration, error: unknown) => TFallback; throwOnAllFailures?: boolean; } -export async function settleIntegrationQueries( +export async function settleIntegrationQueries( integrations: TIntegration[], fn: (integration: TIntegration) => Promise, - options?: Options, -): Promise { + options?: Options, +): Promise<(TResult | TFallback)[]> { const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration))); - const results: TResult[] = []; + const results: (TResult | TFallback)[] = []; const errors: unknown[] = []; settled.forEach((result, index) => { diff --git a/packages/common/src/encryption.ts b/packages/common/src/encryption.ts index a10451f9f..7b1ed98c3 100644 --- a/packages/common/src/encryption.ts +++ b/packages/common/src/encryption.ts @@ -7,6 +7,7 @@ const algorithm = "aes-256-cbc"; //Using AES encryption // We fallback to a key of 0s if the key was not provided because env validation was skipped // This should only be the case in CI const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex"); +const fingerprintKey = crypto.createHmac("sha256", key).update("homarr-keyed-fingerprint-v1").digest(); export function encryptSecret(text: string): `${string}.${string}` { const initializationVector = crypto.randomBytes(16); @@ -29,3 +30,14 @@ export function decryptSecretWithKey(value: `${string}.${string}`, key: Buffer) decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString(); } + +export function createKeyedFingerprint(value: string) { + return crypto.createHmac("sha256", fingerprintKey).update(value).digest("hex"); +} + +export function verifyKeyedFingerprint(value: string, fingerprint: string) { + const expected = Buffer.from(createKeyedFingerprint(value), "hex"); + const actual = Buffer.from(fingerprint, "hex"); + if (expected.length !== actual.length) return false; + return crypto.timingSafeEqual(expected, actual); +} diff --git a/packages/core/src/infrastructure/redis/client.ts b/packages/core/src/infrastructure/redis/client.ts index ef68c0474..ffa6de017 100644 --- a/packages/core/src/infrastructure/redis/client.ts +++ b/packages/core/src/infrastructure/redis/client.ts @@ -9,7 +9,7 @@ const defaultRedisOptions = { export type { Redis as RedisClient } from "ioredis"; -export const createRedisClient = () => +export const createRedisClient = (options: RedisOptions = {}) => redisEnv.IS_EXTERNAL ? new Redis({ ...defaultRedisOptions, @@ -23,5 +23,6 @@ export const createRedisClient = () => : undefined, username: redisEnv.USERNAME, password: redisEnv.PASSWORD, + ...options, }) - : new Redis(defaultRedisOptions); + : new Redis({ ...defaultRedisOptions, ...options }); diff --git a/packages/definitions/package.json b/packages/definitions/package.json index dff0deb5e..0152ff0ea 100644 --- a/packages/definitions/package.json +++ b/packages/definitions/package.json @@ -14,6 +14,7 @@ "exports": { ".": "./index.ts", "./cookie": "./src/cookie.ts", + "./integration": "./src/integration.ts", "./docs/*": "./src/docs/*.ts" }, "scripts": { diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index 36a0d0f58..e58f18c3f 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -1,6 +1,6 @@ import { objectKeys } from "@homarr/common"; -import { integrationDefs } from "./integration"; +import { getIntegrationDockerMetadata, integrationDefs } from "./integration"; import type { IntegrationKind } from "./integration"; export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? ""; @@ -14,30 +14,6 @@ export const integrationIconSlugs: Record = Object.from objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]), ) as Record; -const integrationAliases: Partial> = { - piHole: ["pihole", "pi-hole"], - adGuardHome: ["adguardhome", "adguard-home", "adguard"], - homeAssistant: ["homeassistant", "home-assistant", "hass"], - openmediavault: ["omv"], - qBittorrent: ["qbittorrent"], - sabNzbd: ["sabnzbd"], - nzbGet: ["nzbget"], - unifiController: ["unifi", "unifi-controller"], - jellyseerr: ["jellyseerr"], - overseerr: ["overseerr"], - dashDot: ["dashdot", "dash-dot", "dash."], - speedtestTracker: ["speedtest-tracker"], - uptimeKuma: ["uptime-kuma"], - audiobookshelf: ["audiobookshelf"], - navidrome: ["navidrome"], - paperlessNgx: ["paperless-ngx", "paperless"], - patchmon: ["patchmon", "patch-mon"], - coolify: ["coolify"], - truenas: ["truenas"], - bazarr: ["bazarr"], - synology: ["synology", "diskstation"], -}; - export const matchIntegrationKind = (search: string): IntegrationKind | null => { const normalized = search.toLowerCase().trim(); if (!normalized) return null; @@ -51,8 +27,8 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null => if (integrationIconSlugs[kind] === normalized) return kind; } - for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) { - if (aliases.some((alias) => alias === normalized)) return kind; + for (const kind of objectKeys(integrationDefs)) { + if (getIntegrationDockerMetadata(kind).aliases.some((alias) => alias === normalized)) return kind; } for (const kind of objectKeys(integrationDefs)) { @@ -63,8 +39,6 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null => return null; }; -const notDockerDiscoverable = new Set(["ical", "mock"]); - interface ContainerMatchInput { image: string; name: string; @@ -73,10 +47,10 @@ interface ContainerMatchInput { export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => { const imageName = extractContainerImageName(container.image); const fromImage = matchIntegrationKind(imageName); - if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage; + if (fromImage && getIntegrationDockerMetadata(fromImage).discoverable) return fromImage; const fromName = matchIntegrationKind(container.name); - if (fromName && !notDockerDiscoverable.has(fromName)) return fromName; + if (fromName && getIntegrationDockerMetadata(fromName).discoverable) return fromName; return null; }; diff --git a/packages/definitions/src/docs/codegen.ts b/packages/definitions/src/docs/codegen.ts index d9185e59c..39d9d79da 100644 --- a/packages/definitions/src/docs/codegen.ts +++ b/packages/definitions/src/docs/codegen.ts @@ -5,8 +5,8 @@ import { XMLParser } from "fast-xml-parser"; import { z } from "zod/v4"; import { createDocumentationLink } from "./index"; -import { integrationDocSlugs } from "./integration-doc-slugs"; import { widgetDocSlugs } from "./widget-doc-slugs"; +import { getIntegrationDocumentationSlug, integrationKinds } from "../integration"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -60,7 +60,8 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => { }; const slugMapPaths = [ - ...Object.values(integrationDocSlugs) + ...integrationKinds + .map(getIntegrationDocumentationSlug) .filter(Boolean) .map((slug) => `/docs/integrations/${slug}`), ...Object.values(widgetDocSlugs) diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts deleted file mode 100644 index 3a306b14e..000000000 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { IntegrationKind } from "../integration"; - -export const integrationDocSlugs: Record = { - sabNzbd: "sabnzbd", - nzbGet: "nzbget", - deluge: "deluge", - transmission: "transmission", - qBittorrent: "q-bittorent", - aria2: "aria2", - slskd: "slskd", - sonarr: "sonarr", - radarr: "radarr", - lidarr: "lidarr", - readarr: "readarr", - prowlarr: "prowlarr", - bazarr: "bazarr", - jellyfin: "jellyfin", - emby: "emby", - plex: "plex", - jellyseerr: "jellyseerr", - seerr: "seerr", - overseerr: "overseerr", - piHole: "pi-hole", - adGuardHome: "adguard-home", - technitiumDns: "technitium-dns", - homeAssistant: "home-assistant", - openmediavault: "open-media-vault", - dashDot: "dash-dot", - glances: "glances", - tdarr: "tdarr", - proxmox: "proxmox", - nextcloud: "nextcloud", - unifiController: "unifi-controller", - opnsense: "opnsense", - ntfy: "ntfy", - gotify: "gotify", - ical: "ical", - anchor: "anchor", - truenas: "truenas", - synology: "synology", - unraid: "unraid", - coolify: "coolify", - immich: "immich", - paperlessNgx: "paperless-ngx", - patchmon: "patchmon", - tracearr: "tracearr", - speedtestTracker: "speedtest-tracker", - uptimeKuma: "uptime-kuma", - audiobookshelf: "audiobookshelf", - navidrome: "navidrome", - umami: "umami", - gluetun: "gluetun", - peaNut: "peanut", - beszel: "beszel", - traefik: "traefik", - archiveTeamWarrior: "archiveteam-warrior", - wud: "whats-up-docker", - mock: null, -}; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 466d36369..0a47b3537 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -1,6 +1,6 @@ import type { WidgetKind } from "../widget"; -export const widgetDocSlugs: Record = { +export const widgetDocSlugs = { clock: "clock", weather: "weather", airQuality: "air-quality", @@ -60,4 +60,4 @@ export const widgetDocSlugs: Record = { customApi: "custom-api", assistant: "assistant", wud: "whats-up-docker", -}; +} satisfies Record; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f010dcb4c..68b27ad59 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,7 +1,12 @@ -import { objectKeys } from "@homarr/common"; import type { AtLeastOneOf } from "@homarr/common/types"; import { createDocumentationLink } from "./docs"; +import type { HomarrDocumentationPath } from "./docs/homarr-docs-sitemap"; + +type IntegrationDocumentationPath = Extract; +type IntegrationDocumentationSlug = IntegrationDocumentationPath extends `/docs/integrations/${infer TSlug}` + ? TSlug + : never; export const integrationSecretKindObject = { apiKey: { isPublic: false, multiline: false }, @@ -22,14 +27,29 @@ export const integrationSecretKindObject = { slug: { isPublic: true, multiline: false }, } satisfies Record; -export const integrationSecretKinds = objectKeys(integrationSecretKindObject); +const isIntegrationSecretKind = (value: string): value is IntegrationSecretKind => + Object.hasOwn(integrationSecretKindObject, value); -interface integrationDefinition { +export const integrationSecretKinds = Object.keys(integrationSecretKindObject).filter(isIntegrationSecretKind); + +interface IntegrationFeatureMetadata { + docker?: { + aliases?: readonly string[]; + discoverable?: boolean; + }; + onboarding?: { + featuredOrder?: number; + hidden?: boolean; + }; +} + +interface IntegrationDefinition { name: string; iconUrl: string; - secretKinds: AtLeastOneOf; // at least one secret kind set is required - category: AtLeastOneOf; - documentationUrl: string | null; + secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]]; + category: readonly [IntegrationCategory, ...IntegrationCategory[]]; + documentationSlug: IntegrationDocumentationSlug | null; + features?: IntegrationFeatureMetadata; defaultUrl?: string; defaultPort?: number; apiKeySettingsPath?: string; @@ -41,24 +61,29 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg", category: ["downloadClient", "usenet"], - documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"), + documentationSlug: "sabnzbd", defaultPort: 8080, apiKeySettingsPath: "/config/general/", + features: { + docker: { aliases: ["sabnzbd"] }, + onboarding: { featuredOrder: 3 }, + }, }, nzbGet: { name: "NZBGet", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg", category: ["downloadClient", "usenet"], - documentationUrl: createDocumentationLink("/docs/integrations/nzbget"), + documentationSlug: "nzbget", defaultPort: 6789, + features: { docker: { aliases: ["nzbget"] } }, }, deluge: { name: "Deluge", secretKinds: [["password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg", category: ["downloadClient", "torrent"], - documentationUrl: createDocumentationLink("/docs/integrations/deluge"), + documentationSlug: "deluge", defaultPort: 8112, }, transmission: { @@ -66,7 +91,7 @@ export const integrationDefs = { secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg", category: ["downloadClient", "torrent"], - documentationUrl: createDocumentationLink("/docs/integrations/transmission"), + documentationSlug: "transmission", defaultPort: 9091, }, qBittorrent: { @@ -74,16 +99,20 @@ export const integrationDefs = { secretKinds: [["apiKey"], ["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg", category: ["downloadClient", "torrent"], - documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"), + documentationSlug: "q-bittorent", defaultPort: 8080, apiKeySettingsPath: "/#/settings/webui", + features: { + docker: { aliases: ["qbittorrent"] }, + onboarding: { featuredOrder: 4 }, + }, }, aria2: { name: "Aria2", secretKinds: [[], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg", category: ["downloadClient", "torrent", "miscellaneous"], - documentationUrl: createDocumentationLink("/docs/integrations/aria2"), + documentationSlug: "aria2", defaultPort: 6800, }, slskd: { @@ -91,7 +120,7 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg", category: ["downloadClient", "miscellaneous"], - documentationUrl: createDocumentationLink("/docs/integrations/slskd"), + documentationSlug: "slskd", defaultPort: 5030, }, sonarr: { @@ -99,25 +128,27 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg", category: ["calendar", "mediaOrganizer"], - documentationUrl: createDocumentationLink("/docs/integrations/sonarr"), + documentationSlug: "sonarr", defaultPort: 8989, apiKeySettingsPath: "/settings/general", + features: { onboarding: { featuredOrder: 0 } }, }, radarr: { name: "Radarr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg", category: ["calendar", "mediaOrganizer"], - documentationUrl: createDocumentationLink("/docs/integrations/radarr"), + documentationSlug: "radarr", defaultPort: 7878, apiKeySettingsPath: "/settings/general", + features: { onboarding: { featuredOrder: 1 } }, }, lidarr: { name: "Lidarr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg", category: ["calendar"], - documentationUrl: createDocumentationLink("/docs/integrations/lidarr"), + documentationSlug: "lidarr", defaultPort: 8686, apiKeySettingsPath: "/settings/general", }, @@ -126,7 +157,7 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png", category: ["calendar"], - documentationUrl: createDocumentationLink("/docs/integrations/readarr"), + documentationSlug: "readarr", defaultPort: 8787, apiKeySettingsPath: "/settings/general", }, @@ -135,34 +166,37 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg", category: ["indexerManager"], - documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"), + documentationSlug: "prowlarr", defaultPort: 9696, apiKeySettingsPath: "/settings/general", + features: { onboarding: { featuredOrder: 2 } }, }, bazarr: { name: "Bazarr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg", category: ["subtitleManager"], - documentationUrl: createDocumentationLink("/docs/integrations/bazarr"), + documentationSlug: "bazarr", defaultPort: 6767, apiKeySettingsPath: "/settings/general", + features: { docker: { aliases: ["bazarr"] } }, }, jellyfin: { name: "Jellyfin", secretKinds: [["username", "password"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg", category: ["mediaService", "mediaRelease"], - documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"), + documentationSlug: "jellyfin", defaultPort: 8096, apiKeySettingsPath: "/web/index.html#!/dashboard/keys", + features: { onboarding: { featuredOrder: 6 } }, }, emby: { name: "Emby", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg", category: ["mediaService", "mediaRelease"], - documentationUrl: createDocumentationLink("/docs/integrations/emby"), + documentationSlug: "emby", defaultPort: 8096, }, plex: { @@ -170,7 +204,7 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg", category: ["mediaService", "mediaRelease"], - documentationUrl: createDocumentationLink("/docs/integrations/plex"), + documentationSlug: "plex", defaultPort: 32400, }, jellyseerr: { @@ -178,50 +212,55 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], - documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"), + documentationSlug: "jellyseerr", defaultPort: 5055, apiKeySettingsPath: "/settings", + features: { docker: { aliases: ["jellyseerr"] } }, }, seerr: { name: "Seerr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg", category: ["mediaSearch", "mediaRequest", "search"], - documentationUrl: createDocumentationLink("/docs/integrations/seerr"), + documentationSlug: "seerr", defaultPort: 5055, apiKeySettingsPath: "/settings", + features: { onboarding: { featuredOrder: 5 } }, }, overseerr: { name: "Overseerr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg", category: ["mediaSearch", "mediaRequest", "search"], - documentationUrl: createDocumentationLink("/docs/integrations/overseerr"), + documentationSlug: "overseerr", defaultPort: 5055, apiKeySettingsPath: "/settings", + features: { docker: { aliases: ["overseerr"] } }, }, piHole: { name: "Pi-hole", secretKinds: [["apiKey"], []], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg", category: ["dnsHole"], - documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"), + documentationSlug: "pi-hole", defaultPort: 80, + features: { docker: { aliases: ["pihole", "pi-hole"] } }, }, adGuardHome: { name: "AdGuard Home", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg", category: ["dnsHole"], - documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"), + documentationSlug: "adguard-home", defaultPort: 3000, + features: { docker: { aliases: ["adguardhome", "adguard-home", "adguard"] } }, }, technitiumDns: { name: "Technitium DNS", secretKinds: [["apiKey"], ["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg", category: ["dnsHole"], - documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"), + documentationSlug: "technitium-dns", defaultPort: 5380, }, homeAssistant: { @@ -229,32 +268,35 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg", category: ["smartHomeServer", "calendar"], - documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"), + documentationSlug: "home-assistant", defaultPort: 8123, apiKeySettingsPath: "/profile/security", + features: { docker: { aliases: ["homeassistant", "home-assistant", "hass"] } }, }, openmediavault: { name: "OpenMediaVault", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"), + documentationSlug: "open-media-vault", defaultPort: 80, + features: { docker: { aliases: ["omv"] } }, }, dashDot: { name: "Dash.", secretKinds: [[]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"), + documentationSlug: "dash-dot", defaultPort: 3001, + features: { docker: { aliases: ["dashdot", "dash-dot", "dash."] } }, }, glances: { name: "Glances", secretKinds: [[]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/glances"), + documentationSlug: "glances", defaultPort: 61208, }, tdarr: { @@ -262,7 +304,7 @@ export const integrationDefs = { secretKinds: [[], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png", category: ["mediaTranscoding"], - documentationUrl: createDocumentationLink("/docs/integrations/tdarr"), + documentationSlug: "tdarr", defaultPort: 8265, }, proxmox: { @@ -270,7 +312,7 @@ export const integrationDefs = { secretKinds: [["username", "tokenId", "apiKey", "realm"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/proxmox"), + documentationSlug: "proxmox", defaultPort: 8006, }, nextcloud: { @@ -278,7 +320,7 @@ export const integrationDefs = { secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg", category: ["calendar", "notifications"], - documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"), + documentationSlug: "nextcloud", defaultPort: 443, }, unifiController: { @@ -286,15 +328,16 @@ export const integrationDefs = { secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png", category: ["networkController"], - documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"), + documentationSlug: "unifi-controller", defaultPort: 8443, + features: { docker: { aliases: ["unifi", "unifi-controller"] } }, }, opnsense: { name: "OPNsense", secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg", category: ["firewall"], - documentationUrl: createDocumentationLink("/docs/integrations/opnsense"), + documentationSlug: "opnsense", defaultPort: 443, apiKeySettingsPath: "/system_usermanager.php", }, @@ -303,7 +346,7 @@ export const integrationDefs = { secretKinds: [["topic"], ["topic", "apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg", category: ["notifications"], - documentationUrl: createDocumentationLink("/docs/integrations/ntfy"), + documentationSlug: "ntfy", defaultPort: 80, apiKeySettingsPath: "/account", }, @@ -312,7 +355,7 @@ export const integrationDefs = { secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg", category: ["notifications"], - documentationUrl: createDocumentationLink("/docs/integrations/gotify"), + documentationSlug: "gotify", defaultPort: 80, }, ical: { @@ -320,14 +363,15 @@ export const integrationDefs = { secretKinds: [["url"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg", category: ["calendar"], - documentationUrl: createDocumentationLink("/docs/integrations/ical"), + documentationSlug: "ical", + features: { docker: { discoverable: false } }, }, anchor: { name: "Anchor", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg", category: ["notes"], - documentationUrl: createDocumentationLink("/docs/integrations/anchor"), + documentationSlug: "anchor", defaultPort: 8080, apiKeySettingsPath: "/settings", }, @@ -336,23 +380,25 @@ export const integrationDefs = { secretKinds: [["username", "password"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/truenas"), + documentationSlug: "truenas", defaultPort: 80, + features: { docker: { aliases: ["truenas"] } }, }, synology: { name: "Synology DiskStation", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/synology"), + documentationSlug: "synology", defaultPort: 5000, + features: { docker: { aliases: ["synology", "diskstation"] } }, }, unraid: { name: "Unraid", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/unraid"), + documentationSlug: "unraid", defaultPort: 80, apiKeySettingsPath: "/Settings/ManagementAccess", }, @@ -361,16 +407,17 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/coolify"), + documentationSlug: "coolify", defaultPort: 8000, apiKeySettingsPath: "/security/api-tokens", + features: { docker: { aliases: ["coolify"] } }, }, immich: { name: "Immich", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg", category: ["photoService"], - documentationUrl: createDocumentationLink("/docs/integrations/immich"), + documentationSlug: "immich", defaultPort: 2283, apiKeySettingsPath: "/user-settings", }, @@ -379,24 +426,26 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg", category: ["documents"], - documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"), + documentationSlug: "paperless-ngx", defaultPort: 8000, + features: { docker: { aliases: ["paperless-ngx", "paperless"] } }, }, patchmon: { name: "PatchMon", secretKinds: [["patchmonApiKey", "patchmonApiSecret"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/patchmon"), + documentationSlug: "patchmon", defaultPort: 3413, apiKeySettingsPath: "/settings/integrations", + features: { docker: { aliases: ["patchmon", "patch-mon"] } }, }, tracearr: { name: "Tracearr", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg", category: ["mediaMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/tracearr"), + documentationSlug: "tracearr", defaultPort: 7040, apiKeySettingsPath: "/settings", }, @@ -405,35 +454,39 @@ export const integrationDefs = { secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png", category: ["speedtest"], - documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"), + documentationSlug: "speedtest-tracker", defaultPort: 80, apiKeySettingsPath: "/admin/api-tokens", + features: { docker: { aliases: ["speedtest-tracker"] } }, }, uptimeKuma: { name: "Uptime Kuma", secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg", category: ["uptimeMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"), + documentationSlug: "uptime-kuma", defaultPort: 3001, apiKeySettingsPath: "/settings/api-keys", + features: { docker: { aliases: ["uptime-kuma"] } }, }, audiobookshelf: { name: "Audiobookshelf", secretKinds: [["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg", category: ["mediaLibrary"], - documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"), + documentationSlug: "audiobookshelf", defaultPort: 13378, apiKeySettingsPath: "/account", + features: { docker: { aliases: ["audiobookshelf"] } }, }, navidrome: { name: "Navidrome", secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg", category: ["mediaLibrary", "mediaService"], - documentationUrl: createDocumentationLink("/docs/integrations/navidrome"), + documentationSlug: "navidrome", defaultPort: 4533, + features: { docker: { aliases: ["navidrome"] } }, }, umami: { name: "Umami", @@ -441,7 +494,7 @@ export const integrationDefs = { iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg", category: ["analytics"], defaultUrl: "https://api.umami.is/v1", - documentationUrl: createDocumentationLink("/docs/integrations/umami"), + documentationSlug: "umami", defaultPort: 3000, }, peaNut: { @@ -449,7 +502,7 @@ export const integrationDefs = { secretKinds: [["username", "password"], []], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg", category: ["ups"], - documentationUrl: createDocumentationLink("/docs/integrations/peanut"), + documentationSlug: "peanut", defaultPort: 8080, }, beszel: { @@ -457,7 +510,7 @@ export const integrationDefs = { secretKinds: [["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", category: ["beszel"], - documentationUrl: createDocumentationLink("/docs/integrations/beszel"), + documentationSlug: "beszel", defaultPort: 8090, }, gluetun: { @@ -465,7 +518,7 @@ export const integrationDefs = { secretKinds: [["username", "password"], ["apiKey"], []], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg", category: ["vpn"], - documentationUrl: createDocumentationLink("/docs/integrations/gluetun"), + documentationSlug: "gluetun", defaultPort: 8000, }, traefik: { @@ -473,7 +526,7 @@ export const integrationDefs = { secretKinds: [[], ["username", "password"], ["apiKey"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg", category: ["reverseProxy"], - documentationUrl: createDocumentationLink("/docs/integrations/traefik"), + documentationSlug: "traefik", defaultPort: 8080, }, archiveTeamWarrior: { @@ -481,7 +534,7 @@ export const integrationDefs = { secretKinds: [[], ["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png", category: ["archiving"], - documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"), + documentationSlug: "archiveteam-warrior", defaultPort: 8001, }, wud: { @@ -489,7 +542,7 @@ export const integrationDefs = { secretKinds: [[], ["username", "password"]], iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg", category: ["healthMonitoring"], - documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"), + documentationSlug: "whats-up-docker", defaultPort: 3000, }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) @@ -514,21 +567,36 @@ export const integrationDefs = { "uptimeMonitoring", "mediaOrganizer", ], - documentationUrl: null, + documentationSlug: null, + features: { + docker: { discoverable: false }, + onboarding: { hidden: true }, + }, }, -} as const satisfies Record; +} as const satisfies Record; -export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; +const isIntegrationKind = (value: string): value is IntegrationKind => Object.hasOwn(integrationDefs, value); +const integrationKindValues = Object.keys(integrationDefs).filter(isIntegrationKind); +const firstIntegrationKind = integrationKindValues[0]; +if (!firstIntegrationKind) throw new Error("At least one integration definition is required"); + +export const integrationKinds: AtLeastOneOf = [ + firstIntegrationKind, + ...integrationKindValues.slice(1), +]; export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl; export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name; -export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] => - integrationDefs[integration].secretKinds[0]; +export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] => [ + ...integrationDefs[integration].secretKinds[0], +]; -export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf => - integrationDefs[integration].secretKinds; +export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf => { + const [first, ...rest] = integrationDefs[integration].secretKinds; + return [[...first], ...rest.map((secretKinds) => [...secretKinds])]; +}; export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { const definition = integrationDefs[integration]; @@ -540,8 +608,30 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef return "defaultPort" in definition ? definition.defaultPort : undefined; }; -export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => - integrationDefs[kind].documentationUrl; +export const getIntegrationDocumentationSlug = (kind: IntegrationKind): IntegrationDocumentationSlug | null => + integrationDefs[kind].documentationSlug; + +export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => { + const slug = getIntegrationDocumentationSlug(kind); + if (slug === null) return null; + return createDocumentationLink(`/docs/integrations/${slug}`); +}; + +export const getIntegrationDockerMetadata = (kind: IntegrationKind) => { + const definition: IntegrationDefinition = integrationDefs[kind]; + return { + aliases: definition.features?.docker?.aliases ?? [], + discoverable: definition.features?.docker?.discoverable ?? true, + }; +}; + +export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => { + const definition: IntegrationDefinition = integrationDefs[kind]; + return { + featuredOrder: definition.features?.onboarding?.featuredOrder ?? null, + hidden: definition.features?.onboarding?.hidden ?? false, + }; +}; export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { const definition = integrationDefs[kind]; @@ -567,9 +657,12 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio * @returns Partial list of integration kinds */ export const getIntegrationKindsByCategory = (category: TCategory) => { - return objectKeys(integrationDefs).filter((integration) => - integrationDefs[integration].category.some((defCategory) => defCategory === category), - ) as AtLeastOneOf>; + const matchesCategory = (integration: IntegrationKind): integration is IntegrationKindByCategory => + integrationDefs[integration].category.some((definitionCategory) => definitionCategory === category); + const matches = integrationKinds.filter(matchesCategory); + const firstMatch = matches[0]; + if (!firstMatch) throw new Error(`No integration definition uses the ${category} category`); + return [firstMatch, ...matches.slice(1)] satisfies AtLeastOneOf>; }; /** @@ -579,10 +672,8 @@ export type IntegrationKindByCategory = { [Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number] ? Key : never; -}[keyof typeof integrationDefs] extends infer U - ? //Needed to simplify the type when using it - U - : never; +}[keyof typeof integrationDefs] & + IntegrationKind; export type IntegrationSecretKind = keyof typeof integrationSecretKindObject; export type IntegrationKind = keyof typeof integrationDefs; diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts index ed176be66..6e7cb9e30 100644 --- a/packages/definitions/src/test/api-key-url.spec.ts +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getIntegrationApiKeyUrl, integrationDefs } from "../integration"; +import { objectEntries } from "@homarr/common"; + +import { getIntegrationApiKeyUrl, getIntegrationDocumentationUrl, integrationDefs } from "../integration"; describe("getIntegrationApiKeyUrl", () => { it("links every user-facing integration to its Homarr setup guide", () => { expect( - Object.entries(integrationDefs) - .filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl) + objectEntries(integrationDefs) + .filter(([kind]) => kind !== "mock" && !getIntegrationDocumentationUrl(kind)) .map(([kind]) => kind), ).toEqual([]); }); diff --git a/packages/definitions/src/test/widget-integration-map.spec.ts b/packages/definitions/src/test/widget-integration-map.spec.ts index 9e21bbdcd..538a8de50 100644 --- a/packages/definitions/src/test/widget-integration-map.spec.ts +++ b/packages/definitions/src/test/widget-integration-map.spec.ts @@ -4,19 +4,19 @@ import { integrationKinds } from "../integration"; import { getWidgetKindsForIntegration, integrationWidgetSupport, - nativeFeatureCapabilities, + widgetIntegrationConfigs, widgetIntegrationLimits, widgetIntegrationSupport, widgetKindsWithOptionalIntegrations, } from "../widget-integration-map"; -describe("native feature capability descriptor", () => { +describe("widget integration config", () => { test("derives forward and reverse support without losing relationships", () => { - for (const [widgetKind, capability] of Object.entries(nativeFeatureCapabilities)) { + for (const [widgetKind, capability] of Object.entries(widgetIntegrationConfigs)) { expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual( - capability.integrations, + capability.supportedIntegrations, ); - for (const integrationKind of capability.integrations) { + for (const integrationKind of capability.supportedIntegrations) { expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind); } } @@ -27,8 +27,8 @@ describe("native feature capability descriptor", () => { }); test("supports the mock integration for every integration-backed widget", () => { - for (const capability of Object.values(nativeFeatureCapabilities)) { - expect(capability.integrations).toContain("mock"); + for (const config of Object.values(widgetIntegrationConfigs)) { + expect(config.supportedIntegrations).toContain("mock"); } }); diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index b9fbfb929..6b7953bc7 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -1,82 +1,108 @@ +import { objectEntries } from "@homarr/common"; + import { createDocumentationLink } from "./docs"; import type { IntegrationKind } from "./integration"; import { getIntegrationKindsByCategory, integrationKinds } from "./integration"; import type { WidgetKind } from "./widget"; import { widgetDefaultSizes } from "./widget"; -/** - * Server-safe capability metadata shared by the API and widget catalog. - * Keep this intentionally small: presentation, options, and request contracts - * remain owned by their feature packages. - */ -export interface NativeFeatureCapabilityDescriptor { - integrations: readonly IntegrationKind[]; - connectionOptional?: true; - serverMaxIntegrations?: number; +/** Integration selection shared by the widget UI and API trust boundary. */ +export interface WidgetIntegrationConfig { + supportedIntegrations: IntegrationKind[]; + integrationsRequired?: false; + maxIntegrations?: number; } -export const nativeFeatureCapabilities = { - dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") }, - dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") }, +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") }, "smartHome-entityState": { - integrations: getIntegrationKindsByCategory("smartHomeServer"), - serverMaxIntegrations: 1, + supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), + maxIntegrations: 1, }, "smartHome-executeAutomation": { - integrations: getIntegrationKindsByCategory("smartHomeServer"), - serverMaxIntegrations: 1, + supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), + maxIntegrations: 1, }, - mediaServer: { integrations: getIntegrationKindsByCategory("mediaService") }, - calendar: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true }, - downloads: { integrations: getIntegrationKindsByCategory("downloadClient") }, - "mediaRequests-requestList": { integrations: getIntegrationKindsByCategory("mediaRequest") }, - "mediaRequests-requestStats": { integrations: getIntegrationKindsByCategory("mediaRequest") }, - mediaTranscoding: { integrations: getIntegrationKindsByCategory("mediaTranscoding"), serverMaxIntegrations: 1 }, - mediaMissing: { integrations: getIntegrationKindsByCategory("mediaOrganizer") }, - networkControllerSummary: { integrations: getIntegrationKindsByCategory("networkController") }, - networkControllerStatus: { integrations: getIntegrationKindsByCategory("networkController") }, - indexerManager: { integrations: getIntegrationKindsByCategory("indexerManager") }, + mediaServer: { supportedIntegrations: getIntegrationKindsByCategory("mediaService") }, + calendar: { supportedIntegrations: getIntegrationKindsByCategory("calendar"), integrationsRequired: false }, + downloads: { supportedIntegrations: getIntegrationKindsByCategory("downloadClient") }, + "mediaRequests-requestList": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") }, + "mediaRequests-requestStats": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") }, + mediaTranscoding: { + supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"), + maxIntegrations: 1, + }, + mediaMissing: { supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer") }, + networkControllerSummary: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, + networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, + indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") }, healthMonitoring: { - integrations: getIntegrationKindsByCategory("healthMonitoring").filter( - (kind) => kind !== "patchmon" && kind !== "wud", - ), + supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds, ...healthMonitoringClusterIntegrationKinds], }, - firewall: { integrations: [...getIntegrationKindsByCategory("firewall"), "mock"] }, - notifications: { integrations: getIntegrationKindsByCategory("notifications") }, - mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] }, - systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"] }, - systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] }, - beszelAlerts: { integrations: ["beszel", "mock"] }, - beszelSystemGrid: { integrations: ["beszel", "mock"] }, - beszelSystemStats: { integrations: ["beszel", "mock"] }, - beszelSystemTable: { integrations: ["beszel", "mock"] }, - coolify: { integrations: ["coolify", "mock"] }, - "immich-serverStats": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 }, - "immich-albumCarousel": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 }, - paperlessNgx: { integrations: ["paperlessNgx", "mock"], serverMaxIntegrations: 1 }, - patchmon: { integrations: ["patchmon", "mock"], serverMaxIntegrations: 1 }, - bazarr: { integrations: ["bazarr", "mock"], serverMaxIntegrations: 1 }, - tracearr: { integrations: ["tracearr", "mock"] }, - speedtestTracker: { integrations: ["speedtestTracker", "mock"] }, - uptimeKuma: { integrations: ["uptimeKuma", "mock"] }, - audioStats: { integrations: ["navidrome", "audiobookshelf", "mock"], serverMaxIntegrations: 1 }, - umami: { integrations: ["umami", "mock"], serverMaxIntegrations: 1 }, - vpn: { integrations: [...getIntegrationKindsByCategory("vpn"), "mock"] }, - ups: { integrations: getIntegrationKindsByCategory("ups") }, - archiveTeamWarrior: { integrations: ["archiveTeamWarrior", "mock"], serverMaxIntegrations: 1 }, - anchorNote: { integrations: ["anchor", "mock"], serverMaxIntegrations: 1 }, - traefik: { integrations: ["traefik", "mock"] }, - wud: { integrations: ["wud", "mock"], serverMaxIntegrations: 1 }, -} as const satisfies Partial>; + firewall: { supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"] }, + notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") }, + mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] }, + systemResources: { + supportedIntegrations: [...healthMonitoringSystemIntegrationKinds], + }, + systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] }, + beszelAlerts: { supportedIntegrations: ["beszel", "mock"] }, + beszelSystemGrid: { supportedIntegrations: ["beszel", "mock"] }, + beszelSystemStats: { supportedIntegrations: ["beszel", "mock"] }, + beszelSystemTable: { supportedIntegrations: ["beszel", "mock"] }, + coolify: { supportedIntegrations: ["coolify", "mock"] }, + "immich-serverStats": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 }, + "immich-albumCarousel": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 }, + paperlessNgx: { supportedIntegrations: ["paperlessNgx", "mock"], maxIntegrations: 1 }, + patchmon: { supportedIntegrations: ["patchmon", "mock"], maxIntegrations: 1 }, + bazarr: { supportedIntegrations: ["bazarr", "mock"], maxIntegrations: 1 }, + tracearr: { supportedIntegrations: ["tracearr", "mock"] }, + speedtestTracker: { supportedIntegrations: ["speedtestTracker", "mock"] }, + uptimeKuma: { supportedIntegrations: ["uptimeKuma", "mock"] }, + audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf", "mock"], maxIntegrations: 1 }, + umami: { supportedIntegrations: ["umami", "mock"], maxIntegrations: 1 }, + vpn: { supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"] }, + ups: { supportedIntegrations: getIntegrationKindsByCategory("ups") }, + archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior", "mock"], maxIntegrations: 1 }, + anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 }, + traefik: { supportedIntegrations: ["traefik", "mock"] }, + wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 }, +} satisfies Partial>; -const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [ - WidgetKind, - NativeFeatureCapabilityDescriptor, -][]; +export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs; + +export type WidgetIntegrationKind = + (typeof widgetIntegrationConfigs)[TKind]["supportedIntegrations"][number]; + +export const getWidgetIntegrationConfig = ( + kind: TKind, +): (typeof widgetIntegrationConfigs)[TKind] => widgetIntegrationConfigs[kind]; + +const widgetIntegrationConfigEntries = objectEntries(widgetIntegrationConfigs); const createWidgetIntegrationSupport = () => { const result: Partial> = {}; - for (const [kind, capability] of nativeFeatureCapabilityEntries) result[kind] = capability.integrations; + for (const [kind, config] of widgetIntegrationConfigEntries) result[kind] = config.supportedIntegrations; return result; }; @@ -84,16 +110,15 @@ export const widgetIntegrationSupport = createWidgetIntegrationSupport(); /** Widgets that remain useful without a configured integration. */ export const widgetKindsWithOptionalIntegrations = new Set( - nativeFeatureCapabilityEntries - .filter(([, capability]) => capability.connectionOptional === true) + widgetIntegrationConfigEntries + .filter(([, config]) => "integrationsRequired" in config && config.integrationsRequired === false) .map(([kind]) => kind), ); -/** Widgets that support fewer integrations than the default unlimited selection. */ const createWidgetIntegrationLimits = () => { const result: Partial> = {}; - for (const [kind, capability] of nativeFeatureCapabilityEntries) { - if (capability.serverMaxIntegrations !== undefined) result[kind] = capability.serverMaxIntegrations; + for (const [kind, config] of widgetIntegrationConfigEntries) { + if ("maxIntegrations" in config) result[kind] = config.maxIntegrations; } return result; }; @@ -108,21 +133,26 @@ export type WidgetIntegrationIssue = export const getWidgetIntegrationIssue = ( widgetKind: WidgetKind, - integrationKinds: readonly IntegrationKind[], + selectedIntegrationKinds: readonly IntegrationKind[], ): WidgetIntegrationIssue | null => { const supportedIntegrations = widgetIntegrationSupport[widgetKind]; const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY; - if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit }; - if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" }; + if (selectedIntegrationKinds.length > integrationLimit) { + return { code: "integration-limit", limit: integrationLimit }; + } + if (supportedIntegrations === undefined && selectedIntegrationKinds.length > 0) { + return { code: "integration-not-supported" }; + } if ( supportedIntegrations !== undefined && - integrationKinds.length === 0 && + selectedIntegrationKinds.length === 0 && !widgetKindsWithOptionalIntegrations.has(widgetKind) ) { return { code: "integration-required" }; } - const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); - return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null; + const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); + if (incompatibleKinds.length === 0) return null; + return { code: "incompatible-integration", incompatibleKinds }; }; export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => { @@ -134,22 +164,24 @@ export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`; }; -/** Reverse capability index generated from nativeFeatureCapabilities. */ const createIntegrationWidgetSupport = () => { const result = {} as Record; for (const integrationKind of integrationKinds) { - result[integrationKind] = nativeFeatureCapabilityEntries - .filter(([, capability]) => capability.integrations.includes(integrationKind)) + result[integrationKind] = widgetIntegrationConfigEntries + .filter(([, config]) => supportsIntegration(config, integrationKind)) .map(([widgetKind]) => widgetKind); } return result; }; +const supportsIntegration = (config: WidgetIntegrationConfig, integrationKind: IntegrationKind) => + config.supportedIntegrations.includes(integrationKind); + export const integrationWidgetSupport = createIntegrationWidgetSupport(); -export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => [ - ...integrationWidgetSupport[integrationKind], -]; +export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { + return [...integrationWidgetSupport[integrationKind]]; +}; export interface DefaultWidgetConfig { kind: WidgetKind; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index f8df10772..320c01c24 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -59,15 +59,19 @@ export const widgetKinds = [ "assistant", "wud", ] as const; + export type WidgetKind = (typeof widgetKinds)[number]; export const widgetDefaultSizes: Partial> = { + clock: { width: 2, height: 1 }, + weather: { width: 2, height: 1 }, airQuality: { width: 2, height: 1 }, calendar: { width: 2, height: 2 }, countdown: { width: 2, height: 1 }, downloads: { width: 4, height: 2 }, indexerManager: { width: 2, height: 3 }, timer: { width: 2, height: 1 }, + bookmarks: { width: 2, height: 2 }, uptimeKuma: { width: 2, height: 3 }, audioStats: { width: 2, height: 2 }, paperlessNgx: { width: 2, height: 2 }, diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 026a3a782..37144edc8 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -13,6 +13,9 @@ }, "exports": { ".": "./index.ts", + "./anchor": "./src/anchor/anchor-types.ts", + "./downloads": "./src/interfaces/downloads/download-client-items.ts", + "./factory": "./src/factory.ts", "./response-contract": "./src/base/response-contract.ts", "./test-connection": "./src/base/test-connection/index.ts", "./types": "./src/types.ts" diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 9421e043d..df62678c5 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,146 +1,162 @@ import type { IntegrationKind } from "@homarr/definitions"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration"; -import { BeszelIntegration } from "../beszel/beszel-integration"; -import { BazarrIntegration } from "../bazarr/bazarr-integration"; -import { AnchorIntegration } from "../anchor/anchor-integration"; -import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration"; -import { CoolifyIntegration } from "../coolify/coolify-integration"; -import { DashDotIntegration } from "../dashdot/dashdot-integration"; -import { Aria2Integration } from "../download-client/aria2/aria2-integration"; -import { DelugeIntegration } from "../download-client/deluge/deluge-integration"; -import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration"; -import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration"; -import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration"; -import { SlskdIntegration } from "../download-client/slskd/slskd-integration"; -import { TransmissionIntegration } from "../download-client/transmission/transmission-integration"; -import { EmbyIntegration } from "../emby/emby-integration"; -import { GlancesIntegration } from "../glances/glances-integration"; -import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration"; -import { GluetunIntegration } from "../gluetun/gluetun-integration"; -import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; -import { ICalIntegration } from "../ical/ical-integration"; -import { ImmichIntegration } from "../immich/immich-integration"; -import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; -import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; -import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration"; -import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"; -import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration"; -import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration"; -import { TdarrIntegration } from "../media-transcoding/tdarr-integration"; -import { MockIntegration } from "../mock/mock-integration"; -import { NavidromeIntegration } from "../navidrome/navidrome-integration"; -import { NextcloudIntegration } from "../nextcloud/nextcloud.integration"; -import { GotifyIntegration } from "../gotify/gotify-integration"; -import { NTFYIntegration } from "../ntfy/ntfy-integration"; -import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration"; -import { OPNsenseIntegration } from "../opnsense/opnsense-integration"; -import { OverseerrIntegration } from "../overseerr/overseerr-integration"; -import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration"; -import { PatchMonIntegration } from "../patchmon/patchmon-integration"; -import { PeaNutIntegration } from "../peanut/peanut-integration"; -import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory"; -import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory"; -import { PlexIntegration } from "../plex/plex-integration"; -import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration"; -import { ProxmoxIntegration } from "../proxmox/proxmox-integration"; -import { SeerrIntegration } from "../seerr/seerr-integration"; -import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration"; -import { TracearrIntegration } from "../tracearr/tracearr-integration"; -import { TraefikIntegration } from "../traefik/traefik-integration"; -import { SynologyIntegration } from "../synology/synology-integration"; -import { TrueNasIntegration } from "../truenas/truenas-integration"; -import { UmamiIntegration } from "../umami/umami-integration"; -import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration"; -import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration"; -import { UnraidIntegration } from "../unraid/unraid-integration"; -import { WudIntegration } from "../wud/wud-integration"; import type { Integration, IntegrationInput } from "./integration"; -export const createIntegrationAsync = async ( +const logger = createLogger({ module: "integrationFactory" }); + +// Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk. +const integrationCreators = { + anchor: async (input: IntegrationInput) => + new (await import("../anchor/anchor-integration")).AnchorIntegration(input), + piHole: async (input: IntegrationInput) => + (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input), + adGuardHome: async (input: IntegrationInput) => + new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input), + technitiumDns: async (input: IntegrationInput) => + (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input), + homeAssistant: async (input: IntegrationInput) => + new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input), + jellyfin: async (input: IntegrationInput) => + new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input), + plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input), + sonarr: async (input: IntegrationInput) => + new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input), + radarr: async (input: IntegrationInput) => + new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input), + sabNzbd: async (input: IntegrationInput) => + new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input), + nzbGet: async (input: IntegrationInput) => + new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input), + qBittorrent: async (input: IntegrationInput) => + new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input), + deluge: async (input: IntegrationInput) => + new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input), + transmission: async (input: IntegrationInput) => + new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input), + slskd: async (input: IntegrationInput) => + new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input), + aria2: async (input: IntegrationInput) => + new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input), + jellyseerr: async (input: IntegrationInput) => + new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input), + seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input), + overseerr: async (input: IntegrationInput) => + new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input), + prowlarr: async (input: IntegrationInput) => + new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input), + openmediavault: async (input: IntegrationInput) => + new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input), + lidarr: async (input: IntegrationInput) => + new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input), + readarr: async (input: IntegrationInput) => + new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input), + dashDot: async (input: IntegrationInput) => + new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input), + tdarr: async (input: IntegrationInput) => + new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input), + proxmox: async (input: IntegrationInput) => + new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input), + emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input), + nextcloud: async (input: IntegrationInput) => + new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), + unifiController: async (input: IntegrationInput) => + new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), + opnsense: async (input: IntegrationInput) => + new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), + ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), + ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input), + gotify: async (input: IntegrationInput) => + new (await import("../gotify/gotify-integration")).GotifyIntegration(input), + mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input), + truenas: async (input: IntegrationInput) => + new (await import("../truenas/truenas-integration")).TrueNasIntegration(input), + synology: async (input: IntegrationInput) => + new (await import("../synology/synology-integration")).SynologyIntegration(input), + unraid: async (input: IntegrationInput) => + new (await import("../unraid/unraid-integration")).UnraidIntegration(input), + coolify: async (input: IntegrationInput) => + new (await import("../coolify/coolify-integration")).CoolifyIntegration(input), + tracearr: async (input: IntegrationInput) => + new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input), + glances: async (input: IntegrationInput) => + new (await import("../glances/glances-integration")).GlancesIntegration(input), + immich: async (input: IntegrationInput) => + new (await import("../immich/immich-integration")).ImmichIntegration(input), + paperlessNgx: async (input: IntegrationInput) => + new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input), + patchmon: async (input: IntegrationInput) => + new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input), + speedtestTracker: async (input: IntegrationInput) => + new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input), + audiobookshelf: async (input: IntegrationInput) => + new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input), + navidrome: async (input: IntegrationInput) => + new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input), + umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input), + gluetun: async (input: IntegrationInput) => + new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input), + archiveTeamWarrior: async (input: IntegrationInput) => + new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input), + uptimeKuma: async (input: IntegrationInput) => + new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input), + peaNut: async (input: IntegrationInput) => + new (await import("../peanut/peanut-integration")).PeaNutIntegration(input), + beszel: async (input: IntegrationInput) => + new (await import("../beszel/beszel-integration")).BeszelIntegration(input), + bazarr: async (input: IntegrationInput) => + new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input), + traefik: async (input: IntegrationInput) => + new (await import("../traefik/traefik-integration")).TraefikIntegration(input), + wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input), +} satisfies Record Promise>; + +type IntegrationCreators = typeof integrationCreators; +type IntegrationInstanceOfKind = Awaited< + ReturnType +>; + +export function createIntegrationAsync( integration: IntegrationInput & { kind: TKind }, -) => { - if (!(integration.kind in integrationCreators)) { +): Promise>; +export async function createIntegrationAsync( + integration: IntegrationInput & { kind: IntegrationKind }, +): Promise { + if (!Object.hasOwn(integrationCreators, integration.kind)) { + logger.warn("Integration client creation refused", { + integrationId: integration.id, + integrationKind: integration.kind, + reason: "integration-kind-unregistered", + }); throw new Error( `Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`, ); } - const creator = integrationCreators[integration.kind]; + const startedAt = Date.now(); + logger.debug("Creating integration client", { integrationId: integration.id, integrationKind: integration.kind }); - // factories are an array, to differentiate in js between class constructors and functions - if (Array.isArray(creator)) { - return (await creator[0](integration)) as IntegrationInstanceOfKind; + try { + const instance = await integrationCreators[integration.kind](integration); + logger.debug("Integration client created", { + integrationId: integration.id, + integrationKind: integration.kind, + durationMs: Date.now() - startedAt, + }); + return instance; + } catch (error) { + logger.warn( + new ErrorWithMetadata( + "Integration client creation failed", + { + integrationId: integration.id, + integrationKind: integration.kind, + durationMs: Date.now() - startedAt, + }, + { cause: error }, + ), + ); + throw error; } - - return new creator(integration) as IntegrationInstanceOfKind; -}; - -type IntegrationInstance = new (integration: IntegrationInput) => Integration; - -// factories are an array, to differentiate in js between class constructors and functions -export const integrationCreators = { - anchor: AnchorIntegration, - piHole: [createPiHoleIntegrationAsync], - adGuardHome: AdGuardHomeIntegration, - technitiumDns: [createTechnitiumDnsIntegrationAsync], - homeAssistant: HomeAssistantIntegration, - jellyfin: JellyfinIntegration, - plex: PlexIntegration, - sonarr: SonarrIntegration, - radarr: RadarrIntegration, - sabNzbd: SabnzbdIntegration, - nzbGet: NzbGetIntegration, - qBittorrent: QBitTorrentIntegration, - deluge: DelugeIntegration, - transmission: TransmissionIntegration, - slskd: SlskdIntegration, - aria2: Aria2Integration, - jellyseerr: JellyseerrIntegration, - seerr: SeerrIntegration, - overseerr: OverseerrIntegration, - prowlarr: ProwlarrIntegration, - openmediavault: OpenMediaVaultIntegration, - lidarr: LidarrIntegration, - readarr: ReadarrIntegration, - dashDot: DashDotIntegration, - tdarr: TdarrIntegration, - proxmox: ProxmoxIntegration, - emby: EmbyIntegration, - nextcloud: NextcloudIntegration, - unifiController: UnifiControllerIntegration, - opnsense: OPNsenseIntegration, - ical: ICalIntegration, - ntfy: NTFYIntegration, - gotify: GotifyIntegration, - mock: MockIntegration, - truenas: TrueNasIntegration, - synology: SynologyIntegration, - unraid: UnraidIntegration, - coolify: CoolifyIntegration, - tracearr: TracearrIntegration, - glances: GlancesIntegration, - immich: ImmichIntegration, - paperlessNgx: PaperlessNgxIntegration, - patchmon: PatchMonIntegration, - speedtestTracker: SpeedtestTrackerIntegration, - audiobookshelf: AudiobookshelfIntegration, - navidrome: NavidromeIntegration, - umami: UmamiIntegration, - gluetun: GluetunIntegration, - archiveTeamWarrior: ArchiveTeamWarriorIntegration, - uptimeKuma: UptimeKumaIntegration, - peaNut: PeaNutIntegration, - beszel: BeszelIntegration, - bazarr: BazarrIntegration, - traefik: TraefikIntegration, - wud: WudIntegration, -} satisfies Record Promise]>; - -type IntegrationInstanceOfKind = { - [kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise] - ? Awaited> - : (typeof integrationCreators)[kind] extends IntegrationInstance - ? InstanceType<(typeof integrationCreators)[kind]> - : never; -}[TKind]; +} diff --git a/packages/integrations/src/base/errors/decorator.ts b/packages/integrations/src/base/errors/decorator.ts index 3aac2eff7..0ecbccc05 100644 --- a/packages/integrations/src/base/errors/decorator.ts +++ b/packages/integrations/src/base/errors/decorator.ts @@ -1,15 +1,21 @@ import { isFunction } from "@homarr/common"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { Integration } from "../integration"; import type { IIntegrationErrorHandler } from "./handler"; import { integrationFetchHttpErrorHandler } from "./http"; import { IntegrationError } from "./integration-error"; import { IntegrationUnknownError } from "./integration-unknown-error"; +import { IntegrationRequestError } from "./http/integration-request-error"; +import { IntegrationResponseError } from "./http/integration-response-error"; import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } from "./parse"; const logger = createLogger({ module: "handleIntegrationErrors" }); +const loggedIntegrationErrors = new WeakSet(); +// ponytail: TypeScript requires `any[]` for mixin constructors (TS2545). The +// decorator returns the same constructor type T, so callers retain exact args. // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any type AbstractConstructor = abstract new (...args: any[]) => T; @@ -19,6 +25,68 @@ const defaultErrorHandlers: IIntegrationErrorHandler[] = [ integrationFetchHttpErrorHandler, ]; +interface IntegrationOperationMetadata { + operation: string; + integrationId: string; + integrationKind: string; + integrationName: string; +} + +const safeErrorNameRegex = /^(?:Error|[A-Za-z][A-Za-z0-9]*(?:Error|Err))$/; + +const createSafeErrorCause = (error: unknown, depth = 0): Error => { + let options: ErrorOptions | undefined; + if (depth < 5 && error instanceof Error && error.cause !== undefined) { + options = { cause: createSafeErrorCause(error.cause, depth + 1) }; + } + + const safeError = new Error("Integration operation failed", options); + if (error instanceof Error && safeErrorNameRegex.test(error.name)) { + safeError.name = error.name; + } + if (error instanceof Error && error.stack) { + const originalHeader = Error.prototype.toString.call(error); + if (error.stack.startsWith(originalHeader)) { + safeError.stack = `${safeError.name}: ${safeError.message}${error.stack.slice(originalHeader.length)}`; + } + } + return safeError; +}; + +const logFailure = ( + error: IntegrationError, + failureType: "normalized" | "unknown", + metadata: IntegrationOperationMetadata, + durationMs: number, +) => { + if (loggedIntegrationErrors.has(error)) { + return; + } + + loggedIntegrationErrors.add(error); + const errorMetadata: Record = { errorType: error.name }; + if (error instanceof IntegrationRequestError) { + errorMetadata.requestType = error.cause.type; + errorMetadata.requestReason = error.cause.reason; + errorMetadata.requestCode = error.cause.code; + } + if (error instanceof IntegrationResponseError) { + errorMetadata.statusCode = error.cause.statusCode; + } + logger.warn( + new ErrorWithMetadata( + "Integration operation failed", + { + ...metadata, + durationMs, + failureType, + ...errorMetadata, + }, + { cause: createSafeErrorCause(error) }, + ), + ); +}; + export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[]) => { const combinedErrorHandlers = [...defaultErrorHandlers, ...errorHandlers]; return >(IntegrationBaseClass: T): T => { @@ -44,8 +112,27 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[ processedProperties.add(propertyKey); const wrapped = (...methodArgs: unknown[]) => { - const handleError = (error: unknown) => { + const startedAt = Date.now(); + const operationMetadata: IntegrationOperationMetadata = { + operation: propertyKey, + integrationId: this.publicIntegration.id, + integrationKind: this.integration.kind ?? "unknown", + integrationName: this.publicIntegration.name, + }; + + logger.debug("Integration operation started", operationMetadata); + + const logCompletion = () => { + logger.debug("Integration operation completed", { + ...operationMetadata, + durationMs: Date.now() - startedAt, + }); + }; + + const handleError = (error: unknown): never => { if (error instanceof IntegrationError) { + const failureType = error instanceof IntegrationUnknownError ? "unknown" : "normalized"; + logFailure(error, failureType, operationMetadata, Date.now() - startedAt); throw error; } @@ -53,26 +140,29 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[ const handledError = handler.handleError(error, this.publicIntegration); if (!handledError) continue; + logFailure(handledError, "normalized", operationMetadata, Date.now() - startedAt); throw handledError; } - // If the error was handled and should be thrown again, throw it - logger.debug("Unhandled error in integration", { - error: error instanceof Error ? `${error.name}: ${error.message}` : undefined, - integrationName: this.publicIntegration.name, - }); - throw new IntegrationUnknownError(this.publicIntegration, { cause: error }); + const unknownError = new IntegrationUnknownError(this.publicIntegration, { cause: error }); + logFailure(unknownError, "unknown", operationMetadata, Date.now() - startedAt); + throw unknownError; }; try { const result = original.apply(this, methodArgs); if (result instanceof Promise) { - return result.catch((error: unknown) => { - handleError(error); - }); + return result.then( + (value: unknown) => { + logCompletion(); + return value; + }, + (error: unknown) => handleError(error), + ); } + logCompletion(); return result; } catch (error: unknown) { handleError(error); diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts index e129ca283..6563609f2 100644 --- a/packages/integrations/src/base/integration.ts +++ b/packages/integrations/src/base/integration.ts @@ -5,7 +5,7 @@ import { fetch as undiciFetch } from "undici"; import { removeTrailingSlash } from "@homarr/common"; import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http"; -import type { IntegrationSecretKind } from "@homarr/definitions"; +import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; import { HandleIntegrationErrors } from "./errors/decorator"; import { TestConnectionError } from "./test-connection/test-connection-error"; @@ -15,6 +15,7 @@ import type { IntegrationSecret } from "./types"; export interface IntegrationInput { id: string; + kind?: IntegrationKind; name: string; url: string; externalUrl: string | null; diff --git a/packages/integrations/src/base/session-store.ts b/packages/integrations/src/base/session-store.ts index 5c3ef0ce0..8e969dc74 100644 --- a/packages/integrations/src/base/session-store.ts +++ b/packages/integrations/src/base/session-store.ts @@ -1,15 +1,66 @@ import superjson from "superjson"; -import { decryptSecret, encryptSecret } from "@homarr/common/server"; +import { createKeyedFingerprint, decryptSecret, encryptSecret, verifyKeyedFingerprint } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { createGetSetChannel } from "@homarr/redis"; +import { createGetSetChannel, getIntegrationSessionStoreKey } from "@homarr/redis"; + +import type { IntegrationSecret } from "./types"; const logger = createLogger({ module: "sessionStore" }); +const sessionEnvelopeType = "homarr-integration-session"; +const sessionEnvelopeVersion = 1; -export const createSessionStore = (integration: { id: string }) => { - const channelName = `session-store:${integration.id}`; +interface SessionStoreIntegration { + id: string; + url: string; + decryptedSecrets: readonly IntegrationSecret[]; +} + +interface SessionEnvelope { + type: typeof sessionEnvelopeType; + version: typeof sessionEnvelopeVersion; + configurationFingerprint: string; + value: TValue; +} + +const compareStrings = (left: string, right: string) => { + if (left < right) return -1; + if (left > right) return 1; + return 0; +}; + +const serializeSessionConfiguration = (integration: SessionStoreIntegration) => { + const secrets = integration.decryptedSecrets + .map(({ kind, value }) => [kind, value] as const) + .toSorted(([leftKind, leftValue], [rightKind, rightValue]) => { + const kindComparison = compareStrings(leftKind, rightKind); + if (kindComparison !== 0) return kindComparison; + return compareStrings(leftValue, rightValue); + }); + + return JSON.stringify({ integrationId: integration.id, url: integration.url, secrets }); +}; + +const isSessionEnvelope = (value: unknown): value is SessionEnvelope => { + if (typeof value !== "object" || value === null) return false; + if (!("value" in value)) return false; + + return ( + "type" in value && + value.type === sessionEnvelopeType && + "version" in value && + value.version === sessionEnvelopeVersion && + "configurationFingerprint" in value && + typeof value.configurationFingerprint === "string" + ); +}; + +export const createSessionStore = (integration: SessionStoreIntegration) => { + const channelName = getIntegrationSessionStoreKey(integration.id); const channel = createGetSetChannel<`${string}.${string}`>(channelName); + const serializedConfiguration = serializeSessionConfiguration(integration); + const configurationFingerprint = createKeyedFingerprint(serializedConfiguration); return { async getAsync() { @@ -17,7 +68,16 @@ export const createSessionStore = (integration: { id: string }) => { const value = await channel.getAsync(); if (!value) return null; try { - return superjson.parse(decryptSecret(value)); + const envelope = superjson.parse(decryptSecret(value)); + if (!isSessionEnvelope(envelope)) { + logger.debug("Ignoring legacy or unsupported session", { store: channelName }); + return null; + } + if (!verifyKeyedFingerprint(serializedConfiguration, envelope.configurationFingerprint)) { + logger.debug("Ignoring session for a different integration configuration", { store: channelName }); + return null; + } + return envelope.value; } catch (error) { logger.warn("Failed to load session", { store: channelName, error }); return null; @@ -31,7 +91,13 @@ export const createSessionStore = (integration: { id: string }) => { async setAsync(value: TValue, options?: { ttlSeconds?: number }) { logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds }); try { - await channel.setAsync(encryptSecret(superjson.stringify(value)), options); + const envelope: SessionEnvelope = { + type: sessionEnvelopeType, + version: sessionEnvelopeVersion, + configurationFingerprint, + value, + }; + await channel.setAsync(encryptSecret(superjson.stringify(envelope)), options); } catch (error) { logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error })); } diff --git a/packages/integrations/src/beszel/test/beszel-integration.spec.ts b/packages/integrations/src/beszel/test/beszel-integration.spec.ts index 05e4cbd57..112e6dd4b 100644 --- a/packages/integrations/src/beszel/test/beszel-integration.spec.ts +++ b/packages/integrations/src/beszel/test/beszel-integration.spec.ts @@ -13,6 +13,7 @@ const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? ""; const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)); vi.mock("@homarr/redis", () => ({ + getIntegrationSessionStoreKey: (integrationId: string) => `session-store:${integrationId}`, createGetSetChannel: () => ({ getAsync: vi.fn().mockResolvedValue(null), setAsync: vi.fn().mockResolvedValue(undefined), diff --git a/packages/integrations/src/beszel/test/beszel-session.spec.ts b/packages/integrations/src/beszel/test/beszel-session.spec.ts index 1e77dc4f1..086d08cec 100644 --- a/packages/integrations/src/beszel/test/beszel-session.spec.ts +++ b/packages/integrations/src/beszel/test/beszel-session.spec.ts @@ -12,6 +12,7 @@ vi.hoisted(() => { const store = vi.hoisted(() => new Map()); vi.mock("@homarr/redis", () => ({ + getIntegrationSessionStoreKey: (integrationId: string) => `session-store:${integrationId}`, createGetSetChannel: (name: string) => ({ getAsync: () => Promise.resolve(store.get(name)?.value ?? null), setAsync: (value: string, options?: { ttlSeconds?: number }) => { diff --git a/packages/integrations/src/factory.ts b/packages/integrations/src/factory.ts new file mode 100644 index 000000000..f0c6d6d04 --- /dev/null +++ b/packages/integrations/src/factory.ts @@ -0,0 +1 @@ +export { createIntegrationAsync } from "./base/creator"; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 05d3a7764..b903f76d5 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -97,8 +97,7 @@ export type { export type { WudStats, WudContainerUpdate } from "./wud/wud-types"; // Schemas -export { anchorNotesListInputSchema } from "./anchor/anchor-types"; -export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types"; +export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types"; export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; // Helpers diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index 3efc44c21..37feffea1 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -470,9 +470,11 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS ...generalWidgets, ...discoveredIntegrations.filter((integration) => selectedSourceIds.has(integration.sourceId)), ...appsToCreate, - ].flatMap((selection) => - typeof selection === "string" ? [selection] : selection.widgetKind ? [selection.widgetKind] : [], - ), + ].flatMap((selection) => { + if (typeof selection === "string") return [selection]; + if (!selection.widgetKind) return []; + return [selection.widgetKind]; + }), ), ], }); @@ -1922,7 +1924,10 @@ const getPreviewWidgetKinds = (props: StudioSectionProps) => [ ...props.drafts.filter(isIntegrationDraftComplete).flatMap((draft) => getWidgetKindsForIntegration(draft.kind)), ...props.discoveredApps .filter((app) => props.selectedAppIds.includes(app.sourceId)) - .flatMap((app) => (app.widgetKind ? [app.widgetKind] : [])), + .flatMap((app) => { + if (!app.widgetKind) return []; + return [app.widgetKind]; + }), ]), ]; diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 857f87c4f..d05c833ea 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -16,9 +16,15 @@ export { handshakeAsync, createSubPubChannel, createGetSetChannel, + getIntegrationSessionStoreKey, createLockChannel, + getIntegrationCacheGenerationAsync, + getWidgetCacheGenerationAsync, invalidateIntegrationCacheAsync, + invalidateIntegrationResponseCacheAsync, + invalidateWidgetCache, } from "./lib/channel"; +export type { IntegrationCacheGeneration, WidgetCacheGeneration } from "./lib/channel"; export const pingChannel = createSubPubChannel< { url: string; statusCode: number; durationMs: number } | { url: string; error: string } diff --git a/packages/redis/src/lib/channel-subscription-tracker.ts b/packages/redis/src/lib/channel-subscription-tracker.ts index fb70633a2..4eaee5e16 100644 --- a/packages/redis/src/lib/channel-subscription-tracker.ts +++ b/packages/redis/src/lib/channel-subscription-tracker.ts @@ -3,7 +3,7 @@ import { randomUUID } from "crypto"; import type { MaybePromise } from "@homarr/common/types"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createRedisConnection } from "./connection"; +import { createRedisConnection, requireRedisConnection } from "./connection"; const logger = createLogger({ module: "channelSubscriptionTracker" }); @@ -63,7 +63,9 @@ export class ChannelSubscriptionTracker { // Subscribe when this channel has no active or pending Redis subscription. if (!ready) { logger.debug("Subscribing to redis channel", { channel: channelName }); - ready = this.redis.subscribe(channelName).then(() => undefined); + ready = requireRedisConnection(this.redis) + .subscribe(channelName) + .then(() => undefined); this.subscriptionReadiness.set(channelName, ready); void ready.catch(() => { if (this.subscriptionReadiness.get(channelName) === ready) { @@ -91,7 +93,7 @@ export class ChannelSubscriptionTracker { } logger.debug("Unsubscribing from redis channel", { channel: channelName }); - void this.redis.unsubscribe(channelName); + void requireRedisConnection(this.redis).unsubscribe(channelName); this.subscriptions.delete(channelName); this.subscriptionReadiness.delete(channelName); }; @@ -104,7 +106,7 @@ export class ChannelSubscriptionTracker { */ private static activateListener() { logger.debug("Activating listener"); - this.redis.on("message", (channel, message) => { + requireRedisConnection(this.redis).on("message", (channel, message) => { const channelSubscriptions = this.subscriptions.get(channel); if (!channelSubscriptions) { logger.warn("Received message on unknown channel", { channel }); 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 4731ccfa1..467ea14a7 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -1,12 +1,30 @@ import superjson from "superjson"; import { createId } from "@homarr/common"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { ChannelSubscriptionTracker } from "./channel-subscription-tracker"; -import { createRedisConnection } from "./connection"; +import { createRedisConnection, requireRedisConnection } from "./connection"; const publisher = createRedisConnection(); const lastDataClient = createRedisConnection(); +const logger = createLogger({ module: "redisChannel" }); +const boundedCacheClient = createRedisConnection({ + autoResendUnfulfilledCommands: false, + commandTimeout: 500, + enableOfflineQueue: false, + maxRetriesPerRequest: 1, +}); + +interface RedisChannelOptions { + useBoundedCacheClient?: boolean; +} + +const selectRedisClient = (options: RedisChannelOptions) => { + if (options.useBoundedCacheClient) return boundedCacheClient; + return getSetClient; +}; /** * Creates a new pub/sub channel. @@ -23,11 +41,13 @@ export const createSubPubChannel = (name: string, { persist }: { persist: */ subscribe: (callback: (data: TData) => void) => { if (persist) { - void lastDataClient.get(lastChannelName).then((data) => { - if (data) { - callback(superjson.parse(data)); - } - }); + void requireRedisConnection(lastDataClient) + .get(lastChannelName) + .then((data) => { + if (data) { + callback(superjson.parse(data)); + } + }); } return ChannelSubscriptionTracker.subscribe(channelName, (message) => { callback(superjson.parse(message)); @@ -39,12 +59,12 @@ export const createSubPubChannel = (name: string, { persist }: { persist: */ publishAsync: async (data: TData) => { if (persist) { - await lastDataClient.set(lastChannelName, superjson.stringify(data)); + await requireRedisConnection(lastDataClient).set(lastChannelName, superjson.stringify(data)); } - await publisher.publish(channelName, superjson.stringify(data)); + await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data)); }, getLastDataAsync: async () => { - const data = await lastDataClient.get(lastChannelName); + const data = await requireRedisConnection(lastDataClient).get(lastChannelName); return data ? superjson.parse(data) : null; }, }; @@ -65,7 +85,7 @@ export const createListChannel = (name: string) => { * @returns an array of all items */ getAllAsync: async () => { - const items = await getSetClient.lrange(listChannelName, 0, -1); + const items = await requireRedisConnection(getSetClient).lrange(listChannelName, 0, -1); return items.map((item) => superjson.parse(item)); }, /** @@ -73,20 +93,20 @@ export const createListChannel = (name: string) => { * @param item item to remove */ removeAsync: async (item: TItem) => { - await getSetClient.lrem(listChannelName, 0, superjson.stringify(item)); + await requireRedisConnection(getSetClient).lrem(listChannelName, 0, superjson.stringify(item)); }, /** * Clear all items from the channels list */ clearAsync: async () => { - await getSetClient.del(listChannelName); + await requireRedisConnection(getSetClient).del(listChannelName); }, /** * Add an item to the channels list * @param item item to add */ addAsync: async (item: TItem) => { - await getSetClient.lpush(listChannelName, superjson.stringify(item)); + await requireRedisConnection(getSetClient).lpush(listChannelName, superjson.stringify(item)); }, }; }; @@ -95,33 +115,46 @@ export const createListChannel = (name: string) => { * Creates a new redis channel for getting and setting data * @param name name of channel */ -export const createGetSetChannel = (name: string) => { +export const createGetSetChannel = (name: string, options: RedisChannelOptions = {}) => { + const client = selectRedisClient(options); return { /** * Get data from the channel * @returns data or null if not found */ getAsync: async () => { - const data = await getSetClient.get(name); + const data = await requireRedisConnection(client).get(name); return data ? superjson.parse(data) : null; }, /** * Set data in the channel * @param data data to be stored in the channel - * @param options optional TTL in seconds + * @param options optional TTL in seconds or milliseconds */ - setAsync: async (data: TData, options?: { ttlSeconds?: number }) => { - if (options?.ttlSeconds) { - await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds); + setAsync: async (data: TData, options?: { ttlSeconds?: number; ttlMs?: number }) => { + if (options?.ttlMs !== undefined) { + if (options.ttlMs <= 0) { + await requireRedisConnection(client).del(name); + return; + } + await requireRedisConnection(client).set(name, superjson.stringify(data), "PX", options.ttlMs); return; } - await getSetClient.set(name, superjson.stringify(data)); + if (options?.ttlSeconds !== undefined) { + if (options.ttlSeconds <= 0) { + await requireRedisConnection(client).del(name); + return; + } + await requireRedisConnection(client).set(name, superjson.stringify(data), "EX", options.ttlSeconds); + return; + } + await requireRedisConnection(client).set(name, superjson.stringify(data)); }, /** * Remove data from the channel */ removeAsync: async () => { - await getSetClient.del(name); + await requireRedisConnection(client).del(name); }, }; }; @@ -132,34 +165,571 @@ export const createGetSetChannel = (name: string) => { * The token prevents a process from releasing a lock that expired and was * acquired by another process in the meantime. */ -export const createLockChannel = (name: string) => { +export const createLockChannel = (name: string, options: RedisChannelOptions = {}) => { + const selectedClient = selectRedisClient(options); + const releaseIfOwnedAsync = async (token: string) => { + if (!selectedClient) return; + + await selectedClient.eval( + "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + 1, + name, + token, + ); + }; + return { acquireAsync: async (ttlSeconds: number) => { const token = createId(); - const client = getSetClient as typeof getSetClient | null; + const client = selectedClient; if (!client) return token; - const result = await client.set(name, token, "EX", ttlSeconds, "NX"); - return result === "OK" ? token : null; + try { + const result = await client.set(name, token, "EX", ttlSeconds, "NX"); + return result === "OK" ? token : null; + } catch (error) { + // The SET may have reached Redis even when its reply timed out. Queue a + // token-fenced cleanup so an ambiguous acquisition cannot orphan a lock. + void releaseIfOwnedAsync(token).catch(() => undefined); + throw error; + } }, - releaseAsync: async (token: string) => { - const client = getSetClient as typeof getSetClient | null; - if (!client) return; + renewAsync: async (token: string, ttlSeconds: number) => { + const client = selectedClient; + if (!client) return true; - await client.eval( - "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", + 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; + }, + setIfOwnedAsync: async (token: string, targetName: string, data: TData, ttlMs: number) => { + const client = selectedClient; + if (!client) return true; + + const result = await client.eval( + "if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 end redis.call('set', KEYS[2], ARGV[2], 'PX', ARGV[3]) return 1", + 2, + name, + targetName, + token, + superjson.stringify(data), + ttlMs, + ); + return result === 1; + }, + releaseAsync: async (token: string) => { + await releaseIfOwnedAsync(token); }, }; }; +const integrationCacheGenerationKey = (integrationId: string) => `integration-cache:generation:${integrationId}`; +const integrationCacheGlobalGenerationKey = "integration-cache:global-generation:v1"; +export const getIntegrationSessionStoreKey = (integrationId: string) => `session-store:${integrationId}`; +interface PendingIntegrationCacheInvalidation { + generation: string; + clearSession: boolean; +} + +const pendingIntegrationCacheInvalidations = new Map(); +let integrationCacheOverflowGeneration: string | undefined; +const MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS = 1000; +const CACHE_GENERATION_REDIS_TIMEOUT_MS = 750; +const CACHE_GENERATION_SUCCESS_TTL_MS = 1000; +const CACHE_GENERATION_UNAVAILABLE_TTL_MS = 5000; +const CACHE_GENERATION_REDIS_TTL_SECONDS = 30 * 24 * 60 * 60; +const MAX_CACHE_GENERATION_READS = 1000; + +export interface IntegrationCacheGeneration { + value: string; + isShared: boolean; +} + +interface CacheGenerationReadEntry { + generation: IntegrationCacheGeneration; + expiresAt: number; +} + +interface CacheGenerationReadInFlight { + promise: Promise; + token: object; +} + +const cacheGenerationReads = new Map(); +const cacheGenerationReadsInFlight = new Map(); + +const logCacheGenerationFailure = (message: string, metadata: Record, error: unknown) => { + logger.warn(new ErrorWithMetadata(message, metadata, { cause: error })); +}; + +const withCacheGenerationRedisTimeoutAsync = async (promise: Promise): Promise => { + return await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Redis cache generation operation timed out")), + CACHE_GENERATION_REDIS_TIMEOUT_MS, + ); + timer.unref?.(); + void promise.then(resolve, reject).finally(() => clearTimeout(timer)); + }); +}; + +const combineCacheGenerations = ( + globalGeneration: string | null | undefined, + scopedGeneration: string | null | undefined, +) => { + const scopedValue = scopedGeneration ?? "0"; + if (!globalGeneration || globalGeneration === "0") return scopedValue; + return `${globalGeneration}:${scopedValue}`; +}; + +const advanceGlobalCacheGenerationAsync = async (cacheKey: string) => { + const client = boundedCacheClient; + if (!client) return null; + + const results = await withCacheGenerationRedisTimeoutAsync( + client.multi().incr(cacheKey).expire(cacheKey, CACHE_GENERATION_REDIS_TTL_SECONDS).exec(), + ); + const generationResult = results?.[0]; + const expiryResult = results?.[1]; + if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result"); + if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result"); + return String(generationResult[1]); +}; + +const setCacheGenerationRead = ( + cacheKey: string, + generation: IntegrationCacheGeneration, + ttlMs = generation.isShared ? CACHE_GENERATION_SUCCESS_TTL_MS : CACHE_GENERATION_UNAVAILABLE_TTL_MS, +) => { + if (cacheGenerationReads.has(cacheKey)) cacheGenerationReads.delete(cacheKey); + while (cacheGenerationReads.size >= MAX_CACHE_GENERATION_READS) { + const oldestCacheKey = cacheGenerationReads.keys().next().value; + if (!oldestCacheKey) break; + cacheGenerationReads.delete(oldestCacheKey); + } + cacheGenerationReads.set(cacheKey, { + generation, + expiresAt: Date.now() + ttlMs, + }); +}; + +const beginCacheGenerationInvalidation = (cacheKey: string, pendingGeneration: string) => { + cacheGenerationReadsInFlight.delete(cacheKey); + setCacheGenerationRead(cacheKey, { value: pendingGeneration, isShared: false }); +}; + +interface GetCachedGenerationOptions { + cacheKey: string; + readAsync: () => Promise; + fallback: IntegrationCacheGeneration; + errorMessage: string; + errorMetadata: Record; + force?: boolean; + throwOnError?: boolean; +} + +const getCachedGenerationAsync = async ({ + cacheKey, + readAsync, + fallback, + errorMessage, + errorMetadata, + force = false, + throwOnError = false, +}: GetCachedGenerationOptions): Promise => { + if (!force) { + const cached = cacheGenerationReads.get(cacheKey); + if (cached && Date.now() < cached.expiresAt) { + cacheGenerationReads.delete(cacheKey); + cacheGenerationReads.set(cacheKey, cached); + return cached.generation; + } + if (cached) cacheGenerationReads.delete(cacheKey); + } + + const existing = cacheGenerationReadsInFlight.get(cacheKey); + if (existing) return await existing.promise; + + const token = {}; + const promise = (async () => { + let generation = fallback; + try { + const value = await readAsync(); + if (value !== null) generation = { value, isShared: true }; + } catch (error) { + logCacheGenerationFailure(errorMessage, errorMetadata, error); + if (throwOnError) throw error; + } + + if (cacheGenerationReadsInFlight.get(cacheKey)?.token === token) { + setCacheGenerationRead(cacheKey, generation); + } + return generation; + })().finally(() => { + if (cacheGenerationReadsInFlight.get(cacheKey)?.token === token) { + cacheGenerationReadsInFlight.delete(cacheKey); + } + }); + cacheGenerationReadsInFlight.set(cacheKey, { promise, token }); + return await promise; +}; + +const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => { + const client = boundedCacheClient; + if (!client) return null; + + const results = await withCacheGenerationRedisTimeoutAsync( + client + .multi() + .incr(integrationCacheGenerationKey(integrationId)) + .expire(integrationCacheGenerationKey(integrationId), CACHE_GENERATION_REDIS_TTL_SECONDS) + .del(getIntegrationSessionStoreKey(integrationId)) + .get(integrationCacheGlobalGenerationKey) + .exec(), + ); + const generationResult = results?.[0]; + const expiryResult = results?.[1]; + const sessionResult = results?.[2]; + const globalGenerationResult = results?.[3]; + if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result"); + if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result"); + if (!sessionResult || sessionResult[0]) throw sessionResult?.[0] ?? new Error("Missing session result"); + if (!globalGenerationResult || globalGenerationResult[0]) { + throw globalGenerationResult?.[0] ?? new Error("Missing global generation result"); + } + return combineCacheGenerations(globalGenerationResult[1] as string | null, String(generationResult[1])); +}; + +const advanceIntegrationResponseCacheGenerationAsync = async (integrationId: string) => { + const client = boundedCacheClient; + if (!client) return null; + + const cacheKey = integrationCacheGenerationKey(integrationId); + const results = await withCacheGenerationRedisTimeoutAsync( + client + .multi() + .incr(cacheKey) + .expire(cacheKey, CACHE_GENERATION_REDIS_TTL_SECONDS) + .get(integrationCacheGlobalGenerationKey) + .exec(), + ); + const generationResult = results?.[0]; + const expiryResult = results?.[1]; + const globalGenerationResult = results?.[2]; + if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result"); + if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result"); + if (!globalGenerationResult || globalGenerationResult[0]) { + throw globalGenerationResult?.[0] ?? new Error("Missing global generation result"); + } + return combineCacheGenerations(globalGenerationResult[1] as string | null, String(generationResult[1])); +}; + +const getIntegrationCacheInvalidationOperation = (clearSession: boolean) => { + if (clearSession) { + return { + advanceAsync: advanceIntegrationCacheGenerationAsync, + operation: "generation-advance-and-session-delete", + failureMessage: "Failed to invalidate integration and session caches in Redis", + }; + } + return { + advanceAsync: advanceIntegrationResponseCacheGenerationAsync, + operation: "generation-advance", + failureMessage: "Failed to invalidate integration response caches in Redis", + }; +}; + +let integrationCacheOverflowRecoveryInFlight: Promise | undefined; + +const recoverIntegrationCacheOverflowAsync = async () => { + if (!integrationCacheOverflowGeneration) return true; + if (integrationCacheOverflowRecoveryInFlight) return await integrationCacheOverflowRecoveryInFlight; + + const overflowGeneration = integrationCacheOverflowGeneration; + let promise: Promise; + promise = (async () => { + try { + const generation = await advanceGlobalCacheGenerationAsync(integrationCacheGlobalGenerationKey); + if (generation === null) return false; + + cacheGenerationReads.clear(); + cacheGenerationReadsInFlight.clear(); + if (integrationCacheOverflowGeneration !== overflowGeneration) return false; + integrationCacheOverflowGeneration = undefined; + return true; + } catch (error) { + logCacheGenerationFailure( + "Failed to recover integration cache invalidation overflow", + { operation: "global-generation-advance" }, + error, + ); + return false; + } + })().finally(() => { + if (integrationCacheOverflowRecoveryInFlight === promise) { + integrationCacheOverflowRecoveryInFlight = undefined; + } + }); + integrationCacheOverflowRecoveryInFlight = promise; + return await promise; +}; + +export const getIntegrationCacheGenerationAsync = async ( + integrationId: string, +): Promise => { + const cacheKey = integrationCacheGenerationKey(integrationId); + const pendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId); + if (pendingInvalidation) { + const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession); + const generation = await getCachedGenerationAsync({ + cacheKey, + readAsync: async () => await invalidationOperation.advanceAsync(integrationId), + fallback: { value: pendingInvalidation.generation, isShared: false }, + errorMessage: "Failed to retry integration cache invalidation", + errorMetadata: { + integrationId, + operation: invalidationOperation.operation, + }, + }); + if ( + generation.isShared && + pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation + ) { + pendingIntegrationCacheInvalidations.delete(integrationId); + return generation; + } + const latestPendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId); + if (latestPendingInvalidation) return { value: latestPendingInvalidation.generation, isShared: false }; + return generation; + } + + if (integrationCacheOverflowGeneration) { + await recoverIntegrationCacheOverflowAsync(); + if (integrationCacheOverflowGeneration) { + return { value: integrationCacheOverflowGeneration, isShared: false }; + } + } + + const client = boundedCacheClient; + return await getCachedGenerationAsync({ + cacheKey, + readAsync: async () => { + if (!client) return null; + const [globalGeneration, scopedGeneration] = await withCacheGenerationRedisTimeoutAsync( + client.mget(integrationCacheGlobalGenerationKey, cacheKey), + ); + return combineCacheGenerations(globalGeneration, scopedGeneration); + }, + fallback: { value: "redis-unavailable", isShared: false }, + errorMessage: "Failed to read integration cache generation", + errorMetadata: { integrationId, operation: "generation-read" }, + }); +}; + +const invalidateIntegrationCacheGenerationAsync = async ( + integrationId: string, + clearSession: boolean, +): Promise => { + const previousInvalidation = pendingIntegrationCacheInvalidations.get(integrationId); + const pendingInvalidation: PendingIntegrationCacheInvalidation = { + generation: `local-${createId()}`, + clearSession: clearSession || previousInvalidation?.clearSession === true, + }; + const canTrackInvalidation = + !integrationCacheOverflowGeneration && + (previousInvalidation !== undefined || + pendingIntegrationCacheInvalidations.size < MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS); + if (canTrackInvalidation) { + pendingIntegrationCacheInvalidations.set(integrationId, pendingInvalidation); + } else if (integrationCacheOverflowGeneration) { + integrationCacheOverflowGeneration = pendingInvalidation.generation; + } + const cacheKey = integrationCacheGenerationKey(integrationId); + beginCacheGenerationInvalidation(cacheKey, pendingInvalidation.generation); + const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession); + + let generation: IntegrationCacheGeneration; + try { + generation = await getCachedGenerationAsync({ + cacheKey, + readAsync: async () => await invalidationOperation.advanceAsync(integrationId), + fallback: { value: pendingInvalidation.generation, isShared: false }, + errorMessage: invalidationOperation.failureMessage, + errorMetadata: { + integrationId, + operation: invalidationOperation.operation, + }, + force: true, + throwOnError: clearSession, + }); + } catch (error) { + if (!canTrackInvalidation) integrationCacheOverflowGeneration = pendingInvalidation.generation; + throw error; + } + if (!generation.isShared && !canTrackInvalidation) { + integrationCacheOverflowGeneration = pendingInvalidation.generation; + } + if ( + generation.isShared && + pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation + ) { + pendingIntegrationCacheInvalidations.delete(integrationId); + } +}; + export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { - const client = getSetClient as typeof getSetClient | null; - if (!client) return; - await client.del(`session-store:${integrationId}`); + await invalidateIntegrationCacheGenerationAsync(integrationId, true); +}; + +/** Advances response-cache generation without evicting cached integration credentials. */ +export const invalidateIntegrationResponseCacheAsync = async (integrationId: string): Promise => { + await invalidateIntegrationCacheGenerationAsync(integrationId, false); +}; + +const widgetCacheGenerationKey = (namespace: string) => `widget-cache:generation:${namespace}`; +const widgetCacheGlobalGenerationKey = "widget-cache:global-generation:v1"; +const pendingWidgetCacheInvalidations = new Map(); +let widgetCacheOverflowGeneration: string | undefined; +const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000; + +const advanceWidgetCacheGenerationAsync = async (namespace: string) => { + const client = boundedCacheClient; + if (!client) return null; + + const cacheKey = widgetCacheGenerationKey(namespace); + const results = await withCacheGenerationRedisTimeoutAsync( + client + .multi() + .incr(cacheKey) + .expire(cacheKey, CACHE_GENERATION_REDIS_TTL_SECONDS) + .get(widgetCacheGlobalGenerationKey) + .exec(), + ); + const generationResult = results?.[0]; + const expiryResult = results?.[1]; + const globalGenerationResult = results?.[2]; + if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result"); + if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result"); + if (!globalGenerationResult || globalGenerationResult[0]) { + throw globalGenerationResult?.[0] ?? new Error("Missing global generation result"); + } + return combineCacheGenerations(globalGenerationResult[1] as string | null, String(generationResult[1])); +}; + +export type WidgetCacheGeneration = IntegrationCacheGeneration; + +let widgetCacheOverflowRecoveryInFlight: Promise | undefined; + +const recoverWidgetCacheOverflowAsync = async () => { + if (!widgetCacheOverflowGeneration) return true; + if (widgetCacheOverflowRecoveryInFlight) return await widgetCacheOverflowRecoveryInFlight; + + const overflowGeneration = widgetCacheOverflowGeneration; + let promise: Promise; + promise = (async () => { + try { + const generation = await advanceGlobalCacheGenerationAsync(widgetCacheGlobalGenerationKey); + if (generation === null) return false; + + cacheGenerationReads.clear(); + cacheGenerationReadsInFlight.clear(); + if (widgetCacheOverflowGeneration !== overflowGeneration) return false; + widgetCacheOverflowGeneration = undefined; + return true; + } catch (error) { + logCacheGenerationFailure( + "Failed to recover widget cache invalidation overflow", + { operation: "global-generation-advance" }, + error, + ); + return false; + } + })().finally(() => { + if (widgetCacheOverflowRecoveryInFlight === promise) { + widgetCacheOverflowRecoveryInFlight = undefined; + } + }); + widgetCacheOverflowRecoveryInFlight = promise; + return await promise; +}; + +export const getWidgetCacheGenerationAsync = async (namespace: string): Promise => { + const cacheKey = widgetCacheGenerationKey(namespace); + const pendingGeneration = pendingWidgetCacheInvalidations.get(namespace); + if (pendingGeneration) { + const generation = await getCachedGenerationAsync({ + cacheKey, + readAsync: async () => await advanceWidgetCacheGenerationAsync(namespace), + fallback: { value: pendingGeneration, isShared: false }, + errorMessage: "Failed to retry widget cache invalidation", + errorMetadata: { cacheNamespace: namespace, operation: "generation-advance" }, + }); + if (generation.isShared && pendingWidgetCacheInvalidations.get(namespace) === pendingGeneration) { + pendingWidgetCacheInvalidations.delete(namespace); + return generation; + } + const latestPendingGeneration = pendingWidgetCacheInvalidations.get(namespace); + if (latestPendingGeneration) return { value: latestPendingGeneration, isShared: false }; + return generation; + } + + if (widgetCacheOverflowGeneration) { + await recoverWidgetCacheOverflowAsync(); + if (widgetCacheOverflowGeneration) { + return { value: widgetCacheOverflowGeneration, isShared: false }; + } + } + + const client = boundedCacheClient; + return await getCachedGenerationAsync({ + cacheKey, + readAsync: async () => { + if (!client) return null; + const [globalGeneration, scopedGeneration] = await withCacheGenerationRedisTimeoutAsync( + client.mget(widgetCacheGlobalGenerationKey, cacheKey), + ); + return combineCacheGenerations(globalGeneration, scopedGeneration); + }, + fallback: { value: "redis-unavailable", isShared: false }, + errorMessage: "Failed to read widget cache generation", + errorMetadata: { cacheNamespace: namespace, operation: "generation-read" }, + }); +}; + +export const invalidateWidgetCache = (namespace: string): void => { + const pendingGeneration = `local-${createId()}`; + const previousGeneration = pendingWidgetCacheInvalidations.get(namespace); + const canTrackInvalidation = + !widgetCacheOverflowGeneration && + (previousGeneration !== undefined || pendingWidgetCacheInvalidations.size < MAX_PENDING_WIDGET_CACHE_INVALIDATIONS); + if (canTrackInvalidation) { + pendingWidgetCacheInvalidations.set(namespace, pendingGeneration); + } else if (widgetCacheOverflowGeneration) { + widgetCacheOverflowGeneration = pendingGeneration; + } + const cacheKey = widgetCacheGenerationKey(namespace); + beginCacheGenerationInvalidation(cacheKey, pendingGeneration); + + void getCachedGenerationAsync({ + cacheKey, + readAsync: async () => await advanceWidgetCacheGenerationAsync(namespace), + fallback: { value: pendingGeneration, isShared: false }, + errorMessage: "Failed to invalidate widget caches in Redis", + errorMetadata: { cacheNamespace: namespace, operation: "generation-advance" }, + force: true, + }).then((generation) => { + if (!generation.isShared && !canTrackInvalidation) { + widgetCacheOverflowGeneration = pendingGeneration; + } + if (generation.isShared && pendingWidgetCacheInvalidations.get(namespace) === pendingGeneration) { + pendingWidgetCacheInvalidations.delete(namespace); + } + }); }; /** @@ -167,11 +737,11 @@ export const invalidateIntegrationCacheAsync = async (integrationId: string): Pr */ export const createChannelEventHistoryOld = (channelName: string, maxElements = 15) => { const popElementsOverMaxAsync = async () => { - const length = await getSetClient.llen(channelName); + const length = await requireRedisConnection(getSetClient).llen(channelName); if (length <= maxElements) { return; } - await getSetClient.ltrim(channelName, 0, maxElements - 1); + await requireRedisConnection(getSetClient).ltrim(channelName, 0, maxElements - 1); }; return { @@ -181,33 +751,41 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem }); }, publishAndPushAsync: async (data: TData) => { - await publisher.publish(channelName, superjson.stringify(data)); - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data)); + await requireRedisConnection(getSetClient).lpush( + channelName, + superjson.stringify({ data, timestamp: new Date() }), + ); await popElementsOverMaxAsync(); }, pushAsync: async (data: TData) => { - await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + await requireRedisConnection(getSetClient).lpush( + channelName, + superjson.stringify({ data, timestamp: new Date() }), + ); await popElementsOverMaxAsync(); }, clearAsync: async () => { - await getSetClient.del(channelName); + await requireRedisConnection(getSetClient).del(channelName); }, getLastAsync: async () => { - const length = await getSetClient.llen(channelName); - const data = await getSetClient.lrange(channelName, length - 1, length); + const client = requireRedisConnection(getSetClient); + const length = await client.llen(channelName); + const data = await client.lrange(channelName, length - 1, length); if (data.length !== 1) return null; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!); }, getSliceAsync: async (startIndex: number, endIndex: number) => { - const range = await getSetClient.lrange(channelName, startIndex, endIndex); + const range = await requireRedisConnection(getSetClient).lrange(channelName, startIndex, endIndex); return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)); }, getSliceUntilTimeAsync: async (time: Date) => { - const length = await getSetClient.llen(channelName); + const client = requireRedisConnection(getSetClient); + const length = await client.llen(channelName); const items: TData[] = []; - const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1); + const itemsInCollection = await client.lrange(channelName, 0, length - 1); for (let i = 0; i < length - 1; i++) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -220,7 +798,7 @@ export const createChannelEventHistoryOld = (channelName: string, maxElem return items; }, getLengthAsync: async () => { - return await getSetClient.llen(channelName); + return await requireRedisConnection(getSetClient).llen(channelName); }, name: channelName, }; @@ -238,11 +816,11 @@ type WithId = TItem & { _id: string }; export const createQueueChannel = (name: string) => { const queueChannelName = `queue:${name}`; const getDataAsync = async () => { - const data = await queueClient.get(queueChannelName); + const data = await requireRedisConnection(queueClient).get(queueChannelName); return data ? superjson.parse[]>(data) : []; }; const setDataAsync = async (data: WithId[]) => { - await queueClient.set(queueChannelName, superjson.stringify(data)); + await requireRedisConnection(queueClient).set(queueChannelName, superjson.stringify(data)); }; return { @@ -289,5 +867,5 @@ export const createQueueChannel = (name: string) => { }; export const handshakeAsync = async () => { - await getSetClient.hello(); + await requireRedisConnection(getSetClient).hello(); }; diff --git a/packages/redis/src/lib/connection.ts b/packages/redis/src/lib/connection.ts index ba945de33..a81ab5d6f 100644 --- a/packages/redis/src/lib/connection.ts +++ b/packages/redis/src/lib/connection.ts @@ -1,3 +1,5 @@ +import type { RedisOptions } from "ioredis"; + import type { RedisClient } from "@homarr/core/infrastructure/redis"; import { createRedisClient } from "@homarr/core/infrastructure/redis"; @@ -5,11 +7,15 @@ import { createRedisClient } from "@homarr/core/infrastructure/redis"; * Creates a new Redis connection * @returns redis client */ -export const createRedisConnection = () => { +export const createRedisConnection = (options: RedisOptions = {}) => { if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) { - // Return null if we are in CI as we don't want to connect to Redis - return null as unknown as RedisClient; + return null; } - return createRedisClient(); + return createRedisClient(options); +}; + +export const requireRedisConnection = (client: RedisClient | null): RedisClient => { + if (!client) throw new Error("Redis is unavailable in this process"); + return client; }; diff --git a/packages/request-handler/src/air-quality.ts b/packages/request-handler/src/air-quality.ts index 6a77a871a..9e5b62d7d 100644 --- a/packages/request-handler/src/air-quality.ts +++ b/packages/request-handler/src/air-quality.ts @@ -5,7 +5,6 @@ import { z } from "zod"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; @@ -44,10 +43,12 @@ const requestedVariables = [ ] as const; export const airQualityRequestHandler = createWidgetRequestHandler({ + // Coordinates are a public, continuous key space. Keep this cache process-local and bounded. cacheTtlMs: 15 * 60 * 1000, fallbackToStaleOnError: true, staleIfErrorTtlMs: 24 * 60 * 60 * 1000, - async requestAsync(input: { latitude: number; longitude: number }) { + requestTimeoutMs: 10_000, + async requestAsync(input: { latitude: number; longitude: number }, signal: AbortSignal) { const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality"); url.searchParams.set("latitude", input.latitude.toString()); url.searchParams.set("longitude", input.longitude.toString()); @@ -56,9 +57,7 @@ export const airQualityRequestHandler = createWidgetRequestHandler({ url.searchParams.set("forecast_days", FORECAST_DAYS.toString()); url.searchParams.set("timezone", "auto"); - const response = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); - }); + const response = await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); if (!response.ok) throw new ResponseError(response); const json: unknown = await response.json(); diff --git a/packages/request-handler/src/anchor-notes.ts b/packages/request-handler/src/anchor-notes.ts index 75677b8ba..cc5d91e77 100644 --- a/packages/request-handler/src/anchor-notes.ts +++ b/packages/request-handler/src/anchor-notes.ts @@ -1,5 +1,5 @@ import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler< "anchor", AnchorNotesListInput >({ + cacheNamespace: "anchor-notes:list", async requestAsync(integration, input) { const instance = await createIntegrationAsync(integration); return instance.listNotesAsync(input); @@ -15,6 +16,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler< }); export const anchorNoteRequestHandler = createIntegrationRequestHandler({ + cacheNamespace: "anchor-notes:detail", async requestAsync(integration, input) { const instance = await createIntegrationAsync(integration); return instance.getNoteAsync(input.noteId); diff --git a/packages/request-handler/src/archive-team-warrior.ts b/packages/request-handler/src/archive-team-warrior.ts index e02391f19..69904972f 100644 --- a/packages/request-handler/src/archive-team-warrior.ts +++ b/packages/request-handler/src/archive-team-warrior.ts @@ -1,6 +1,6 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { ArchiveTeamWarriorStatus } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"archiving">, Record >({ + cacheNamespace: "archive-team-warrior:status", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getStatusAsync(); diff --git a/packages/request-handler/src/audiobookshelf.ts b/packages/request-handler/src/audiobookshelf.ts index 12562342c..5be8943fc 100644 --- a/packages/request-handler/src/audiobookshelf.ts +++ b/packages/request-handler/src/audiobookshelf.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const audiobookshelfRequestHandler = createIntegrationRequestHandler< "audiobookshelf", Record >({ + cacheNamespace: "audiobookshelf:stats", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); diff --git a/packages/request-handler/src/bazarr.ts b/packages/request-handler/src/bazarr.ts index 56b391658..4f7bc2d48 100644 --- a/packages/request-handler/src/bazarr.ts +++ b/packages/request-handler/src/bazarr.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { BazarrBadges } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const bazarrBadgesRequestHandler = createIntegrationRequestHandler< "bazarr", Record >({ + cacheNamespace: "bazarr:badges", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getBadgesAsync(); diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts index dab263e3a..f082709da 100644 --- a/packages/request-handler/src/beszel.ts +++ b/packages/request-handler/src/beszel.ts @@ -1,5 +1,5 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { BeszelAlert, BeszelAlertHistory, @@ -57,6 +57,7 @@ export const beszelSystemsRequestHandler = createIntegrationRequestHandler< "beszel" | "mock", Record >({ + cacheNamespace: "beszel:systems", async requestAsync(integration) { const start = performance.now(); const instance = await createIntegrationAsync(integration); @@ -89,6 +90,7 @@ export const beszelAlertsRequestHandler = createIntegrationRequestHandler< "beszel" | "mock", { includeHistory: boolean; maxHistoryItems: number } >({ + cacheNamespace: "beszel:alerts", async requestAsync(integration, input) { const start = performance.now(); const instance = await createIntegrationAsync(integration); @@ -123,6 +125,7 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler< "beszel" | "mock", { systemId: string; timePeriod: string; includeDocker: boolean } >({ + cacheNamespace: "beszel:stats", // No cache — the widget polls every 5s for live updates; // a TTL here would serve stale records between polls. cacheTtlMs: 0, diff --git a/packages/request-handler/src/calendar.ts b/packages/request-handler/src/calendar.ts index 312121bd6..2899c0c8f 100644 --- a/packages/request-handler/src/calendar.ts +++ b/packages/request-handler/src/calendar.ts @@ -1,7 +1,7 @@ import dayjs from "dayjs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -19,6 +19,7 @@ export const calendarMonthRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"calendar">, { year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean } >({ + cacheNamespace: "calendar:month", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); // Calendar component shows up to 6 days before and after the month, for example if 1. of january is sunday, it shows the last 6 days of december. diff --git a/packages/request-handler/src/coolify.ts b/packages/request-handler/src/coolify.ts index 25ddfe962..30d05acf7 100644 --- a/packages/request-handler/src/coolify.ts +++ b/packages/request-handler/src/coolify.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const coolifyRequestHandler = createIntegrationRequestHandler< "coolify", Record >({ + cacheNamespace: "coolify:resources", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getInstanceInfoAsync(); diff --git a/packages/request-handler/src/dns-hole.ts b/packages/request-handler/src/dns-hole.ts index 85188c740..73a977b73 100644 --- a/packages/request-handler/src/dns-hole.ts +++ b/packages/request-handler/src/dns-hole.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { DnsHoleSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const dnsHoleRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"dnsHole">, Record >({ + cacheNamespace: "dns-hole:summary", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getSummaryAsync(); diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts index 430d08ec0..c8c5f27f4 100644 --- a/packages/request-handler/src/docker.ts +++ b/packages/request-handler/src/docker.ts @@ -207,6 +207,7 @@ const demoDockerEndpoint = { } satisfies DockerEndpointStatus; export const dockerContainersRequestHandler = createWidgetRequestHandler({ + cacheNamespace: "docker-containers", async requestAsync({ endpointIds }: { endpointIds?: string[] }) { if (isDemoMode()) { const includesDemoEndpoint = diff --git a/packages/request-handler/src/downloads.ts b/packages/request-handler/src/downloads.ts index 12106b07a..1c2b9b900 100644 --- a/packages/request-handler/src/downloads.ts +++ b/packages/request-handler/src/downloads.ts @@ -1,6 +1,6 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,8 @@ export const downloadClientRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"downloadClient">, { limit: number } >({ + cacheNamespace: "downloads:jobs-and-status", + cacheTtlMs: 10 * 1000, async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getClientJobsAndStatusAsync(input); diff --git a/packages/request-handler/src/firewall.ts b/packages/request-handler/src/firewall.ts index ad4f25b70..4ff2f96bc 100644 --- a/packages/request-handler/src/firewall.ts +++ b/packages/request-handler/src/firewall.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { FirewallCpuSummary, FirewallInterfacesSummary, @@ -14,6 +14,7 @@ export const firewallCpuRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"firewall">, Record >({ + cacheNamespace: "firewall:cpu", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return integrationInstance.getFirewallCpuAsync(); @@ -25,6 +26,7 @@ export const firewallMemoryRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"firewall">, Record >({ + cacheNamespace: "firewall:memory", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getFirewallMemoryAsync(); @@ -36,6 +38,7 @@ export const firewallInterfacesRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"firewall">, Record >({ + cacheNamespace: "firewall:interfaces", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getFirewallInterfacesAsync(); @@ -47,6 +50,7 @@ export const firewallVersionRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"firewall">, Record >({ + cacheNamespace: "firewall:version", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getFirewallVersionAsync(); diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts index 463a0b217..36ed3f6c1 100644 --- a/packages/request-handler/src/health-monitoring.ts +++ b/packages/request-handler/src/health-monitoring.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const systemInfoRequestHandler = createIntegrationRequestHandler< Exclude, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">, Record >({ + cacheNamespace: "health-monitoring:system-info", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getSystemInfoAsync(); @@ -20,6 +21,7 @@ export const clusterInfoRequestHandler = createIntegrationRequestHandler< "proxmox" | "mock", Record >({ + cacheNamespace: "health-monitoring:cluster-info", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getClusterInfoAsync(); diff --git a/packages/request-handler/src/immich.spec.ts b/packages/request-handler/src/immich.spec.ts index 199341933..30370b5d2 100644 --- a/packages/request-handler/src/immich.spec.ts +++ b/packages/request-handler/src/immich.spec.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ createIntegrationAsync: vi.fn(), })); -vi.mock("@homarr/integrations", () => ({ +vi.mock("@homarr/integrations/factory", () => ({ createIntegrationAsync: mocks.createIntegrationAsync, })); diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts index bc93eb83c..2e025a373 100644 --- a/packages/request-handler/src/immich.ts +++ b/packages/request-handler/src/immich.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const immichStatsRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"photoService">, Record >({ + cacheNamespace: "immich:server-stats", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getServerStatsAsync(); @@ -24,6 +25,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"photoService">, { limit?: number } >({ + cacheNamespace: "immich:albums", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); const albums = await integrationInstance.getAlbumsAsync(); @@ -48,6 +50,7 @@ export const immichAlbumRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"photoService">, { albumId?: string } >({ + cacheNamespace: "immich:album", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getAlbumAsync(input.albumId); diff --git a/packages/request-handler/src/indexer-manager.ts b/packages/request-handler/src/indexer-manager.ts index f21a61f8f..ea2536087 100644 --- a/packages/request-handler/src/indexer-manager.ts +++ b/packages/request-handler/src/indexer-manager.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { Indexer } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const indexerManagerRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"indexerManager">, Record >({ + cacheNamespace: "indexer-manager:indexers", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getIndexersAsync(); diff --git a/packages/request-handler/src/lib/integration-request-handler.ts b/packages/request-handler/src/lib/integration-request-handler.ts index e07ba3881..94c3a216a 100644 --- a/packages/request-handler/src/lib/integration-request-handler.ts +++ b/packages/request-handler/src/lib/integration-request-handler.ts @@ -1,22 +1,63 @@ +import { createKeyedFingerprint } from "@homarr/common/server"; import type { Modify } from "@homarr/common/types"; import type { Integration, IntegrationSecret } from "@homarr/db/schema"; import type { IntegrationKind } from "@homarr/definitions"; +import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis"; +import { createIntegrationSharedCacheAsync, hashSharedCacheInput } from "./shared-cache"; import { createRequestHandler } from "./request-handler"; +import type { SharedCacheAdapter } from "./request-handler"; type IntegrationOfKind = Omit & { kind: TKind; - decryptedSecrets: Modify, { value: string }>[]; + decryptedSecrets: Modify< + Pick & Partial>, + { value: string } + >[]; externalUrl: string | null; }; interface Options> { - requestAsync: (integration: IntegrationOfKind, input: TInput) => Promise; + requestAsync: (integration: IntegrationOfKind, input: TInput, signal: AbortSignal) => Promise; cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; + requestTimeoutMs?: number; + cacheNamespace?: string; + cacheVersion?: string; } +const getIntegrationCacheIdentity = >({ + integration, + options, +}: { + integration: IntegrationOfKind; + options: TInput; +}) => { + const secretRevisions = (integration.decryptedSecrets ?? []) + .map((secret) => ({ + kind: secret.kind, + fingerprint: createKeyedFingerprint(JSON.stringify([secret.kind, secret.value])), + updatedAt: secret.updatedAt?.toISOString(), + })) + .toSorted((left, right) => { + const kindComparison = left.kind.localeCompare(right.kind); + if (kindComparison !== 0) return kindComparison; + return left.fingerprint.localeCompare(right.fingerprint); + }); + + return hashSharedCacheInput({ + integration: { + id: integration.id, + kind: integration.kind, + url: integration.url, + externalUrl: integration.externalUrl, + secretRevisions, + }, + options, + }); +}; + export const createIntegrationRequestHandler = < TData, TKind extends IntegrationKind, @@ -24,17 +65,46 @@ export const createIntegrationRequestHandler = < >( options: Options, ) => { + const cacheNamespace = options.cacheNamespace; + let getSharedCacheAsync: + | (( + input: { integration: IntegrationOfKind; options: TInput }, + cacheIdentity: string, + ) => Promise>) + | undefined; + if (cacheNamespace) { + getSharedCacheAsync = async ({ integration }, cacheIdentity) => + await createIntegrationSharedCacheAsync({ + namespace: cacheNamespace, + integrationId: integration.id, + cacheIdentity, + cacheVersion: options.cacheVersion, + }); + } + const inner = createRequestHandler; options: TInput }>({ - requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions), - getCacheKey: ({ integration, options: itemOptions }) => - JSON.stringify({ integrationId: integration.id, itemOptions }), + requestAsync: async ({ integration, options: itemOptions }, signal) => + options.requestAsync(integration, itemOptions, signal), + getCacheKey: getIntegrationCacheIdentity, cacheTtlMs: options.cacheTtlMs, fallbackToStaleOnError: options.fallbackToStaleOnError, staleIfErrorTtlMs: options.staleIfErrorTtlMs, + requestTimeoutMs: options.requestTimeoutMs, + getSharedCacheAsync, }); return { invalidateCache: inner.invalidateCache, + /** Clears this handler's L1 and every shared response-cache namespace for these integrations. */ + invalidateCacheAsync: async (integrationIds: readonly string[]) => { + inner.invalidateCache(); + if (!cacheNamespace) return; + await Promise.all( + [...new Set(integrationIds)].map(async (integrationId) => { + await invalidateIntegrationResponseCacheAsync(integrationId); + }), + ); + }, handler: (integration: IntegrationOfKind, itemOptions: TInput) => inner.handler({ integration, options: itemOptions }), }; diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index 85ee34339..ea6e2801a 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Integration, IntegrationSecret } from "@homarr/db/schema"; import { createIntegrationRequestHandler } from "./integration-request-handler"; +import type { CacheEntry, SharedCacheAdapter } from "./request-handler"; import { createRequestHandler } from "./request-handler"; import { createWidgetRequestHandler } from "./widget-request-handler"; @@ -52,6 +53,134 @@ describe("createRequestHandler", () => { expect(calls).toBe(1); }); + it("uses a fresh shared cache entry before calling the upstream service", async () => { + const timestamp = new Date(); + const requestAsync = vi.fn(async () => "upstream"); + const sharedCache = { + generation: "shared-1", + isShared: true, + getAsync: vi.fn(async () => ({ + data: "shared", + timestamp, + expiresAt: Date.now() + 1_000, + staleUntil: Date.now() + 1_000, + })), + setAsync: vi.fn(async () => undefined), + acquireRefreshLockAsync: vi.fn(async () => "lock"), + renewRefreshLockAsync: vi.fn(async () => true), + releaseRefreshLockAsync: vi.fn(async () => undefined), + }; + const handler = createRequestHandler({ + cacheTtlMs: 1_000, + requestAsync, + getSharedCacheAsync: async () => sharedCache, + }); + + await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp }); + expect(requestAsync).not.toHaveBeenCalled(); + 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(); + let sharedEntry: CacheEntry | null = null; + const requestAsync = vi.fn(async () => "upstream"); + const sharedCache = { + generation: "shared-1", + isShared: true, + getAsync: vi.fn(async () => sharedEntry), + 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({ + cacheTtlMs: 1_000, + requestAsync, + getSharedCacheAsync: async () => sharedCache, + }); + + const pending = handler.handler({ id: "same" }).getDataAsync(); + + // 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(20_000); + expect(requestAsync).not.toHaveBeenCalled(); + + // The lock holder finishes and publishes the entry to the shared cache. + sharedEntry = { + data: "shared", + timestamp, + expiresAt: Date.now() + 1_000, + staleUntil: Date.now() + 1_000, + }; + await vi.advanceTimersByTimeAsync(100); + + await expect(pending).resolves.toMatchObject({ data: "shared", timestamp }); + expect(requestAsync).not.toHaveBeenCalled(); + }); + + it("deduplicates while the shared cache is being resolved", async () => { + const sharedCacheRequest = deferred>(); + const upstreamRequest = deferred(); + const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise); + const requestAsync = vi.fn(async () => await upstreamRequest.promise); + const handler = createRequestHandler({ + cacheTtlMs: 1_000, + requestAsync, + getSharedCacheAsync, + }); + + const first = handler.handler({ id: "same" }).getDataAsync(); + const second = handler.handler({ id: "same" }).getDataAsync(); + expect(getSharedCacheAsync).toHaveBeenCalledTimes(1); + + sharedCacheRequest.resolve({ + generation: "shared-1", + isShared: false, + getAsync: async () => undefined, + setAsync: async () => undefined, + acquireRefreshLockAsync: async () => undefined, + renewRefreshLockAsync: async () => undefined, + releaseRefreshLockAsync: async () => undefined, + }); + await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1)); + upstreamRequest.resolve("value"); + + await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]); + }); + it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => { let calls = 0; const request = deferred<{ call: number }>(); @@ -193,7 +322,7 @@ describe("createRequestHandler", () => { }); describe("createIntegrationRequestHandler", () => { - it("keys requests by integration id and options without retaining secret values", async () => { + it("does not deduplicate requests across rotated integration secrets", async () => { let calls = 0; const request = deferred(); const handler = createIntegrationRequestHandler({ @@ -203,11 +332,16 @@ describe("createIntegrationRequestHandler", () => { }, }); const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync(); + const duplicate = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync(); const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync(); - expect(calls).toBe(1); + expect(calls).toBe(2); request.resolve("value"); - await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]); + await expect(Promise.all([first, duplicate, second])).resolves.toMatchObject([ + { data: "value" }, + { data: "value" }, + { data: "value" }, + ]); }); }); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index a447c377b..715d98861 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -1,16 +1,114 @@ interface Options> { - requestAsync: (input: TInput) => Promise; + requestAsync: (input: TInput, signal: AbortSignal) => Promise; getCacheKey?: (input: TInput) => string; cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; + requestTimeoutMs?: number; + getSharedCacheAsync?: (input: TInput, cacheIdentity: string) => Promise>; } -type CacheEntry = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number }; +export interface CacheEntry { + data: TData; + timestamp: Date; + expiresAt: number; + staleUntil: number; +} + +export interface SharedCacheAdapter { + generation: string; + isShared: boolean; + getAsync: () => Promise | null | undefined>; + setAsync: (entry: CacheEntry, refreshLockToken: string) => Promise; + acquireRefreshLockAsync: () => Promise; + renewRefreshLockAsync: (token: string) => Promise; + releaseRefreshLockAsync: (token: string) => Promise; +} const MAX_CACHE_SIZE = 1000; +const MAX_INFLIGHT_REQUESTS = 100; +const MAX_UPSTREAM_SETTLEMENT_GRACE_MS = 60_000; const DEFAULT_TTL_MS = 10_000; const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000; +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +const SHARED_REFRESH_POLL_MS = 100; +const SHARED_REFRESH_LOCK_RETRY_MS = 1_000; +const SHARED_REFRESH_STALE_WAIT_MS = 15_000; +const SHARED_REFRESH_MAX_WAIT_MS = 30_000; +// Must remain comfortably below REFRESH_LOCK_TTL_SECONDS in shared-cache.ts. +const SHARED_REFRESH_LOCK_RENEW_MS = 5_000; + +class RequestHandlerTimeoutError extends Error { + constructor() { + super("Request handler deadline exceeded"); + this.name = RequestHandlerTimeoutError.name; + } +} + +class RequestHandlerOverloadedError extends Error { + constructor() { + super("Request handler concurrency limit exceeded"); + this.name = RequestHandlerOverloadedError.name; + } +} + +const throwIfDeadlineExceeded = (deadlineAt: number) => { + if (Date.now() >= deadlineAt) throw new RequestHandlerTimeoutError(); +}; + +const delayAsync = async (durationMs: number, deadlineAt: number) => { + throwIfDeadlineExceeded(deadlineAt); + const remainingMs = deadlineAt - Date.now(); + await new Promise((resolve) => { + const timer = setTimeout(resolve, Math.min(durationMs, remainingMs)); + timer.unref?.(); + }); + throwIfDeadlineExceeded(deadlineAt); +}; + +const waitForSettlementGraceAsync = async (settledAsync: Promise, durationMs: number) => { + await new Promise((resolve) => { + const timer = setTimeout(resolve, durationMs); + timer.unref?.(); + void settledAsync.then(() => { + clearTimeout(timer); + resolve(); + }); + }); +}; + +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(); @@ -22,12 +120,94 @@ const evictExpired = (cache: Map>) => { export const createRequestHandler = >( options: Options, ) => { + const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; + if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) { + throw new Error("Request timeout must be a positive finite duration"); + } + const cache = new Map>(); const inflight = new Map>>(); + const sharedCacheResolutions = new Map>>(); + const upstreamSettlements = new Map>(); let expiryTimer: ReturnType | undefined; let expiryTimerAt: number | undefined; let generation = 0; + const storeInMemory = (key: string, entry: CacheEntry, requestGeneration: number) => { + if (generation !== requestGeneration || Date.now() >= entry.staleUntil) return; + evictExpired(cache); + cache.delete(key); + if (cache.size >= MAX_CACHE_SIZE) { + const oldest = cache.keys().next().value; + if (oldest !== undefined) cache.delete(oldest); + } + cache.set(key, entry); + scheduleExpiry(entry.staleUntil); + }; + + const getFromMemory = (key: string) => { + const entry = cache.get(key); + if (!entry) return undefined; + cache.delete(key); + cache.set(key, entry); + return entry; + }; + + const startRequestWithDeadline = (input: TInput, deadlineAt: number, upstreamKey: string) => { + throwIfDeadlineExceeded(deadlineAt); + if (upstreamSettlements.has(upstreamKey) || upstreamSettlements.size >= MAX_INFLIGHT_REQUESTS) { + throw new RequestHandlerOverloadedError(); + } + + const controller = new AbortController(); + const timeoutError = new RequestHandlerTimeoutError(); + const remainingMs = deadlineAt - Date.now(); + let upstreamRequest: Promise; + try { + upstreamRequest = Promise.resolve(options.requestAsync(input, controller.signal)); + } catch (error) { + upstreamRequest = Promise.reject(error); + } + const settledAsync = upstreamRequest.then( + () => undefined, + () => undefined, + ); + upstreamSettlements.set(upstreamKey, settledAsync); + void settledAsync.then(() => { + if (upstreamSettlements.get(upstreamKey) === settledAsync) upstreamSettlements.delete(upstreamKey); + }); + + const resultAsync = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + controller.abort(timeoutError); + reject(timeoutError); + }, remainingMs); + timer.unref?.(); + + void upstreamRequest.then(resolve, reject).finally(() => clearTimeout(timer)); + }); + + return { resultAsync, settledAsync }; + }; + + const resolveSharedCacheAsync = (input: TInput, cacheIdentity: string, requestGeneration: number) => { + if (!options.getSharedCacheAsync) return undefined; + + const resolutionKey = `${requestGeneration}:${cacheIdentity}`; + const existing = sharedCacheResolutions.get(resolutionKey); + if (existing) return existing; + if (sharedCacheResolutions.size >= MAX_INFLIGHT_REQUESTS) { + throw new RequestHandlerOverloadedError(); + } + + let promise: Promise>; + promise = options.getSharedCacheAsync(input, cacheIdentity).finally(() => { + if (sharedCacheResolutions.get(resolutionKey) === promise) sharedCacheResolutions.delete(resolutionKey); + }); + sharedCacheResolutions.set(resolutionKey, promise); + return promise; + }; + const scheduleExpiry = (candidateExpiryAt?: number) => { if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return; if (expiryTimer) clearTimeout(expiryTimer); @@ -53,72 +233,149 @@ export const createRequestHandler = { generation += 1; cache.clear(); - inflight.clear(); if (expiryTimer) clearTimeout(expiryTimer); expiryTimer = undefined; expiryTimerAt = undefined; }, handler: (input: TInput) => ({ async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { + const deadlineAt = Date.now() + requestTimeoutMs; const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; - const key = options.getCacheKey?.(input) ?? JSON.stringify(input); - - let cached = cache.get(key); - const now = Date.now(); - if (cached && now < cached.expiresAt) { - return { data: cached.data, timestamp: cached.timestamp }; + const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input); + const requestGeneration = generation; + let sharedCache: SharedCacheAdapter | undefined; + const sharedCacheResolution = ttl > 0 ? resolveSharedCacheAsync(input, baseKey, requestGeneration) : undefined; + if (sharedCacheResolution) { + sharedCache = await sharedCacheResolution; + throwIfDeadlineExceeded(deadlineAt); } + + const key = `${sharedCache?.generation ?? "local"}:${baseKey}`; + const inflightKey = `${requestGeneration}:${key}`; + const existing = inflight.get(inflightKey); + if (existing) return existing; + + let cached = getFromMemory(key); + const now = Date.now(); + if (cached && now < cached.expiresAt) return cached; if (cached && now >= cached.staleUntil) { cache.delete(key); scheduleExpiry(); cached = undefined; } + if (upstreamSettlements.has(inflightKey) || inflight.size >= MAX_INFLIGHT_REQUESTS) { + if (options.fallbackToStaleOnError && cached) return cached; + throw new RequestHandlerOverloadedError(); + } - const existing = inflight.get(key); - if (existing) return existing; - - const requestGeneration = generation; + let cleanupAfterUpstreamAsync: Promise | undefined; let promise: Promise>; - promise = options - .requestAsync(input) - .then((data) => { - const expiresAt = Date.now() + ttl; + promise = (async () => { + let stale = cached; + let lockToken: string | null | undefined; + + if (sharedCache?.isShared) { + const sharedEntry = await sharedCache.getAsync(); + if (sharedEntry && Date.now() < sharedEntry.expiresAt) { + storeInMemory(key, sharedEntry, requestGeneration); + return sharedEntry; + } + if (sharedEntry && Date.now() < sharedEntry.staleUntil) stale = sharedEntry; + + lockToken = await sharedCache.acquireRefreshLockAsync(); + if (lockToken === null) { + const returnStaleAt = Date.now() + SHARED_REFRESH_STALE_WAIT_MS; + const waitDeadlineAt = Math.min(deadlineAt, Date.now() + SHARED_REFRESH_MAX_WAIT_MS); + let retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS; + while (lockToken === null && sharedCache.isShared) { + await delayAsync(SHARED_REFRESH_POLL_MS, waitDeadlineAt); + const refreshedEntry = await sharedCache.getAsync(); + if (refreshedEntry && Date.now() < refreshedEntry.expiresAt) { + storeInMemory(key, refreshedEntry, requestGeneration); + return refreshedEntry; + } + if (refreshedEntry && Date.now() < refreshedEntry.staleUntil) stale = refreshedEntry; + if (Date.now() >= 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; + let upstreamSettledAsync: Promise | undefined; + try { + const request = startRequestWithDeadline(input, deadlineAt, inflightKey); + upstreamSettledAsync = request.settledAsync; + const data = await request.resultAsync; + const completedAt = Date.now(); const staleIfErrorTtlMs = options.fallbackToStaleOnError ? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS) : 0; const entry: CacheEntry = { data, - timestamp: new Date(), - expiresAt, - staleUntil: expiresAt + staleIfErrorTtlMs, + timestamp: new Date(completedAt), + expiresAt: completedAt + ttl, + staleUntil: completedAt + ttl + staleIfErrorTtlMs, }; - if (ttl > 0 && generation === requestGeneration) { - evictExpired(cache); - if (cache.size >= MAX_CACHE_SIZE) { - const oldest = cache.keys().next().value; - if (oldest !== undefined) cache.delete(oldest); + if (ttl > 0) { + let canStoreInMemory = true; + if (sharedCache?.isShared) { + canStoreInMemory = false; + if (lockToken && refreshLockRenewal?.isOwned() !== false) { + const stored = await sharedCache.setAsync(entry, lockToken); + if (stored !== false || !sharedCache.isShared) canStoreInMemory = true; + } } - cache.set(key, entry); - scheduleExpiry(entry.staleUntil); + if (canStoreInMemory) storeInMemory(key, entry, requestGeneration); } return entry; - }) - .catch((err) => { + } catch (error) { if ( options.fallbackToStaleOnError && - cached && - Date.now() < cached.staleUntil && + stale && + Date.now() < stale.staleUntil && generation === requestGeneration ) { - return cached; + storeInMemory(key, stale, requestGeneration); + return stale; } - throw err; - }) - .finally(() => { - if (inflight.get(key) === promise) inflight.delete(key); - }); + throw error; + } finally { + const cleanupAsync = async () => { + refreshLockRenewal?.stop(); + if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken); + }; + if (upstreamSettledAsync) { + const settlementGraceMs = Math.min(requestTimeoutMs, MAX_UPSTREAM_SETTLEMENT_GRACE_MS); + cleanupAfterUpstreamAsync = waitForSettlementGraceAsync(upstreamSettledAsync, settlementGraceMs).then( + cleanupAsync, + ); + } else { + await cleanupAsync(); + } + } + })(); - inflight.set(key, promise); + inflight.set(inflightKey, promise); + const removeInflight = () => { + const cleanupAsync = cleanupAfterUpstreamAsync ?? Promise.resolve(); + void cleanupAsync.then( + () => { + if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey); + }, + () => { + if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey); + }, + ); + }; + void promise.then(removeInflight, removeInflight); return promise; }, }), diff --git a/packages/request-handler/src/lib/shared-cache.ts b/packages/request-handler/src/lib/shared-cache.ts new file mode 100644 index 000000000..6e2c6b199 --- /dev/null +++ b/packages/request-handler/src/lib/shared-cache.ts @@ -0,0 +1,249 @@ +import { createHash } from "node:crypto"; + +import { serialize } from "superjson"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import { + createGetSetChannel, + createLockChannel, + getIntegrationCacheGenerationAsync, + getWidgetCacheGenerationAsync, +} from "@homarr/redis"; +import type { IntegrationCacheGeneration, WidgetCacheGeneration } from "@homarr/redis"; + +import type { CacheEntry, SharedCacheAdapter } from "./request-handler"; + +const logger = createLogger({ module: "sharedRequestCache" }); +// The dedicated cache client rejects commands after 500 ms. This guard stays +// above that deadline so lock outcomes are never abandoned while still pending. +const REDIS_OPERATION_TIMEOUT_MS = 750; +const REFRESH_LOCK_TTL_SECONDS = 15; +const SHARED_CACHE_ENVELOPE_VERSION = 2; +const DEFAULT_HANDLER_CACHE_VERSION = "v1"; +const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/; +const cacheVersionPattern = /^[a-z0-9][a-z0-9._-]*$/; +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalize); + if (!isRecord(value)) return value; + + const result: Record = {}; + for (const key of Object.keys(value).toSorted()) { + result[key] = canonicalize(value[key]); + } + return result; +}; + +export const hashSharedCacheInput = (options: Record) => { + const serialized = JSON.stringify(canonicalize(serialize(options))); + return createHash("sha256").update(serialized).digest("hex"); +}; + +export const hashIntegrationCacheOptions = hashSharedCacheInput; + +const withTimeoutAsync = async (promise: Promise, durationMs: number): Promise => { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Redis operation timed out")), durationMs); + timer.unref?.(); + void promise.then(resolve, reject).finally(() => clearTimeout(timer)); + }); +}; + +interface SharedCacheEnvelope { + version: typeof SHARED_CACHE_ENVELOPE_VERSION; + entry: CacheEntry; +} + +const isSharedCacheEnvelope = (value: unknown): value is SharedCacheEnvelope => { + if (!isRecord(value) || value.version !== SHARED_CACHE_ENVELOPE_VERSION || !isRecord(value.entry)) return false; + + const entry = value.entry; + if (!Object.hasOwn(entry, "data") || !(entry.timestamp instanceof Date)) return false; + const timestamp = entry.timestamp.getTime(); + return ( + Number.isFinite(timestamp) && + typeof entry.expiresAt === "number" && + Number.isFinite(entry.expiresAt) && + typeof entry.staleUntil === "number" && + Number.isFinite(entry.staleUntil) && + timestamp <= entry.expiresAt && + entry.expiresAt <= entry.staleUntil + ); +}; + +interface IntegrationSharedCacheOptions { + namespace: string; + integrationId: string; + cacheIdentity: string; + cacheVersion?: string; +} + +interface RedisSharedCacheOptions { + namespace: string; + cacheKind: "integration" | "widget"; + cacheIdentity: string; + cacheVersion: string; + generation: string; + metadata?: Record; + isShared?: boolean; +} + +const logRedisFailure = (operation: string, metadata: Record, error: unknown) => { + logger.warn( + new ErrorWithMetadata( + "Redis response cache operation failed", + { + ...metadata, + operation, + }, + { cause: error }, + ), + ); +}; + +const createRedisSharedCache = ({ + namespace, + cacheKind, + cacheIdentity, + cacheVersion, + generation, + metadata = {}, + isShared: initialIsShared = true, +}: RedisSharedCacheOptions): SharedCacheAdapter => { + const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata }; + const payloadName = `${cacheKind}-cache:payload:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`; + const lockName = `${cacheKind}-cache:lock:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`; + const payloadChannel = createGetSetChannel(payloadName, { useBoundedCacheClient: true }); + const lock = createLockChannel(lockName, { useBoundedCacheClient: true }); + let isShared = initialIsShared; + + const runRedisOperationAsync = async (operation: string, callback: () => Promise, fallback: T): Promise => { + if (!isShared) return fallback; + try { + return await withTimeoutAsync(callback(), REDIS_OPERATION_TIMEOUT_MS); + } catch (error) { + isShared = false; + logRedisFailure(operation, logMetadata, error); + return fallback; + } + }; + + return { + generation, + get isShared() { + return isShared; + }, + getAsync: async () => { + const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined); + if (value === undefined || value === null) return value; + if (isSharedCacheEnvelope(value)) return value.entry as CacheEntry; + + logger.warn("Ignored invalid Redis response cache entry", { + ...logMetadata, + operation: "payload-validate", + }); + await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined); + return null; + }, + setAsync: async (entry, refreshLockToken) => { + const retentionMs = entry.staleUntil - Date.now(); + if (retentionMs <= 0) return false; + const envelope: SharedCacheEnvelope = { + version: SHARED_CACHE_ENVELOPE_VERSION, + entry, + }; + return await runRedisOperationAsync( + "payload-write", + () => lock.setIfOwnedAsync(refreshLockToken, payloadName, envelope, retentionMs), + undefined, + ); + }, + acquireRefreshLockAsync: async () => + await runRedisOperationAsync( + "refresh-lock-acquire", + () => 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) => { + try { + await withTimeoutAsync(lock.releaseAsync(token), REDIS_OPERATION_TIMEOUT_MS); + } catch (error) { + logRedisFailure("refresh-lock-release", logMetadata, error); + } + }, + }; +}; + +interface WidgetSharedCacheOptions { + namespace: string; + cacheIdentity: string; + cacheVersion?: string; +} + +export const createWidgetSharedCacheAsync = async ({ + namespace, + cacheIdentity, + cacheVersion = DEFAULT_HANDLER_CACHE_VERSION, +}: WidgetSharedCacheOptions): Promise> => { + if (typeof window !== "undefined") throw new Error("Widget request caching is server-only"); + if (!namespacePattern.test(namespace)) throw new Error(`Invalid widget cache namespace '${namespace}'`); + if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid widget cache version '${cacheVersion}'`); + + const metadata = { cacheNamespace: namespace }; + let generation: WidgetCacheGeneration; + try { + generation = await getWidgetCacheGenerationAsync(namespace); + } catch (error) { + logRedisFailure("generation-read", metadata, error); + generation = { value: "redis-unavailable", isShared: false }; + } + + return createRedisSharedCache({ + namespace, + cacheKind: "widget", + cacheIdentity, + cacheVersion, + generation: generation.value, + isShared: generation.isShared, + }); +}; + +export const createIntegrationSharedCacheAsync = async ({ + namespace, + integrationId, + cacheIdentity, + cacheVersion = DEFAULT_HANDLER_CACHE_VERSION, +}: IntegrationSharedCacheOptions): Promise> => { + if (typeof window !== "undefined") throw new Error("Integration request caching is server-only"); + if (!namespacePattern.test(namespace)) throw new Error(`Invalid integration cache namespace '${namespace}'`); + if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid integration cache version '${cacheVersion}'`); + + const metadata = { cacheNamespace: namespace, integrationId }; + let generation: IntegrationCacheGeneration; + try { + generation = await getIntegrationCacheGenerationAsync(integrationId); + } catch (error) { + logRedisFailure("generation-read", metadata, error); + generation = { value: "redis-unavailable", isShared: false }; + } + + const sharedCache = createRedisSharedCache({ + namespace, + cacheKind: "integration", + cacheIdentity, + cacheVersion, + generation: generation.value, + metadata: { integrationId }, + isShared: generation.isShared, + }); + return sharedCache; +}; diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts index 350826fe3..19882500d 100644 --- a/packages/request-handler/src/lib/widget-request-handler.ts +++ b/packages/request-handler/src/lib/widget-request-handler.ts @@ -1,15 +1,47 @@ +import { invalidateWidgetCache } from "@homarr/redis"; + +import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache"; import { createRequestHandler } from "./request-handler"; +import type { SharedCacheAdapter } from "./request-handler"; interface Options> { - requestAsync: (input: TInput) => Promise; + requestAsync: (input: TInput, signal: AbortSignal) => Promise; cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; + requestTimeoutMs?: number; + cacheNamespace?: string; + cacheVersion?: string; } export const createWidgetRequestHandler = >( options: Options, ) => { - const inner = createRequestHandler(options); - return inner; + const cacheNamespace = options.cacheNamespace; + let getSharedCacheAsync: ((input: TInput, cacheIdentity: string) => Promise>) | undefined; + if (cacheNamespace) { + getSharedCacheAsync = async (_input, cacheIdentity) => + await createWidgetSharedCacheAsync({ + namespace: cacheNamespace, + cacheIdentity, + cacheVersion: options.cacheVersion, + }); + } + + const inner = createRequestHandler({ + ...options, + cacheTtlMs: options.cacheTtlMs, + fallbackToStaleOnError: options.fallbackToStaleOnError, + staleIfErrorTtlMs: options.staleIfErrorTtlMs, + requestTimeoutMs: options.requestTimeoutMs, + getCacheKey: hashSharedCacheInput, + getSharedCacheAsync, + }); + return { + invalidateCache: () => { + inner.invalidateCache(); + if (cacheNamespace) invalidateWidgetCache(cacheNamespace); + }, + handler: inner.handler, + }; }; diff --git a/packages/request-handler/src/media-organizer.ts b/packages/request-handler/src/media-organizer.ts index fbb4d3460..174c59307 100644 --- a/packages/request-handler/src/media-organizer.ts +++ b/packages/request-handler/src/media-organizer.ts @@ -1,6 +1,6 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; -import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; +import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -16,8 +16,9 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"mediaOrganizer">, { pageSize: number } >({ + cacheNamespace: "media-organizer:summary", async requestAsync(integration, input) { - const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration; + const integrationInstance = await createIntegrationAsync(integration); const [missingResult, queueResult] = await Promise.all([ integrationInstance.getMissingAsync(input.pageSize), integrationInstance.getMediaQueueAsync(input.pageSize), diff --git a/packages/request-handler/src/media-release.ts b/packages/request-handler/src/media-release.ts index 994214b8e..ab2d0d52e 100644 --- a/packages/request-handler/src/media-release.ts +++ b/packages/request-handler/src/media-release.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { MediaRelease } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"mediaRelease">, Record >({ + cacheNamespace: "media-release:list", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMediaReleasesAsync(); diff --git a/packages/request-handler/src/media-request-list.ts b/packages/request-handler/src/media-request-list.ts index d8a7c6336..f49d8f458 100644 --- a/packages/request-handler/src/media-request-list.ts +++ b/packages/request-handler/src/media-request-list.ts @@ -1,7 +1,7 @@ import { z } from "zod/v4"; import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -28,6 +28,7 @@ export const mediaRequestListRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"mediaRequest">, MediaRequestListInput >({ + cacheNamespace: "media-requests:list", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); const requests = await integrationInstance.getRequestsAsync(); diff --git a/packages/request-handler/src/media-request-stats.spec.ts b/packages/request-handler/src/media-request-stats.spec.ts index 09ff5609e..194c1606a 100644 --- a/packages/request-handler/src/media-request-stats.spec.ts +++ b/packages/request-handler/src/media-request-stats.spec.ts @@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({ createIntegrationAsync: vi.fn(), })); -vi.mock("@homarr/integrations", () => ({ +vi.mock("@homarr/integrations/factory", () => ({ createIntegrationAsync: mocks.createIntegrationAsync, })); diff --git a/packages/request-handler/src/media-request-stats.ts b/packages/request-handler/src/media-request-stats.ts index 57d67b508..52cd5f440 100644 --- a/packages/request-handler/src/media-request-stats.ts +++ b/packages/request-handler/src/media-request-stats.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { MediaRequestStats } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"mediaRequest">, Record >({ + cacheNamespace: "media-requests:stats", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); const [stats, users] = await Promise.all([ diff --git a/packages/request-handler/src/media-server.ts b/packages/request-handler/src/media-server.ts index 55a7850e0..75d591b66 100644 --- a/packages/request-handler/src/media-server.ts +++ b/packages/request-handler/src/media-server.ts @@ -1,6 +1,6 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { StreamSession } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -11,6 +11,7 @@ export const mediaServerRequestHandler = createIntegrationRequestHandler< showOnlyPlaying: boolean; } >({ + cacheNamespace: "media-server:sessions", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying }); diff --git a/packages/request-handler/src/media-transcoding.spec.ts b/packages/request-handler/src/media-transcoding.spec.ts index 9edca2b0d..6bfa97076 100644 --- a/packages/request-handler/src/media-transcoding.spec.ts +++ b/packages/request-handler/src/media-transcoding.spec.ts @@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({ createIntegrationAsync: vi.fn(), })); -vi.mock("@homarr/integrations", () => ({ +vi.mock("@homarr/integrations/factory", () => ({ createIntegrationAsync: mocks.createIntegrationAsync, })); diff --git a/packages/request-handler/src/media-transcoding.ts b/packages/request-handler/src/media-transcoding.ts index 7409b25d2..a08e5d729 100644 --- a/packages/request-handler/src/media-transcoding.ts +++ b/packages/request-handler/src/media-transcoding.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const mediaTranscodingRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"mediaTranscoding">, { pageOffset: number; pageSize: number } >({ + cacheNamespace: "media-transcoding:summary", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); const [queue, workers, statistics] = await Promise.all([ diff --git a/packages/request-handler/src/minecraft-server-status.ts b/packages/request-handler/src/minecraft-server-status.ts index 664a0a7f3..f9b70ad80 100644 --- a/packages/request-handler/src/minecraft-server-status.ts +++ b/packages/request-handler/src/minecraft-server-status.ts @@ -1,17 +1,16 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({ - async requestAsync(input: { domain: string; isBedrockServer: boolean }) { + // Domains are supplied through a public procedure and form an unbounded key space. + requestTimeoutMs: 10_000, + async requestAsync(input: { domain: string; isBedrockServer: boolean }, signal) { const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`; - const response = await withTimeoutAsync(async (signal) => - fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal }), - ); + const response = await fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal }); return responseSchema.parse(await response.json()); }, }); diff --git a/packages/request-handler/src/navidrome.ts b/packages/request-handler/src/navidrome.ts index 0ad39ed9a..f68e391c5 100644 --- a/packages/request-handler/src/navidrome.ts +++ b/packages/request-handler/src/navidrome.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { NavidromeDashboardData } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const navidromeRequestHandler = createIntegrationRequestHandler< "navidrome", Record >({ + cacheNamespace: "navidrome:dashboard", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); diff --git a/packages/request-handler/src/network-controller.ts b/packages/request-handler/src/network-controller.ts index 123261025..d49276470 100644 --- a/packages/request-handler/src/network-controller.ts +++ b/packages/request-handler/src/network-controller.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { NetworkControllerSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const networkControllerRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"networkController">, Record >({ + cacheNamespace: "network-controller:status", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getNetworkSummaryAsync(); diff --git a/packages/request-handler/src/notifications.ts b/packages/request-handler/src/notifications.ts index 479ff81fa..d4949e614 100644 --- a/packages/request-handler/src/notifications.ts +++ b/packages/request-handler/src/notifications.ts @@ -1,6 +1,6 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { Notification } from "@homarr/integrations"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const notificationsRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"notifications">, Record >({ + cacheNamespace: "notifications:list", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getNotificationsAsync(); diff --git a/packages/request-handler/src/paperless-ngx.ts b/packages/request-handler/src/paperless-ngx.ts index e6b088006..534b03e2e 100644 --- a/packages/request-handler/src/paperless-ngx.ts +++ b/packages/request-handler/src/paperless-ngx.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { PaperlessNgxStats } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler< "paperlessNgx", Record >({ + cacheNamespace: "paperless-ngx:stats", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getStatsAsync(); diff --git a/packages/request-handler/src/patchmon.ts b/packages/request-handler/src/patchmon.ts index 021897511..17a3b20e0 100644 --- a/packages/request-handler/src/patchmon.ts +++ b/packages/request-handler/src/patchmon.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { PatchMonStats } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const patchmonStatsRequestHandler = createIntegrationRequestHandler< "patchmon", Record >({ + cacheNamespace: "patchmon:stats", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getStatsAsync(); diff --git a/packages/request-handler/src/rss-feeds.ts b/packages/request-handler/src/rss-feeds.ts index 0597a325f..0ffcfd381 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -10,18 +10,23 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler"; const logger = createLogger({ module: "rssFeedsRequestHandler" }); export const rssFeedsRequestHandler = createWidgetRequestHandler({ - async requestAsync(input: { url: string; count: number }) { - const result = (await extract(input.url, { - getExtraEntryFields: (feedEntry) => { - const media = attemptGetImageFromEntry(input.url, feedEntry); - if (!media) { - return {}; - } - return { - enclosure: media, - }; + // Authorized editors can supply arbitrary URLs, so keep this cache process-local and bounded. + async requestAsync(input: { url: string; count: number }, signal) { + const result = (await extract( + input.url, + { + getExtraEntryFields: (feedEntry) => { + const media = attemptGetImageFromEntry(input.url, feedEntry); + if (!media) { + return {}; + } + return { + enclosure: media, + }; + }, }, - })) as ExtendedFeedData; + { signal }, + )) as ExtendedFeedData; return { ...result, diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts index 1d0d2471c..1afe0dce8 100644 --- a/packages/request-handler/src/smart-home-entity-state.ts +++ b/packages/request-handler/src/smart-home-entity-state.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { EntityState } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -27,6 +27,7 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle IntegrationKindByCategory<"smartHomeServer">, { entityId: string } >({ + cacheNamespace: "smart-home:entity-state", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); const result = await integrationInstance.getEntityStateAsync(input.entityId); diff --git a/packages/request-handler/src/speedtest-tracker.ts b/packages/request-handler/src/speedtest-tracker.ts index 3f03f7187..779ed271c 100644 --- a/packages/request-handler/src/speedtest-tracker.ts +++ b/packages/request-handler/src/speedtest-tracker.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const speedtestTrackerRequestHandler = createIntegrationRequestHandler< "speedtestTracker", Record >({ + cacheNamespace: "speedtest-tracker:dashboard", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); diff --git a/packages/request-handler/src/stock-price.ts b/packages/request-handler/src/stock-price.ts index 177b223aa..4ad6324f0 100644 --- a/packages/request-handler/src/stock-price.ts +++ b/packages/request-handler/src/stock-price.ts @@ -1,18 +1,17 @@ import { z } from "zod/v4"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const fetchStockPriceHandler = createWidgetRequestHandler({ - async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) { - const response = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync( - `https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`, - { signal }, - ); - }); + // Symbols are supplied through a public procedure and form an unbounded key space. + requestTimeoutMs: 10_000, + async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }, signal) { + const response = await fetchWithTrustedCertificatesAsync( + `https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`, + { signal }, + ); const data = dataSchema.parse(await response.json()); if ("error" in data) { diff --git a/packages/request-handler/src/test/media-release.spec.ts b/packages/request-handler/src/test/media-release.spec.ts index 825107afd..8a2e54c4f 100644 --- a/packages/request-handler/src/test/media-release.spec.ts +++ b/packages/request-handler/src/test/media-release.spec.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ createIntegrationAsync: vi.fn(), })); -vi.mock("@homarr/integrations", () => ({ +vi.mock("@homarr/integrations/factory", () => ({ createIntegrationAsync: mocks.createIntegrationAsync, })); diff --git a/packages/request-handler/src/tracearr.ts b/packages/request-handler/src/tracearr.ts index 641bf2922..85b97846c 100644 --- a/packages/request-handler/src/tracearr.ts +++ b/packages/request-handler/src/tracearr.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { TracearrDashboardData } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const tracearrRequestHandler = createIntegrationRequestHandler< "tracearr", Record >({ + cacheNamespace: "tracearr:dashboard", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); diff --git a/packages/request-handler/src/traefik.ts b/packages/request-handler/src/traefik.ts index 1b87e2bb1..26191529e 100644 --- a/packages/request-handler/src/traefik.ts +++ b/packages/request-handler/src/traefik.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { TraefikDashboardData } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const traefikRequestHandler = createIntegrationRequestHandler< "traefik", Record >({ + cacheNamespace: "traefik:summary", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); diff --git a/packages/request-handler/src/umami.ts b/packages/request-handler/src/umami.ts index 02ef71140..0416045fa 100644 --- a/packages/request-handler/src/umami.ts +++ b/packages/request-handler/src/umami.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler< "umami", { websiteId: string; timeFrame: string; eventName?: string } >({ + cacheNamespace: "umami:stats", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName); @@ -15,6 +16,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler< }); export const umamiEventNamesRequestHandler = createIntegrationRequestHandler({ + cacheNamespace: "umami:event-names", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getEventNamesAsync(input.websiteId); @@ -26,6 +28,7 @@ export const umamiTopPagesRequestHandler = createIntegrationRequestHandler< "umami", { websiteId: string; timeFrame: string; limit: number } >({ + cacheNamespace: "umami:top-pages", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit); @@ -37,6 +40,7 @@ export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler< "umami", { websiteId: string; timeFrame: string; limit: number } >({ + cacheNamespace: "umami:top-referrers", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit); @@ -48,6 +52,7 @@ export const umamiMultiEventRequestHandler = createIntegrationRequestHandler< "umami", { websiteId: string; timeFrame: string; eventNames: string[] } >({ + cacheNamespace: "umami:multi-event", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames); @@ -59,6 +64,7 @@ export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler "umami", { websiteId: string } >({ + cacheNamespace: "umami:active-visitors", async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getActiveVisitorsAsync(input.websiteId); diff --git a/packages/request-handler/src/ups.ts b/packages/request-handler/src/ups.ts index 4ca17b5ee..ae7a2ab3f 100644 --- a/packages/request-handler/src/ups.ts +++ b/packages/request-handler/src/ups.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { UpsSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const upsSummariesRequestHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"ups">, Record >({ + cacheNamespace: "ups:summaries", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getUpsSummariesAsync(); diff --git a/packages/request-handler/src/uptime-kuma.ts b/packages/request-handler/src/uptime-kuma.ts index b9453eb76..4fd613586 100644 --- a/packages/request-handler/src/uptime-kuma.ts +++ b/packages/request-handler/src/uptime-kuma.ts @@ -1,4 +1,4 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { UptimeKumaDashboardData } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -8,6 +8,7 @@ export const uptimeKumaRequestHandler = createIntegrationRequestHandler< "uptimeKuma", Record >({ + cacheNamespace: "uptime-kuma:monitors", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getDashboardDataAsync(); diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts index 97182ab11..800b07bf5 100644 --- a/packages/request-handler/src/vpn.ts +++ b/packages/request-handler/src/vpn.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { VpnSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -9,6 +9,7 @@ export const vpnSummaryHandler = createIntegrationRequestHandler< IntegrationKindByCategory<"vpn">, Record >({ + cacheNamespace: "vpn:summary", async requestAsync(integration, _input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getSummaryAsync(); diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts index 24d29ed1d..a6b694529 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -5,7 +5,6 @@ import { z } from "zod"; import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; @@ -15,7 +14,10 @@ dayjs.extend(timezone); const HOURLY_FORECAST_LENGTH = 24; const DAILY_FORECAST_LENGTH = 7; -const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise => { +const requestWeatherAsync = async ( + input: { latitude: number; longitude: number }, + signal: AbortSignal, +): Promise => { const url = new URL("https://api.open-meteo.com/v1/forecast"); url.searchParams.set("latitude", input.latitude.toString()); url.searchParams.set("longitude", input.longitude.toString()); @@ -68,9 +70,7 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString()); url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString()); - const res = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); - }); + const res = await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); if (!res.ok) throw new ResponseError(res); const json: unknown = await res.json(); @@ -140,7 +140,9 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number }; export const weatherRequestHandler = createWidgetRequestHandler({ + // Coordinates are a public, continuous key space. Keep this cache process-local and bounded. cacheTtlMs: 5 * 60 * 1000, + requestTimeoutMs: 10_000, requestAsync: requestWeatherAsync, }); diff --git a/packages/request-handler/src/wud.ts b/packages/request-handler/src/wud.ts index 03ab11f07..b86596b9f 100644 --- a/packages/request-handler/src/wud.ts +++ b/packages/request-handler/src/wud.ts @@ -1,9 +1,10 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { WudStats } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; export const wudStatsRequestHandler = createIntegrationRequestHandler>({ + cacheNamespace: "wud:stats", async requestAsync(integration) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getStatsAsync(); diff --git a/packages/translation/src/lang/en-gb.json b/packages/translation/src/lang/en-gb.json index 908465523..099909374 100644 --- a/packages/translation/src/lang/en-gb.json +++ b/packages/translation/src/lang/en-gb.json @@ -2567,7 +2567,7 @@ } }, "footer": { - "streams": "", + "streams": "{count, plural, one {# active stream} other {# active streams}}", "totalBitrate": "" }, "items": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 171086637..6dba81083 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,6 +14,7 @@ "exports": { ".": "./index.ts", "./integration-select-grid": "./src/integration-select-grid/index.ts", + "./widget-icons": "./src/widget-icons.ts", "./styles.css": "./src/styles.css", "./hooks": "./src/hooks/index.ts", "./icons": "./src/icons/index.ts" diff --git a/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts index 829ecafbc..16928a650 100644 --- a/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.spec.ts @@ -8,7 +8,7 @@ describe("buildSortedIntegrations", () => { it("keeps every real integration available during onboarding", () => { const kinds = buildSortedIntegrations({ onboarding: true }).map((integration) => integration.kind); - expect(kinds).toHaveLength(integrationKinds.length - 1); + expect(kinds).toHaveLength(integrationKinds.filter((kind) => kind !== "mock").length); expect(kinds).not.toContain("mock"); }); diff --git a/packages/ui/src/integration-select-grid/integration-grid-shared.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.ts index ffbc4483d..d36230f7a 100644 --- a/packages/ui/src/integration-select-grid/integration-grid-shared.ts +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.ts @@ -1,9 +1,8 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { - featuredIntegrations, getIntegrationName, + getIntegrationOnboardingMetadata, getWidgetKindsForIntegration, - hiddenFromOnboarding, integrationDefs, integrationKinds, } from "@homarr/definitions"; @@ -56,7 +55,7 @@ export const buildSortedIntegrations = ( integrationKinds .filter((kind) => { if (options.allowedKinds && !options.allowedKinds.includes(kind)) return false; - if (options.onboarding && hiddenFromOnboarding.has(kind)) return false; + if (options.onboarding && getIntegrationOnboardingMetadata(kind).hidden) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; }) @@ -67,11 +66,11 @@ export const buildSortedIntegrations = ( widgets: getWidgetKindsForIntegration(kind), })) .toSorted((left, right) => { - const leftIndex = featuredIntegrations.indexOf(left.kind); - const rightIndex = featuredIntegrations.indexOf(right.kind); - if (leftIndex !== -1 && rightIndex !== -1) return leftIndex - rightIndex; - if (leftIndex !== -1) return -1; - if (rightIndex !== -1) return 1; + const leftFeaturedOrder = getIntegrationOnboardingMetadata(left.kind).featuredOrder; + const rightFeaturedOrder = getIntegrationOnboardingMetadata(right.kind).featuredOrder; + if (leftFeaturedOrder !== null && rightFeaturedOrder !== null) return leftFeaturedOrder - rightFeaturedOrder; + if (leftFeaturedOrder !== null) return -1; + if (rightFeaturedOrder !== null) return 1; return right.widgets.length - left.widgets.length || left.name.localeCompare(right.name); }); diff --git a/packages/ui/src/integration-select-grid/integration-select-grid.tsx b/packages/ui/src/integration-select-grid/integration-select-grid.tsx index a3d548308..22d5ceed1 100644 --- a/packages/ui/src/integration-select-grid/integration-select-grid.tsx +++ b/packages/ui/src/integration-select-grid/integration-select-grid.tsx @@ -2,45 +2,7 @@ import { useMemo, useState } from "react"; import { Badge, Center, Group, Input, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; -import { - IconAd, - IconApps, - IconBinaryTree, - IconBookmark, - IconBrandDocker, - IconBrandMinecraft, - IconBrowser, - IconBuildingBank, - IconBusStop, - IconCalendar, - IconChartBar, - IconClock, - IconClockPlay, - IconCloud, - IconDeviceCctv, - IconDeviceGamepad, - IconDownload, - IconGraphFilled, - IconHeartRateMonitor, - IconHourglass, - IconMessage, - IconMovie, - IconNotes, - IconPuzzle, - IconReportSearch, - IconRobot, - IconRocket, - IconRss, - IconSearch, - IconServer2, - IconTicket, - IconTopologyFull, - IconTransform, - IconVideo, - IconWall, - IconWind, - IconZoomQuestion, -} from "@tabler/icons-react"; +import { IconSearch } from "@tabler/icons-react"; import { getWidgetName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; @@ -49,6 +11,7 @@ import { useI18n } from "@homarr/translation/client"; import { IntegrationAvatar } from "../components/integration-avatar"; import { SelectableCard } from "../components/selectable-card"; import { selectGridCols } from "../select-grid-layout"; +import { widgetCatalogIcons } from "../widget-icons"; import { buildSortedIntegrations, filterIntegrations } from "./integration-grid-shared"; import type { IntegrationGridItem } from "./integration-grid-shared"; @@ -155,48 +118,6 @@ const IntegrationCard = ({ ); }; -const widgetIconsMap: Partial> = { - clock: IconClock, - weather: IconCloud, - airQuality: IconWind, - countdown: IconHourglass, - timer: IconClockPlay, - app: IconApps, - assistant: IconRobot, - iframe: IconBrowser, - video: IconDeviceCctv, - notebook: IconNotes, - anchorNote: IconNotes, - dnsHoleSummary: IconAd, - dnsHoleControls: IconDeviceGamepad, - "smartHome-entityState": IconBinaryTree, - "smartHome-executeAutomation": IconBinaryTree, - stockPrice: IconBuildingBank, - mediaServer: IconVideo, - calendar: IconCalendar, - downloads: IconDownload, - "mediaRequests-requestList": IconZoomQuestion, - "mediaRequests-requestStats": IconChartBar, - mediaTranscoding: IconTransform, - mediaMissing: IconMovie, - minecraftServerStatus: IconBrandMinecraft, - networkControllerSummary: IconTopologyFull, - networkControllerStatus: IconTopologyFull, - rssFeed: IconRss, - bookmarks: IconBookmark, - indexerManager: IconReportSearch, - healthMonitoring: IconHeartRateMonitor, - releases: IconRocket, - mediaReleases: IconTicket, - dockerContainers: IconBrandDocker, - firewall: IconWall, - notifications: IconMessage, - systemResources: IconGraphFilled, - coolify: IconCloud, - systemDisks: IconServer2, - timetable: IconBusStop, -}; - const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; limit?: number }) => { const t = useI18n(); if (widgets.length === 0) { @@ -213,7 +134,7 @@ const IntegrationTiedWidgets = ({ widgets, limit }: { widgets: WidgetKind[]; lim return ( {items.map((widgetKind) => { - const WidgetIcon = widgetIconsMap[widgetKind] ?? IconPuzzle; + const WidgetIcon = widgetCatalogIcons[widgetKind]; return ( = { + clock: IconClock, + weather: IconCloud, + airQuality: IconWind, + countdown: IconHourglass, + timer: IconClockPlay, + app: IconApps, + assistant: IconRobot, + iframe: IconBrowser, + video: IconDeviceCctv, + notebook: IconNotes, + anchorNote: IconNotes, + dnsHoleSummary: IconAd, + dnsHoleControls: IconDeviceGamepad, + "smartHome-entityState": IconBinaryTree, + "smartHome-executeAutomation": IconBinaryTree, + stockPrice: IconBuildingBank, + mediaServer: IconVideo, + calendar: IconCalendar, + downloads: IconDownload, + "mediaRequests-requestList": IconZoomQuestion, + "mediaRequests-requestStats": IconChartBar, + mediaTranscoding: IconTransform, + mediaMissing: IconMovie, + minecraftServerStatus: IconBrandMinecraft, + networkControllerSummary: IconTopologyFull, + networkControllerStatus: IconTopologyFull, + rssFeed: IconRss, + bookmarks: IconBookmark, + indexerManager: IconReportSearch, + healthMonitoring: IconHeartRateMonitor, + releases: IconRocket, + mediaReleases: IconTicket, + dockerContainers: IconBrandDocker, + firewall: IconWall, + notifications: IconMessage, + systemResources: IconGraphFilled, + coolify: IconCloud, + systemDisks: IconServer2, + timetable: IconBusStop, + "immich-serverStats": IconGraphFilled, + "immich-albumCarousel": IconPhoto, + paperlessNgx: IconFileText, + patchmon: IconShieldCheck, + bazarr: IconBadgeCc, + tracearr: IconActivityHeartbeat, + speedtestTracker: IconSpeedboat, + uptimeKuma: IconHeartbeat, + audioStats: IconHeadphones, + umami: IconChartBar, + vpn: IconShieldLock, + archiveTeamWarrior: IconArchive, + ups: IconBatteryCharging, + beszelSystemTable: IconTable, + beszelSystemGrid: IconLayoutGrid, + beszelAlerts: IconBell, + beszelSystemStats: IconChartAreaLine, + traefik: IconRoute, + customApi: IconApi, + wud: IconBrandDocker, +}; diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts index d7a155911..0cd71a333 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -1,5 +1,7 @@ import { IconNotes } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,8 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("anchorNot icon: IconNotes, supportsAdvancedFocus: true, queryKey: [["widget", "anchorNotes"]], - maxIntegrations: 1, - supportedIntegrations: ["anchor", "mock"], + ...getWidgetIntegrationConfig("anchorNote"), createOptions() { return optionsBuilder.from((factory) => ({ noteId: factory.anchorNote(), 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/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index f91f2d0ac..99163abe9 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -1,12 +1,14 @@ import { IconArchive } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", { icon: IconArchive, supportsAdvancedFocus: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("archiveTeamWarrior"), createOptions() { return optionsBuilder.from((factory) => ({ showBroadcastMessage: factory.switch({ @@ -14,5 +16,4 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe }), })); }, - supportedIntegrations: ["archiveTeamWarrior", "mock"], }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index 836a2d6f6..69adf770d 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -1,5 +1,7 @@ import { IconHeadphones, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -17,9 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("audioStat supportsAdvancedFocus: true, queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]], queryMatcher: matchesWidgetRuntimeQuery, - supportedIntegrations: ["navidrome", "audiobookshelf", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("audioStats"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index ec8e68f15..ff47fc3de 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -1,14 +1,14 @@ import { IconBadgeCc, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { supportsAdvancedFocus: true, icon: IconBadgeCc, - supportedIntegrations: ["bazarr", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("bazarr"), createOptions() { return optionsBuilder.from((factory) => ({ showMissingEpisodes: factory.switch({ diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index 84ea1a7ae..9a98c733a 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,6 +1,8 @@ import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -17,8 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle maxHistoryItems: scope.options.maxHistoryItems, }); }, - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, + ...getWidgetIntegrationConfig("beszelAlerts"), createOptions() { return optionsBuilder.from((factory) => ({ showHistory: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index cb7a2d83d..f908ac400 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -1,5 +1,7 @@ import { IconLayoutGrid, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { statusOptions } from "../beszel/_shared/options"; import { optionsBuilder } from "../options"; @@ -7,8 +9,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, queryKey: [["widget", "beszel", "getSystems"]], - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, + ...getWidgetIntegrationConfig("beszelSystemGrid"), createOptions() { return optionsBuilder.from((factory) => ({ statusFilter: factory.select({ diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index 12a1b41a6..4479bd274 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -1,6 +1,7 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -40,8 +41,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys (dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true })) ); }, - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, + ...getWidgetIntegrationConfig("beszelSystemStats"), createOptions() { return optionsBuilder.from((factory) => ({ systemId: factory.integrationSelect({ diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index 0c5af9ac9..4d96c7895 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -1,6 +1,6 @@ import { IconServerOff, IconTable } from "@tabler/icons-react"; -import { invariantTechnicalLabels } from "@homarr/definitions"; +import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import type { SelectOption } from "../_inputs/widget-select-input"; @@ -33,8 +33,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys query.path.at(-1) === "getSystems" ? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds }) : matchesWidgetRuntimeQuery(query, scope), - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, + ...getWidgetIntegrationConfig("beszelSystemTable"), createOptions() { return optionsBuilder.from( (factory) => ({ 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/calendar/index.ts b/packages/widgets/src/calendar/index.ts index 471c50f17..25873188b 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -1,7 +1,7 @@ import { IconCalendar } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; @@ -34,6 +34,5 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar" }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("calendar"), - integrationsRequired: false, + ...getWidgetIntegrationConfig("calendar"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index 3287a15d5..2d00c1b90 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -1,119 +1,2 @@ -import { - IconActivityHeartbeat, - IconAd, - IconApi, - IconApps, - IconArchive, - IconBadgeCc, - IconBatteryCharging, - IconBell, - IconBinaryTree, - IconBookmark, - IconBrandDocker, - IconBrandMinecraft, - IconBrowser, - IconBuildingBank, - IconBusStop, - IconCalendar, - IconChartAreaLine, - IconChartBar, - IconClock, - IconClockPlay, - IconCloud, - IconDeviceCctv, - IconDeviceGamepad, - IconDownload, - IconFileText, - IconGraphFilled, - IconHourglass, - IconHeadphones, - IconHeartRateMonitor, - IconHeartbeat, - IconLayoutGrid, - IconMessage, - IconMovie, - IconNotes, - IconPhoto, - IconReportSearch, - IconRocket, - IconRobot, - IconRoute, - IconRss, - IconServer2, - IconShieldCheck, - IconShieldLock, - IconSpeedboat, - IconTable, - IconTicket, - IconTopologyFull, - IconTransform, - IconVideo, - IconWall, - IconWind, - IconZoomQuestion, -} from "@tabler/icons-react"; - -import type { WidgetKind } from "@homarr/definitions"; -import type { TablerIcon } from "@homarr/ui"; - -export const widgetCatalogIcons: Record = { - clock: IconClock, - weather: IconCloud, - airQuality: IconWind, - countdown: IconHourglass, - timer: IconClockPlay, - app: IconApps, - assistant: IconRobot, - iframe: IconBrowser, - video: IconDeviceCctv, - notebook: IconNotes, - anchorNote: IconNotes, - dnsHoleSummary: IconAd, - dnsHoleControls: IconDeviceGamepad, - "smartHome-entityState": IconBinaryTree, - "smartHome-executeAutomation": IconBinaryTree, - stockPrice: IconBuildingBank, - mediaServer: IconVideo, - calendar: IconCalendar, - downloads: IconDownload, - "mediaRequests-requestList": IconZoomQuestion, - "mediaRequests-requestStats": IconChartBar, - mediaTranscoding: IconTransform, - mediaMissing: IconMovie, - minecraftServerStatus: IconBrandMinecraft, - networkControllerSummary: IconTopologyFull, - networkControllerStatus: IconTopologyFull, - rssFeed: IconRss, - bookmarks: IconBookmark, - indexerManager: IconReportSearch, - healthMonitoring: IconHeartRateMonitor, - releases: IconRocket, - mediaReleases: IconTicket, - dockerContainers: IconBrandDocker, - firewall: IconWall, - notifications: IconMessage, - systemResources: IconGraphFilled, - coolify: IconCloud, - systemDisks: IconServer2, - timetable: IconBusStop, - "immich-serverStats": IconGraphFilled, - "immich-albumCarousel": IconPhoto, - paperlessNgx: IconFileText, - patchmon: IconShieldCheck, - bazarr: IconBadgeCc, - tracearr: IconActivityHeartbeat, - speedtestTracker: IconSpeedboat, - uptimeKuma: IconHeartbeat, - audioStats: IconHeadphones, - umami: IconChartBar, - vpn: IconShieldLock, - archiveTeamWarrior: IconArchive, - ups: IconBatteryCharging, - beszelSystemTable: IconTable, - beszelSystemGrid: IconLayoutGrid, - beszelAlerts: IconBell, - beszelSystemStats: IconChartAreaLine, - traefik: IconRoute, - customApi: IconApi, - wud: IconBrandDocker, -}; +/** @deprecated Import widgetCatalogIcons from "@homarr/ui/widget-icons" instead. */ +export { widgetCatalogIcons } from "@homarr/ui/widget-icons"; diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts index 2c1ab2d8d..58b16e725 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -1,5 +1,7 @@ import { IconCloud, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -19,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify", }), })); }, - supportedIntegrations: ["coolify", "mock"], + ...getWidgetIntegrationConfig("coolify"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 6ec7f7336..979241f8a 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,5 +1,4 @@ import type React from "react"; -import type { LoaderComponent } from "next/dynamic"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { hashKey } from "@tanstack/react-query"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; @@ -10,7 +9,7 @@ import type { SettingsContextProps } from "@homarr/settings/creator"; import type { stringOrTranslation } from "@homarr/translation"; import type { TablerIcon } from "@homarr/ui"; -import type { WidgetImports } from "."; +import type { WidgetImports } from "./registry"; import type { inferOptionsFromCreator, WidgetOptionsRecord } from "./options"; export interface WidgetContextMenuAction { @@ -75,7 +74,7 @@ export interface WidgetContextActionProps { const createWithDynamicImport = (kind: TKind, definition: TDefinition) => - (componentLoader: () => LoaderComponent>) => ({ + (componentLoader: () => Promise<{ default: React.ComponentType> }>) => ({ definition: { ...definition, kind, @@ -84,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/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 5c3170c15..6d01ed1e5 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -1,6 +1,6 @@ import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -19,7 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), + ...getWidgetIntegrationConfig("dnsHoleControls"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index 6f4f62154..7ff85a714 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -1,6 +1,6 @@ import { IconAd, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), + ...getWidgetIntegrationConfig("dnsHoleSummary"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index cad34eac4..31e0b66f7 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -1,7 +1,7 @@ import { IconDownload } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getIntegrationKindsByCategory, getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -111,5 +111,5 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("downloadClient"), + ...getWidgetIntegrationConfig("downloads"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index f48c37dff..6a22c10e5 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -1,6 +1,6 @@ import { IconWall, IconWallOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -12,7 +12,7 @@ export const { definition, componentLoader } = createWidgetDefinition("firewall" createOptions() { return optionsBuilder.from(() => ({})); }, - supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"], + ...getWidgetIntegrationConfig("firewall"), errors: { INTERNAL_SERVER_ERROR: { icon: IconWallOff, diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index 7cfcdbcd6..09b090818 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -1,6 +1,6 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -114,9 +114,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter( - (kind) => kind !== "patchmon" && kind !== "wud", - ), + ...getWidgetIntegrationConfig("healthMonitoring"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, 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/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts index 14e1c46e2..3dbb322e3 100644 --- a/packages/widgets/src/immich/album-carousel/index.ts +++ b/packages/widgets/src/immich/album-carousel/index.ts @@ -2,6 +2,7 @@ import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@ import z from "zod"; import { clientApi } from "@homarr/api/client"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition"; @@ -62,9 +63,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al : undefined, }), refetchInterval: null, - supportedIntegrations: ["immich", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("immich-albumCarousel"), contextActions: ({ widgetRuntimeRef }) => { const actions = widgetRuntimeRef.current.actions; return [ diff --git a/packages/widgets/src/immich/server-stats/index.ts b/packages/widgets/src/immich/server-stats/index.ts index c1e45a68a..ebb40aa2e 100644 --- a/packages/widgets/src/immich/server-stats/index.ts +++ b/packages/widgets/src/immich/server-stats/index.ts @@ -1,5 +1,7 @@ import { IconGraphFilled } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -9,9 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]], queryMatcher: matchesWidgetRuntimeQuery, refetchInterval: null, - supportedIntegrations: ["immich", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("immich-serverStats"), createOptions() { return optionsBuilder.from((factory) => ({ showUsers: factory.switch({ diff --git a/packages/widgets/src/import.ts b/packages/widgets/src/import.ts deleted file mode 100644 index 911871a38..000000000 --- a/packages/widgets/src/import.ts +++ /dev/null @@ -1,3 +0,0 @@ -import type { WidgetKind } from "@homarr/definitions"; - -export type WidgetImportRecord = Record; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index a9ee650fe..bac08f28a 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -1,72 +1,6 @@ -import type { ComponentType } from "react"; -import type { Loader } from "next/dynamic"; -import dynamic from "next/dynamic"; -import { Center, Loader as UiLoader } from "@mantine/core"; - -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; - -import * as anchorNote from "./anchor-note"; -import * as audioStats from "./audio-stats"; -import * as app from "./app"; -import * as archiveTeamWarrior from "./archive-team-warrior"; -import * as bookmarks from "./bookmarks"; -import * as bazarr from "./bazarr"; -import * as calendar from "./calendar"; -import * as clock from "./clock"; -import * as coolify from "./coolify"; import type { WidgetComponentProps } from "./definition"; -import * as dnsHoleControls from "./dns-hole/controls"; -import * as dnsHoleSummary from "./dns-hole/summary"; -import * as dockerContainers from "./docker"; -import * as downloads from "./downloads"; -import * as firewall from "./firewall"; -import * as healthMonitoring from "./health-monitoring"; -import * as iframe from "./iframe"; -import * as immichAlbumCarousel from "./immich/album-carousel"; -import * as immichServerStats from "./immich/server-stats"; -import type { WidgetImportRecord } from "./import"; -import * as indexerManager from "./indexer-manager"; -import * as mediaReleases from "./media-releases"; -import * as mediaRequestsList from "./media-requests/list"; -import * as mediaRequestsStats from "./media-requests/stats"; -import * as mediaServer from "./media-server"; -import * as mediaTranscoding from "./media-transcoding"; -import * as mediaMissing from "./media-missing"; -import * as minecraftServerStatus from "./minecraft/server-status"; -import * as networkControllerStatus from "./network-controller/network-status"; -import * as networkControllerSummary from "./network-controller/summary"; -import * as notebook from "./notebook"; -import * as paperlessNgx from "./paperless-ngx"; -import * as patchmon from "./patchmon"; -import * as notifications from "./notifications"; -import * as releases from "./releases"; -import * as rssFeed from "./rssFeed"; -import * as smartHomeEntityState from "./smart-home/entity-state"; -import * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; -import * as speedtestTracker from "./speedtest-tracker"; -import * as uptimeKuma from "./uptime-kuma"; -import * as stockPrice from "./stocks"; -import * as systemDisks from "./system-disks"; -import * as systemResources from "./system-resources"; -import * as timetable from "./timetable"; -import * as traefik from "./traefik"; -import * as tracearr from "./tracearr"; -import * as umami from "./umami"; -import * as ups from "./ups"; -import * as vpn from "./vpn"; -import * as beszelSystemTable from "./beszel-system-table"; -import * as beszelSystemGrid from "./beszel-system-grid"; -import * as beszelAlerts from "./beszel-alerts"; -import * as beszelSystemStats from "./beszel-system-stats"; -import * as video from "./video"; -import * as weather from "./weather"; -import * as airQuality from "./air-quality"; -import * as countdown from "./countdown"; -import * as timer from "./timer"; -import * as customApi from "./custom-api"; -import * as assistant from "./assistant"; -import * as wud from "./wud"; +export { AssistantWidgetRendererProvider } from "./assistant/context"; export type { NormalizedWidgetQuery, WidgetDefinition, @@ -89,102 +23,9 @@ export { } from "./definition"; export type { WidgetComponentProps }; export type { WidgetOptionDefinition, WidgetOptionType } from "./options"; -export { AssistantWidgetRendererProvider } from "./assistant/context"; - -export const widgetImports = { - clock, - weather, - airQuality, - countdown, - timer, - app, - archiveTeamWarrior, - anchorNote, - notebook, - iframe, - video, - dnsHoleSummary, - dnsHoleControls, - "smartHome-entityState": smartHomeEntityState, - "smartHome-executeAutomation": smartHomeExecuteAutomation, - stockPrice, - mediaServer, - calendar, - downloads, - "mediaRequests-requestList": mediaRequestsList, - "mediaRequests-requestStats": mediaRequestsStats, - mediaMissing, - networkControllerSummary, - networkControllerStatus, - rssFeed, - bookmarks, - bazarr, - indexerManager, - healthMonitoring, - mediaTranscoding, - minecraftServerStatus, - dockerContainers, - releases, - firewall, - notifications, - mediaReleases, - systemResources, - coolify, - systemDisks, - timetable, - "immich-serverStats": immichServerStats, - "immich-albumCarousel": immichAlbumCarousel, - paperlessNgx, - patchmon, - tracearr, - speedtestTracker, - uptimeKuma, - audioStats, - umami, - vpn, - ups, - beszelSystemTable, - beszelSystemGrid, - beszelAlerts, - beszelSystemStats, - traefik, - customApi, - assistant, - wud, -} satisfies WidgetImportRecord; - -export type WidgetImports = typeof widgetImports; -export type WidgetImportKey = keyof WidgetImports; - -const loadedComponents = new Map>>(); - -export const loadWidgetDynamic = (kind: TKind) => { - const existingComponent = loadedComponents.get(kind); - if (existingComponent) return existingComponent; - - const newlyLoadedComponent = dynamic>( - widgetImports[kind].componentLoader as Loader>, - { - loading: () => ( -
- -
- ), - }, - ); - - loadedComponents.set(kind, newlyLoadedComponent as never); - return newlyLoadedComponent; -}; - -export type inferSupportedIntegrations = (WidgetImports[TKind]["definition"] extends { - supportedIntegrations: string[]; -} - ? WidgetImports[TKind]["definition"]["supportedIntegrations"] - : string[])[number]; - -export type inferSupportedIntegrationsStrict = (WidgetImports[TKind]["definition"] extends { - supportedIntegrations: IntegrationKind[]; -} - ? WidgetImports[TKind]["definition"]["supportedIntegrations"] - : never[])[number]; +export type { + inferSupportedIntegrations, + inferSupportedIntegrationsStrict, + WidgetImportKey, + WidgetImports, +} from "./registry"; diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index 8cc1e0eed..dfe25faba 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -1,6 +1,6 @@ import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -30,7 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa ], refetchInterval: null, createOptions, - supportedIntegrations: getIntegrationKindsByCategory("indexerManager"), + ...getWidgetIntegrationConfig("indexerManager"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts index c8a1f319f..7685f935a 100644 --- a/packages/widgets/src/manifest.spec.ts +++ b/packages/widgets/src/manifest.spec.ts @@ -1,7 +1,7 @@ import type { QueryKey } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; -import { widgetIntegrationSupport, widgetKinds as definedWidgetKinds } from "@homarr/definitions"; +import { widgetIntegrationSupport, widgetKinds as declaredWidgetKinds } from "@homarr/definitions"; import { createRetryableLoader, @@ -14,10 +14,16 @@ 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 = (entry: { queryKey: QueryKey; intervalSeconds: number | null }) => JSON.stringify(entry); +const serializePollingPolicy = ({ + queryKey, + intervalSeconds, +}: { + queryKey: QueryKey; + intervalSeconds: number | null; +}) => JSON.stringify({ queryKey, intervalSeconds }); describe("widget manifest promise stability", () => { it("returns the same module promise for every render", () => { @@ -58,7 +64,7 @@ describe("widget manifest promise stability", () => { }); it("covers every declared widget kind", () => { - expect(new Set(widgetKinds)).toEqual(new Set(definedWidgetKinds)); + expect(new Set(widgetKinds)).toEqual(new Set(declaredWidgetKinds)); }); it("loads matching definitions and component loaders for every widget", async () => { @@ -70,6 +76,7 @@ describe("widget manifest promise stability", () => { const canonicalComponent = await module.componentLoader(); const component = await loadWidgetComponent(kind); const resources = await loadWidgetResources(kind); + expect(module.definition.kind).toBe(kind); expect(definitions.get(kind)).toBe(module.definition); expect(component.default).toBe(canonicalComponent.default); expect(component.default).toBeDefined(); @@ -98,7 +105,8 @@ describe("widget manifest promise stability", () => { const definition = definitions.get(kind); const supportedIntegrations = definition && "supportedIntegrations" in definition ? (definition.supportedIntegrations ?? []) : []; - expect([...(widgetIntegrationSupport[kind] ?? [])].toSorted()).toEqual([...supportedIntegrations].toSorted()); + const expectedIntegrations = widgetIntegrationSupport[kind] ?? []; + expect(expectedIntegrations.toSorted()).toEqual([...supportedIntegrations].toSorted()); } }); diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 09795db10..f7e3f44a4 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -2,156 +2,54 @@ import type { ComponentType } from "react"; import { objectEntries } from "@homarr/common"; import type { WidgetKind } from "@homarr/definitions"; +import { widgetKinds as declaredWidgetKinds } from "@homarr/definitions"; import type { SettingsContextProps } from "@homarr/settings/creator"; import type { WidgetComponentProps, WidgetDefinition } from "./definition"; import type { WidgetOptionDefinition } from "./options"; +import type { WidgetImports } from "./registry"; +import { widgetComponentLoaders, widgetModuleLoaders } from "./registry"; -type WidgetModule = { +type RegisteredWidgetModule = WidgetImports[WidgetKind]; +type RegisteredWidgetComponentModule = Awaited>; +type WidgetDefinitionFor = WidgetImports[TKind]["definition"] & WidgetDefinition; + +type WidgetComponentModule = { + default: ComponentType>; +}; + +export type WidgetModule = { + definition: WidgetDefinitionFor; + componentLoader: () => Promise>; +}; + +export type WidgetResources = { + definition: WidgetDefinitionFor; + Component: ComponentType>; +}; + +type RegisteredWidgetResources = { + definition: RegisteredWidgetModule["definition"]; + Component: RegisteredWidgetComponentModule["default"]; +}; + +type WidgetManifestValue = { + module: WidgetModule; + component: WidgetComponentModule; + resources: WidgetResources; + definition: WidgetDefinitionFor; +}; + +type RegisteredWidgetManifestValue = { + module: RegisteredWidgetModule; + component: RegisteredWidgetComponentModule; + resources: RegisteredWidgetResources; definition: WidgetDefinition; - componentLoader: () => Promise<{ default: ComponentType> }>; -}; - -type WidgetComponentModule = { - default: ComponentType>; -}; - -export type WidgetResources = { - definition: WidgetDefinition; - Component: ComponentType>; -}; - -const moduleLoaders: Record Promise> = { - clock: () => import("./clock") as Promise, - weather: () => import("./weather") as Promise, - airQuality: () => import("./air-quality") as Promise, - countdown: () => import("./countdown") as Promise, - timer: () => import("./timer") as Promise, - app: () => import("./app") as Promise, - assistant: () => import("./assistant") as Promise, - archiveTeamWarrior: () => import("./archive-team-warrior") as Promise, - anchorNote: () => import("./anchor-note") as Promise, - notebook: () => import("./notebook") as Promise, - iframe: () => import("./iframe") as Promise, - video: () => import("./video") as Promise, - dnsHoleSummary: () => import("./dns-hole/summary") as Promise, - dnsHoleControls: () => import("./dns-hole/controls") as Promise, - "smartHome-entityState": () => import("./smart-home/entity-state") as Promise, - "smartHome-executeAutomation": () => import("./smart-home/execute-automation") as Promise, - stockPrice: () => import("./stocks") as Promise, - mediaServer: () => import("./media-server") as Promise, - calendar: () => import("./calendar") as Promise, - downloads: () => import("./downloads") as Promise, - "mediaRequests-requestList": () => import("./media-requests/list") as Promise, - "mediaRequests-requestStats": () => import("./media-requests/stats") as Promise, - mediaMissing: () => import("./media-missing") as Promise, - networkControllerSummary: () => import("./network-controller/summary") as Promise, - networkControllerStatus: () => import("./network-controller/network-status") as Promise, - rssFeed: () => import("./rssFeed") as Promise, - bookmarks: () => import("./bookmarks") as Promise, - bazarr: () => import("./bazarr") as Promise, - indexerManager: () => import("./indexer-manager") as Promise, - healthMonitoring: () => import("./health-monitoring") as Promise, - mediaTranscoding: () => import("./media-transcoding") as Promise, - minecraftServerStatus: () => import("./minecraft/server-status") as Promise, - dockerContainers: () => import("./docker") as Promise, - releases: () => import("./releases") as Promise, - firewall: () => import("./firewall") as Promise, - notifications: () => import("./notifications") as Promise, - mediaReleases: () => import("./media-releases") as Promise, - systemResources: () => import("./system-resources") as Promise, - coolify: () => import("./coolify") as Promise, - systemDisks: () => import("./system-disks") as Promise, - timetable: () => import("./timetable") as Promise, - "immich-serverStats": () => import("./immich/server-stats") as Promise, - "immich-albumCarousel": () => import("./immich/album-carousel") as Promise, - paperlessNgx: () => import("./paperless-ngx") as Promise, - patchmon: () => import("./patchmon") as Promise, - tracearr: () => import("./tracearr") as Promise, - speedtestTracker: () => import("./speedtest-tracker") as Promise, - uptimeKuma: () => import("./uptime-kuma") as Promise, - audioStats: () => import("./audio-stats") as Promise, - umami: () => import("./umami") as Promise, - vpn: () => import("./vpn") as Promise, - ups: () => import("./ups") as Promise, - beszelSystemTable: () => import("./beszel-system-table") as Promise, - beszelSystemGrid: () => import("./beszel-system-grid") as Promise, - beszelAlerts: () => import("./beszel-alerts") as Promise, - beszelSystemStats: () => import("./beszel-system-stats") as Promise, - traefik: () => import("./traefik") as Promise, - customApi: () => import("./custom-api") as Promise, - wud: () => import("./wud") as Promise, -}; - -// Keep these imports explicit so Turbopack can create one discoverable chunk per -// widget implementation. Loading this registry does not load the implementations. -const componentLoaders: Record Promise> = { - clock: () => import("./clock/component") as Promise, - weather: () => import("./weather/component") as Promise, - airQuality: () => import("./air-quality/component") as Promise, - countdown: () => import("./countdown/component") as Promise, - timer: () => import("./timer/component") as Promise, - app: () => import("./app/component") as Promise, - assistant: () => import("./assistant/component") as Promise, - archiveTeamWarrior: () => import("./archive-team-warrior/component") as Promise, - anchorNote: () => import("./anchor-note/component") as Promise, - notebook: () => import("./notebook/component") as Promise, - iframe: () => import("./iframe/component") as Promise, - video: () => import("./video/component") as Promise, - dnsHoleSummary: () => import("./dns-hole/summary/component") as Promise, - dnsHoleControls: () => import("./dns-hole/controls/component") as Promise, - "smartHome-entityState": () => import("./smart-home/entity-state/component") as Promise, - "smartHome-executeAutomation": () => - import("./smart-home/execute-automation/component") as Promise, - stockPrice: () => import("./stocks/component") as Promise, - mediaServer: () => import("./media-server/component") as Promise, - calendar: () => import("./calendar/component") as Promise, - downloads: () => import("./downloads/component") as Promise, - "mediaRequests-requestList": () => import("./media-requests/list/component") as Promise, - "mediaRequests-requestStats": () => import("./media-requests/stats/component") as Promise, - mediaMissing: () => import("./media-missing/component") as Promise, - networkControllerSummary: () => import("./network-controller/summary/component") as Promise, - networkControllerStatus: () => - import("./network-controller/network-status/component") as Promise, - rssFeed: () => import("./rssFeed/component") as Promise, - bookmarks: () => import("./bookmarks/component") as Promise, - bazarr: () => import("./bazarr/component") as Promise, - indexerManager: () => import("./indexer-manager/component") as Promise, - healthMonitoring: () => import("./health-monitoring/component") as Promise, - mediaTranscoding: () => import("./media-transcoding/component") as Promise, - minecraftServerStatus: () => import("./minecraft/server-status/component") as Promise, - dockerContainers: () => import("./docker/component") as Promise, - releases: () => import("./releases/component") as Promise, - firewall: () => import("./firewall/component") as Promise, - notifications: () => import("./notifications/component") as Promise, - mediaReleases: () => import("./media-releases/component") as Promise, - systemResources: () => import("./system-resources/component") as Promise, - coolify: () => import("./coolify/component") as Promise, - systemDisks: () => import("./system-disks/component") as Promise, - timetable: () => import("./timetable/component") as Promise, - "immich-serverStats": () => import("./immich/server-stats/component") as Promise, - "immich-albumCarousel": () => import("./immich/album-carousel/component") as Promise, - paperlessNgx: () => import("./paperless-ngx/component") as Promise, - patchmon: () => import("./patchmon/component") as Promise, - tracearr: () => import("./tracearr/component") as Promise, - speedtestTracker: () => import("./speedtest-tracker/component") as Promise, - uptimeKuma: () => import("./uptime-kuma/component") as Promise, - audioStats: () => import("./audio-stats/component") as Promise, - umami: () => import("./umami/component") as Promise, - vpn: () => import("./vpn/component") as Promise, - ups: () => import("./ups/component") as Promise, - beszelSystemTable: () => import("./beszel-system-table/component") as Promise, - beszelSystemGrid: () => import("./beszel-system-grid/component") as Promise, - beszelAlerts: () => import("./beszel-alerts/component") as Promise, - beszelSystemStats: () => import("./beszel-system-stats/component") as Promise, - traefik: () => import("./traefik/component") as Promise, - customApi: () => import("./custom-api/component") as Promise, - wud: () => import("./wud/component") as Promise, }; const definitionPromises = new Map>(); -export const widgetKinds = Object.keys(moduleLoaders) as WidgetKind[]; +export const widgetKinds = [...declaredWidgetKinds]; export const createRetryableLoader = (loaders: ReadonlyMap Promise>) => { const promises = new Map>(); @@ -172,39 +70,59 @@ export const createRetryableLoader = (loaders: ReadonlyMap Promise][]), +const loadRegisteredWidgetModule = createRetryableLoader( + new Map Promise>(objectEntries(widgetModuleLoaders)), ); -export const loadWidgetComponent = createRetryableLoader( - new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise][]), +// ponytail: TypeScript cannot correlate a generic key with the matching value +// in a Map. The registry itself is checked against WidgetImports, so this +// assertion only restores that already-verified relationship after caching. +const correlateWidgetPromise = >( + _kind: TKind, + _type: TType, + promise: Promise, +) => promise as Promise[TType]>; + +export const loadWidgetModule = (kind: TKind) => + correlateWidgetPromise(kind, "module", loadRegisteredWidgetModule(kind)); + +const loadRegisteredWidgetComponent = createRetryableLoader( + new Map Promise>(objectEntries(widgetComponentLoaders)), ); -export const loadWidgetResources = createRetryableLoader( - new Map( - (Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [ +export const loadWidgetComponent = (kind: TKind) => + correlateWidgetPromise(kind, "component", loadRegisteredWidgetComponent(kind)); + +const loadRegisteredWidgetResources = createRetryableLoader( + new Map Promise>( + widgetKinds.map((kind) => [ kind, async () => { const [{ definition }, { default: Component }] = await Promise.all([ - loadWidgetModule(kind), - loadWidgetComponent(kind), + loadRegisteredWidgetModule(kind), + loadRegisteredWidgetComponent(kind), ]); - return { definition, Component } satisfies WidgetResources; + return { definition, Component }; }, ]), ), ); -export const loadWidgetDefinition = (kind: WidgetKind) => { - const existing = definitionPromises.get(kind); - if (existing) return existing; +export const loadWidgetResources = (kind: TKind) => + correlateWidgetPromise(kind, "resources", loadRegisteredWidgetResources(kind)); - const promise = loadWidgetModule(kind).then(({ definition }) => definition); +export const loadWidgetDefinition = (kind: TKind) => { + const existing = definitionPromises.get(kind); + if (existing) { + return correlateWidgetPromise(kind, "definition", existing); + } + + const promise = loadRegisteredWidgetModule(kind).then(({ definition }) => definition); definitionPromises.set(kind, promise); void promise.catch(() => { if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind); }); - return promise; + return correlateWidgetPromise(kind, "definition", promise); }; let allWidgetDefinitionsPromise: Promise> | undefined; @@ -228,12 +146,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/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts index 38ac8cd68..72316791d 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -1,6 +1,6 @@ import { IconMovie } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -25,5 +25,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer"), + ...getWidgetIntegrationConfig("mediaMissing"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-releases/index.ts b/packages/widgets/src/media-releases/index.ts index 55eff84e5..26e06452a 100644 --- a/packages/widgets/src/media-releases/index.ts +++ b/packages/widgets/src/media-releases/index.ts @@ -1,5 +1,7 @@ import { IconTicket } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -34,5 +36,5 @@ export const { definition, componentLoader } = createWidgetDefinition("mediaRele }), })); }, - supportedIntegrations: ["mock", "emby", "jellyfin", "plex"], + ...getWidgetIntegrationConfig("mediaReleases"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index 8b67f6eed..febf07955 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,7 +1,7 @@ import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition"; @@ -50,5 +50,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }, }, ], - supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), + ...getWidgetIntegrationConfig("mediaRequests-requestList"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index 0c264e78a..d5b9b01a0 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -1,6 +1,6 @@ import { IconChartBar, IconSearch } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; @@ -22,5 +22,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }, }, ], - supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), + ...getWidgetIntegrationConfig("mediaRequests-requestStats"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 6967fdcff..349705127 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -425,9 +425,7 @@ export default function MediaServerWidget({ style={isAdvanced ? { flexShrink: 0 } : zoomCompensatedSize(16)} /> - {(t as unknown as (key: string, params?: { count: number }) => string)("footer.streams", { - count: flatSessions.length, - })} + {t("footer.streams", { count: flatSessions.length })} {totalBitrateLabel && ( <> diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 5dfac266d..f6ed833ce 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -1,6 +1,6 @@ import { IconVideo } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -22,5 +22,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ showLocation: factory.switch({ defaultValue: true, withDescription: true }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("mediaService"), + ...getWidgetIntegrationConfig("mediaServer"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index d85db6a58..adc06fb10 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -3,25 +3,13 @@ import { useEffect, useMemo, useState } from "react"; import type { ReactNode } from "react"; import { getQueryKey } from "@trpc/react-query"; -import { - Button, - Divider, - Group, - Pagination, - Paper, - ScrollArea, - Select, - SimpleGrid, - Stack, - Text, -} from "@mantine/core"; +import { Button, Divider, Group, Pagination, Paper, ScrollArea, Select, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; -import { views } from "."; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; @@ -30,6 +18,7 @@ import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; import { StatisticsPanel } from "./panels/statistics.panel"; import { WorkersPanel } from "./panels/workers.panel"; +import { views } from "./views"; type View = (typeof views)[number]; diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts index bff9c1ea3..ea13dbe52 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -2,19 +2,20 @@ import { IconTransform } from "@tabler/icons-react"; import { z } from "zod/v4"; import { capitalize } from "@homarr/common"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; +import { views } from "./views"; -export const views = ["workers", "queue", "statistics"] as const; +export { views }; export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { icon: IconTransform, supportsAdvancedFocus: true, queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], queryMatcher: matchesWidgetRuntimeQuery, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("mediaTranscoding"), refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ @@ -25,5 +26,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaTran queuePageSize: factory.number({ defaultValue: 10, validate: z.number().min(1).max(30) }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-transcoding/views.ts b/packages/widgets/src/media-transcoding/views.ts new file mode 100644 index 000000000..38f3c6167 --- /dev/null +++ b/packages/widgets/src/media-transcoding/views.ts @@ -0,0 +1 @@ +export const views = ["workers", "queue", "statistics"] as const; diff --git a/packages/widgets/src/network-controller/network-status/index.ts b/packages/widgets/src/network-controller/network-status/index.ts index e3741d8e1..735037ac0 100644 --- a/packages/widgets/src/network-controller/network-status/index.ts +++ b/packages/widgets/src/network-controller/network-status/index.ts @@ -1,6 +1,6 @@ import { IconServerOff, IconTopologyFull } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -21,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("networkCo }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("networkController"), + ...getWidgetIntegrationConfig("networkControllerStatus"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/network-controller/summary/index.ts b/packages/widgets/src/network-controller/summary/index.ts index 7cdc77a35..ffa1c2f9e 100644 --- a/packages/widgets/src/network-controller/summary/index.ts +++ b/packages/widgets/src/network-controller/summary/index.ts @@ -1,6 +1,6 @@ import { IconServerOff, IconTopologyFull } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -13,7 +13,7 @@ export const { definition, componentLoader } = createWidgetDefinition("networkCo createOptions() { return optionsBuilder.from(() => ({})); }, - supportedIntegrations: getIntegrationKindsByCategory("networkController"), + ...getWidgetIntegrationConfig("networkControllerSummary"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 60bdc8758..c97e3462c 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -1,6 +1,6 @@ import { IconMessage } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -19,5 +19,5 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat hideLogos: factory.switch({ defaultValue: false }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("notifications"), + ...getWidgetIntegrationConfig("notifications"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts index ac4519677..ce851e7f9 100644 --- a/packages/widgets/src/paperless-ngx/index.ts +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -1,5 +1,7 @@ import { IconFileText, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,9 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("paperless supportsAdvancedFocus: false, icon: IconFileText, refetchInterval: null, - supportedIntegrations: ["paperlessNgx", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("paperlessNgx"), createOptions() { return optionsBuilder.from((factory) => ({ showInboxRatio: factory.switch({ diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts index 46688b3cf..3953f7852 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -1,6 +1,8 @@ import { IconServerOff, IconShieldCheck } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors"; @@ -9,9 +11,7 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { supportsAdvancedFocus: true, icon: IconShieldCheck, - supportedIntegrations: ["patchmon", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("patchmon"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts index 36e2f3415..528874c50 100644 --- a/packages/widgets/src/prefetch.ts +++ b/packages/widgets/src/prefetch.ts @@ -5,32 +5,40 @@ import type { WidgetKind } from "@homarr/definitions"; import { createSettings } from "@homarr/settings/creator"; import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "./manifest"; -import prefetchForApps from "./app/prefetch"; -import prefetchForBookmarks from "./bookmarks/prefetch"; -import type { Prefetch, WidgetOptionsRecordOf } from "./definition"; -import type { inferOptionsFromCreator } from "./options"; +import type { PrefetchLoader } from "./definition"; -const prefetchCallbacks: Partial<{ - [TKind in WidgetKind]: Prefetch; -}> = { - bookmarks: prefetchForBookmarks, - app: prefetchForApps, -}; +const definePrefetchLoaders = >>(loaders: TLoaders) => + loaders; -export const prefetchForKindAsync = async ( - kind: TKind, +// Keep these imports explicit so Next.js can trace each optional prefetch +// module without loading its database-specific implementation eagerly. +const prefetchLoaders = definePrefetchLoaders({ + app: () => import("./app/prefetch"), + bookmarks: () => import("./bookmarks/prefetch"), +}); + +type PrefetchWidgetKind = keyof typeof prefetchLoaders; + +const hasPrefetchLoader = (kind: WidgetKind): kind is PrefetchWidgetKind => + Object.prototype.hasOwnProperty.call(prefetchLoaders, kind); + +export const prefetchForKindAsync = async ( + kind: WidgetKind, queryClient: QueryClient, items: { - options: inferOptionsFromCreator>; + options: Record; integrationIds: string[]; }[], ) => { - const callback = prefetchCallbacks[kind]; - if (!callback) { + if (!hasPrefetchLoader(kind)) { return; } - const [serverSettings, definition] = await Promise.all([getRscServerSettingsAsync(), loadWidgetDefinition(kind)]); + const [{ default: callback }, serverSettings, definition] = await Promise.all([ + prefetchLoaders[kind](), + getRscServerSettingsAsync(), + loadWidgetDefinition(kind), + ]); const itemsWithDefaultOptions = items.map((item) => ({ ...item, @@ -41,5 +49,5 @@ export const prefetchForKindAsync = async ( ), })); - await callback(queryClient, itemsWithDefaultOptions as never[]); + await callback(queryClient, itemsWithDefaultOptions); }; diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index 7f60c3154..926087703 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -1,19 +1,26 @@ /** - * Query policy used by the root client provider. + * Lightweight query defaults for the root client provider. * - * Keep this module free of widget component/definition imports. Importing the - * full widget registry from a root provider makes every widget part of the - * initial client graph, even before a board has rendered one widget. + * Keep this list free of widget imports: importing widget definitions here + * would put every widget in the initial client bundle. */ export const widgetQueryRefetchIntervals = [ { queryKey: [["docker", "getContainers"]], intervalSeconds: 30 }, { queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 }, - { queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 10 }, + { + queryKey: [["widget", "downloads", "getJobsAndStatuses"]], + intervalSeconds: 10, + staleTimeSeconds: 10, + }, { queryKey: [["widget", "firewall"]], intervalSeconds: 10 }, { queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 }, { queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 }, { queryKey: [["widget", "tracearr"]], intervalSeconds: 10 }, - { queryKey: [["widget", "weather", "atLocation"]], intervalSeconds: 600 }, + { + queryKey: [["widget", "weather", "atLocation"]], + intervalSeconds: 600, + staleTimeSeconds: 300, + }, { queryKey: [["widget", "airQuality", "atLocation"]], intervalSeconds: 900 }, { queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null }, { queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null }, diff --git a/packages/widgets/src/registry.ts b/packages/widgets/src/registry.ts new file mode 100644 index 000000000..747b0eddc --- /dev/null +++ b/packages/widgets/src/registry.ts @@ -0,0 +1,270 @@ +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; + +import type * as airQuality from "./air-quality"; +import type * as anchorNote from "./anchor-note"; +import type * as app from "./app"; +import type * as archiveTeamWarrior from "./archive-team-warrior"; +import type * as assistant from "./assistant"; +import type * as audioStats from "./audio-stats"; +import type * as bazarr from "./bazarr"; +import type * as beszelAlerts from "./beszel-alerts"; +import type * as beszelSystemGrid from "./beszel-system-grid"; +import type * as beszelSystemStats from "./beszel-system-stats"; +import type * as beszelSystemTable from "./beszel-system-table"; +import type * as bookmarks from "./bookmarks"; +import type * as calendar from "./calendar"; +import type * as clock from "./clock"; +import type * as coolify from "./coolify"; +import type * as countdown from "./countdown"; +import type * as customApi from "./custom-api"; +import type * as dnsHoleControls from "./dns-hole/controls"; +import type * as dnsHoleSummary from "./dns-hole/summary"; +import type * as dockerContainers from "./docker"; +import type * as downloads from "./downloads"; +import type * as firewall from "./firewall"; +import type * as healthMonitoring from "./health-monitoring"; +import type * as iframe from "./iframe"; +import type * as immichAlbumCarousel from "./immich/album-carousel"; +import type * as immichServerStats from "./immich/server-stats"; +import type * as indexerManager from "./indexer-manager"; +import type * as mediaMissing from "./media-missing"; +import type * as mediaReleases from "./media-releases"; +import type * as mediaRequestsList from "./media-requests/list"; +import type * as mediaRequestsStats from "./media-requests/stats"; +import type * as mediaServer from "./media-server"; +import type * as mediaTranscoding from "./media-transcoding"; +import type * as minecraftServerStatus from "./minecraft/server-status"; +import type * as networkControllerStatus from "./network-controller/network-status"; +import type * as networkControllerSummary from "./network-controller/summary"; +import type * as notebook from "./notebook"; +import type * as notifications from "./notifications"; +import type * as paperlessNgx from "./paperless-ngx"; +import type * as patchmon from "./patchmon"; +import type * as releases from "./releases"; +import type * as rssFeed from "./rssFeed"; +import type * as smartHomeEntityState from "./smart-home/entity-state"; +import type * as smartHomeExecuteAutomation from "./smart-home/execute-automation"; +import type * as speedtestTracker from "./speedtest-tracker"; +import type * as stockPrice from "./stocks"; +import type * as systemDisks from "./system-disks"; +import type * as systemResources from "./system-resources"; +import type * as timetable from "./timetable"; +import type * as timer from "./timer"; +import type * as tracearr from "./tracearr"; +import type * as traefik from "./traefik"; +import type * as umami from "./umami"; +import type * as ups from "./ups"; +import type * as uptimeKuma from "./uptime-kuma"; +import type * as video from "./video"; +import type * as vpn from "./vpn"; +import type * as weather from "./weather"; +import type * as wud from "./wud"; + +// Keep these imports explicit so Next.js and Turbopack can discover every widget +// module without loading any widget definition or component eagerly. +export const widgetModuleLoaders = { + clock: () => import("./clock"), + airQuality: () => import("./air-quality"), + countdown: () => import("./countdown"), + timer: () => import("./timer"), + app: () => import("./app"), + assistant: () => import("./assistant"), + archiveTeamWarrior: () => import("./archive-team-warrior"), + anchorNote: () => import("./anchor-note"), + notebook: () => import("./notebook"), + iframe: () => import("./iframe"), + video: () => import("./video"), + dnsHoleSummary: () => import("./dns-hole/summary"), + dnsHoleControls: () => import("./dns-hole/controls"), + "smartHome-entityState": () => import("./smart-home/entity-state"), + "smartHome-executeAutomation": () => import("./smart-home/execute-automation"), + stockPrice: () => import("./stocks"), + mediaServer: () => import("./media-server"), + calendar: () => import("./calendar"), + downloads: () => import("./downloads"), + "mediaRequests-requestList": () => import("./media-requests/list"), + "mediaRequests-requestStats": () => import("./media-requests/stats"), + mediaMissing: () => import("./media-missing"), + networkControllerSummary: () => import("./network-controller/summary"), + networkControllerStatus: () => import("./network-controller/network-status"), + rssFeed: () => import("./rssFeed"), + bookmarks: () => import("./bookmarks"), + bazarr: () => import("./bazarr"), + indexerManager: () => import("./indexer-manager"), + healthMonitoring: () => import("./health-monitoring"), + mediaTranscoding: () => import("./media-transcoding"), + minecraftServerStatus: () => import("./minecraft/server-status"), + dockerContainers: () => import("./docker"), + releases: () => import("./releases"), + firewall: () => import("./firewall"), + notifications: () => import("./notifications"), + mediaReleases: () => import("./media-releases"), + systemResources: () => import("./system-resources"), + coolify: () => import("./coolify"), + systemDisks: () => import("./system-disks"), + timetable: () => import("./timetable"), + "immich-serverStats": () => import("./immich/server-stats"), + "immich-albumCarousel": () => import("./immich/album-carousel"), + paperlessNgx: () => import("./paperless-ngx"), + patchmon: () => import("./patchmon"), + tracearr: () => import("./tracearr"), + speedtestTracker: () => import("./speedtest-tracker"), + uptimeKuma: () => import("./uptime-kuma"), + audioStats: () => import("./audio-stats"), + umami: () => import("./umami"), + vpn: () => import("./vpn"), + ups: () => import("./ups"), + beszelSystemTable: () => import("./beszel-system-table"), + beszelSystemGrid: () => import("./beszel-system-grid"), + beszelAlerts: () => import("./beszel-alerts"), + beszelSystemStats: () => import("./beszel-system-stats"), + traefik: () => import("./traefik"), + customApi: () => import("./custom-api"), + weather: () => import("./weather"), + wud: () => import("./wud"), +} satisfies { [TKind in WidgetKind]: () => Promise }; + +export type WidgetImports = { + clock: typeof clock; + airQuality: typeof airQuality; + countdown: typeof countdown; + timer: typeof timer; + app: typeof app; + assistant: typeof assistant; + archiveTeamWarrior: typeof archiveTeamWarrior; + anchorNote: typeof anchorNote; + notebook: typeof notebook; + iframe: typeof iframe; + video: typeof video; + dnsHoleSummary: typeof dnsHoleSummary; + dnsHoleControls: typeof dnsHoleControls; + "smartHome-entityState": typeof smartHomeEntityState; + "smartHome-executeAutomation": typeof smartHomeExecuteAutomation; + stockPrice: typeof stockPrice; + mediaServer: typeof mediaServer; + calendar: typeof calendar; + downloads: typeof downloads; + "mediaRequests-requestList": typeof mediaRequestsList; + "mediaRequests-requestStats": typeof mediaRequestsStats; + mediaMissing: typeof mediaMissing; + networkControllerSummary: typeof networkControllerSummary; + networkControllerStatus: typeof networkControllerStatus; + rssFeed: typeof rssFeed; + bookmarks: typeof bookmarks; + bazarr: typeof bazarr; + indexerManager: typeof indexerManager; + healthMonitoring: typeof healthMonitoring; + mediaTranscoding: typeof mediaTranscoding; + minecraftServerStatus: typeof minecraftServerStatus; + dockerContainers: typeof dockerContainers; + releases: typeof releases; + firewall: typeof firewall; + notifications: typeof notifications; + mediaReleases: typeof mediaReleases; + systemResources: typeof systemResources; + coolify: typeof coolify; + systemDisks: typeof systemDisks; + timetable: typeof timetable; + "immich-serverStats": typeof immichServerStats; + "immich-albumCarousel": typeof immichAlbumCarousel; + paperlessNgx: typeof paperlessNgx; + patchmon: typeof patchmon; + tracearr: typeof tracearr; + speedtestTracker: typeof speedtestTracker; + uptimeKuma: typeof uptimeKuma; + audioStats: typeof audioStats; + umami: typeof umami; + vpn: typeof vpn; + ups: typeof ups; + beszelSystemTable: typeof beszelSystemTable; + beszelSystemGrid: typeof beszelSystemGrid; + beszelAlerts: typeof beszelAlerts; + beszelSystemStats: typeof beszelSystemStats; + traefik: typeof traefik; + customApi: typeof customApi; + weather: typeof weather; + wud: typeof wud; +}; + +type WidgetComponentLoaders = { + [TKind in WidgetKind]: WidgetImports[TKind]["componentLoader"]; +}; + +// Keep component imports literal and separate from widgetModuleLoaders. The +// manifest starts both chunk requests together on a cold widget load; resolving +// this loader through the widget module would serialize those requests. +export const widgetComponentLoaders = { + clock: () => import("./clock/component"), + airQuality: () => import("./air-quality/component"), + countdown: () => import("./countdown/component"), + timer: () => import("./timer/component"), + app: () => import("./app/component"), + assistant: () => import("./assistant/component"), + archiveTeamWarrior: () => import("./archive-team-warrior/component"), + anchorNote: () => import("./anchor-note/component"), + notebook: () => import("./notebook/component"), + iframe: () => import("./iframe/component"), + video: () => import("./video/component"), + dnsHoleSummary: () => import("./dns-hole/summary/component"), + dnsHoleControls: () => import("./dns-hole/controls/component"), + "smartHome-entityState": () => import("./smart-home/entity-state/component"), + "smartHome-executeAutomation": () => import("./smart-home/execute-automation/component"), + stockPrice: () => import("./stocks/component"), + mediaServer: () => import("./media-server/component"), + calendar: () => import("./calendar/component"), + downloads: () => import("./downloads/component"), + "mediaRequests-requestList": () => import("./media-requests/list/component"), + "mediaRequests-requestStats": () => import("./media-requests/stats/component"), + mediaMissing: () => import("./media-missing/component"), + networkControllerSummary: () => import("./network-controller/summary/component"), + networkControllerStatus: () => import("./network-controller/network-status/component"), + rssFeed: () => import("./rssFeed/component"), + bookmarks: () => import("./bookmarks/component"), + bazarr: () => import("./bazarr/component"), + indexerManager: () => import("./indexer-manager/component"), + healthMonitoring: () => import("./health-monitoring/component"), + mediaTranscoding: () => import("./media-transcoding/component"), + minecraftServerStatus: () => import("./minecraft/server-status/component"), + dockerContainers: () => import("./docker/component"), + releases: () => import("./releases/component"), + firewall: () => import("./firewall/component"), + notifications: () => import("./notifications/component"), + mediaReleases: () => import("./media-releases/component"), + systemResources: () => import("./system-resources/component"), + coolify: () => import("./coolify/component"), + systemDisks: () => import("./system-disks/component"), + timetable: () => import("./timetable/component"), + "immich-serverStats": () => import("./immich/server-stats/component"), + "immich-albumCarousel": () => import("./immich/album-carousel/component"), + paperlessNgx: () => import("./paperless-ngx/component"), + patchmon: () => import("./patchmon/component"), + tracearr: () => import("./tracearr/component"), + speedtestTracker: () => import("./speedtest-tracker/component"), + uptimeKuma: () => import("./uptime-kuma/component"), + audioStats: () => import("./audio-stats/component"), + umami: () => import("./umami/component"), + vpn: () => import("./vpn/component"), + ups: () => import("./ups/component"), + beszelSystemTable: () => import("./beszel-system-table/component"), + beszelSystemGrid: () => import("./beszel-system-grid/component"), + beszelAlerts: () => import("./beszel-alerts/component"), + beszelSystemStats: () => import("./beszel-system-stats/component"), + traefik: () => import("./traefik/component"), + customApi: () => import("./custom-api/component"), + weather: () => import("./weather/component"), + wud: () => import("./wud/component"), +} satisfies WidgetComponentLoaders; + +export type WidgetImportKey = keyof WidgetImports; + +export type inferSupportedIntegrations = (WidgetImports[TKind]["definition"] extends { + supportedIntegrations: string[]; +} + ? WidgetImports[TKind]["definition"]["supportedIntegrations"] + : string[])[number]; + +export type inferSupportedIntegrationsStrict = (WidgetImports[TKind]["definition"] extends { + supportedIntegrations: IntegrationKind[]; +} + ? WidgetImports[TKind]["definition"]["supportedIntegrations"] + : never[])[number]; diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts index 551fa42de..65a6102c3 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -1,6 +1,6 @@ import { IconBinaryTree, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -9,7 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome icon: IconBinaryTree, supportsAdvancedFocus: true, queryKey: [["widget", "smartHome"]], - maxIntegrations: 1, + ...getWidgetIntegrationConfig("smartHome-entityState"), createOptions() { return optionsBuilder.from((factory) => ({ entityId: factory.text({ @@ -22,7 +22,6 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome clickable: factory.switch(), })); }, - supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/smart-home/execute-automation/index.ts b/packages/widgets/src/smart-home/execute-automation/index.ts index 2a349a84e..4f962c666 100644 --- a/packages/widgets/src/smart-home/execute-automation/index.ts +++ b/packages/widgets/src/smart-home/execute-automation/index.ts @@ -1,6 +1,6 @@ import { IconBinaryTree } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -8,12 +8,11 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", { icon: IconBinaryTree, supportsAdvancedFocus: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("smartHome-executeAutomation"), createOptions() { return optionsBuilder.from((factory) => ({ displayName: factory.text(), automationId: factory.text(), })); }, - supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts index b61f5488d..4ab6118f1 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -1,5 +1,7 @@ import { IconServerOff, IconSpeedboat } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -29,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest }, ); }, - supportedIntegrations: ["speedtestTracker", "mock"], + ...getWidgetIntegrationConfig("speedtestTracker"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts index 18671bf7f..059649719 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -1,5 +1,7 @@ import { IconServer2 } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options"; @@ -9,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis icon: IconServer2, queryKey: [["widget", "healthMonitoring"]], refetchInterval: 10, - supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"], + ...getWidgetIntegrationConfig("systemDisks"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index 6d87c1e6b..80f3cc1b7 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -1,7 +1,7 @@ import { IconAlignLeft, IconEyeOff, IconGraphFilled, IconListDetails, IconPhoto } from "@tabler/icons-react"; import { objectEntries } from "@homarr/common"; -import { invariantTechnicalLabels } from "@homarr/definitions"; +import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -24,7 +24,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes supportsAdvancedFocus: true, queryKey: [["widget", "healthMonitoring"]], refetchInterval: 10, - supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"], + ...getWidgetIntegrationConfig("systemResources"), createOptions() { return optionsBuilder.from((factory) => ({ hasShadow: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index 6a17f3fec..074a34bab 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -1,5 +1,7 @@ import { IconActivityHeartbeat, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -24,7 +26,7 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr" }), })); }, - supportedIntegrations: ["tracearr", "mock"], + ...getWidgetIntegrationConfig("tracearr"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts index 431c4a462..f679b0c9c 100644 --- a/packages/widgets/src/traefik/index.ts +++ b/packages/widgets/src/traefik/index.ts @@ -1,5 +1,7 @@ import { IconRoute, IconRouteOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -19,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik", }), })); }, - supportedIntegrations: ["traefik", "mock"], + ...getWidgetIntegrationConfig("traefik"), errors: { INTERNAL_SERVER_ERROR: { icon: IconRouteOff, diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts index b4a5a7d0e..9d92cbada 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -1,6 +1,8 @@ import { IconChartBar, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -18,9 +20,7 @@ export const { definition, componentLoader } = createWidgetDefinition("umami", { ], queryMatcher: matchesWidgetRuntimeQuery, refetchInterval: null, - supportedIntegrations: ["umami", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("umami"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/ups/index.ts b/packages/widgets/src/ups/index.ts index 0ee8c70d5..e0b8e0739 100644 --- a/packages/widgets/src/ups/index.ts +++ b/packages/widgets/src/ups/index.ts @@ -1,6 +1,6 @@ import { IconBatteryCharging, IconBatteryOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -21,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("ups", { }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("ups"), + ...getWidgetIntegrationConfig("ups"), errors: { INTERNAL_SERVER_ERROR: { icon: IconBatteryOff, diff --git a/packages/widgets/src/uptime-kuma/index.ts b/packages/widgets/src/uptime-kuma/index.ts index f5274f6c6..ad67a4601 100644 --- a/packages/widgets/src/uptime-kuma/index.ts +++ b/packages/widgets/src/uptime-kuma/index.ts @@ -1,5 +1,7 @@ import { IconHeartbeat, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -34,7 +36,7 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum }), })); }, - supportedIntegrations: ["uptimeKuma", "mock"], + ...getWidgetIntegrationConfig("uptimeKuma"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts index 001f2eec0..e22fe6a3a 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -1,6 +1,6 @@ import { IconShieldLock } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -12,5 +12,5 @@ export const { componentLoader, definition } = createWidgetDefinition("vpn", { createOptions() { return optionsBuilder.from(() => ({})); }, - supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"], + ...getWidgetIntegrationConfig("vpn"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/wud/index.ts b/packages/widgets/src/wud/index.ts index 622b10b6b..d5bcae3e6 100644 --- a/packages/widgets/src/wud/index.ts +++ b/packages/widgets/src/wud/index.ts @@ -1,5 +1,7 @@ import { IconBrandDocker } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,9 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("wud", { icon: IconBrandDocker, supportsAdvancedFocus: true, refetchInterval: null, - supportedIntegrations: ["wud", "mock"], - integrationsRequired: true, - maxIntegrations: 1, + ...getWidgetIntegrationConfig("wud"), createOptions() { return optionsBuilder.from((factory) => ({ showTitle: factory.switch({ defaultValue: true }),