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..955dcd81a --- /dev/null +++ b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx @@ -0,0 +1,188 @@ +--- +title: Widgets and integrations +tags: + - Developer + - Technical documentation + - Widgets + - Integrations +sidebar_position: 2 +--- + +Homarr is moving native widgets and integrations to vertical feature modules: the stable identity, lightweight metadata, +client entry, server registration, cache policy, and documentation source live beside the implementation. Generated +files preserve static imports and package boundaries for Next.js, tRPC, and Docusaurus. + +Registration means availability. There is no separate feature-enable list or runtime kill switch. + +## Current status + +Weather and Downloads are the reference widget modules. Their folders now own registration and canonical docs: + +```text +packages/widgets/src/weather/ +├── module.ts # authored manifest and client/server data policy +├── index.ts # options and presentation-specific definition +├── component.tsx # client UI +├── module.generated.ts # generated icon projection; do not edit +└── docs/ # canonical MDX, metadata, and assets +``` + +The remaining widgets still use the compatibility registries and can be migrated one folder at a time without changing +their persisted `WidgetKind` or tRPC paths. The generated and legacy entries are merged behind the same runtime loaders. + +The server router implementation is not physically inside these first two widget folders yet. `@homarr/widgets` +currently imports `@homarr/api/client`; making `@homarr/api` import widget server files would create a package cycle and +risk shipping server code to the browser. The module already owns the lazy router registration, and the neutral server +operation types are the cycle-breaking seam for the next migration stage. + +## The module manifest + +`module.ts` is pure and server-safe. It must not import React, tRPC, an integration implementation, secrets, or a request +handler. It describes import locations as strings so the generator can emit literal imports into the correct package. + +```ts +import { defineWidgetModule } from "@homarr/definitions"; + +export default defineWidgetModule({ + kind: "example", + icon: "IconSparkles", + clientEntry: ".", + documentation: { + slug: "example", + sourceDirectory: "docs", + }, + integration: { + categories: ["downloadClient"], + serverMaxIntegrations: 1, + }, + operations: [ + { + name: "getData", + path: ["widget", "example", "getData"], + client: { + refetchIntervalSeconds: 30, + persist: true, + }, + serverCache: { + namespace: "example:data", + ttlMs: 30_000, + scope: "integration", + }, + }, + ], + routers: [ + { + namespace: "example", + module: "./example", + exportName: "exampleRouter", + }, + ], +}); +``` + +Run the synchronizer after adding or changing a module: + +```sh +pnpm widgets:new example --icon IconSparkles +pnpm widgets:sync +pnpm widgets:sync:check +``` + +`widgets:new` creates a minimal client-only folder and immediately runs the synchronizer. Replace the empty component, +add options, and extend the manifest with operations or router registrations as needed; it never edits a hand-written +central feature list. + +The check command is read-only and reports drift. The synchronizer rejects duplicate widget kinds, docs slugs, router +namespaces with conflicting targets, operation names, and cache namespaces. It also verifies client entries, router +modules, and local docs before producing anything. + +It generates these compatibility projections: + +| Consumer | Generated projection | +| ---------------------- | -------------------------------------------------------------------- | +| Definitions and policy | `packages/definitions/src/generated/widget-modules.ts` | +| Client lazy loading | `packages/widgets/src/generated/widget-modules.ts` | +| Lightweight icons | `packages/ui/src/generated/widget-icons.ts` | +| Lazy tRPC routers | `packages/api/src/router/widgets/generated-registry.ts` | +| Docusaurus | `apps/docs/docs/widgets//` from the module's canonical `docs/` | + +Generated client and server registries contain explicit import literals. Do not replace them with a variable dynamic +import: explicit paths let Next.js and Turbopack discover chunks without eagerly loading every widget. + +## Client and server data caching + +The operation is the shared identity for both cache layers: + +- The tRPC path drives the TanStack Query key. +- `refetchIntervalSeconds` configures the root Query Client without importing widget UI code. +- `persist` controls session-scoped browser persistence. Persisted payloads are size-bounded, sanitized, and separated + by the authenticated user and permission scope. +- `serverCache` is imported by the request handler through `getWidgetServerCachePolicy`, so its namespace and TTL are + not copied into another file. + +Server reads use bounded in-process L1 caching and a shared Redis L2. Inputs are canonically serialized and SHA-256 +hashed; raw URLs, options, and secrets never appear in Redis keys or cache logs. Redis operations and lock waits are +bounded, refreshes use token-safe distributed locks, and stale data can be retained for the declared error window. +Redis failure degrades to local caching and produces structured logs with safe metadata and the original cause. + +Integration-backed cache keys include the integration ID and its Redis generation. Mutations advance that generation, +which invalidates all replicas without scanning keys. Integration-free widget handlers use a namespace generation; +manual refreshes such as Docker inventory invalidation therefore clear both local and shared values. A zero TTL keeps +only overlapping in-flight request deduplication and never stores a Redis payload, which is appropriate for realtime +or subscription data. + +TanStack Query remains the browser cache and Redis remains the server cache. A browser persistence hit never bypasses +server authorization, and Redis never caches permission decisions or decrypted credentials. + +## Server operations and tRPC + +The neutral contract includes helpers for queries, mutations, subscriptions, and routes: + +- `defineWidgetServerOperation` +- `defineWidgetServerSubscription` +- `defineWidgetServerRoute` +- `defineWidgetServerModule` + +They describe validated input, authorization intent, cache policy, an `AbortSignal`, and a framework-neutral resolver. +The future API adapter will inject tRPC procedure builders, integration middleware, logging, and error normalization. +This dependency direction lets a local `server.ts` be consumed by API code without the widget client importing API +runtime code. + +Until that adapter replaces the current client dependency, keep actual upstream calls in `@homarr/request-handler` and +the tRPC compatibility router in `packages/api/src/router/widgets/`. Put the router registration in the local manifest; +do not add it to the hand-written registry. + +An integration-backed procedure must use the widget-aware middleware. It derives compatible kinds and maximum +selection limits from the module/catalog, resolves only the requested records, decrypts secrets on the server, and +checks direct user and group permissions before the procedure runs. Use `query` permission for reads and `interact` +permission for mutations. + +## Errors and logs + +- Root tRPC instrumentation records the procedure path, operation type, user, duration, and outcome. +- Integration middleware records safe requested/resolved IDs and kinds plus denial reasons; it never logs credentials. +- Integration calls are timed and normalized into public errors while preserving a structured server-side cause. +- Multi-integration reads use `settleIntegrationQueries` so one failed service does not erase successful providers. +- Widget components read through the shared usable-data helper: stale data remains visible after a refresh error, while + first-load failures reach the common widget error boundary and logs link. +- Lazy client, router, and integration imports cache successful and in-flight loads, evict failed promises, and log + load duration so a transient chunk failure can retry. + +## Complex modules + +The manifest is the common path, not a limit on implementation size. + +- Beszel can group multiple stable widget kinds behind one router and keep live statistics on an SSE subscription with + Redis payload caching disabled. +- Assistant can use a route/stream adapter for chat while keeping client runtime state and tools in subfolders. +- A proxy-only widget can expose a server route without inventing a polling query. +- A widget can add custom query matchers, context actions, prefetching, or nested client components while retaining the + same registration, permissions, logging, error, and cache contracts. + +## Legacy integration path + +Integration metadata is currently derived from `integrationDefs`, while implementation constructors are loaded lazily +through `@homarr/integrations/factory`. Keep integrations server-only, implement only their capability interfaces, and +use narrow schema/interface exports instead of the broad runtime barrel. The same local-manifest and generated-registry +shape is being introduced for integrations so metadata, constructor registration, and canonical docs can move beside +each implementation without changing `IntegrationKind` or widget capability checks. diff --git a/apps/docs/docs/integrations/beszel/.integration-module-generated b/apps/docs/docs/integrations/beszel/.integration-module-generated new file mode 100644 index 000000000..975231da4 --- /dev/null +++ b/apps/docs/docs/integrations/beszel/.integration-module-generated @@ -0,0 +1 @@ +packages/integrations/src/beszel/docs diff --git a/apps/docs/docs/integrations/beszel/index.ts b/apps/docs/docs/integrations/beszel/index.ts index bcb8065e8..c67aae6bf 100644 --- a/apps/docs/docs/integrations/beszel/index.ts +++ b/apps/docs/docs/integrations/beszel/index.ts @@ -1,9 +1,7 @@ -import { 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", -}; +} as const; diff --git a/apps/docs/docs/widgets/downloads/.widget-module-generated b/apps/docs/docs/widgets/downloads/.widget-module-generated new file mode 100644 index 000000000..73d20026f --- /dev/null +++ b/apps/docs/docs/widgets/downloads/.widget-module-generated @@ -0,0 +1 @@ +packages/widgets/src/downloads/docs diff --git a/apps/docs/docs/widgets/weather/.widget-module-generated b/apps/docs/docs/widgets/weather/.widget-module-generated new file mode 100644 index 000000000..c2fc7efdc --- /dev/null +++ b/apps/docs/docs/widgets/weather/.widget-module-generated @@ -0,0 +1 @@ +packages/widgets/src/weather/docs diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index cf7dcc354..c20857c32 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -2,8 +2,22 @@ import type * as Preset from "@docusaurus/preset-classic"; import type { Config } from "@docusaurus/types"; import { themes as prismThemes } from "prism-react-renderer"; import { resolveHomarrUrlConfig } from "@homarr/workshop/schema"; +import { generatedWidgetDocumentationSources } from "@homarr/definitions"; const a11yEmoji = require("@fec/remark-a11y-emoji"); +const githubDocsEditRoot = "https://github.com/homarr-labs/homarr/edit/dev"; + +const getDocumentationEditUrl = (docPath: string) => { + const widgetPath = /^widgets\/([^/]+)\/(.+)$/u.exec(docPath); + const slug = widgetPath?.[1]; + const relativePath = widgetPath?.[2]; + if (slug && relativePath && Object.hasOwn(generatedWidgetDocumentationSources, slug)) { + const source = generatedWidgetDocumentationSources[slug as keyof typeof generatedWidgetDocumentationSources]; + return `${githubDocsEditRoot}/${source}/${relativePath}`; + } + return `${githubDocsEditRoot}/apps/docs/docs/${docPath}`; +}; + const deprecatedWorkshopUrl = process.env.WORKSHOP_URL; if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) { console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead."); @@ -65,7 +79,7 @@ const config: Config = { { docs: { sidebarPath: require.resolve("./sidebars.js"), - editUrl: ({ docPath }) => `https://github.com/homarr-labs/homarr/edit/dev/apps/docs/docs/${docPath}`, + editUrl: ({ docPath }) => getDocumentationEditUrl(docPath), remarkPlugins: [a11yEmoji], exclude: [], showLastUpdateAuthor: false, diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index dc5242de4..d4a0bc453 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -141,10 +141,12 @@ const ScopedTRPCReactProvider = ({ refetchOnWindowFocus: false, refetchOnReconnect: false, }); - for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) { - client.setQueryDefaults(queryKey, { - refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000, - }); + for (const { queryKey, intervalSeconds, staleTimeSeconds } of widgetQueryRefetchIntervals) { + const policy: { refetchInterval?: number | false; staleTime?: number } = {}; + if (intervalSeconds === null) policy.refetchInterval = false; + if (typeof intervalSeconds === "number") policy.refetchInterval = intervalSeconds * 1000; + if (staleTimeSeconds !== undefined) policy.staleTime = staleTimeSeconds * 1000; + client.setQueryDefaults(queryKey, policy); } for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { client.setQueryDefaults(queryKey, policy); diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index 3baca0b39..a907fe6e1 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -102,14 +102,16 @@ export default async function Layout(props: { }) : null, ); - const assistantAvailabilityPromise: Promise = sessionPromise.then((session) => - !session - ? "unauthenticated" - : api.assistant - .getAvailability() - .then((availability) => (availability.enabled ? "enabled" : "unconfigured")) - .catch(() => "error"), - ); + const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise => { + if (!session) return "unauthenticated"; + + 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, userPromise, 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 73370b5c8..cfd71ad79 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"; @@ -11,29 +10,15 @@ import { getI18n } from "@homarr/translation/server"; import { createMetaTitle } from "~/metadata"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; +import { getMcpRuntimeAsync } from "~/app/api/mcp/_extract-tools"; import { ApiKeysManagement } from "./components/api-keys"; import { ApiPageTabs } from "./components/api-page-tabs"; 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); - } - } - +async function getMcpToolGroupsAsync(): Promise { + const { tools, procedureTypes } = await getMcpRuntimeAsync(); const groups = new Map(); for (const tool of tools) { const namespace = tool.pathInRouter[0] ?? "other"; @@ -44,14 +29,13 @@ function getMcpToolGroups(): McpToolGroup[] { groups.get(namespace)?.push({ name: tool.name, description: tool.description, - type: procedureTypeMap.get(procedureKey) ?? "query", + type: procedureTypes.get(procedureKey) ?? "query", }); } - 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() { @@ -72,12 +56,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"), + getMcpToolGroupsAsync(), + ]); 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/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index d59733da0..f56e51c6d 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -6,7 +6,7 @@ import type { ToolSet, UIMessage } from "ai"; import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai"; import { z } from "zod/v4"; -import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; +import { createTRPCContext } from "@homarr/api/mcp"; import { createAssistantGenerationAccessToken, getAssistantContextEntitiesAsync, @@ -16,6 +16,7 @@ import { auth } from "@homarr/auth/next"; import { env } from "@homarr/common/env"; import { decryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { and, eq } from "@homarr/db"; import { db } from "@homarr/db"; import { assistantConfigurations, assistantThreads } from "@homarr/db/schema"; @@ -35,7 +36,7 @@ import type { AssistantWebSearchSource, } from "~/components/assistant/assistant-message-metadata"; -import { extractMcpTools } from "../../mcp/_extract-tools"; +import { getMcpRuntimeAsync } from "../../mcp/_extract-tools"; import { buildAssistantRequestContext, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getAssistantModelLookupStatus } from "./assistant-model-lookup"; import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion"; @@ -322,20 +323,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) => { @@ -506,15 +493,24 @@ 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(); + let mcpRuntime; + try { + mcpRuntime = await getMcpRuntimeAsync(); + } catch (error) { + logger.error(new ErrorWithMetadata("Assistant tools are unavailable", { requestId }, { cause: error })); + return Response.json( + { error: "Homarr tools could not be loaded. Check the Homarr logs and try again." }, + { status: 503 }, + ); + } + const caller = mcpRuntime.router.createCaller(context); const omittedCustomWidgetTools = new Set(customWidgetAuthoringContext.omittedToolNames); - const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name)); + const mcpTools = mcpRuntime.tools.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 = mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"; return [ mcpTool.name, tool({ @@ -563,7 +559,7 @@ export async function POST(request: Request) { ) satisfies ToolSet; const toolApproval = Object.fromEntries( mcpTools.flatMap((mcpTool) => - procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation" + mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation" ? [[mcpTool.name, "user-approval" as const]] : [], ), 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..67a1bade2 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -16,7 +16,6 @@ const mocks = vi.hoisted(() => ({ vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) })); vi.mock("@homarr/api/mcp", () => ({ createTRPCContext: vi.fn(() => ({})), - mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) }, })); vi.mock("@homarr/auth/api-key", () => ({ API_KEY_HEADER_NAME: "ApiKey", @@ -42,26 +41,30 @@ vi.mock("@homarr/custom-widgets/core", () => ({ vi.mock("@homarr/db", () => ({ db: {} })); vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" })); vi.mock("../_extract-tools", () => ({ - extractMcpTools: () => [ - { - name: "board_getAllBoards", - description: "List boards", - pathInRouter: ["board", "getAllBoards"], - inputSchema: { type: "object", properties: {} }, - }, - { - name: "customWidget_list", - description: "List Custom Widgets", - pathInRouter: ["customWidget", "list"], - inputSchema: { type: "object", properties: {} }, - }, - { - name: "customWidget_workshopSearch", - description: "Search Workshop", - pathInRouter: ["customWidget", "workshopSearch"], - inputSchema: { type: "object", properties: {} }, - }, - ], + getMcpRuntimeAsync: async () => ({ + router: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) }, + procedureTypes: new Map([["board.getAllBoards", "query"]]), + tools: [ + { + name: "board_getAllBoards", + description: "List boards", + pathInRouter: ["board", "getAllBoards"], + inputSchema: { type: "object", properties: {} }, + }, + { + name: "customWidget_list", + description: "List Custom Widgets", + pathInRouter: ["customWidget", "list"], + inputSchema: { type: "object", properties: {} }, + }, + { + name: "customWidget_workshopSearch", + description: "Search Workshop", + pathInRouter: ["customWidget", "workshopSearch"], + inputSchema: { type: "object", properties: {} }, + }, + ], + }), })); import { POST } from "./route"; diff --git a/apps/nextjs/src/app/api/mcp/_extract-tools.ts b/apps/nextjs/src/app/api/mcp/_extract-tools.ts index 5b90d2963..2b5b684c8 100644 --- a/apps/nextjs/src/app/api/mcp/_extract-tools.ts +++ b/apps/nextjs/src/app/api/mcp/_extract-tools.ts @@ -1,11 +1,55 @@ -import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp"; +import type { McpRouter, McpTool } from "@homarr/api/mcp"; +import { extractMcpToolsFromProcedures, getMcpRouterAsync } from "@homarr/api/mcp"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -let cache: ReturnType | null = null; +const logger = createLogger({ module: "mcpTools" }); -export function extractMcpTools() { - if (cache) return cache; +export type McpProcedureType = "query" | "mutation"; - cache = extractMcpToolsFromProcedures(mcpRouter); - - return cache; +export interface McpRuntime { + router: McpRouter; + tools: McpTool[]; + procedureTypes: ReadonlyMap; +} + +const createMcpRuntimeAsync = async (): Promise => { + const startedAt = Date.now(); + const router = await getMcpRouterAsync(); + const tools = extractMcpToolsFromProcedures(router); + const procedures = router["_def"].procedures as unknown as Record; + const procedureTypes = new Map(); + + for (const [path, procedure] of Object.entries(procedures)) { + const type = procedure["_def"]?.type; + if (type === "query" || type === "mutation") procedureTypes.set(path, type); + } + + logger.info("Extracted MCP tools", { + durationMs: Date.now() - startedAt, + toolCount: tools.length, + }); + return { router, tools, procedureTypes }; +}; + +let mcpRuntimePromise: Promise | null = null; + +export async function getMcpRuntimeAsync(): Promise { + if (mcpRuntimePromise) return await mcpRuntimePromise; + + const loadPromise = createMcpRuntimeAsync(); + mcpRuntimePromise = loadPromise; + + try { + return await loadPromise; + } catch (error) { + if (mcpRuntimePromise === loadPromise) mcpRuntimePromise = null; + logger.error(new ErrorWithMetadata("Failed to extract MCP tools", {}, { cause: error })); + throw error; + } +} + +export async function extractMcpTools(): Promise { + const { tools } = await getMcpRuntimeAsync(); + return tools; } diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts index d1fe6f5ac..74936f347 100644 --- a/apps/nextjs/src/app/api/mcp/_handler.ts +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -5,11 +5,12 @@ import { ResourceTemplate } from "@modelcontextprotocol/server"; import { z } from "zod/v4"; import type { McpTool } from "@homarr/api/mcp"; -import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; +import { createTRPCContext } from "@homarr/api/mcp"; import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; import { extractBaseUrlFromHeaders } from "@homarr/common"; import { ipAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt"; import { getCustomWidgetComponent, @@ -22,7 +23,7 @@ import { db } from "@homarr/db"; import { getPackageVersion } from "~/versions/package-reader"; import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error"; -import { extractMcpTools } from "./_extract-tools"; +import { getMcpRuntimeAsync } from "./_extract-tools"; import { createMcpProtocolHandler } from "./_protocol"; const logger = createLogger({ module: "mcpRoute" }); @@ -80,18 +81,8 @@ 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) => + isAdmin ? tools : tools.filter((tool) => !tool.name.startsWith("customWidget_")); function registerCustomWidgetAuthoring(server: McpServer) { server.registerPrompt( @@ -331,12 +322,23 @@ export const handleMcpRequest = async (req: NextRequest) => { clearAuthFailures(ipAddress); logger.info("MCP request authenticated", { userId: session.user.id }); + let mcpRuntime; + try { + mcpRuntime = await getMcpRuntimeAsync(); + } catch (error) { + logger.error(new ErrorWithMetadata("MCP runtime is unavailable", {}, { cause: error })); + return jsonErrorResponse(503, { + error: "service_unavailable", + hint: "The MCP tool registry could not be loaded. Check the Homarr logs and try again.", + }); + } + const ctx = createTRPCContext({ session, headers: req.headers }); - const caller = mcpRouter.createCaller(ctx); + const caller = mcpRuntime.router.createCaller(ctx); const isAdmin = session.user.permissions.includes("admin"); const protocolHandler = createMcpProtocolHandler({ caller, - tools: getVisibleTools(isAdmin), + tools: getVisibleTools(mcpRuntime.tools, isAdmin), version: getPackageVersion(), instructions: SERVER_INSTRUCTIONS, configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined, diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx index f33fe5810..024854c01 100644 --- a/apps/nextjs/src/components/assistant/assistant-gate.tsx +++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx @@ -74,7 +74,6 @@ const unavailableMessageKeys = { export const AssistantGate = ({ availability, children }: AssistantGateProps) => { const t = useI18n("assistant"); - if (availability === "enabled") { return {children}; } diff --git a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts index f74aea66d..7f947c0a2 100644 --- a/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts +++ b/apps/nextjs/src/components/assistant/assistant-spotlight-placeholder.ts @@ -7,7 +7,7 @@ import { useI18n } from "@homarr/translation/client"; /** * Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the - * assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap. + * assistant runtime. Lives apart from `assistant-provider` so the unavailable path stays cheap. */ export const useRegisterAssistantSpotlightPlaceholder = (description: string) => { const t = useI18n("assistant"); 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/items/board-recipe-recommendations.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts index cd2b848a9..032bf9a4d 100644 --- a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts @@ -55,6 +55,6 @@ export const getBoardRecipeRecommendations = ({ }, ]; }) - .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..7dbb9abc6 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -91,6 +91,7 @@ const ItemSelectModalContent = ({ initialSearch = "", }: ItemSelectModalContentProps) => { const [search, setSearch] = useState(initialSearch); + const canBrowseCustomWidgets = isAdmin; const t = useI18n(); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); @@ -479,7 +480,7 @@ const ItemSelectModalContent = ({ /> ))} - {isAdmin && ( + {canBrowseCustomWidgets && ( <> - {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && ( + {filteredItems.length === 0 && (!canBrowseCustomWidgets || filteredCustomWidgets.length === 0) && (
{t("common.noResults")}
@@ -653,12 +654,13 @@ const ItemSelectModalFrame = ({ const utils = clientApi.useUtils(); const { data: session } = useSession(); const isAdmin = session?.user.permissions.includes("admin") ?? false; + const canBrowseCustomWidgets = isAdmin; const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false; const t = useI18n(); const { data: integrationData } = clientApi.integration.all.useQuery(); const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery( { boardId: innerProps.boardId }, - { enabled: isAdmin }, + { enabled: canBrowseCustomWidgets }, ); return ( 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/package.json b/package.json index 347e76aa0..ead235a09 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis", "format": "turbo format --continue -- --check", "format:fix": "turbo format --continue", + "integrations:sync": "tsx ./scripts/integrations/sync-integration-modules.mts", + "integrations:sync:check": "tsx ./scripts/integrations/sync-integration-modules.mts --check", "lint": "turbo lint --continue", "lint:fix": "turbo lint --continue -- --fix", "lint:ws": "pnpm dlx sherif@latest", @@ -54,6 +56,9 @@ "test:workshop-image": "sh apps/workshop/test-image.sh", "test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled", "typecheck": "turbo typecheck", + "widgets:sync": "tsx ./scripts/widgets/sync-widget-modules.mts", + "widgets:sync:check": "tsx ./scripts/widgets/sync-widget-modules.mts --check", + "widgets:new": "tsx ./scripts/widgets/create-widget-module.mts", "with-env": "dotenv -e .env --" }, "dependencies": { diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index c2b51dbda..7ece4fcff 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -2,6 +2,9 @@ export { createTRPCContext } from "./trpc"; export { extractMcpToolsFromProcedures } from "./mcp-tools"; export type { McpTool } from "./mcp-tools"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; + import { createTRPCRouter } from "./trpc"; import { appRouter as appRouterForApps } from "./router/app"; import { apiKeysRouter } from "./router/apiKeys"; @@ -12,41 +15,51 @@ import { iconsRouter } from "./router/icons"; import { infoRouter } from "./router/info"; import { integrationRouter } from "./router/integration/integration-router"; import { inviteRouter } from "./router/invite"; -import { airQualityRouter } from "./router/widgets/air-quality"; -import { bazarrRouter } from "./router/widgets/bazarr"; import { serverSettingsRouter } from "./router/serverSettings"; -import { beszelRouter } from "./router/widgets/beszel"; -import { calendarRouter } from "./router/widgets/calendar"; -import { dnsHoleRouter } from "./router/widgets/dns-hole"; -import { downloadsRouter } from "./router/widgets/downloads"; -import { healthMonitoringRouter } from "./router/widgets/health-monitoring"; -import { mediaRequestsRouter } from "./router/widgets/media-requests"; -import { mediaServerRouter } from "./router/widgets/media-server"; -import { patchmonRouter } from "./router/widgets/patchmon"; -import { smartHomeRouter } from "./router/widgets/smart-home"; -import { widgetSecretsRouter } from "./router/widgets/widget-secrets"; +import { loadMcpWidgetRoutersAsync } from "./router/widgets/registry"; -export const mcpRouter = createTRPCRouter({ - app: appRouterForApps, - apiKeys: apiKeysRouter, - board: boardRouter, - customWidget: customWidgetRouter, - docker: dockerRouter, - icon: iconsRouter, - info: infoRouter, - integration: integrationRouter, - invite: inviteRouter, - airQuality: airQualityRouter, - bazarr: bazarrRouter, - serverSettings: serverSettingsRouter, - beszel: beszelRouter, - calendar: calendarRouter, - dnsHole: dnsHoleRouter, - downloads: downloadsRouter, - healthMonitoring: healthMonitoringRouter, - mediaRequests: mediaRequestsRouter, - mediaServer: mediaServerRouter, - patchmon: patchmonRouter, - smartHome: smartHomeRouter, - widgetSecrets: widgetSecretsRouter, -}); +const logger = createLogger({ module: "mcpRouter" }); + +const createMcpRouterAsync = async () => { + const startedAt = Date.now(); + const widgetRouters = await loadMcpWidgetRoutersAsync(); + + const router = createTRPCRouter({ + app: appRouterForApps, + apiKeys: apiKeysRouter, + board: boardRouter, + customWidget: customWidgetRouter, + docker: dockerRouter, + icon: iconsRouter, + info: infoRouter, + integration: integrationRouter, + invite: inviteRouter, + serverSettings: serverSettingsRouter, + ...widgetRouters, + }); + + logger.info("Materialized MCP router", { + durationMs: Date.now() - startedAt, + widgetRouters: Object.keys(widgetRouters), + }); + return router; +}; + +export type McpRouter = Awaited>; + +let mcpRouterPromise: Promise | null = null; + +export async function getMcpRouterAsync(): Promise { + if (mcpRouterPromise) return await mcpRouterPromise; + + const loadPromise = createMcpRouterAsync(); + mcpRouterPromise = loadPromise; + + try { + return await loadPromise; + } catch (error) { + if (mcpRouterPromise === loadPromise) mcpRouterPromise = null; + logger.error(new ErrorWithMetadata("Failed to materialize MCP router", {}, { cause: error })); + throw error; + } +} diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts index e4f04f192..7fa6d0a48 100644 --- a/packages/api/src/middlewares/docker.ts +++ b/packages/api/src/middlewares/docker.ts @@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server"; import { env } from "@homarr/docker/env"; -import { isDemoMode, publicProcedure } from "../trpc"; +import { isDemoMode, middlewareProcedure } from "../trpc"; export const dockerMiddleware = () => { - return publicProcedure.use(async ({ next }) => { + return middlewareProcedure.use(async ({ next }) => { if (env.ENABLE_DOCKER || isDemoMode) { return await next(); } diff --git a/packages/api/src/middlewares/integration.ts b/packages/api/src/middlewares/integration.ts index 320cc7f66..317ec498e 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -9,28 +9,74 @@ import type { AtLeastOneOf } from "@homarr/common/types"; import type { Database } from "@homarr/db"; import { and, eq, inArray } from "@homarr/db"; import { integrations } from "@homarr/db/schema"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions"; +import { getWidgetFeatureIntegrationKinds } from "@homarr/definitions"; +import { createLogger } from "@homarr/core/infrastructure/logs"; -import { publicProcedure } from "../trpc"; +import { middlewareProcedure } from "../trpc"; export type IntegrationAction = "query" | "interact"; -/** - * 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 +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[]; +} + +interface WidgetIntegrationAvailability { + widgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]]; +} + +const getCompatibleIntegrationKindsOrThrow = ( + kinds: readonly TKind[], + widgetAvailability?: WidgetIntegrationAvailability, ) => { - return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { + if (!widgetAvailability) return kinds; + + const widgetIntegrationKinds = new Set( + widgetAvailability.widgetKinds.flatMap((widgetKind) => getWidgetFeatureIntegrationKinds(widgetKind)), + ); + const compatibleKinds = kinds.filter((kind) => widgetIntegrationKinds.has(kind)); + if (compatibleKinds.length >= 1) return compatibleKinds; + + logger.warn("No compatible integration kinds for widget route", { + widgetKinds: widgetAvailability.widgetKinds, + configuredIntegrationKinds: kinds, + reason: "no-compatible-widget-integration-kinds", + }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "No integration kind is compatible with the consuming widget", + }); +}; + +const createOneIntegrationMiddlewareForKinds = ( + action: IntegrationAction, + kinds: readonly TKind[], + widgetAvailability?: WidgetIntegrationAvailability, +) => { + return middlewareProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { + const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability); + const accessMetadata: IntegrationAccessMetadata = { + action, + cardinality: "one", + userId: ctx.session?.user.id, + requestedIntegrationIds: [input.integrationId], + allowedIntegrationKinds: compatibleKinds, + }; + 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)), + where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, compatibleKinds)), with: { app: true, secrets: true, @@ -45,13 +91,18 @@ 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(",")}`, + message: `Integration with id ${input.integrationId} not found or not of kinds ${compatibleKinds.join(",")}`, }); } await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session); + logger.debug("Integration access granted", { + ...accessMetadata, + resolvedIntegrationKinds: [integration.kind], + }); const { secrets, @@ -78,6 +129,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[], + widgetAvailability?: WidgetIntegrationAvailability, +) => { + return middlewareProcedure + .input(z.object({ integrationIds: z.array(z.string()) })) + .use(async ({ ctx, input, next }) => { + const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability); + const accessMetadata: IntegrationAccessMetadata = { + action, + cardinality: "many", + userId: ctx.session?.user.id, + requestedIntegrationIds: input.integrationIds, + allowedIntegrationKinds: compatibleKinds, + }; + 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, compatibleKinds)), + 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 ${compatibleKinds.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 }) => ({ + ...rest, + externalUrl: rest.app?.href ?? null, + kind: kind as TKind, + 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 +238,115 @@ 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); }; +/** + * Widget procedures should use these adapters so their server-side permission + * guards stay aligned with the feature catalog automatically. + */ +export function createOneWidgetIntegrationMiddleware( + action: IntegrationAction, + widgetKind: TWidgetKind, +): ReturnType>>; +export function createOneWidgetIntegrationMiddleware< + TWidgetKind extends WidgetKindWithIntegration, + TIntegrationKind extends WidgetIntegrationKind, +>( + action: IntegrationAction, + widgetKind: TWidgetKind, + integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]], +): ReturnType>; +export function createOneWidgetIntegrationMiddleware( + action: IntegrationAction, + widgetKind: WidgetKindWithIntegration, + integrationKinds?: readonly IntegrationKind[], +) { + const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind); + return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] }); +} + +export function createOneSharedWidgetIntegrationMiddleware< + TContractWidgetKind extends WidgetKindWithIntegration, + const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]], +>( + action: IntegrationAction, + contractWidgetKind: TContractWidgetKind, + additionalWidgetKinds: TAdditionalWidgetKinds, +): ReturnType>>; +export function createOneSharedWidgetIntegrationMiddleware< + TContractWidgetKind extends WidgetKindWithIntegration, + const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]], + TIntegrationKind extends WidgetIntegrationKind, +>( + action: IntegrationAction, + contractWidgetKind: TContractWidgetKind, + additionalWidgetKinds: TAdditionalWidgetKinds, + integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]], +): ReturnType>; +export function createOneSharedWidgetIntegrationMiddleware( + action: IntegrationAction, + contractWidgetKind: WidgetKindWithIntegration, + additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]], + integrationKinds?: readonly IntegrationKind[], +) { + const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind); + return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { + widgetKinds: [contractWidgetKind, ...additionalWidgetKinds], + }); +} + +export function createManyWidgetIntegrationMiddleware( + action: IntegrationAction, + widgetKind: TWidgetKind, +): ReturnType>>; +export function createManyWidgetIntegrationMiddleware< + TWidgetKind extends WidgetKindWithIntegration, + TIntegrationKind extends WidgetIntegrationKind, +>( + action: IntegrationAction, + widgetKind: TWidgetKind, + integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]], +): ReturnType>; +export function createManyWidgetIntegrationMiddleware( + action: IntegrationAction, + widgetKind: WidgetKindWithIntegration, + integrationKinds?: readonly IntegrationKind[], +) { + const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind); + return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] }); +} + +export function createManySharedWidgetIntegrationMiddleware< + TContractWidgetKind extends WidgetKindWithIntegration, + const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]], +>( + action: IntegrationAction, + contractWidgetKind: TContractWidgetKind, + additionalWidgetKinds: TAdditionalWidgetKinds, +): ReturnType>>; +export function createManySharedWidgetIntegrationMiddleware< + TContractWidgetKind extends WidgetKindWithIntegration, + const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]], + TIntegrationKind extends WidgetIntegrationKind, +>( + action: IntegrationAction, + contractWidgetKind: TContractWidgetKind, + additionalWidgetKinds: TAdditionalWidgetKinds, + integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]], +): ReturnType>; +export function createManySharedWidgetIntegrationMiddleware( + action: IntegrationAction, + contractWidgetKind: WidgetKindWithIntegration, + additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]], + integrationKinds?: readonly IntegrationKind[], +) { + const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind); + return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { + widgetKinds: [contractWidgetKind, ...additionalWidgetKinds], + }); +} + /** * 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 +358,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/middlewares/item.ts b/packages/api/src/middlewares/item.ts index 375e21607..815e94f86 100644 --- a/packages/api/src/middlewares/item.ts +++ b/packages/api/src/middlewares/item.ts @@ -5,10 +5,10 @@ import { and, eq } from "@homarr/db"; import { items } from "@homarr/db/schema"; import type { WidgetKind } from "@homarr/definitions"; -import { publicProcedure } from "../trpc"; +import { middlewareProcedure } from "../trpc"; export const createOneItemMiddleware = (kind: WidgetKind) => { - return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => { + return middlewareProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => { const item = await ctx.db.query.items.findFirst({ where: and(eq(items.id, input.itemId), eq(items.kind, kind)), }); diff --git a/packages/api/src/middlewares/kubernetes.ts b/packages/api/src/middlewares/kubernetes.ts index 8cefc0c35..9468274e4 100644 --- a/packages/api/src/middlewares/kubernetes.ts +++ b/packages/api/src/middlewares/kubernetes.ts @@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server"; import { env } from "@homarr/docker/env"; -import { publicProcedure } from "../trpc"; +import { middlewareProcedure } from "../trpc"; export const kubernetesMiddleware = () => { - return publicProcedure.use(async ({ next }) => { + return middlewareProcedure.use(async ({ next }) => { if (env.ENABLE_KUBERNETES) { return await next(); } diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index a1cb201ea..e2ca9aafe 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -1,5 +1,7 @@ import type { QueryKey } from "@tanstack/react-query"; +import { generatedWidgetOperationPolicies } from "@homarr/definitions"; + // RSC-prefetched data must survive the hydration pass without an immediate // duplicate fetch. Widget-specific intervals still control live refreshes. export const queryCacheDefaultStaleTimeMs = 30_000; @@ -11,8 +13,15 @@ const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContai // Beszel system stats are large time-series payloads and were deliberately kept out of // the persisted cache in #6289; live stats come from an SSE subscription instead. const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); +const generatedWidgetPersistencePolicies = new Map( + generatedWidgetOperationPolicies + .filter((policy) => policy.kind === "query") + .map((policy) => [policy.path.join("."), policy.persist] as const), +); export const isWidgetDataTrpcPath = (path: string) => { + const generatedPolicy = generatedWidgetPersistencePolicies.get(path); + if (generatedPolicy !== undefined) return generatedPolicy; if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path); return widgetDataQueryPaths.has(path); }; diff --git a/packages/api/src/router/assistant.ts b/packages/api/src/router/assistant.ts index 03540ef0a..d84a13a4b 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -25,7 +25,9 @@ import { assistantProviderPresets, assistantProviderRequiresApiKey, getIconUrl, + integrationKinds, resolveAssistantModelId, + widgetKinds, } from "@homarr/definitions"; import type { createTRPCContext } from "../trpc"; @@ -336,6 +338,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P boardRouter.createCaller(ctx).getAllBoards(), ctx.db.query.integrations.findMany({ columns: { id: true, name: true, kind: true }, + where: inArray(integrations.kind, integrationKinds), with: { userPermissions: { where: eq(integrationUserPermissions.userId, ctx.session.user.id), @@ -364,9 +367,12 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P ? [] : await ctx.db.query.items.findMany({ columns: { id: true, boardId: true, kind: true }, - where: inArray( - items.boardId, - availableBoards.map((board) => board.id), + where: and( + inArray( + items.boardId, + availableBoards.map((board) => board.id), + ), + inArray(items.kind, widgetKinds), ), orderBy: asc(items.kind), limit: 500, diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 345dd1161..b57e6c37b 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -19,9 +19,7 @@ import { groupMembers, groupPermissions, groups, - integrationGroupPermissions, integrationItems, - integrationUserPermissions, integrations, apps, itemLayouts, @@ -70,12 +68,11 @@ 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, } from "./board/custom-widget-placement-access"; -import { validateTimetableOptionsChangeAsync } from "./widgets/timetable"; interface BoardItemPlacementRectangle { xOffset: number; @@ -123,6 +120,81 @@ const findFirstAvailableBoardItemPosition = ( return null; }; +interface WidgetPlacementRecord { + id: string; + kind: WidgetKind; +} + +interface WidgetConfigurationRecord extends WidgetPlacementRecord { + integrationIds: readonly string[]; +} + +interface IntegrationKindRecord { + id: string; + kind: IntegrationKind; +} + +const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => + left.length === right.length && left.every((integrationId) => right.includes(integrationId)); + +const validateWidgetConfigurationsAsync = async ( + ctx: Parameters[0], + submittedItems: readonly WidgetConfigurationRecord[], + storedItems: readonly WidgetConfigurationRecord[], +) => { + 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: IntegrationKindRecord[] = []; + 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: "BAD_REQUEST", + message: `Invalid integration IDs: ${invalidIntegrationIds.join(", ")}`, + }); + } + + 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) => { + const integration = integrationRecordsById.get(integrationId); + return integration ? [integration.kind] : []; + }); + const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds); + if (!integrationIssue) continue; + + throw new TRPCError({ + code: "BAD_REQUEST", + message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue), + }); + } +}; + export const boardRouter = createTRPCRouter({ exists: permissionRequiredProcedure .requiresPermission("board-create") @@ -633,7 +705,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. Disabled or invalid widget configurations must be removed before duplication. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }", }, }) .input(boardDuplicateSchema) @@ -670,6 +742,15 @@ 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(); @@ -750,17 +831,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) => ({ @@ -777,11 +860,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()])); @@ -796,15 +881,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) => ({ @@ -818,34 +905,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, { @@ -1488,6 +1553,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, @@ -1497,6 +1563,7 @@ 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); + const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); await validateTimetableOptionsChangeAsync( item.options, previousItem?.kind === "timetable" ? previousItem.options : undefined, @@ -1524,15 +1591,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) { @@ -1554,15 +1623,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, + }), + ), ), ); } @@ -1720,15 +1791,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) { @@ -1755,15 +1828,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(); @@ -2066,6 +2141,12 @@ 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 }], @@ -2073,38 +2154,10 @@ export const boardRouter = createTRPCRouter({ }); if (input.kind === "timetable") { + const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); 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/home.ts b/packages/api/src/router/home.ts index b94cbc18c..96a24df2c 100644 --- a/packages/api/src/router/home.ts +++ b/packages/api/src/router/home.ts @@ -19,12 +19,12 @@ import { import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions"; import { createTRPCRouter, publicProcedure } from "../trpc"; -const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => { - const groupsOfCurrentUser = await db.query.groupMembers.findMany({ +const getFullAccessIntegrationIdsAsync = async (database: Database, userId: string) => { + const groupsOfCurrentUser = await database.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId), }); - const accessibleIntegrations = await db.query.integrations.findMany({ + const accessibleIntegrations = await database.query.integrations.findMany({ columns: { id: true, }, diff --git a/packages/api/src/router/integration/integration-access.ts b/packages/api/src/router/integration/integration-access.ts index 78f8961e4..d8e76273c 100644 --- a/packages/api/src/router/integration/integration-access.ts +++ b/packages/api/src/router/integration/integration-access.ts @@ -4,9 +4,26 @@ import type { Session } from "@homarr/auth"; 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"; +interface IntegrationPermissionRecord { + userPermissions: { permission: IntegrationPermission }[]; + groupPermissions: { permission: IntegrationPermission }[]; +} + +const hasIntegrationPermission = ( + integration: IntegrationPermissionRecord, + 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; + return false; +}; + /** * Throws NOT_FOUND if user is not allowed to perform action on integration * @param ctx trpc router context @@ -44,23 +61,51 @@ export const throwIfActionForbiddenAsync = async ( notAllowed(); } - 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 - } + if (hasIntegrationPermission(integration, session, permission)) return; notAllowed(); }; +/** + * Batched variant for feature configuration flows. It applies the same direct + * user, group, and global permission rules without issuing one query per ID. + */ +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 { 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: inArray(integrations.id, uniqueIntegrationIds), + 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 !== uniqueIntegrationIds.length || + !integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission)) + ) { + notAllowed(); + } +}; + /** * This method returns NOT_FOUND to prevent snooping on board existence * A function is used to use the method without return statement diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index a3a20f6e9..2c09e585b 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -27,7 +27,7 @@ import { integrationKinds, integrationSecretKindObject, } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { invalidateIntegrationCacheAsync } from "@homarr/redis"; import { byIdSchema } from "@homarr/validation/common"; import { @@ -156,9 +156,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 +191,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", 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/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/timetable-options-validation.ts b/packages/api/src/router/timetable-options-validation.ts new file mode 100644 index 000000000..755f5e722 --- /dev/null +++ b/packages/api/src/router/timetable-options-validation.ts @@ -0,0 +1,169 @@ +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; +import z from "zod"; + +import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable"; +import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; + +const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); + +const blockedTimetableIpv4Addresses = new BlockList(); +const blockedTimetableIpv6Addresses = new BlockList(); +const blockedTimetableSubnets = [ + ["0.0.0.0", 8, "ipv4"], + ["10.0.0.0", 8, "ipv4"], + ["100.64.0.0", 10, "ipv4"], + ["127.0.0.0", 8, "ipv4"], + ["169.254.0.0", 16, "ipv4"], + ["172.16.0.0", 12, "ipv4"], + ["192.0.0.0", 24, "ipv4"], + ["192.0.2.0", 24, "ipv4"], + ["192.88.99.0", 24, "ipv4"], + ["192.168.0.0", 16, "ipv4"], + ["198.18.0.0", 15, "ipv4"], + ["198.51.100.0", 24, "ipv4"], + ["203.0.113.0", 24, "ipv4"], + ["224.0.0.0", 4, "ipv4"], + ["240.0.0.0", 4, "ipv4"], + ["::", 96, "ipv6"], + ["::ffff:0:0", 96, "ipv6"], + ["::ffff:0:0:0", 96, "ipv6"], + ["64:ff9b::", 96, "ipv6"], + ["64:ff9b:1::", 48, "ipv6"], + ["100::", 64, "ipv6"], + ["2001::", 32, "ipv6"], + ["2001:2::", 48, "ipv6"], + ["2001:10::", 28, "ipv6"], + ["2001:db8::", 32, "ipv6"], + ["2002::", 16, "ipv6"], + ["fc00::", 7, "ipv6"], + ["fe80::", 10, "ipv6"], + ["fec0::", 10, "ipv6"], + ["ff00::", 8, "ipv6"], +] as const; + +for (const [network, prefix, family] of blockedTimetableSubnets) { + const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses; + blockList.addSubnet(network, prefix, family); +} + +export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: cause instanceof Error ? cause.message : "Invalid timetable base URL", + cause, + }); + } +}; + +const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Timetable widget configuration is invalid", + cause, + }); +}; + +export const parseSavedTimetableOptions = (serializedOptions: string) => { + try { + return timetableOptionsSchema.parse(parse(serializedOptions)); + } catch (cause) { + return throwInvalidSavedTimetableConfiguration(cause); + } +}; + +export const normalizeSavedTimetableBaseUrl = (baseUrl: string) => { + try { + return normalizeTimetableBaseUrl(baseUrl); + } catch (cause) { + return throwInvalidSavedTimetableConfiguration(cause); + } +}; + +const normalizeHostname = (hostname: string) => { + const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + return withoutBrackets.toLowerCase().replace(/\.$/, ""); +}; + +const isBlockedTimetableAddress = (address: string, family: number) => { + return family === 6 + ? blockedTimetableIpv6Addresses.check(address, "ipv6") + : blockedTimetableIpv4Addresses.check(address, "ipv4"); +}; + +export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { + const hostname = normalizeHostname(new URL(baseUrl).hostname); + const hostnameFamily = isIP(hostname); + const isInternalHostname = + hostnameFamily === 0 && + (!hostname.includes(".") || + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname.endsWith(".local") || + hostname.endsWith(".internal") || + hostname.endsWith(".lan") || + hostname.endsWith(".home.arpa")); + + if (isInternalHostname) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); + } + + let addresses: TimetableResolvedAddress[]; + try { + const resolvedAddresses = + hostnameFamily === 0 + ? await lookup(hostname, { all: true, verbatim: true }) + : [{ address: hostname, family: hostnameFamily }]; + addresses = resolvedAddresses.filter( + (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, + ); + } catch (cause) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL host could not be resolved", + cause, + }); + } + + if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Timetable base URL must not target a private or internal address", + }); + } + + return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); +}; + +const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { + const parsedOptions = timetableOptionsSchema.safeParse(options); + if (!parsedOptions.success) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); + } + return normalizeTimetableBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); +}; + +export const validateTimetableOptionsChangeAsync = async ( + nextOptions: Record, + previousOptions?: Record, +) => { + const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); + if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; + + let previousBaseUrl: string | undefined; + if (previousOptions) { + try { + previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); + } catch { + // Invalid legacy options must not prevent replacing them with a valid configuration. + } + } + if (nextBaseUrl === previousBaseUrl) return; + + await resolvePublicTimetableAddressesAsync(nextBaseUrl); +}; diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts index d431c0de9..3075d294d 100644 --- a/packages/api/src/router/widgets/anchor-notes.ts +++ b/packages/api/src/router/widgets/anchor-notes.ts @@ -3,10 +3,11 @@ import { z } from "zod/v4"; import { ResponseError } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } 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({ @@ -40,7 +41,7 @@ const normalizeAnchorContent = (content: string | undefined) => { export const anchorNotesRouter = createTRPCRouter({ listNotes: publicProcedure - .concat(createOneIntegrationMiddleware("query", "anchor")) + .concat(createOneWidgetIntegrationMiddleware("query", "anchorNote")) .input(anchorNotesListInputSchema) .query(async ({ ctx, input }) => { const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 }); @@ -50,7 +51,7 @@ export const anchorNotesRouter = createTRPCRouter({ return data; }), getNote: publicProcedure - .concat(createOneIntegrationMiddleware("query", "anchor")) + .concat(createOneWidgetIntegrationMiddleware("query", "anchorNote")) .input(noteIdInput) .query(async ({ ctx, input }) => { const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId }); @@ -60,7 +61,7 @@ export const anchorNotesRouter = createTRPCRouter({ return data; }), updateNote: protectedProcedure - .concat(createOneIntegrationMiddleware("interact", "anchor")) + .concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote")) .input(anchorNoteUpdateInputSchema) .mutation(async ({ ctx, input }) => { const integrationInstance = await createIntegrationAsync(ctx.integration); diff --git a/packages/api/src/router/widgets/archive-team-warrior.ts b/packages/api/src/router/widgets/archive-team-warrior.ts index d71b7546a..2110149be 100644 --- a/packages/api/src/router/widgets/archive-team-warrior.ts +++ b/packages/api/src/router/widgets/archive-team-warrior.ts @@ -1,11 +1,11 @@ import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior"; -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")) + .concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior")) .query(async ({ ctx }) => { const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {}); const { data, timestamp } = await handler.getDataAsync(); diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts index 14fd7d49c..f9de4300d 100644 --- a/packages/api/src/router/widgets/audio-stats.ts +++ b/packages/api/src/router/widgets/audio-stats.ts @@ -1,12 +1,12 @@ import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf"; import { navidromeRequestHandler } from "@homarr/request-handler/navidrome"; -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")) + .concat(createOneWidgetIntegrationMiddleware("query", "audioStats")) .query(async ({ ctx }) => { const { kind } = ctx.integration; diff --git a/packages/api/src/router/widgets/bazarr.ts b/packages/api/src/router/widgets/bazarr.ts index c47ad4173..a79c17f72 100644 --- a/packages/api/src/router/widgets/bazarr.ts +++ b/packages/api/src/router/widgets/bazarr.ts @@ -1,6 +1,6 @@ import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const bazarrRouter = createTRPCRouter({ @@ -12,7 +12,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")) + .concat(createOneWidgetIntegrationMiddleware("query", "bazarr")) .query(async ({ ctx }) => { const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {}); const data = await innerHandler.getDataAsync(); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index c52ca1717..8cbb8ee0b 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,10 @@ import { } from "@homarr/request-handler/beszel"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { + createManySharedWidgetIntegrationMiddleware, + createManyWidgetIntegrationMiddleware, +} from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -27,7 +30,12 @@ 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( + createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [ + "beszelSystemTable", + "beszelSystemStats", + ]), + ) .query(async ({ ctx }) => { const integrationIds = ctx.integrations.map((i) => i.id); logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds }); @@ -71,7 +79,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 +153,12 @@ 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( + createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [ + "beszelSystemTable", + "beszelSystemGrid", + ]), + ) .input( z.object({ systemId: z.string(), @@ -196,7 +209,12 @@ export const beszelRouter = createTRPCRouter({ }), subscribeSystemStats: publicProcedure - .concat(createManyIntegrationMiddleware("query", "beszel", "mock")) + .concat( + createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [ + "beszelSystemTable", + "beszelSystemGrid", + ]), + ) .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..bd54157aa 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, diff --git a/packages/api/src/router/widgets/coolify.ts b/packages/api/src/router/widgets/coolify.ts index 133d36f14..a1f032918 100644 --- a/packages/api/src/router/widgets/coolify.ts +++ b/packages/api/src/router/widgets/coolify.ts @@ -1,7 +1,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types"; 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"; @@ -16,7 +16,7 @@ interface CoolifyInstanceResult { export const coolifyRouter = createTRPCRouter({ getInstancesInfo: publicProcedure - .concat(createManyIntegrationMiddleware("query", "coolify")) + .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..34d4ff327 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 { + createManySharedWidgetIntegrationMiddleware, + 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(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"])) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>( ctx.integrations, @@ -66,7 +68,7 @@ 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(); @@ -85,7 +87,7 @@ 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); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 261d3ba46..089bf7603 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; @@ -51,7 +47,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,7 +82,7 @@ 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( ctx.integrations.map(async (integration) => { @@ -97,7 +93,7 @@ export const downloadsRouter = createTRPCRouter({ downloadClientRequestHandler.invalidateCache(); }), pauseItem: protectedProcedure - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema })) .mutation(async ({ ctx, input }) => { await Promise.all( @@ -116,7 +112,7 @@ 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( ctx.integrations.map(async (integration) => { @@ -127,7 +123,7 @@ export const downloadsRouter = createTRPCRouter({ downloadClientRequestHandler.invalidateCache(); }), resumeItem: protectedProcedure - .concat(createDownloadClientIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema })) .mutation(async ({ ctx, input }) => { await Promise.all( @@ -139,7 +135,7 @@ export const downloadsRouter = createTRPCRouter({ 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( diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 335f75d09..38d4ea89b 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -1,4 +1,3 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { firewallCpuRequestHandler, firewallInterfacesRequestHandler, @@ -6,11 +5,11 @@ 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")); +const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall"); const queryFirewall = < THandler extends { diff --git a/packages/api/src/router/widgets/generated-registry.ts b/packages/api/src/router/widgets/generated-registry.ts new file mode 100644 index 000000000..88b39e970 --- /dev/null +++ b/packages/api/src/router/widgets/generated-registry.ts @@ -0,0 +1,24 @@ +// This file is generated by pnpm widgets:sync. Do not edit it directly. + +import type { AnyTRPCRouter } from "@trpc/server"; + +import type { WidgetKind } from "@homarr/definitions"; + +const registerGeneratedWidgetRouter = ( + widgetKinds: readonly [WidgetKind, ...WidgetKind[]], + loadRouter: () => Promise, + mcp: false | true | string, +) => ({ widgetKinds, loadRouter, mcp }); + +export const generatedWidgetRouterRegistry = { + downloads: registerGeneratedWidgetRouter( + ["downloads"], + () => import("./downloads").then((module) => module.downloadsRouter), + true, + ), + weather: registerGeneratedWidgetRouter( + ["clock", "weather"], + () => import("./weather").then((module) => module.weatherRouter), + false, + ), +}; diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 5c123ecb1..7d490cbd9 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,12 +1,17 @@ -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { SystemHealthMonitoring } from "@homarr/integrations"; -import { SynologyIntegration } from "@homarr/integrations"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; -import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { + createManySharedWidgetIntegrationMiddleware, + createOneSharedWidgetIntegrationMiddleware, + createOneWidgetIntegrationMiddleware, +} from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +// This endpoint uses the generic system-info handler, so it intentionally excludes +// health-monitoring integrations with specialized data models such as Proxmox and Coolify. const healthMonitoringIntegrationKinds = [ "openmediavault", "dashDot", @@ -34,7 +39,14 @@ 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( + createManySharedWidgetIntegrationMiddleware( + "query", + "healthMonitoring", + ["systemResources", "systemDisks"], + healthMonitoringIntegrationKinds, + ), + ) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>( ctx.integrations, @@ -66,13 +78,9 @@ export const healthMonitoringRouter = createTRPCRouter({ "List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)", }, }) - .concat(createOneIntegrationMiddleware("query", "synology")) + .concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["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 +91,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(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"])) .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 136d0e111..871de5521 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -1,28 +1,28 @@ import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { immichAlbumRequestHandler, immichAlbumsRequestHandler, immichStatsRequestHandler, } from "@homarr/request-handler/immich"; -import type { IntegrationAction } from "../../middlewares/integration"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { + createOneSharedWidgetIntegrationMiddleware, + createOneWidgetIntegrationMiddleware, +} from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const createImmichIntegrationMiddleware = (action: IntegrationAction) => - createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService")); - export const immichRouter = createTRPCRouter({ - getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { - const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {}); - const data = await innerHandler.getDataAsync(); - return data.data; - }), + getServerStats: publicProcedure + .concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats")) + .query(async ({ ctx }) => { + const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {}); + 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(), @@ -40,7 +40,7 @@ export const immichRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500).optional(), }), ) - .concat(createImmichIntegrationMiddleware("query")) + .concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"])) .query(async ({ ctx, input }) => { const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit }); const data = await innerHandler.getDataAsync(); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 01edcbd3b..d4ab56234 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -1,49 +1,30 @@ +import type { AnyTRPCRouter } from "@trpc/server"; import { lazy } from "@trpc/server"; import { createTRPCRouter } from "../../trpc"; +import { widgetRouterRegistry } from "./registry"; +import type { WidgetRouterName } from "./registry"; + +const createLazyWidgetRouter = (loadRouter: () => Promise) => lazy(loadRouter); + +const lazyWidgetRouters = Object.fromEntries( + Object.entries(widgetRouterRegistry).map(([name, registration]) => [ + name, + createLazyWidgetRouter(registration.loadRouter as () => Promise), + ]), +) as unknown as { + [TName in WidgetRouterName]: ReturnType< + typeof createLazyWidgetRouter< + (typeof widgetRouterRegistry)[TName] extends { loadRouter: () => Promise } + ? TRouter extends AnyTRPCRouter + ? TRouter + : never + : never + > + >; +}; export const widgetRouter = createTRPCRouter({ - archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)), - beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)), - anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)), - coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), - immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), - paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)), - patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)), - bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)), - notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), - weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), - airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)), - app: lazy(() => import("./app").then((mod) => mod.appRouter)), - dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)), - smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)), - stockPrice: lazy(() => import("./stocks").then((mod) => mod.stockPriceRouter)), - mediaServer: lazy(() => import("./media-server").then((mod) => mod.mediaServerRouter)), - mediaRelease: lazy(() => import("./media-release").then((mod) => mod.mediaReleaseRouter)), - calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)), - downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)), - mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)), - mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)), - rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)), - indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)), - healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)), - mediaTranscoding: lazy(() => import("./media-transcoding").then((mod) => mod.mediaTranscodingRouter)), - minecraft: lazy(() => import("./minecraft").then((mod) => mod.minecraftRouter)), + ...lazyWidgetRouters, options: lazy(() => import("./options").then((mod) => mod.optionsRouter)), - releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)), - networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)), - firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)), - notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)), - timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)), - tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)), - speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)), - uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)), - audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)), - umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)), - vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)), - ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)), - traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)), - customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)), - secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)), - wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)), }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 4d1d893d8..a433d4f0f 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, @@ -41,7 +36,7 @@ export const indexerManagerRouter = createTRPCRouter({ ); }), testAllIndexers: protectedProcedure - .concat(createIndexerManagerIntegrationMiddleware("interact")) + .concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager")) .mutation(async ({ ctx }) => { await Promise.all( ctx.integrations.map(async (integration) => { diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index 91738b4a8..5d34e1563 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( 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..b8b9a28e8 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( @@ -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; @@ -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(), diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 77e00204b..b85d9a72a 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 { createManySharedWidgetIntegrationMiddleware } 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(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"])) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { return await settleIntegrationQueries( 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..a9e60bcbd 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 { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -22,7 +21,9 @@ interface NetworkControllerQueryResult { export const networkControllerRouter = createTRPCRouter({ summary: publicProcedure - .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController"))) + .concat( + createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]), + ) .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..94237bb20 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, diff --git a/packages/api/src/router/widgets/options.ts b/packages/api/src/router/widgets/options.ts index e39e5f1dc..c548e039e 100644 --- a/packages/api/src/router/widgets/options.ts +++ b/packages/api/src/router/widgets/options.ts @@ -9,7 +9,6 @@ import { boards, items } from "@homarr/db/schema"; import type { WidgetOptionsSettings } from "../../../../widgets/src"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; -import { validateTimetableOptionsChangeAsync } from "./timetable"; import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access"; export const optionsRouter = createTRPCRouter({ @@ -50,6 +49,7 @@ export const optionsRouter = createTRPCRouter({ const previousOptions = SuperJSON.parse>(item.options); const updatedOptions = { ...previousOptions, ...input.newOptions }; if (item.kind === "timetable") { + const { validateTimetableOptionsChangeAsync } = await import("../timetable-options-validation"); await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions); } throwIfCustomWidgetPlacementChangeForbidden({ diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts index 2a1bf40de..722cb94ab 100644 --- a/packages/api/src/router/widgets/paperless-ngx.ts +++ b/packages/api/src/router/widgets/paperless-ngx.ts @@ -1,12 +1,14 @@ import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx"; -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")).query(async ({ ctx }) => { - const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); - const data = await innerHandler.getDataAsync(); - return data.data; - }), + getStats: publicProcedure + .concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx")) + .query(async ({ ctx }) => { + const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getDataAsync(); + return data.data; + }), }); diff --git a/packages/api/src/router/widgets/patchmon.ts b/packages/api/src/router/widgets/patchmon.ts index aee758823..20ad0b79f 100644 --- a/packages/api/src/router/widgets/patchmon.ts +++ b/packages/api/src/router/widgets/patchmon.ts @@ -1,6 +1,6 @@ import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; export const patchmonRouter = createTRPCRouter({ @@ -12,7 +12,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")) + .concat(createOneWidgetIntegrationMiddleware("query", "patchmon")) .query(async ({ ctx }) => { const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {}); const { data } = await innerHandler.getDataAsync(); diff --git a/packages/api/src/router/widgets/registry.ts b/packages/api/src/router/widgets/registry.ts new file mode 100644 index 000000000..eea618a08 --- /dev/null +++ b/packages/api/src/router/widgets/registry.ts @@ -0,0 +1,225 @@ +import type { AnyTRPCRouter } from "@trpc/server"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import type { WidgetKind } from "@homarr/definitions"; + +import { generatedWidgetRouterRegistry } from "./generated-registry"; + +const { + weather: generatedWeatherRouter, + downloads: generatedDownloadsRouter, + ...additionalGeneratedWidgetRouters +} = generatedWidgetRouterRegistry; + +const logger = createLogger({ module: "widgetRouterRegistry" }); + +type McpRouterNamespace = false | true | string; + +type WidgetRouterRegistration = { + widgetKinds: readonly [WidgetKind, ...WidgetKind[]]; + loadRouter: () => Promise; + mcp: TMcpNamespace; +}; + +const registerWidgetRouter = ( + widgetKinds: readonly [WidgetKind, ...WidgetKind[]], + loadRouter: () => Promise, + options?: { mcp?: TMcpNamespace }, +): WidgetRouterRegistration => ({ + widgetKinds, + loadRouter, + mcp: (options?.mcp ?? false) as TMcpNamespace, +}); + +/** + * Shared namespace-to-feature contract for widget tRPC consumers. + * + * Keep import paths explicit so Next.js can split every implementation while + * consumers share one widget-to-router association. + */ +export const widgetRouterRegistry = { + archiveTeamWarrior: registerWidgetRouter(["archiveTeamWarrior"], () => + import("./archive-team-warrior").then((module) => module.archiveTeamWarriorRouter), + ), + beszel: registerWidgetRouter( + ["beszelSystemTable", "beszelSystemGrid", "beszelAlerts", "beszelSystemStats"], + () => import("./beszel").then((module) => module.beszelRouter), + { mcp: true }, + ), + anchorNotes: registerWidgetRouter(["anchorNote"], () => + import("./anchor-notes").then((module) => module.anchorNotesRouter), + ), + coolify: registerWidgetRouter(["coolify"], () => import("./coolify").then((module) => module.coolifyRouter)), + immich: registerWidgetRouter(["immich-serverStats", "immich-albumCarousel"], () => + import("./immich").then((module) => module.immichRouter), + ), + paperlessNgx: registerWidgetRouter(["paperlessNgx"], () => + import("./paperless-ngx").then((module) => module.paperlessNgxRouter), + ), + patchmon: registerWidgetRouter(["patchmon"], () => import("./patchmon").then((module) => module.patchmonRouter), { + mcp: true, + }), + bazarr: registerWidgetRouter(["bazarr"], () => import("./bazarr").then((module) => module.bazarrRouter), { + mcp: true, + }), + notebook: registerWidgetRouter(["notebook"], () => import("./notebook").then((module) => module.notebookRouter)), + weather: generatedWeatherRouter, + airQuality: registerWidgetRouter( + ["airQuality"], + () => import("./air-quality").then((module) => module.airQualityRouter), + { mcp: true }, + ), + app: registerWidgetRouter(["app"], () => import("./app").then((module) => module.appRouter)), + dnsHole: registerWidgetRouter( + ["dnsHoleSummary", "dnsHoleControls"], + () => import("./dns-hole").then((module) => module.dnsHoleRouter), + { mcp: true }, + ), + smartHome: registerWidgetRouter( + ["smartHome-entityState", "smartHome-executeAutomation"], + () => import("./smart-home").then((module) => module.smartHomeRouter), + { mcp: true }, + ), + stockPrice: registerWidgetRouter(["stockPrice"], () => import("./stocks").then((module) => module.stockPriceRouter)), + mediaServer: registerWidgetRouter( + ["mediaServer", "audioStats"], + () => import("./media-server").then((module) => module.mediaServerRouter), + { mcp: true }, + ), + mediaRelease: registerWidgetRouter(["mediaReleases"], () => + import("./media-release").then((module) => module.mediaReleaseRouter), + ), + calendar: registerWidgetRouter(["calendar"], () => import("./calendar").then((module) => module.calendarRouter), { + mcp: true, + }), + downloads: generatedDownloadsRouter, + mediaRequests: registerWidgetRouter( + ["mediaRequests-requestList", "mediaRequests-requestStats"], + () => import("./media-requests").then((module) => module.mediaRequestsRouter), + { mcp: true }, + ), + mediaOrganizer: registerWidgetRouter(["mediaMissing"], () => + import("./media-organizer").then((module) => module.mediaOrganizerRouter), + ), + rssFeed: registerWidgetRouter(["rssFeed"], () => import("./rssFeed").then((module) => module.rssFeedRouter)), + indexerManager: registerWidgetRouter(["indexerManager"], () => + import("./indexer-manager").then((module) => module.indexerManagerRouter), + ), + healthMonitoring: registerWidgetRouter( + ["healthMonitoring", "systemResources", "systemDisks"], + () => import("./health-monitoring").then((module) => module.healthMonitoringRouter), + { mcp: true }, + ), + mediaTranscoding: registerWidgetRouter(["mediaTranscoding"], () => + import("./media-transcoding").then((module) => module.mediaTranscodingRouter), + ), + minecraft: registerWidgetRouter(["minecraftServerStatus"], () => + import("./minecraft").then((module) => module.minecraftRouter), + ), + releases: registerWidgetRouter(["releases"], () => import("./releases").then((module) => module.releasesRouter)), + networkController: registerWidgetRouter(["networkControllerSummary", "networkControllerStatus"], () => + import("./network-controller").then((module) => module.networkControllerRouter), + ), + firewall: registerWidgetRouter(["firewall"], () => import("./firewall").then((module) => module.firewallRouter)), + notifications: registerWidgetRouter(["notifications"], () => + import("./notifications").then((module) => module.notificationsRouter), + ), + timetable: registerWidgetRouter(["timetable"], () => import("./timetable").then((module) => module.timetableRouter)), + tracearr: registerWidgetRouter(["tracearr"], () => import("./tracearr").then((module) => module.tracearrRouter)), + speedtestTracker: registerWidgetRouter(["speedtestTracker"], () => + import("./speedtest-tracker").then((module) => module.speedtestTrackerRouter), + ), + uptimeKuma: registerWidgetRouter(["uptimeKuma"], () => + import("./uptime-kuma").then((module) => module.uptimeKumaRouter), + ), + audioStats: registerWidgetRouter(["audioStats"], () => + import("./audio-stats").then((module) => module.audioStatsRouter), + ), + umami: registerWidgetRouter(["umami"], () => import("./umami").then((module) => module.umamiRouter)), + vpn: registerWidgetRouter(["vpn"], () => import("./vpn").then((module) => module.vpnRouter)), + ups: registerWidgetRouter(["ups"], () => import("./ups").then((module) => module.upsRouter)), + traefik: registerWidgetRouter(["traefik"], () => import("./traefik").then((module) => module.traefikRouter)), + customApi: registerWidgetRouter(["customApi"], () => import("./custom-api").then((module) => module.customApiRouter)), + secrets: registerWidgetRouter( + ["releases"], + () => import("./widget-secrets").then((module) => module.widgetSecretsRouter), + { mcp: "widgetSecrets" }, + ), + wud: registerWidgetRouter(["wud"], () => import("./wud").then((module) => module.wudRouter)), + ...additionalGeneratedWidgetRouters, +}; + +export type WidgetRouterName = keyof typeof widgetRouterRegistry; +type LoadedWidgetRouterRecord = { + [TName in WidgetRouterName]: Awaited>; +}; + +interface McpWidgetRouterSelection { + name: WidgetRouterName; + namespace: string; +} + +export const mcpWidgetRouterSelections: readonly McpWidgetRouterSelection[] = Object.entries( + widgetRouterRegistry, +).flatMap(([name, registration]) => { + if (!registration.mcp) return []; + + let namespace = name; + if (typeof registration.mcp === "string") namespace = registration.mcp; + return [{ name: name as WidgetRouterName, namespace }]; +}); + +export const mcpWidgetRouterNames: readonly WidgetRouterName[] = mcpWidgetRouterSelections.map( + (selection) => selection.name, +); + +export async function loadWidgetRoutersAsync( + names: TNames, + consumer: string, +): Promise> { + const loadedRouters: Partial> = {}; + + await Promise.all( + names.map(async (name) => { + const registration = widgetRouterRegistry[name]; + const startedAt = Date.now(); + try { + loadedRouters[name] = await registration.loadRouter(); + logger.debug("Loaded widget router", { + consumer, + routerName: name, + widgetKinds: registration.widgetKinds, + durationMs: Date.now() - startedAt, + }); + } catch (error) { + logger.error( + new ErrorWithMetadata( + "Failed to load widget router", + { + consumer, + routerName: name, + widgetKinds: registration.widgetKinds, + durationMs: Date.now() - startedAt, + }, + { cause: error }, + ), + ); + throw error; + } + }), + ); + + return loadedRouters as Pick; +} + +export async function loadMcpWidgetRoutersAsync(): Promise> { + const loadedRouters = await loadWidgetRoutersAsync(mcpWidgetRouterNames, "mcp"); + const mcpRouters: Record = {}; + + for (const selection of mcpWidgetRouterSelections) { + mcpRouters[selection.namespace] = loadedRouters[selection.name]; + } + + return mcpRouters; +} diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index a0f38c7fa..28917f23a 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); @@ -72,7 +67,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); diff --git a/packages/api/src/router/widgets/speedtest-tracker.ts b/packages/api/src/router/widgets/speedtest-tracker.ts index 4730940a4..578e4bc7d 100644 --- a/packages/api/src/router/widgets/speedtest-tracker.ts +++ b/packages/api/src/router/widgets/speedtest-tracker.ts @@ -1,12 +1,12 @@ import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker"; -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")) + .concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index ced16645e..acdb19266 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,7 +1,4 @@ -import { lookup } from "node:dns/promises"; -import { BlockList, isIP } from "node:net"; import { TRPCError } from "@trpc/server"; -import { parse } from "superjson"; import z from "zod"; import type { Session } from "@homarr/auth"; @@ -13,10 +10,16 @@ import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; -import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; +import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-url"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; +import { + normalizeSavedTimetableBaseUrl, + normalizeTimetableBaseUrlOrThrowBadRequest, + parseSavedTimetableOptions, + resolvePublicTimetableAddressesAsync, +} from "../timetable-options-validation"; const baseUrlSchema = z.string().url().max(2_048); const timetableSourceSchema = z.object({ @@ -24,167 +27,6 @@ const timetableSourceSchema = z.object({ itemId: z.string().max(100).optional(), boardId: z.string().max(100).optional(), }); -const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); - -const blockedTimetableIpv4Addresses = new BlockList(); -const blockedTimetableIpv6Addresses = new BlockList(); -const blockedTimetableSubnets = [ - ["0.0.0.0", 8, "ipv4"], - ["10.0.0.0", 8, "ipv4"], - ["100.64.0.0", 10, "ipv4"], - ["127.0.0.0", 8, "ipv4"], - ["169.254.0.0", 16, "ipv4"], - ["172.16.0.0", 12, "ipv4"], - ["192.0.0.0", 24, "ipv4"], - ["192.0.2.0", 24, "ipv4"], - ["192.88.99.0", 24, "ipv4"], - ["192.168.0.0", 16, "ipv4"], - ["198.18.0.0", 15, "ipv4"], - ["198.51.100.0", 24, "ipv4"], - ["203.0.113.0", 24, "ipv4"], - ["224.0.0.0", 4, "ipv4"], - ["240.0.0.0", 4, "ipv4"], - ["::", 96, "ipv6"], - ["::ffff:0:0", 96, "ipv6"], - ["::ffff:0:0:0", 96, "ipv6"], - ["64:ff9b::", 96, "ipv6"], - ["64:ff9b:1::", 48, "ipv6"], - ["100::", 64, "ipv6"], - ["2001::", 32, "ipv6"], - ["2001:2::", 48, "ipv6"], - ["2001:10::", 28, "ipv6"], - ["2001:db8::", 32, "ipv6"], - ["2002::", 16, "ipv6"], - ["fc00::", 7, "ipv6"], - ["fe80::", 10, "ipv6"], - ["fec0::", 10, "ipv6"], - ["ff00::", 8, "ipv6"], -] as const; - -for (const [network, prefix, family] of blockedTimetableSubnets) { - const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses; - blockList.addSubnet(network, prefix, family); -} - -const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => { - try { - return normalizeTimetableBaseUrl(baseUrl); - } catch (cause) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: cause instanceof Error ? cause.message : "Invalid timetable base URL", - cause, - }); - } -}; - -const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Timetable widget configuration is invalid", - cause, - }); -}; - -const parseSavedTimetableOptions = (serializedOptions: string) => { - try { - return timetableOptionsSchema.parse(parse(serializedOptions)); - } catch (cause) { - return throwInvalidSavedTimetableConfiguration(cause); - } -}; - -const normalizeSavedTimetableBaseUrl = (baseUrl: string) => { - try { - return normalizeTimetableBaseUrl(baseUrl); - } catch (cause) { - return throwInvalidSavedTimetableConfiguration(cause); - } -}; - -const normalizeHostname = (hostname: string) => { - const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; - return withoutBrackets.toLowerCase().replace(/\.$/, ""); -}; - -const isBlockedTimetableAddress = (address: string, family: number) => { - return family === 6 - ? blockedTimetableIpv6Addresses.check(address, "ipv6") - : blockedTimetableIpv4Addresses.check(address, "ipv4"); -}; - -const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { - const hostname = normalizeHostname(new URL(baseUrl).hostname); - const hostnameFamily = isIP(hostname); - const isInternalHostname = - hostnameFamily === 0 && - (!hostname.includes(".") || - hostname === "localhost" || - hostname.endsWith(".localhost") || - hostname.endsWith(".local") || - hostname.endsWith(".internal") || - hostname.endsWith(".lan") || - hostname.endsWith(".home.arpa")); - - if (isInternalHostname) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" }); - } - - let addresses: TimetableResolvedAddress[]; - try { - const resolvedAddresses = - hostnameFamily === 0 - ? await lookup(hostname, { all: true, verbatim: true }) - : [{ address: hostname, family: hostnameFamily }]; - addresses = resolvedAddresses.filter( - (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, - ); - } catch (cause) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Timetable base URL host could not be resolved", - cause, - }); - } - - if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Timetable base URL must not target a private or internal address", - }); - } - - return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address)); -}; - -const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record) => { - const parsedOptions = timetableOptionsSchema.safeParse(options); - if (!parsedOptions.success) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" }); - } - return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); -}; - -export const validateTimetableOptionsChangeAsync = async ( - nextOptions: Record, - previousOptions?: Record, -) => { - const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions); - if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return; - - let previousBaseUrl: string | undefined; - if (previousOptions) { - try { - previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions); - } catch { - // Invalid legacy options must not prevent replacing them with a valid configuration. - } - } - if (nextBaseUrl === previousBaseUrl) return; - - await resolvePublicTimetableAddressesAsync(nextBaseUrl); -}; - const getSavedTimetableBaseUrl = (serializedOptions: string) => { const options = parseSavedTimetableOptions(serializedOptions); return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); @@ -200,7 +42,7 @@ const resolveTimetableBaseUrlAsync = async ( input: z.infer, allowBoardConfiguration = false, ): Promise => { - const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl); + const requestedBaseUrl = normalizeTimetableBaseUrlOrThrowBadRequest(input.baseUrl); if (allowBoardConfiguration) { if (input.boardId) { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); diff --git a/packages/api/src/router/widgets/tracearr.ts b/packages/api/src/router/widgets/tracearr.ts index d96a02932..e2e02273a 100644 --- a/packages/api/src/router/widgets/tracearr.ts +++ b/packages/api/src/router/widgets/tracearr.ts @@ -1,37 +1,39 @@ import { tracearrRequestHandler } from "@homarr/request-handler/tracearr"; -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")).query(async ({ ctx }) => { - return await settleIntegrationQueries( - ctx.integrations, - async (integration) => { - const innerHandler = tracearrRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + getDashboard: publicProcedure + .concat(createManyWidgetIntegrationMiddleware("query", "tracearr")) + .query(async ({ ctx }) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = tracearrRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data as typeof data | null, - updatedAt: timestamp, - error: undefined as string | undefined, - }; - }, - { - fallback: (integration, error) => ({ - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: null, - updatedAt: new Date(0), - error: toPublicIntegrationError(error), - }), - throwOnAllFailures: true, - }, - ); - }), + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data as typeof data | null, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); + }), }); diff --git a/packages/api/src/router/widgets/traefik.ts b/packages/api/src/router/widgets/traefik.ts index ad7e8479d..6109d25b9 100644 --- a/packages/api/src/router/widgets/traefik.ts +++ b/packages/api/src/router/widgets/traefik.ts @@ -1,6 +1,6 @@ import { traefikRequestHandler } from "@homarr/request-handler/traefik"; -import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -13,7 +13,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")) + .concat(createManyWidgetIntegrationMiddleware("query", "traefik")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, diff --git a/packages/api/src/router/widgets/umami.ts b/packages/api/src/router/widgets/umami.ts index 3994f398e..e89fd335c 100644 --- a/packages/api/src/router/widgets/umami.ts +++ b/packages/api/src/router/widgets/umami.ts @@ -2,7 +2,7 @@ import { z } from "zod/v4"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import { umamiActiveVisitorsRequestHandler, umamiEventNamesRequestHandler, @@ -12,14 +12,17 @@ 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")).query(async ({ ctx }) => { + getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => { try { const instance = await createIntegrationAsync(ctx.integration); return await instance.getWebsitesAsync(); @@ -37,7 +40,7 @@ export const umamiRouter = createTRPCRouter({ eventName: z.string().optional(), }), ) - .concat(createManyIntegrationMiddleware("query", "umami")) + .concat(createManyWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { return await settleIntegrationQueries(ctx.integrations, async (integration) => { const innerHandler = umamiRequestHandler.handler(integration, { @@ -59,7 +62,7 @@ export const umamiRouter = createTRPCRouter({ getEventNames: publicProcedure .input(z.object({ websiteId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "umami")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { try { const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId }); @@ -81,7 +84,7 @@ export const umamiRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500), }), ) - .concat(createOneIntegrationMiddleware("query", "umami")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { try { const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, { @@ -111,7 +114,7 @@ export const umamiRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500), }), ) - .concat(createOneIntegrationMiddleware("query", "umami")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { try { const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, { @@ -141,7 +144,7 @@ export const umamiRouter = createTRPCRouter({ eventNames: z.array(z.string()), }), ) - .concat(createOneIntegrationMiddleware("query", "umami")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { try { const sortedNames = [...input.eventNames].toSorted(); @@ -166,7 +169,7 @@ export const umamiRouter = createTRPCRouter({ getActiveVisitors: publicProcedure .input(z.object({ websiteId: z.string() })) - .concat(createOneIntegrationMiddleware("query", "umami")) + .concat(createOneWidgetIntegrationMiddleware("query", "umami")) .query(async ({ ctx, input }) => { try { const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, { diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts index bc8481690..c0eca54ae 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 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, + }, + ); + }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index e3082c284..8d377cd06 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,12 +1,12 @@ import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; -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")) + .concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 276367440..72a1bbd3b 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -1,13 +1,12 @@ -import { getIntegrationKindsByCategory } from "@homarr/definitions"; 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"; export const vpnRouter = createTRPCRouter({ getSummaries: publicProcedure - .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) + .unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn")) .query(async ({ ctx }) => { return await settleIntegrationQueries( ctx.integrations, diff --git a/packages/api/src/router/widgets/wud.ts b/packages/api/src/router/widgets/wud.ts index 4b510e660..9929ddaba 100644 --- a/packages/api/src/router/widgets/wud.ts +++ b/packages/api/src/router/widgets/wud.ts @@ -1,6 +1,6 @@ import { wudStatsRequestHandler } from "@homarr/request-handler/wud"; -import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const wudRouter = createTRPCRouter({ @@ -12,7 +12,7 @@ export const wudRouter = createTRPCRouter({ "Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.", }, }) - .concat(createOneIntegrationMiddleware("query", "wud")) + .concat(createOneWidgetIntegrationMiddleware("query", "wud")) .query(async ({ ctx }) => { const handler = wudStatsRequestHandler.handler(ctx.integration, {}); const { data, timestamp } = await handler.getDataAsync(); diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index a708701b5..ac9bc1e00 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -2,11 +2,13 @@ import type { TRPCError } from "@trpc/server"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod/v4"; -import { mcpRouter } from "../mcp"; +import { getMcpRouterAsync } from "../mcp"; import { extractMcpToolsFromProcedures } from "../mcp-tools"; vi.mock("@homarr/auth", () => ({})); +const mcpRouter = await getMcpRouterAsync(); + const MCP_TOOL_ALLOWLIST = { query: [ "airQuality_atLocation", diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 2c0936dbb..9d7389304 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -16,6 +16,7 @@ import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common"; import { userAgent } from "@homarr/common/server"; import type { DeviceType } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { db } from "@homarr/db"; import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; @@ -105,6 +106,13 @@ export const createCallerFactory = t.createCallerFactory; */ export const createTRPCRouter = t.router; +/** + * Unadorned procedure builder for middleware fragments that are concatenated + * onto a public, protected, or internal procedure. Global middleware belongs + * on the outer procedure so observability and policy checks run exactly once. + */ +export const middlewareProcedure = t.procedure; + export const isDemoMode = env.DEMO_MODE; // The public "real demo" (demo + mock integrations) stays read-only, while preview @@ -121,7 +129,47 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { return next({ ctx }); }); -const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; +const observeProcedure = t.middleware(async ({ ctx, next, path, type }) => { + const startedAt = Date.now(); + const metadata = { + path, + type, + userId: ctx.session?.user.id, + }; + + logger.debug("tRPC procedure started", metadata); + + const result = await next(); + const durationMs = Date.now() - startedAt; + + if (result.ok) { + logger.debug("tRPC procedure completed", { ...metadata, durationMs }); + return result; + } + + const safeError = new Error(result.error.message); + safeError.name = result.error.name; + safeError.stack = result.error.stack; + + logger.warn( + new ErrorWithMetadata( + "tRPC procedure failed", + { + ...metadata, + durationMs, + errorCode: result.error.code, + causeType: result.error.cause instanceof Error ? result.error.cause.name : undefined, + }, + { cause: safeError }, + ), + ); + return result; +}); + +let baseProcedure = t.procedure.use(observeProcedure); +if (isDemoReadOnly) { + baseProcedure = baseProcedure.use(enforceDemoModeReadOnly); +} /** * Public (unauthed) procedure @@ -145,7 +193,7 @@ const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => { export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess); -export const internalProcedure = t.procedure; +export const internalProcedure = t.procedure.use(observeProcedure); const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { if (!ctx.session?.user) { diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts index c3a246b2f..3036250f8 100644 --- a/packages/db/queries/widget-placement.ts +++ b/packages/db/queries/widget-placement.ts @@ -124,6 +124,7 @@ export const placeAllWidgetsAsync = async ( } } + let placedAppCount = 0; for (const app of allApps) { await placeWidgetAsync( db, @@ -133,6 +134,7 @@ export const placeAllWidgetsAsync = async ( superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), { width: 1, height: 1 }, ); + placedAppCount += 1; } const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); @@ -153,5 +155,5 @@ export const placeAllWidgetsAsync = async ( }); } - return placedWidgets.size + allApps.length; + return placedWidgets.size + placedAppCount; }; diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index 36a0d0f58..becadf00c 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -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 (integrationDefs[kind].features.docker.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 && integrationDefs[fromImage].features.docker.discoverable) return fromImage; const fromName = matchIntegrationKind(container.name); - if (fromName && !notDockerDiscoverable.has(fromName)) return fromName; + if (fromName && integrationDefs[fromName].features.docker.discoverable) return fromName; return null; }; diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts index 3a306b14e..aca3a949e 100644 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ b/packages/definitions/src/docs/integration-doc-slugs.ts @@ -1,59 +1,8 @@ -import type { IntegrationKind } from "../integration"; +import { objectEntries } from "@homarr/common"; -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, -}; +import type { IntegrationKind } from "../integration"; +import { integrationDefs } from "../integration"; + +export const integrationDocSlugs = Object.fromEntries( + objectEntries(integrationDefs).map(([kind, definition]) => [kind, definition.documentationSlug]), +) as Record; diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 466d36369..f5cc6a57a 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -1,63 +1,6 @@ import type { WidgetKind } from "../widget"; +import { widgetFeatureCatalog, widgetKinds } from "../widget"; -export const widgetDocSlugs: Record = { - clock: "clock", - weather: "weather", - airQuality: "air-quality", - countdown: "countdown", - timer: "timer", - app: "app", - iframe: "iframe", - video: "video", - notebook: "notebook", - anchorNote: "anchor-note", - dnsHoleSummary: "dns-hole-summary", - dnsHoleControls: "dns-hole-controls", - "smartHome-entityState": "smart-home-entity-state", - "smartHome-executeAutomation": "smart-home-execute-automation", - stockPrice: "stock-price", - mediaServer: "media-server", - calendar: "calendar", - downloads: "downloads", - "mediaRequests-requestList": "media-request-list", - "mediaRequests-requestStats": "media-request-stats", - mediaTranscoding: "media-transcoding", - mediaMissing: "media-missing", - minecraftServerStatus: "minecraft-server-status", - networkControllerSummary: "network-controller-summary", - networkControllerStatus: "network-controller-status", - rssFeed: "rss-feed", - bookmarks: "bookmarks", - indexerManager: "indexer-manager", - healthMonitoring: "health-monitoring", - releases: "releases", - mediaReleases: "media-releases", - dockerContainers: "docker-containers", - firewall: "firewall", - notifications: "notifications", - systemResources: "system-resources", - coolify: "coolify", - systemDisks: "system-disks", - timetable: "timetable", - "immich-serverStats": "immich-server-stats", - "immich-albumCarousel": "immich-album-carousel", - paperlessNgx: "paperless-ngx", - patchmon: "patchmon", - bazarr: "bazarr", - tracearr: "tracearr", - speedtestTracker: "speedtest-tracker", - uptimeKuma: "uptime-kuma", - audioStats: "audio-stats", - umami: "umami", - vpn: "vpn", - ups: "ups", - archiveTeamWarrior: null, - beszelSystemTable: "beszel-system-table", - beszelSystemGrid: "beszel-system-grid", - beszelAlerts: "beszel-alerts", - beszelSystemStats: "beszel-system-stats", - traefik: "traefik", - customApi: "custom-api", - assistant: "assistant", - wud: "whats-up-docker", -}; +export const widgetDocSlugs = Object.fromEntries( + widgetKinds.map((kind) => [kind, widgetFeatureCatalog[kind].documentationSlug]), +) as Record; diff --git a/packages/definitions/src/generated/integration-modules.ts b/packages/definitions/src/generated/integration-modules.ts new file mode 100644 index 000000000..19cd65e78 --- /dev/null +++ b/packages/definitions/src/generated/integration-modules.ts @@ -0,0 +1,12 @@ +// This file is generated by pnpm integrations:sync. Do not edit it directly. + +export const generatedIntegrationDefinitions = { + beszel: { + name: "Beszel", + secretKinds: [["username", "password"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", + category: ["beszel"], + documentationSlug: "beszel", + defaultPort: 8090, + }, +} as const; diff --git a/packages/definitions/src/generated/widget-modules.ts b/packages/definitions/src/generated/widget-modules.ts new file mode 100644 index 000000000..32af57a67 --- /dev/null +++ b/packages/definitions/src/generated/widget-modules.ts @@ -0,0 +1,59 @@ +// This file is generated by pnpm widgets:sync. Do not edit it directly. + +export const generatedWidgetFeatureCatalog = { + downloads: { + documentationSlug: "downloads", + capability: { + integrations: ["sabNzbd", "nzbGet", "deluge", "transmission", "qBittorrent", "aria2", "slskd", "mock"] as const, + }, + query: { paths: [["widget", "downloads", "getJobsAndStatuses"]] as const, refetchIntervalSeconds: 10 }, + }, + weather: { + documentationSlug: "weather", + defaultSize: { width: 2, height: 1 }, + query: { paths: [["widget", "weather", "atLocation"]] as const, refetchIntervalSeconds: 600 }, + }, +} as const; + +export const widgetServerCachePolicies = { + downloads: { + getJobsAndStatuses: { namespace: "downloads:jobs-and-status", ttlMs: 10000, scope: "integration" }, + }, + weather: { + atLocation: { namespace: "weather:at-location", ttlMs: 300000, scope: "shared" }, + }, +} as const; + +export const generatedWidgetOperationPolicies = [ + { + widgetKind: "downloads", + operation: "getJobsAndStatuses", + kind: "query", + path: ["widget", "downloads", "getJobsAndStatuses"], + refetchIntervalSeconds: 10, + staleTimeSeconds: 10, + persist: true, + }, + { + widgetKind: "weather", + operation: "atLocation", + kind: "query", + path: ["widget", "weather", "atLocation"], + refetchIntervalSeconds: 600, + staleTimeSeconds: 300, + persist: true, + }, +] as const; + +export const generatedWidgetDocumentationSources = { + downloads: "packages/widgets/src/downloads/docs", + weather: "packages/widgets/src/weather/docs", +} as const; + +export const getWidgetServerCachePolicy = < + TKind extends keyof typeof widgetServerCachePolicies, + TOperation extends keyof (typeof widgetServerCachePolicies)[TKind], +>( + kind: TKind, + operation: TOperation, +): (typeof widgetServerCachePolicies)[TKind][TOperation] => widgetServerCachePolicies[kind][operation]; diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts index b8d094c27..504d3236e 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -1,5 +1,7 @@ export * from "./board"; export * from "./integration"; +export * from "./integration-module"; +export * from "./generated/integration-modules"; export * from "./section"; export * from "./widget"; export * from "./widget-name"; @@ -14,6 +16,8 @@ export * from "./cookie"; export * from "./search-engine"; export * from "./onboarding"; export * from "./widget-integration-map"; +export * from "./widget-module"; +export * from "./generated/widget-modules"; export * from "./emptysuperjson"; export * from "./hotkeys"; export * from "./docker-integration-match"; diff --git a/packages/definitions/src/integration-module.ts b/packages/definitions/src/integration-module.ts new file mode 100644 index 000000000..bbb0a481e --- /dev/null +++ b/packages/definitions/src/integration-module.ts @@ -0,0 +1,53 @@ +import type { IntegrationCategory, IntegrationSecretKind } from "./integration"; + +export interface IntegrationModuleDocumentation { + slug: string; + sourceDirectory: string; +} + +export interface IntegrationModuleDockerMetadata { + aliases?: readonly string[]; + discoverable?: boolean; +} + +export interface IntegrationModuleOnboardingMetadata { + featuredOrder?: number; + hidden?: boolean; +} + +export type IntegrationModuleCreatorEntry = + | { + type: "constructor"; + module: string; + exportName: string; + } + | { + type: "factory"; + module: string; + exportName: string; + }; + +/** + * Pure, server-safe authoring contract for a vertical integration module. + * + * Runtime entry points are strings so reading metadata never imports an + * integration implementation or its transitive server dependencies. + */ +export interface IntegrationModuleDefinition { + kind: string; + name: string; + iconUrl: string; + secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]]; + categories: readonly [IntegrationCategory, ...IntegrationCategory[]]; + documentation: IntegrationModuleDocumentation; + creator: IntegrationModuleCreatorEntry; + defaultUrl?: string; + defaultPort?: number; + apiKeySettingsPath?: string; + docker?: IntegrationModuleDockerMetadata; + onboarding?: IntegrationModuleOnboardingMetadata; +} + +export const defineIntegrationModule = ( + definition: TDefinition, +) => definition; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index f010dcb4c..3eae126d7 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,7 +1,12 @@ -import { objectKeys } from "@homarr/common"; +import { objectEntries, objectKeys } from "@homarr/common"; import type { AtLeastOneOf } from "@homarr/common/types"; import { createDocumentationLink } from "./docs"; +import type { HomarrDocumentationPath } from "./docs/homarr-docs-sitemap"; +import { generatedIntegrationDefinitions } from "./generated/integration-modules"; + +type IntegrationDocumentationPath = Extract; +type IntegrationDocumentationSlug = string; export const integrationSecretKindObject = { apiKey: { isPublic: false, multiline: false }, @@ -24,41 +29,107 @@ export const integrationSecretKindObject = { export const integrationSecretKinds = objectKeys(integrationSecretKindObject); +interface IntegrationFeatureMetadata { + docker?: { + aliases?: readonly string[]; + discoverable?: boolean; + }; + onboarding?: { + featuredOrder?: number; + hidden?: boolean; + }; +} + +interface ResolvedIntegrationFeatureMetadata { + docker: { + aliases: readonly string[]; + discoverable: boolean; + }; + onboarding: { + featuredOrder: number | null; + 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; } -export const integrationDefs = { +type ResolvedIntegrationDefinitions> = { + [TKind in keyof TDefinitions]: TDefinitions[TKind] & { + documentationUrl: string | null; + features: ResolvedIntegrationFeatureMetadata; + }; +}; + +const createIntegrationDefinitions = >( + definitions: TDefinitions, +): ResolvedIntegrationDefinitions => { + return Object.fromEntries( + objectEntries(definitions).map(([kind, definition]) => { + let documentationUrl: string | null = null; + if (definition.documentationSlug !== null) { + const documentationPath = `/docs/integrations/${definition.documentationSlug}` as IntegrationDocumentationPath; + documentationUrl = createDocumentationLink(documentationPath); + } + return [ + kind, + { + ...definition, + documentationUrl, + features: { + docker: { + aliases: definition.features?.docker?.aliases ?? [], + discoverable: definition.features?.docker?.discoverable ?? true, + }, + onboarding: { + featuredOrder: definition.features?.onboarding?.featuredOrder ?? null, + hidden: definition.features?.onboarding?.hidden ?? false, + }, + }, + }, + ]; + }), + ) as unknown as ResolvedIntegrationDefinitions; +}; + +export const integrationDefs = createIntegrationDefinitions({ sabNzbd: { name: "SABnzbd", 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 +137,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 +145,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 +166,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 +174,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 +203,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 +212,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 +250,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 +258,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 +314,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 +350,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 +358,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 +366,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 +374,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 +392,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 +401,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 +409,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 +426,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 +453,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 +472,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 +500,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 +540,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,23 +548,16 @@ 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: { - name: "Beszel", - secretKinds: [["username", "password"]], - iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", - category: ["beszel"], - documentationUrl: createDocumentationLink("/docs/integrations/beszel"), - defaultPort: 8090, - }, + ...generatedIntegrationDefinitions, gluetun: { name: "Gluetun", 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 +565,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 +573,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 +581,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,9 +606,13 @@ 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; @@ -524,11 +620,14 @@ export const getIconUrl = (integration: IntegrationKind) => integrationDefs[inte 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; + integrationDefs[integration].secretKinds.map((secretKinds) => [...secretKinds]) as AtLeastOneOf< + IntegrationSecretKind[] + >; export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { const definition = integrationDefs[integration]; diff --git a/packages/definitions/src/widget-feature-catalog.ts b/packages/definitions/src/widget-feature-catalog.ts new file mode 100644 index 000000000..3a45ff968 --- /dev/null +++ b/packages/definitions/src/widget-feature-catalog.ts @@ -0,0 +1,419 @@ +import type { AtLeastOneOf } from "@homarr/common/types"; + +import type { IntegrationKind } from "./integration"; +import { getIntegrationKindsByCategory } from "./integration"; +import type { IntegrationKindByCategory } from "./integration"; +import { generatedWidgetFeatureCatalog } from "./generated/widget-modules"; + +const { + weather: generatedWeatherFeature, + downloads: generatedDownloadsFeature, + ...additionalGeneratedWidgetFeatures +} = generatedWidgetFeatureCatalog; + +export interface NativeFeatureCapabilityDescriptor { + integrations: Readonly>; + connectionOptional?: true; + serverMaxIntegrations?: number; +} + +interface WidgetFeatureDescriptorBase { + documentationSlug: string | null; + defaultSize?: Readonly<{ width: number; height: number }>; + query?: { + paths: readonly (readonly string[])[]; + refetchIntervalSeconds?: number | null; + }; +} + +interface WidgetFeatureDescriptorWithDefaultName extends WidgetFeatureDescriptorBase { + displayNameFromIntegration?: false; + capability?: NativeFeatureCapabilityDescriptor; +} + +interface WidgetFeatureDescriptorWithIntegrationName extends WidgetFeatureDescriptorBase { + displayNameFromIntegration: true; + capability: Omit & { + integrations: readonly [IntegrationKind]; + }; +} + +export type WidgetFeatureDescriptor = + | WidgetFeatureDescriptorWithDefaultName + | WidgetFeatureDescriptorWithIntegrationName; + +const defineWidgetFeatureCatalog = >( + catalog: TCatalog, +) => catalog; + +type GeneralHealthMonitoringIntegrationKind = Exclude< + IntegrationKindByCategory<"healthMonitoring">, + "patchmon" | "wud" +>; + +const generalHealthMonitoringIntegrationKinds = getIntegrationKindsByCategory("healthMonitoring").filter( + (kind): kind is GeneralHealthMonitoringIntegrationKind => kind !== "patchmon" && kind !== "wud", +) as AtLeastOneOf; + +/** + * Server-safe source of truth for native widgets. + * + * Keep each widget's stable kind, documentation, default size, and integration + * contract together so every lightweight consumer can derive the same view. + */ +export const widgetFeatureCatalog = defineWidgetFeatureCatalog({ + clock: { + documentationSlug: "clock", + defaultSize: { width: 2, height: 1 }, + query: { paths: [["widget", "weather", "atLocation"]] }, + }, + weather: generatedWeatherFeature, + airQuality: { + documentationSlug: "air-quality", + defaultSize: { width: 2, height: 1 }, + query: { paths: [["widget", "airQuality", "atLocation"]], refetchIntervalSeconds: 900 }, + }, + countdown: { documentationSlug: "countdown", defaultSize: { width: 2, height: 1 } }, + timer: { documentationSlug: "timer", defaultSize: { width: 2, height: 1 } }, + app: { + documentationSlug: "app", + query: { + paths: [ + ["app", "byId"], + ["widget", "app", "ping"], + ], + }, + }, + iframe: { documentationSlug: "iframe" }, + video: { documentationSlug: "video" }, + notebook: { documentationSlug: "notebook" }, + anchorNote: { + documentationSlug: "anchor-note", + capability: { integrations: ["anchor"], serverMaxIntegrations: 1 }, + query: { paths: [["widget", "anchorNotes"]] }, + }, + dnsHoleSummary: { + documentationSlug: "dns-hole-summary", + capability: { integrations: getIntegrationKindsByCategory("dnsHole") }, + query: { paths: [["widget", "dnsHole"]], refetchIntervalSeconds: 10 }, + }, + dnsHoleControls: { + documentationSlug: "dns-hole-controls", + capability: { integrations: getIntegrationKindsByCategory("dnsHole") }, + query: { paths: [["widget", "dnsHole"]], refetchIntervalSeconds: 10 }, + }, + "smartHome-entityState": { + documentationSlug: "smart-home-entity-state", + capability: { + integrations: getIntegrationKindsByCategory("smartHomeServer"), + serverMaxIntegrations: 1, + }, + query: { paths: [["widget", "smartHome"]] }, + }, + "smartHome-executeAutomation": { + documentationSlug: "smart-home-execute-automation", + capability: { + integrations: getIntegrationKindsByCategory("smartHomeServer"), + serverMaxIntegrations: 1, + }, + }, + stockPrice: { + documentationSlug: "stock-price", + query: { paths: [["widget", "stockPrice", "getPriceHistory"]], refetchIntervalSeconds: null }, + }, + mediaServer: { + documentationSlug: "media-server", + capability: { integrations: getIntegrationKindsByCategory("mediaService") }, + query: { paths: [["widget", "mediaServer", "getCurrentStreams"]], refetchIntervalSeconds: 10 }, + }, + calendar: { + documentationSlug: "calendar", + capability: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true }, + query: { paths: [["widget", "calendar", "findAllEvents"]] }, + }, + downloads: generatedDownloadsFeature, + "mediaRequests-requestList": { + documentationSlug: "media-request-list", + capability: { integrations: getIntegrationKindsByCategory("mediaRequest") }, + query: { paths: [["widget", "mediaRequests", "getLatestRequests"]] }, + }, + "mediaRequests-requestStats": { + documentationSlug: "media-request-stats", + capability: { integrations: getIntegrationKindsByCategory("mediaRequest") }, + query: { paths: [["widget", "mediaRequests", "getStats"]] }, + }, + mediaTranscoding: { + documentationSlug: "media-transcoding", + capability: { + integrations: getIntegrationKindsByCategory("mediaTranscoding"), + serverMaxIntegrations: 1, + }, + query: { paths: [["widget", "mediaTranscoding", "getDataAsync"]], refetchIntervalSeconds: null }, + }, + mediaMissing: { + documentationSlug: "media-missing", + defaultSize: { width: 4, height: 3 }, + capability: { integrations: getIntegrationKindsByCategory("mediaOrganizer") }, + query: { paths: [["widget", "mediaOrganizer", "getData"]] }, + }, + minecraftServerStatus: { + documentationSlug: "minecraft-server-status", + query: { paths: [["widget", "minecraft", "getServerStatus"]], refetchIntervalSeconds: null }, + }, + networkControllerSummary: { + documentationSlug: "network-controller-summary", + capability: { integrations: getIntegrationKindsByCategory("networkController") }, + query: { paths: [["widget", "networkController"]], refetchIntervalSeconds: null }, + }, + networkControllerStatus: { + documentationSlug: "network-controller-status", + capability: { integrations: getIntegrationKindsByCategory("networkController") }, + query: { paths: [["widget", "networkController"]], refetchIntervalSeconds: null }, + }, + rssFeed: { + documentationSlug: "rss-feed", + query: { paths: [["widget", "rssFeed", "getFeeds"]], refetchIntervalSeconds: null }, + }, + bookmarks: { + documentationSlug: "bookmarks", + defaultSize: { width: 2, height: 2 }, + query: { paths: [["app", "byIds"]] }, + }, + indexerManager: { + documentationSlug: "indexer-manager", + capability: { integrations: getIntegrationKindsByCategory("indexerManager") }, + query: { paths: [["widget", "indexerManager"]], refetchIntervalSeconds: null }, + }, + healthMonitoring: { + documentationSlug: "health-monitoring", + capability: { integrations: generalHealthMonitoringIntegrationKinds }, + query: { + paths: [ + ["integration", "byIds"], + ["widget", "healthMonitoring"], + ], + refetchIntervalSeconds: 10, + }, + }, + releases: { + documentationSlug: "releases", + query: { paths: [["widget", "releases", "getLatest"]], refetchIntervalSeconds: null }, + }, + mediaReleases: { + documentationSlug: "media-releases", + capability: { integrations: ["mock", "emby", "jellyfin", "plex"] }, + query: { paths: [["widget", "mediaRelease"]], refetchIntervalSeconds: null }, + }, + dockerContainers: { + documentationSlug: "docker-containers", + query: { paths: [["docker", "getContainers"]], refetchIntervalSeconds: 30 }, + }, + firewall: { + documentationSlug: "firewall", + capability: { integrations: getIntegrationKindsByCategory("firewall") }, + query: { paths: [["widget", "firewall"]], refetchIntervalSeconds: 10 }, + }, + notifications: { + documentationSlug: "notifications", + capability: { integrations: getIntegrationKindsByCategory("notifications") }, + query: { paths: [["widget", "notifications", "getNotifications"]] }, + }, + systemResources: { + documentationSlug: "system-resources", + capability: { + integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], + }, + query: { paths: [["widget", "healthMonitoring"]], refetchIntervalSeconds: 10 }, + }, + coolify: { + documentationSlug: "coolify", + displayNameFromIntegration: true, + capability: { integrations: ["coolify"] }, + query: { paths: [["widget", "coolify"]] }, + }, + systemDisks: { + documentationSlug: "system-disks", + capability: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] }, + query: { paths: [["widget", "healthMonitoring"]], refetchIntervalSeconds: 10 }, + }, + timetable: { + documentationSlug: "timetable", + query: { paths: [["widget", "timetable"]] }, + }, + "immich-serverStats": { + documentationSlug: "immich-server-stats", + capability: { integrations: ["immich"], serverMaxIntegrations: 1 }, + query: { + paths: [ + ["widget", "immich", "getServerStats"], + ["widget", "immich", "getAlbums"], + ], + refetchIntervalSeconds: null, + }, + }, + "immich-albumCarousel": { + documentationSlug: "immich-album-carousel", + capability: { integrations: ["immich"], serverMaxIntegrations: 1 }, + query: { + paths: [ + ["widget", "immich", "getAlbum"], + ["widget", "immich", "getAlbums"], + ], + refetchIntervalSeconds: null, + }, + }, + paperlessNgx: { + documentationSlug: "paperless-ngx", + defaultSize: { width: 2, height: 2 }, + displayNameFromIntegration: true, + capability: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 }, + query: { paths: [["widget", "paperlessNgx"]], refetchIntervalSeconds: null }, + }, + patchmon: { + documentationSlug: "patchmon", + defaultSize: { width: 2, height: 2 }, + displayNameFromIntegration: true, + capability: { integrations: ["patchmon"], serverMaxIntegrations: 1 }, + query: { paths: [["widget", "patchmon"]] }, + }, + bazarr: { + documentationSlug: "bazarr", + defaultSize: { width: 2, height: 2 }, + displayNameFromIntegration: true, + capability: { integrations: ["bazarr"], serverMaxIntegrations: 1 }, + query: { paths: [["widget", "bazarr"]] }, + }, + tracearr: { + documentationSlug: "tracearr", + displayNameFromIntegration: true, + capability: { integrations: ["tracearr"] }, + query: { paths: [["widget", "tracearr"]], refetchIntervalSeconds: 10 }, + }, + speedtestTracker: { + documentationSlug: "speedtest-tracker", + capability: { integrations: ["speedtestTracker"] }, + query: { paths: [["widget", "speedtestTracker"]], refetchIntervalSeconds: null }, + }, + uptimeKuma: { + documentationSlug: "uptime-kuma", + defaultSize: { width: 2, height: 3 }, + displayNameFromIntegration: true, + capability: { integrations: ["uptimeKuma"] }, + query: { paths: [["widget", "uptimeKuma"]] }, + }, + audioStats: { + documentationSlug: "audio-stats", + defaultSize: { width: 2, height: 2 }, + capability: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 }, + query: { + paths: [ + ["widget", "audioStats", "getStats"], + ["widget", "mediaServer", "getCurrentStreams"], + ], + }, + }, + umami: { + documentationSlug: "umami", + capability: { integrations: ["umami"], serverMaxIntegrations: 1 }, + query: { + paths: [ + ["widget", "umami", "getVisitorStats"], + ["widget", "umami", "getActiveVisitors"], + ["widget", "umami", "getMultiEventTimeSeries"], + ["widget", "umami", "getTopPages"], + ["widget", "umami", "getTopReferrers"], + ], + refetchIntervalSeconds: null, + }, + }, + vpn: { + documentationSlug: "vpn", + capability: { integrations: getIntegrationKindsByCategory("vpn") }, + query: { paths: [["widget", "vpn"]], refetchIntervalSeconds: null }, + }, + archiveTeamWarrior: { + documentationSlug: null, + capability: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 }, + query: { paths: [["widget", "archiveTeamWarrior"]] }, + }, + ups: { + documentationSlug: "ups", + capability: { integrations: getIntegrationKindsByCategory("ups") }, + query: { paths: [["widget", "ups"]] }, + }, + beszelSystemTable: { + documentationSlug: "beszel-system-table", + capability: { integrations: ["beszel", "mock"] }, + query: { + paths: [ + ["widget", "beszel", "getSystems"], + ["widget", "beszel", "getSystemStats"], + ], + }, + }, + beszelSystemGrid: { + documentationSlug: "beszel-system-grid", + capability: { integrations: ["beszel", "mock"] }, + query: { + paths: [ + ["widget", "beszel", "getSystems"], + ["widget", "beszel", "getSystemStats"], + ], + }, + }, + beszelAlerts: { + documentationSlug: "beszel-alerts", + capability: { integrations: ["beszel", "mock"] }, + query: { paths: [["widget", "beszel", "getAlerts"]] }, + }, + beszelSystemStats: { + documentationSlug: "beszel-system-stats", + capability: { integrations: ["beszel", "mock"] }, + query: { + paths: [ + ["widget", "beszel", "getSystems"], + ["widget", "beszel", "getSystemStats"], + ], + }, + }, + traefik: { + documentationSlug: "traefik", + displayNameFromIntegration: true, + capability: { integrations: ["traefik"] }, + query: { paths: [["widget", "traefik"]] }, + }, + customApi: { + documentationSlug: "custom-api", + query: { paths: [["widget", "customApi"]] }, + }, + assistant: { documentationSlug: "assistant", defaultSize: { width: 10, height: 4 } }, + wud: { + documentationSlug: "whats-up-docker", + capability: { integrations: ["wud"], serverMaxIntegrations: 1 }, + query: { paths: [["widget", "wud"]], refetchIntervalSeconds: null }, + }, + ...additionalGeneratedWidgetFeatures, +}); + +export type WidgetFeatureKind = keyof typeof widgetFeatureCatalog; + +export type WidgetIntegrationKind = (typeof widgetFeatureCatalog)[TKind] extends { + capability: { integrations: readonly (infer TIntegrationKind extends IntegrationKind)[] }; +} + ? TIntegrationKind + : never; + +export type WidgetKindWithIntegration = { + [TKind in WidgetFeatureKind]: WidgetIntegrationKind extends never ? never : TKind; +}[WidgetFeatureKind]; + +export const getWidgetFeatureIntegrationKinds = ( + kind: TKind, +): AtLeastOneOf> => { + const descriptor = widgetFeatureCatalog[kind]; + if (!("capability" in descriptor) || descriptor.capability.integrations.length === 0) { + throw new Error(`Widget kind ${kind} does not declare integration support`); + } + + return [...descriptor.capability.integrations] as AtLeastOneOf>; +}; diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index b83fa3706..529dba2fc 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -1,73 +1,22 @@ import { createDocumentationLink } from "./docs"; import type { IntegrationKind } from "./integration"; -import { getIntegrationKindsByCategory, integrationKinds } from "./integration"; +import { integrationKinds } from "./integration"; import type { WidgetKind } from "./widget"; -import { widgetDefaultSizes } from "./widget"; +import { widgetDefaultSizes, widgetFeatureCatalog, widgetKinds } from "./widget"; +import type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog"; -/** - * 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; -} +export type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog"; -export const nativeFeatureCapabilities = { - dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") }, - dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") }, - "smartHome-entityState": { - integrations: getIntegrationKindsByCategory("smartHomeServer"), - serverMaxIntegrations: 1, - }, - "smartHome-executeAutomation": { - integrations: getIntegrationKindsByCategory("smartHomeServer"), - serverMaxIntegrations: 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") }, - healthMonitoring: { - integrations: getIntegrationKindsByCategory("healthMonitoring").filter( - (kind) => kind !== "patchmon" && kind !== "wud", - ), - }, - firewall: { integrations: getIntegrationKindsByCategory("firewall") }, - notifications: { integrations: getIntegrationKindsByCategory("notifications") }, - mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] }, - systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"] }, - systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] }, - beszelAlerts: { integrations: ["beszel", "mock"] }, - beszelSystemGrid: { integrations: ["beszel", "mock"] }, - beszelSystemStats: { integrations: ["beszel", "mock"] }, - beszelSystemTable: { integrations: ["beszel", "mock"] }, - coolify: { integrations: ["coolify"] }, - "immich-serverStats": { integrations: ["immich"], serverMaxIntegrations: 1 }, - "immich-albumCarousel": { integrations: ["immich"], serverMaxIntegrations: 1 }, - paperlessNgx: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 }, - patchmon: { integrations: ["patchmon"], serverMaxIntegrations: 1 }, - bazarr: { integrations: ["bazarr"], serverMaxIntegrations: 1 }, - tracearr: { integrations: ["tracearr"] }, - speedtestTracker: { integrations: ["speedtestTracker"] }, - uptimeKuma: { integrations: ["uptimeKuma"] }, - audioStats: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 }, - umami: { integrations: ["umami"], serverMaxIntegrations: 1 }, - vpn: { integrations: getIntegrationKindsByCategory("vpn") }, - ups: { integrations: getIntegrationKindsByCategory("ups") }, - archiveTeamWarrior: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 }, - anchorNote: { integrations: ["anchor"], serverMaxIntegrations: 1 }, - traefik: { integrations: ["traefik"] }, - wud: { integrations: ["wud"], serverMaxIntegrations: 1 }, -} as const satisfies Partial>; +const createNativeFeatureCapabilities = () => { + const result: Partial> = {}; + for (const kind of widgetKinds) { + const descriptor = widgetFeatureCatalog[kind]; + if ("capability" in descriptor) result[kind] = descriptor.capability; + } + return result; +}; + +export const nativeFeatureCapabilities = createNativeFeatureCapabilities(); const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [ WidgetKind, @@ -108,20 +57,24 @@ 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)); + const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null; }; @@ -147,9 +100,9 @@ const createIntegrationWidgetSupport = () => { 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; @@ -159,12 +112,28 @@ export interface DefaultWidgetConfig { skip?: boolean; } -export const defaultWidgetConfigs: DefaultWidgetConfig[] = [ - { kind: "clock", width: 2, height: 1 }, - { kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } }, - { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, +type WidgetKindWithDefaultSize = { + [TKind in WidgetKind]: (typeof widgetFeatureCatalog)[TKind] extends { defaultSize: Readonly<{ width: number }> } + ? TKind + : never; +}[WidgetKind]; + +type DefaultWidgetConfigRecipe = Omit & { + kind: WidgetKindWithDefaultSize; +}; + +const defaultWidgetConfigDefinitions: DefaultWidgetConfigRecipe[] = [ + { kind: "clock" }, + { kind: "weather", options: { showHumidity: false, showCity: false, hasForecast: false } }, + { kind: "bookmarks", options: { title: "Useful Links", layout: "grid", openNewTab: true } }, ]; +export const defaultWidgetConfigs: DefaultWidgetConfig[] = defaultWidgetConfigDefinitions.map((recipe) => { + const defaultSize = widgetDefaultSizes[recipe.kind]; + if (!defaultSize) throw new Error(`Default widget ${recipe.kind} must declare a default size`); + return { ...recipe, ...defaultSize }; +}); + const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); export const getDefaultWidgetConfig = (kind: WidgetKind): DefaultWidgetConfig => @@ -174,18 +143,6 @@ export const generalWidgets: WidgetKind[] = defaultWidgetConfigs .filter((config) => !config.skip) .map((config) => config.kind); -export const featuredIntegrations: readonly IntegrationKind[] = [ - "sonarr", - "radarr", - "prowlarr", - "sabNzbd", - "qBittorrent", - "seerr", - "jellyfin", -]; - -export const hiddenFromOnboarding = new Set(["mock"]); - export const defaultBookmarkApps = [ { name: "Homarr Docs", diff --git a/packages/definitions/src/widget-module.ts b/packages/definitions/src/widget-module.ts new file mode 100644 index 000000000..ef41330d8 --- /dev/null +++ b/packages/definitions/src/widget-module.ts @@ -0,0 +1,139 @@ +import type { AtLeastOneOf } from "@homarr/common/types"; + +import type { IntegrationCategory, IntegrationKind } from "./integration"; + +export type WidgetModuleIconName = `Icon${string}`; +export type WidgetModuleOperationKind = "query" | "mutation" | "subscription" | "route"; + +export interface WidgetModuleClientPolicy { + refetchIntervalSeconds?: number | null; + staleTimeSeconds?: number; + persist?: boolean; +} + +export interface WidgetModuleServerCachePolicy { + namespace: string; + ttlMs: number; + staleIfErrorTtlMs?: number; + scope: "integration" | "shared" | "private"; +} + +export interface WidgetModuleOperation { + name: string; + kind?: WidgetModuleOperationKind; + path: readonly [string, ...string[]]; + client?: WidgetModuleClientPolicy; + serverCache?: WidgetModuleServerCachePolicy; +} + +type WidgetModuleIntegrationSelection = + | { + categories: Readonly>; + kinds?: readonly IntegrationKind[]; + } + | { + categories?: readonly IntegrationCategory[]; + kinds: Readonly>; + }; + +export type WidgetModuleIntegrationCapability = WidgetModuleIntegrationSelection & { + excludeKinds?: readonly IntegrationKind[]; + connectionOptional?: true; + serverMaxIntegrations?: number; +}; + +export interface WidgetModuleRouterRegistration { + namespace: string; + module: string; + exportName: string; + additionalWidgetKinds?: readonly string[]; + mcp?: true | string; +} + +export interface WidgetModuleDocumentation { + slug: string; + sourceDirectory: string; +} + +/** + * Pure authoring contract for a vertical widget module. + * + * This descriptor intentionally contains import paths instead of importing + * client or server implementations. The sync tool turns them into explicit + * static imports for Next.js and Turbopack without crossing runtime boundaries. + */ +export interface WidgetModuleDefinition { + kind: string; + icon: WidgetModuleIconName; + clientEntry: string; + documentation: WidgetModuleDocumentation; + defaultSize?: Readonly<{ width: number; height: number }>; + displayNameFromIntegration?: true; + integration?: WidgetModuleIntegrationCapability; + operations?: readonly WidgetModuleOperation[]; + routers?: readonly WidgetModuleRouterRegistration[]; +} + +export const defineWidgetModule = (definition: TDefinition) => + definition; + +export type WidgetServerPermission = + | "public" + | "authenticated" + | "integration-query" + | "integration-interact" + | "custom"; + +export interface WidgetServerInputSchema { + parseAsync: (input: unknown) => Promise; +} + +export interface WidgetServerContext { + services: TServices; + authorization: TAuthorization; + signal: AbortSignal; +} + +export interface WidgetServerOperationDefinition { + kind: "query" | "mutation"; + permission: WidgetServerPermission; + input: WidgetServerInputSchema; + cache?: WidgetModuleServerCachePolicy; + resolve: (context: TContext, input: TInput) => Promise; +} + +export interface WidgetServerSubscriptionDefinition { + kind: "subscription"; + permission: WidgetServerPermission; + input: WidgetServerInputSchema; + transport: "sse" | "websocket"; + subscribe: (context: TContext, input: TInput) => AsyncIterable; +} + +export interface WidgetServerRouteDefinition { + kind: "route"; + permission: WidgetServerPermission; + handle: (context: TContext, request: TRequest) => Promise; +} + +export const defineWidgetServerOperation = ( + operation: WidgetServerOperationDefinition, +) => operation; + +export const defineWidgetServerSubscription = ( + subscription: WidgetServerSubscriptionDefinition, +) => subscription; + +export const defineWidgetServerRoute = ( + route: WidgetServerRouteDefinition, +) => route; + +export const defineWidgetServerModule = < + const TModule extends { + operations?: Readonly>; + subscriptions?: Readonly>; + routes?: Readonly>; + }, +>( + module: TModule, +) => module; diff --git a/packages/definitions/src/widget-name.ts b/packages/definitions/src/widget-name.ts index 1db43a479..ee544ae7a 100644 --- a/packages/definitions/src/widget-name.ts +++ b/packages/definitions/src/widget-name.ts @@ -1,21 +1,11 @@ -import type { IntegrationKind } from "./integration"; import { getIntegrationName } from "./integration"; import type { WidgetKind } from "./widget"; - -const widgetIntegrationKinds: Partial> = { - coolify: "coolify", - tracearr: "tracearr", - paperlessNgx: "paperlessNgx", - patchmon: "patchmon", - bazarr: "bazarr", - uptimeKuma: "uptimeKuma", - traefik: "traefik", -}; +import { widgetFeatureCatalog } from "./widget-feature-catalog"; export const getWidgetIntegrationName = (kind: WidgetKind): string | undefined => { - const integrationKind = widgetIntegrationKinds[kind]; - if (!integrationKind) return undefined; - return getIntegrationName(integrationKind); + const descriptor = widgetFeatureCatalog[kind]; + if (!("displayNameFromIntegration" in descriptor) || !descriptor.displayNameFromIntegration) return undefined; + return getIntegrationName(descriptor.capability.integrations[0]); }; type WidgetNameTranslator = (key: never) => string; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 5279df2e0..d438697bb 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -1,75 +1,19 @@ -export const widgetKinds = [ - "clock", - "weather", - "airQuality", - "countdown", - "timer", - "app", - "iframe", - "video", - "notebook", - "anchorNote", - "dnsHoleSummary", - "dnsHoleControls", - "smartHome-entityState", - "smartHome-executeAutomation", - "stockPrice", - "mediaServer", - "calendar", - "downloads", - "mediaRequests-requestList", - "mediaRequests-requestStats", - "mediaTranscoding", - "mediaMissing", - "minecraftServerStatus", - "networkControllerSummary", - "networkControllerStatus", - "rssFeed", - "bookmarks", - "indexerManager", - "healthMonitoring", - "releases", - "mediaReleases", - "dockerContainers", - "firewall", - "notifications", - "systemResources", - "coolify", - "systemDisks", - "timetable", - "immich-serverStats", - "immich-albumCarousel", - "paperlessNgx", - "patchmon", - "bazarr", - "tracearr", - "speedtestTracker", - "uptimeKuma", - "audioStats", - "umami", - "vpn", - "archiveTeamWarrior", - "ups", - "beszelSystemTable", - "beszelSystemGrid", - "beszelAlerts", - "beszelSystemStats", - "traefik", - "customApi", - "assistant", - "wud", -] as const; -export type WidgetKind = (typeof widgetKinds)[number]; +import { widgetFeatureCatalog } from "./widget-feature-catalog"; -export const widgetDefaultSizes: Partial> = { - airQuality: { width: 2, height: 1 }, - countdown: { width: 2, height: 1 }, - timer: { width: 2, height: 1 }, - uptimeKuma: { width: 2, height: 3 }, - audioStats: { width: 2, height: 2 }, - paperlessNgx: { width: 2, height: 2 }, - patchmon: { width: 2, height: 2 }, - mediaMissing: { width: 4, height: 3 }, - bazarr: { width: 2, height: 2 }, - assistant: { width: 10, height: 4 }, +export * from "./widget-feature-catalog"; +export type { WidgetFeatureDescriptor } from "./widget-feature-catalog"; + +export type WidgetKind = keyof typeof widgetFeatureCatalog; + +export const widgetKinds = Object.keys(widgetFeatureCatalog) as unknown as readonly [WidgetKind, ...WidgetKind[]]; + +const createWidgetDefaultSizes = () => { + const result: Partial> = {}; + for (const kind of widgetKinds) { + const descriptor = widgetFeatureCatalog[kind]; + if ("defaultSize" in descriptor) result[kind] = descriptor.defaultSize; + } + return result; }; + +export const widgetDefaultSizes = createWidgetDefaultSizes(); diff --git a/packages/integrations/index.ts b/packages/integrations/index.ts index 3bd16e178..0b5335443 100644 --- a/packages/integrations/index.ts +++ b/packages/integrations/index.ts @@ -1 +1,6 @@ -export * from "./src"; +/** + * The package root is intentionally type-only. Runtime integrations must use + * a narrow export such as `@homarr/integrations/factory` so importing one + * capability cannot eagerly pull every concrete integration into the graph. + */ +export type * from "./src"; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 026a3a782..82c0f5bbc 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -13,7 +13,11 @@ }, "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", + "./synology": "./src/synology/synology-integration.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..bbdbf9b6c 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -1,146 +1,198 @@ 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 { generatedIntegrationCreatorLoaders } from "../generated/integration-creators"; import type { Integration, IntegrationInput } from "./integration"; -export const createIntegrationAsync = async ( +const logger = createLogger({ module: "integrationFactory" }); + +type IntegrationCreator = ( + input: IntegrationInput, +) => Promise; + +type IntegrationConstructor = new ( + input: IntegrationInput, +) => TIntegration; + +const createConstructorCreator = + ( + IntegrationClass: IntegrationConstructor, + ): IntegrationCreator => + async (input) => + new IntegrationClass(input); + +// Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk. +const integrationCreatorLoaders = { + anchor: async () => createConstructorCreator((await import("../anchor/anchor-integration")).AnchorIntegration), + piHole: async () => (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync, + adGuardHome: async () => + createConstructorCreator((await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration), + technitiumDns: async () => + (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync, + homeAssistant: async () => + createConstructorCreator((await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration), + jellyfin: async () => + createConstructorCreator((await import("../jellyfin/jellyfin-integration")).JellyfinIntegration), + plex: async () => createConstructorCreator((await import("../plex/plex-integration")).PlexIntegration), + sonarr: async () => + createConstructorCreator((await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration), + radarr: async () => + createConstructorCreator((await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration), + sabNzbd: async () => + createConstructorCreator((await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration), + nzbGet: async () => + createConstructorCreator((await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration), + qBittorrent: async () => + createConstructorCreator( + (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration, + ), + deluge: async () => + createConstructorCreator((await import("../download-client/deluge/deluge-integration")).DelugeIntegration), + transmission: async () => + createConstructorCreator( + (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration, + ), + slskd: async () => + createConstructorCreator((await import("../download-client/slskd/slskd-integration")).SlskdIntegration), + aria2: async () => + createConstructorCreator((await import("../download-client/aria2/aria2-integration")).Aria2Integration), + jellyseerr: async () => + createConstructorCreator((await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration), + seerr: async () => createConstructorCreator((await import("../seerr/seerr-integration")).SeerrIntegration), + overseerr: async () => + createConstructorCreator((await import("../overseerr/overseerr-integration")).OverseerrIntegration), + prowlarr: async () => + createConstructorCreator((await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration), + openmediavault: async () => + createConstructorCreator((await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration), + lidarr: async () => + createConstructorCreator((await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration), + readarr: async () => + createConstructorCreator((await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration), + dashDot: async () => createConstructorCreator((await import("../dashdot/dashdot-integration")).DashDotIntegration), + tdarr: async () => + createConstructorCreator((await import("../media-transcoding/tdarr-integration")).TdarrIntegration), + proxmox: async () => createConstructorCreator((await import("../proxmox/proxmox-integration")).ProxmoxIntegration), + emby: async () => createConstructorCreator((await import("../emby/emby-integration")).EmbyIntegration), + nextcloud: async () => + createConstructorCreator((await import("../nextcloud/nextcloud.integration")).NextcloudIntegration), + unifiController: async () => + createConstructorCreator( + (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration, + ), + opnsense: async () => + createConstructorCreator((await import("../opnsense/opnsense-integration")).OPNsenseIntegration), + ical: async () => createConstructorCreator((await import("../ical/ical-integration")).ICalIntegration), + ntfy: async () => createConstructorCreator((await import("../ntfy/ntfy-integration")).NTFYIntegration), + gotify: async () => createConstructorCreator((await import("../gotify/gotify-integration")).GotifyIntegration), + mock: async () => createConstructorCreator((await import("../mock/mock-integration")).MockIntegration), + truenas: async () => createConstructorCreator((await import("../truenas/truenas-integration")).TrueNasIntegration), + synology: async () => + createConstructorCreator((await import("../synology/synology-integration")).SynologyIntegration), + unraid: async () => createConstructorCreator((await import("../unraid/unraid-integration")).UnraidIntegration), + coolify: async () => createConstructorCreator((await import("../coolify/coolify-integration")).CoolifyIntegration), + tracearr: async () => + createConstructorCreator((await import("../tracearr/tracearr-integration")).TracearrIntegration), + glances: async () => createConstructorCreator((await import("../glances/glances-integration")).GlancesIntegration), + immich: async () => createConstructorCreator((await import("../immich/immich-integration")).ImmichIntegration), + paperlessNgx: async () => + createConstructorCreator((await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration), + patchmon: async () => + createConstructorCreator((await import("../patchmon/patchmon-integration")).PatchMonIntegration), + speedtestTracker: async () => + createConstructorCreator( + (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration, + ), + audiobookshelf: async () => + createConstructorCreator((await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration), + navidrome: async () => + createConstructorCreator((await import("../navidrome/navidrome-integration")).NavidromeIntegration), + umami: async () => createConstructorCreator((await import("../umami/umami-integration")).UmamiIntegration), + gluetun: async () => createConstructorCreator((await import("../gluetun/gluetun-integration")).GluetunIntegration), + archiveTeamWarrior: async () => + createConstructorCreator( + (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration, + ), + uptimeKuma: async () => + createConstructorCreator((await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration), + peaNut: async () => createConstructorCreator((await import("../peanut/peanut-integration")).PeaNutIntegration), + ...generatedIntegrationCreatorLoaders, + bazarr: async () => createConstructorCreator((await import("../bazarr/bazarr-integration")).BazarrIntegration), + traefik: async () => createConstructorCreator((await import("../traefik/traefik-integration")).TraefikIntegration), + wud: async () => createConstructorCreator((await import("../wud/wud-integration")).WudIntegration), +}; + +type AssertIntegrationCreatorLoaders Promise>> = + TLoaders; +type AssertNoUnexpectedIntegrationKinds = TKind; +type IntegrationCreatorLoadersCheck = AssertIntegrationCreatorLoaders; +type UnexpectedIntegrationKindsCheck = AssertNoUnexpectedIntegrationKinds< + Exclude +>; + +type IntegrationCreatorLoaders = IntegrationCreatorLoadersCheck & Record; +type IntegrationCreatorOfKind = Awaited< + ReturnType +>; +type IntegrationInstanceOfKind = Awaited< + ReturnType> +>; + +const integrationCreatorCache = new Map>(); + +const loadIntegrationCreatorAsync = async ( + kind: TKind, +): Promise> => { + const cachedCreator = integrationCreatorCache.get(kind); + if (cachedCreator) { + return (await cachedCreator) as IntegrationCreatorOfKind; + } + + const startedAt = Date.now(); + logger.debug("Loading integration implementation", { integrationKind: kind }); + const creatorPromise = integrationCreatorLoaders[kind](); + integrationCreatorCache.set(kind, creatorPromise); + + try { + const creator = (await creatorPromise) as IntegrationCreatorOfKind; + logger.debug("Integration implementation loaded", { + integrationKind: kind, + durationMs: Date.now() - startedAt, + }); + return creator; + } catch (error) { + if (integrationCreatorCache.get(kind) === creatorPromise) { + integrationCreatorCache.delete(kind); + } + logger.warn( + new ErrorWithMetadata( + "Integration implementation failed to load", + { + integrationKind: kind, + durationMs: Date.now() - startedAt, + }, + { cause: error }, + ), + ); + throw error; + } +}; + +export const createIntegrationAsync = async ( integration: IntegrationInput & { kind: TKind }, -) => { - if (!(integration.kind in integrationCreators)) { +): Promise> => { + if (!Object.hasOwn(integrationCreatorLoaders, 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]; - - // factories are an array, to differentiate in js between class constructors and functions - if (Array.isArray(creator)) { - return (await creator[0](integration)) as IntegrationInstanceOfKind; - } - - return new creator(integration) as IntegrationInstanceOfKind; + const creator = await loadIntegrationCreatorAsync(integration.kind); + return (await 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..69966dde8 100644 --- a/packages/integrations/src/base/errors/decorator.ts +++ b/packages/integrations/src/base/errors/decorator.ts @@ -1,14 +1,18 @@ 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(); // eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any type AbstractConstructor = abstract new (...args: any[]) => T; @@ -19,6 +23,62 @@ 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; + } + 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 +104,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 +132,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/beszel/docs/img/beszel-dashboard.png b/packages/integrations/src/beszel/docs/img/beszel-dashboard.png new file mode 100644 index 000000000..38344a54d Binary files /dev/null and b/packages/integrations/src/beszel/docs/img/beszel-dashboard.png differ diff --git a/packages/integrations/src/beszel/docs/index.mdx b/packages/integrations/src/beszel/docs/index.mdx new file mode 100644 index 000000000..8ddda77c6 --- /dev/null +++ b/packages/integrations/src/beszel/docs/index.mdx @@ -0,0 +1,58 @@ +--- +title: "Beszel" +description: "Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions." +hide_title: true +--- + +import { IntegrationHeader } from "@site/src/components/integrations/header"; +import { IntegrationCapabilites } from "@site/src/components/integrations/widgets"; +import { AddingIntegration } from "@site/src/components/integrations/adding"; +import { IntegrationSecrets } from "@site/src/components/integrations/secrets"; +import { beszelIntegration } from "."; +import { beszelSystemStatsWidget } from "@site/docs/widgets/beszel-system-stats"; +import { beszelSystemGridWidget } from "@site/docs/widgets/beszel-system-grid"; +import { beszelSystemTableWidget } from "@site/docs/widgets/beszel-system-table"; +import { beszelAlertsWidget } from "@site/docs/widgets/beszel-alerts"; + +import beszelDashboard from "./img/beszel-dashboard.png"; + + + +Beszel monitors your servers in real time via lightweight agents, collecting CPU, memory, disk, network, GPU, temperature, and Docker container metrics. It stores historical data and supports configurable alerts. + +### Screenshots + +Beszel dashboard overview showing all four widgets + +### Widgets & Capabilities + + + +### Adding the integration + + + +### Secrets + + + +:::tip +Beszel uses PocketBase under the hood. The default admin credentials are set during the initial Beszel setup. +::: diff --git a/packages/integrations/src/beszel/docs/index.ts b/packages/integrations/src/beszel/docs/index.ts new file mode 100644 index 000000000..c67aae6bf --- /dev/null +++ b/packages/integrations/src/beszel/docs/index.ts @@ -0,0 +1,7 @@ +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", +} as const; diff --git a/packages/integrations/src/beszel/module.ts b/packages/integrations/src/beszel/module.ts new file mode 100644 index 000000000..c9cc6f951 --- /dev/null +++ b/packages/integrations/src/beszel/module.ts @@ -0,0 +1,19 @@ +import { defineIntegrationModule } from "@homarr/definitions"; + +export default defineIntegrationModule({ + kind: "beszel", + name: "Beszel", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg", + secretKinds: [["username", "password"]], + categories: ["beszel"], + defaultPort: 8090, + documentation: { + slug: "beszel", + sourceDirectory: "docs", + }, + creator: { + type: "constructor", + module: "./beszel-integration", + exportName: "BeszelIntegration", + }, +}); 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/generated/integration-creators.ts b/packages/integrations/src/generated/integration-creators.ts new file mode 100644 index 000000000..0071e883d --- /dev/null +++ b/packages/integrations/src/generated/integration-creators.ts @@ -0,0 +1,12 @@ +// This file is generated by pnpm integrations:sync. Do not edit it directly. + +import type { Integration, IntegrationInput } from "../base/integration"; + +type GeneratedIntegrationCreator = (input: IntegrationInput) => Promise; + +export const generatedIntegrationCreatorLoaders = { + beszel: async () => { + const IntegrationClass = (await import("../beszel/beszel-integration")).BeszelIntegration; + return async (input: IntegrationInput) => new IntegrationClass(input); + }, +} satisfies Record Promise>; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index d4f200618..21fdfa443 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,53 +1,6 @@ -// General integrations -export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration"; -export { WudIntegration } from "./wud/wud-integration"; -export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration"; -export { TechnitiumDnsIntegration } from "./technitium/technitium-integration"; -export { AnchorIntegration } from "./anchor/anchor-integration"; -export { Aria2Integration } from "./download-client/aria2/aria2-integration"; -export { DelugeIntegration } from "./download-client/deluge/deluge-integration"; -export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration"; -export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration"; -export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration"; -export { SlskdIntegration } from "./download-client/slskd/slskd-integration"; -export { TransmissionIntegration } from "./download-client/transmission/transmission-integration"; -export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration"; -export { JellyfinIntegration } from "./jellyfin/jellyfin-integration"; -export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration"; -export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration"; -export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration"; -export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration"; -export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration"; -export { NextcloudIntegration } from "./nextcloud/nextcloud.integration"; -export { GotifyIntegration } from "./gotify/gotify-integration"; -export { NTFYIntegration } from "./ntfy/ntfy-integration"; -export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration"; -export { GlancesIntegration } from "./glances/glances-integration"; -export { OverseerrIntegration } from "./overseerr/overseerr-integration"; -export { SeerrIntegration } from "./seerr/seerr-integration"; -export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5"; -export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6"; -export { PlexIntegration } from "./plex/plex-integration"; -export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration"; -export { TrueNasIntegration } from "./truenas/truenas-integration"; -export { SynologyIntegration } from "./synology/synology-integration"; -export { UnraidIntegration } from "./unraid/unraid-integration"; -export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; -export { ICalIntegration } from "./ical/ical-integration"; -export { CoolifyIntegration } from "./coolify/coolify-integration"; -export { ImmichIntegration } from "./immich/immich-integration"; -export { TracearrIntegration } from "./tracearr/tracearr-integration"; -export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration"; -export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration"; -export { NavidromeIntegration } from "./navidrome/navidrome-integration"; -export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration"; -export { UmamiIntegration } from "./umami/umami-integration"; -export { PeaNutIntegration } from "./peanut/peanut-integration"; -export { BazarrIntegration } from "./bazarr/bazarr-integration"; -export { TraefikIntegration } from "./traefik/traefik-integration"; - -// Types +// Type-only compatibility surface. Runtime capabilities have explicit subpaths. export type { IntegrationInput } from "./base/integration"; +export type { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6"; export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data"; export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items"; export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status"; @@ -59,8 +12,11 @@ export type { FirewallMemorySummary, } from "./interfaces/firewall-summary/firewall-summary-types"; export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types"; -export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types"; -export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types"; +export type { + MediaRequestList, + MediaRequestStats, + UpstreamMediaRequestStatus, +} from "./interfaces/media-requests/media-request-types"; export type { StreamSession } from "./interfaces/media-server/media-server-types"; export type { EntityState } from "./interfaces/smart-home/smart-home-types"; export type { @@ -95,12 +51,4 @@ export type { } from "./archive-team-warrior/archive-team-warrior-types"; export type { WudStats, WudContainerUpdate } from "./wud/wud-types"; -// Schemas -export { anchorNotesListInputSchema } from "./anchor/anchor-types"; -export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types"; -export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; - -// Helpers -export { createIntegrationAsync } from "./base/creator"; -export { simulateResponseContractAsync } from "./base/response-contract"; export type { ResponseContractFixture, ResponseContractParser } from "./base/response-contract"; diff --git a/packages/integrations/test/aria2.spec.ts b/packages/integrations/test/aria2.spec.ts index a204d7ccf..049c21f65 100644 --- a/packages/integrations/test/aria2.spec.ts +++ b/packages/integrations/test/aria2.spec.ts @@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; -import { Aria2Integration } from "../src"; +import { Aria2Integration } from "../src/download-client/aria2/aria2-integration"; vi.mock("@homarr/db", async (importActual) => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports diff --git a/packages/integrations/test/home-assistant.spec.ts b/packages/integrations/test/home-assistant.spec.ts index 91952b37e..35996a744 100644 --- a/packages/integrations/test/home-assistant.spec.ts +++ b/packages/integrations/test/home-assistant.spec.ts @@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; -import { HomeAssistantIntegration } from "../src"; +import { HomeAssistantIntegration } from "../src/homeassistant/homeassistant-integration"; import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; vi.mock("@homarr/db", async (importActual) => { diff --git a/packages/integrations/test/nextcloud.spec.ts b/packages/integrations/test/nextcloud.spec.ts index f9b4860bb..d31891e18 100644 --- a/packages/integrations/test/nextcloud.spec.ts +++ b/packages/integrations/test/nextcloud.spec.ts @@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { createId } from "@homarr/common"; import { createDb } from "@homarr/db/test"; -import { NextcloudIntegration } from "../src"; +import { NextcloudIntegration } from "../src/nextcloud/nextcloud.integration"; // Mock the database vi.mock("@homarr/db", async (importActual) => { diff --git a/packages/integrations/test/nzbget.spec.ts b/packages/integrations/test/nzbget.spec.ts index 6b485e71d..21a19756b 100644 --- a/packages/integrations/test/nzbget.spec.ts +++ b/packages/integrations/test/nzbget.spec.ts @@ -6,7 +6,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; -import { NzbGetIntegration } from "../src"; +import { NzbGetIntegration } from "../src/download-client/nzbget/nzbget-integration"; import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; vi.mock("@homarr/db", async (importActual) => { diff --git a/packages/integrations/test/pi-hole.spec.ts b/packages/integrations/test/pi-hole.spec.ts index 78c272689..41b8719a4 100644 --- a/packages/integrations/test/pi-hole.spec.ts +++ b/packages/integrations/test/pi-hole.spec.ts @@ -4,7 +4,8 @@ import { describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; -import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src"; +import { PiHoleIntegrationV5 } from "../src/pi-hole/v5/pi-hole-integration-v5"; +import { PiHoleIntegrationV6 } from "../src/pi-hole/v6/pi-hole-integration-v6"; import type { SessionStore } from "../src/base/session-store"; import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; diff --git a/packages/integrations/test/sabnzbd.spec.ts b/packages/integrations/test/sabnzbd.spec.ts index 8603f04a0..fd3ef994f 100644 --- a/packages/integrations/test/sabnzbd.spec.ts +++ b/packages/integrations/test/sabnzbd.spec.ts @@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; -import { SabnzbdIntegration } from "../src"; +import { SabnzbdIntegration } from "../src/download-client/sabnzbd/sabnzbd-integration"; import { TestConnectionError } from "../src/base/test-connection/test-connection-error"; import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items"; diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index 75c44d090..fc72f54b1 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -463,9 +463,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]; + }), ), ], }); @@ -1931,13 +1933,19 @@ 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]; + }), ]), ]; -const getPreviewAppCount = (props: StudioSectionProps) => - props.drafts.filter(isIntegrationDraftComplete).length + - props.discoveredApps.filter((app) => props.selectedAppIds.includes(app.sourceId)).length; +const getPreviewAppCount = (props: StudioSectionProps) => { + return ( + props.drafts.filter(isIntegrationDraftComplete).length + + props.discoveredApps.filter((app) => props.selectedAppIds.includes(app.sourceId)).length + ); +}; const createDraft = ({ id, diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 857f87c4f..d470c9c03 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -17,8 +17,12 @@ export { createSubPubChannel, createGetSetChannel, createLockChannel, + getIntegrationCacheGenerationAsync, + getWidgetCacheGenerationAsync, invalidateIntegrationCacheAsync, + 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.ts b/packages/redis/src/lib/channel.ts index 4731ccfa1..f46d24c8c 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -1,12 +1,15 @@ 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"; const publisher = createRedisConnection(); const lastDataClient = createRedisConnection(); +const logger = createLogger({ module: "redisChannel" }); /** * Creates a new pub/sub channel. @@ -108,9 +111,17 @@ export const createGetSetChannel = (name: string) => { /** * 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 }) => { + setAsync: async (data: TData, options?: { ttlSeconds?: number; ttlMs?: number }) => { + if (options?.ttlMs !== undefined) { + if (options.ttlMs <= 0) { + await getSetClient.del(name); + return; + } + await getSetClient.set(name, superjson.stringify(data), "PX", options.ttlMs); + return; + } if (options?.ttlSeconds) { await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds); return; @@ -156,10 +167,187 @@ export const createLockChannel = (name: string) => { }; }; -export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { +const integrationCacheGenerationKey = (integrationId: string) => `integration-cache:generation:${integrationId}`; +const pendingIntegrationCacheInvalidations = new Map(); +const MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS = 1000; +const INTEGRATION_CACHE_REDIS_TIMEOUT_MS = 500; + +const logCacheGenerationFailure = (message: string, metadata: Record, error: unknown) => { + logger.warn(new ErrorWithMetadata(message, metadata, { cause: error })); +}; + +const withIntegrationCacheRedisTimeoutAsync = async (promise: Promise): Promise => { + return await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("Redis integration cache operation timed out")), + INTEGRATION_CACHE_REDIS_TIMEOUT_MS, + ); + timer.unref?.(); + void promise.then(resolve, reject).finally(() => clearTimeout(timer)); + }); +}; + +const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => { const client = getSetClient as typeof getSetClient | null; - if (!client) return; - await client.del(`session-store:${integrationId}`); + if (!client) return null; + + const results = await withIntegrationCacheRedisTimeoutAsync( + client.multi().incr(integrationCacheGenerationKey(integrationId)).del(`session-store:${integrationId}`).exec(), + ); + const generationResult = results?.[0]; + const sessionResult = results?.[1]; + if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result"); + if (!sessionResult || sessionResult[0]) throw sessionResult?.[0] ?? new Error("Missing session result"); + return String(generationResult[1]); +}; + +export interface IntegrationCacheGeneration { + value: string; + isShared: boolean; +} + +export const getIntegrationCacheGenerationAsync = async ( + integrationId: string, +): Promise => { + const pendingGeneration = pendingIntegrationCacheInvalidations.get(integrationId); + if (pendingGeneration) { + try { + const generation = await advanceIntegrationCacheGenerationAsync(integrationId); + if (generation) { + if (pendingIntegrationCacheInvalidations.get(integrationId) === pendingGeneration) { + pendingIntegrationCacheInvalidations.delete(integrationId); + return { value: generation, isShared: true }; + } + return { + value: pendingIntegrationCacheInvalidations.get(integrationId) ?? generation, + isShared: false, + }; + } + } catch (error) { + logCacheGenerationFailure( + "Failed to retry integration cache invalidation", + { integrationId, operation: "generation-advance" }, + error, + ); + } + return { value: pendingGeneration, isShared: false }; + } + + const client = getSetClient as typeof getSetClient | null; + if (!client) return { value: "redis-unavailable", isShared: false }; + + try { + const generation = await withIntegrationCacheRedisTimeoutAsync( + client.get(integrationCacheGenerationKey(integrationId)), + ); + return { value: generation ?? "0", isShared: true }; + } catch (error) { + logCacheGenerationFailure( + "Failed to read integration cache generation", + { integrationId, operation: "generation-read" }, + error, + ); + return { value: "redis-unavailable", isShared: false }; + } +}; + +export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { + const pendingGeneration = `local-${createId()}`; + if (pendingIntegrationCacheInvalidations.size >= MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS) { + const oldestIntegrationId = pendingIntegrationCacheInvalidations.keys().next().value; + if (oldestIntegrationId) pendingIntegrationCacheInvalidations.delete(oldestIntegrationId); + } + pendingIntegrationCacheInvalidations.set(integrationId, pendingGeneration); + + try { + const generation = await advanceIntegrationCacheGenerationAsync(integrationId); + if (generation && pendingIntegrationCacheInvalidations.get(integrationId) === pendingGeneration) { + pendingIntegrationCacheInvalidations.delete(integrationId); + } + } catch (error) { + logCacheGenerationFailure( + "Failed to invalidate integration caches in Redis", + { integrationId, operation: "generation-advance-and-session-delete" }, + error, + ); + } +}; + +const widgetCacheGenerationKey = (namespace: string) => `widget-cache:generation:${namespace}`; +const pendingWidgetCacheInvalidations = new Map(); +const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000; + +const advanceWidgetCacheGenerationAsync = async (namespace: string) => { + const client = getSetClient as typeof getSetClient | null; + if (!client) return null; + + return String(await withIntegrationCacheRedisTimeoutAsync(client.incr(widgetCacheGenerationKey(namespace)))); +}; + +export type WidgetCacheGeneration = IntegrationCacheGeneration; + +export const getWidgetCacheGenerationAsync = async (namespace: string): Promise => { + const pendingGeneration = pendingWidgetCacheInvalidations.get(namespace); + if (pendingGeneration) { + try { + const generation = await advanceWidgetCacheGenerationAsync(namespace); + if (generation) { + if (pendingWidgetCacheInvalidations.get(namespace) === pendingGeneration) { + pendingWidgetCacheInvalidations.delete(namespace); + return { value: generation, isShared: true }; + } + return { + value: pendingWidgetCacheInvalidations.get(namespace) ?? generation, + isShared: false, + }; + } + } catch (error) { + logCacheGenerationFailure( + "Failed to retry widget cache invalidation", + { cacheNamespace: namespace, operation: "generation-advance" }, + error, + ); + } + return { value: pendingGeneration, isShared: false }; + } + + const client = getSetClient as typeof getSetClient | null; + if (!client) return { value: "redis-unavailable", isShared: false }; + + try { + const generation = await withIntegrationCacheRedisTimeoutAsync(client.get(widgetCacheGenerationKey(namespace))); + return { value: generation ?? "0", isShared: true }; + } catch (error) { + logCacheGenerationFailure( + "Failed to read widget cache generation", + { cacheNamespace: namespace, operation: "generation-read" }, + error, + ); + return { value: "redis-unavailable", isShared: false }; + } +}; + +export const invalidateWidgetCache = (namespace: string): void => { + const pendingGeneration = `local-${createId()}`; + if (pendingWidgetCacheInvalidations.size >= MAX_PENDING_WIDGET_CACHE_INVALIDATIONS) { + const oldestNamespace = pendingWidgetCacheInvalidations.keys().next().value; + if (oldestNamespace) pendingWidgetCacheInvalidations.delete(oldestNamespace); + } + pendingWidgetCacheInvalidations.set(namespace, pendingGeneration); + + void advanceWidgetCacheGenerationAsync(namespace) + .then((generation) => { + if (generation && pendingWidgetCacheInvalidations.get(namespace) === pendingGeneration) { + pendingWidgetCacheInvalidations.delete(namespace); + } + }) + .catch((error: unknown) => { + logCacheGenerationFailure( + "Failed to invalidate widget caches in Redis", + { cacheNamespace: namespace, operation: "generation-advance" }, + error, + ); + }); }; /** diff --git a/packages/request-handler/src/air-quality.ts b/packages/request-handler/src/air-quality.ts index 6a77a871a..8cccc4248 100644 --- a/packages/request-handler/src/air-quality.ts +++ b/packages/request-handler/src/air-quality.ts @@ -44,6 +44,7 @@ const requestedVariables = [ ] as const; export const airQualityRequestHandler = createWidgetRequestHandler({ + cacheNamespace: "air-quality", cacheTtlMs: 15 * 60 * 1000, fallbackToStaleOnError: true, staleIfErrorTtlMs: 24 * 60 * 60 * 1000, 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..a96f21136 100644 --- a/packages/request-handler/src/downloads.ts +++ b/packages/request-handler/src/downloads.ts @@ -1,14 +1,18 @@ +import { getWidgetServerCachePolicy } from "@homarr/definitions"; 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"; +const downloadsCachePolicy = getWidgetServerCachePolicy("downloads", "getJobsAndStatuses"); + export const downloadClientRequestHandler = createIntegrationRequestHandler< DownloadClientJobsAndStatus, IntegrationKindByCategory<"downloadClient">, { limit: number } >({ + cachePolicy: downloadsCachePolicy, 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..67b8bbaeb 100644 --- a/packages/request-handler/src/lib/integration-request-handler.ts +++ b/packages/request-handler/src/lib/integration-request-handler.ts @@ -1,8 +1,10 @@ import type { Modify } from "@homarr/common/types"; import type { Integration, IntegrationSecret } from "@homarr/db/schema"; -import type { IntegrationKind } from "@homarr/definitions"; +import type { IntegrationKind, WidgetModuleServerCachePolicy } from "@homarr/definitions"; +import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./integration-shared-cache"; import { createRequestHandler } from "./request-handler"; +import type { SharedCacheAdapter } from "./request-handler"; type IntegrationOfKind = Omit & { kind: TKind; @@ -15,6 +17,8 @@ interface Options( options: Options, ) => { + const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace; + let getSharedCacheAsync: + | ((input: { integration: IntegrationOfKind; options: TInput }) => Promise>) + | undefined; + if (cacheNamespace) { + getSharedCacheAsync = async ({ integration, options: itemOptions }) => + await createIntegrationSharedCacheAsync({ + namespace: cacheNamespace, + integrationId: integration.id, + cacheOptions: itemOptions, + }); + } + const inner = createRequestHandler; options: TInput }>({ requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions), getCacheKey: ({ integration, options: itemOptions }) => - JSON.stringify({ integrationId: integration.id, itemOptions }), - cacheTtlMs: options.cacheTtlMs, - fallbackToStaleOnError: options.fallbackToStaleOnError, - staleIfErrorTtlMs: options.staleIfErrorTtlMs, + `${integration.id}:${hashIntegrationCacheOptions(itemOptions)}`, + cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs, + fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError, + staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs, + getSharedCacheAsync, }); return { diff --git a/packages/request-handler/src/lib/integration-shared-cache.ts b/packages/request-handler/src/lib/integration-shared-cache.ts new file mode 100644 index 000000000..7062e3294 --- /dev/null +++ b/packages/request-handler/src/lib/integration-shared-cache.ts @@ -0,0 +1,209 @@ +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" }); +const REDIS_OPERATION_TIMEOUT_MS = 250; +const REFRESH_LOCK_TTL_SECONDS = 15; +const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/; + +const canonicalize = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(canonicalize); + if (!value || typeof value !== "object") return value; + + const result: Record = {}; + for (const key of Object.keys(value).toSorted()) { + result[key] = canonicalize((value as Record)[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)); + }); +}; + +const isCacheEntry = (value: unknown): value is CacheEntry => { + if (!value || typeof value !== "object") return false; + const entry = value as Partial>; + return ( + entry.timestamp instanceof Date && + typeof entry.expiresAt === "number" && + Number.isFinite(entry.expiresAt) && + typeof entry.staleUntil === "number" && + Number.isFinite(entry.staleUntil) + ); +}; + +interface IntegrationSharedCacheOptions { + namespace: string; + integrationId: string; + cacheOptions: Record; +} + +interface RedisSharedCacheOptions { + namespace: string; + cacheKind: "integration" | "widget"; + cacheIdentity: string; + generation: string; + metadata?: Record; + isShared?: boolean; +} + +const logRedisFailure = (operation: string, metadata: Record, error: unknown) => { + logger.warn( + new ErrorWithMetadata( + "Redis widget cache operation failed", + { + ...metadata, + operation, + }, + { cause: error }, + ), + ); +}; + +const createRedisSharedCache = ({ + namespace, + cacheKind, + cacheIdentity, + generation, + metadata = {}, + isShared: initialIsShared = true, +}: RedisSharedCacheOptions): SharedCacheAdapter => { + const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata }; + const payloadChannel = createGetSetChannel( + `${cacheKind}-cache:payload:v1:${namespace}:${generation}:${cacheIdentity}`, + ); + const lock = createLockChannel(`${cacheKind}-cache:lock:v1:${namespace}:${generation}:${cacheIdentity}`); + 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 (isCacheEntry(value)) return value as CacheEntry; + + logger.warn("Ignored invalid Redis widget cache entry", { + ...logMetadata, + operation: "payload-validate", + }); + await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined); + return null; + }, + setAsync: async (entry) => { + const retentionMs = entry.staleUntil - Date.now(); + if (retentionMs <= 0) return; + await runRedisOperationAsync( + "payload-write", + () => payloadChannel.setAsync(entry, { ttlMs: retentionMs }), + undefined, + ); + }, + acquireRefreshLockAsync: async () => + await runRedisOperationAsync( + "refresh-lock-acquire", + () => lock.acquireAsync(REFRESH_LOCK_TTL_SECONDS), + undefined, + ), + releaseRefreshLockAsync: async (token) => { + await runRedisOperationAsync("refresh-lock-release", () => lock.releaseAsync(token), undefined); + }, + }; +}; + +interface WidgetSharedCacheOptions { + namespace: string; + cacheInput: Record; +} + +export const createWidgetSharedCacheAsync = async ({ + namespace, + cacheInput, +}: 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}'`); + + const metadata = { cacheNamespace: namespace }; + let generation: WidgetCacheGeneration; + try { + generation = await withTimeoutAsync(getWidgetCacheGenerationAsync(namespace), REDIS_OPERATION_TIMEOUT_MS); + } catch (error) { + logRedisFailure("generation-read", metadata, error); + generation = { value: "redis-unavailable", isShared: false }; + } + + return createRedisSharedCache({ + namespace, + cacheKind: "widget", + cacheIdentity: hashSharedCacheInput(cacheInput), + generation: generation.value, + isShared: generation.isShared, + }); +}; + +export const createIntegrationSharedCacheAsync = async ({ + namespace, + integrationId, + cacheOptions, +}: 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}'`); + + const metadata = { cacheNamespace: namespace, integrationId }; + let generation: IntegrationCacheGeneration; + try { + generation = await withTimeoutAsync(getIntegrationCacheGenerationAsync(integrationId), REDIS_OPERATION_TIMEOUT_MS); + } catch (error) { + logRedisFailure("generation-read", metadata, error); + generation = { value: "redis-unavailable", isShared: false }; + } + + const optionsHash = hashIntegrationCacheOptions(cacheOptions); + const sharedCache = createRedisSharedCache({ + namespace, + cacheKind: "integration", + cacheIdentity: `${integrationId}:${optionsHash}`, + generation: generation.value, + metadata: { integrationId }, + isShared: generation.isShared, + }); + return sharedCache; +}; diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index a447c377b..a945587c0 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -4,13 +4,35 @@ interface Options> { cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; + getSharedCacheAsync?: (input: TInput) => 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) => Promise; + acquireRefreshLockAsync: () => Promise; + releaseRefreshLockAsync: (token: string) => Promise; +} const MAX_CACHE_SIZE = 1000; const DEFAULT_TTL_MS = 10_000; const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000; +const SHARED_REFRESH_WAIT_MS = 750; +const SHARED_REFRESH_POLL_MS = 75; + +const delayAsync = async (durationMs: number) => + await new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); const evictExpired = (cache: Map>) => { const now = Date.now(); @@ -28,6 +50,17 @@ export const createRequestHandler = , requestGeneration: number) => { + if (generation !== requestGeneration || Date.now() >= entry.staleUntil) return; + evictExpired(cache); + 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 scheduleExpiry = (candidateExpiryAt?: number) => { if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return; if (expiryTimer) clearTimeout(expiryTimer); @@ -61,7 +94,9 @@ export const createRequestHandler = ({ async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; - const key = options.getCacheKey?.(input) ?? JSON.stringify(input); + const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input); + const sharedCache = ttl > 0 ? await options.getSharedCacheAsync?.(input) : undefined; + const key = `${sharedCache?.generation ?? "local"}:${baseKey}`; let cached = cache.get(key); const now = Date.now(); @@ -79,44 +114,73 @@ export const createRequestHandler = >; - 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 waitUntil = Date.now() + SHARED_REFRESH_WAIT_MS; + while (Date.now() < waitUntil) { + await delayAsync(SHARED_REFRESH_POLL_MS); + 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; + } + + lockToken = await sharedCache.acquireRefreshLockAsync(); + if (lockToken === null && options.fallbackToStaleOnError && stale) { + storeInMemory(key, stale, requestGeneration); + return stale; + } + } + } + + try { + const data = await options.requestAsync(input); + 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); - } - cache.set(key, entry); - scheduleExpiry(entry.staleUntil); + if (ttl > 0) { + storeInMemory(key, entry, requestGeneration); + if (sharedCache?.isShared) await sharedCache.setAsync(entry); } 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 { + if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken); + } + })().finally(() => { + if (inflight.get(key) === promise) inflight.delete(key); + }); inflight.set(key, promise); return promise; diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts index 350826fe3..553ac6443 100644 --- a/packages/request-handler/src/lib/widget-request-handler.ts +++ b/packages/request-handler/src/lib/widget-request-handler.ts @@ -1,15 +1,45 @@ +import { invalidateWidgetCache } from "@homarr/redis"; +import type { WidgetModuleServerCachePolicy } from "@homarr/definitions"; + +import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./integration-shared-cache"; import { createRequestHandler } from "./request-handler"; +import type { SharedCacheAdapter } from "./request-handler"; interface Options> { requestAsync: (input: TInput) => Promise; cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; + cacheNamespace?: string; + cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" }; } export const createWidgetRequestHandler = >( options: Options, ) => { - const inner = createRequestHandler(options); - return inner; + const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace; + let getSharedCacheAsync: ((input: TInput) => Promise>) | undefined; + if (cacheNamespace) { + getSharedCacheAsync = async (input) => + await createWidgetSharedCacheAsync({ + namespace: cacheNamespace, + cacheInput: input, + }); + } + + const inner = createRequestHandler({ + ...options, + cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs, + fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError, + staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs, + 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..4f51ccc75 100644 --- a/packages/request-handler/src/media-organizer.ts +++ b/packages/request-handler/src/media-organizer.ts @@ -1,5 +1,5 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; -import { createIntegrationAsync } from "@homarr/integrations"; +import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -16,6 +16,7 @@ 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 [missingResult, queueResult] = await Promise.all([ 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..1fe6e0504 100644 --- a/packages/request-handler/src/minecraft-server-status.ts +++ b/packages/request-handler/src/minecraft-server-status.ts @@ -6,6 +6,7 @@ import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({ + cacheNamespace: "minecraft-server-status", async requestAsync(input: { domain: string; isBedrockServer: boolean }) { const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`; 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..c1dbce8c2 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -10,6 +10,7 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler"; const logger = createLogger({ module: "rssFeedsRequestHandler" }); export const rssFeedsRequestHandler = createWidgetRequestHandler({ + cacheNamespace: "rss-feeds", async requestAsync(input: { url: string; count: number }) { const result = (await extract(input.url, { getExtraEntryFields: (feedEntry) => { 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..60d466956 100644 --- a/packages/request-handler/src/stock-price.ts +++ b/packages/request-handler/src/stock-price.ts @@ -6,6 +6,7 @@ import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; export const fetchStockPriceHandler = createWidgetRequestHandler({ + cacheNamespace: "stock-price", async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) { const response = await withTimeoutAsync(async (signal) => { return await fetchWithTrustedCertificatesAsync( 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..8ad60a95b 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -6,12 +6,15 @@ 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 { getWidgetServerCachePolicy } from "@homarr/definitions"; import { createWidgetRequestHandler } from "./lib/widget-request-handler"; dayjs.extend(utc); dayjs.extend(timezone); +const weatherCachePolicy = getWidgetServerCachePolicy("weather", "atLocation"); + const HOURLY_FORECAST_LENGTH = 24; const DAILY_FORECAST_LENGTH = 7; @@ -140,7 +143,7 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number }; export const weatherRequestHandler = createWidgetRequestHandler({ - cacheTtlMs: 5 * 60 * 1000, + cachePolicy: weatherCachePolicy, 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/spotlight/src/modes/command/children/new-integration.tsx b/packages/spotlight/src/modes/command/children/new-integration.tsx index aebb6ede1..d5bd668aa 100644 --- a/packages/spotlight/src/modes/command/children/new-integration.tsx +++ b/packages/spotlight/src/modes/command/children/new-integration.tsx @@ -13,7 +13,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions integrationDef.name.toLowerCase().includes(normalizedQuery)) - .sort( + .toSorted( ([, definitionA], [, definitionB]) => definitionA.name.toLowerCase().indexOf(normalizedQuery) - definitionB.name.toLowerCase().indexOf(normalizedQuery), 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/generated/widget-icons.ts b/packages/ui/src/generated/widget-icons.ts new file mode 100644 index 000000000..05b9001a9 --- /dev/null +++ b/packages/ui/src/generated/widget-icons.ts @@ -0,0 +1,11 @@ +// This file is generated by pnpm widgets:sync. Do not edit it directly. + +import { IconCloud, IconDownload } from "@tabler/icons-react"; +import type { TablerIcon } from "@tabler/icons-react"; + +import type { WidgetKind } from "@homarr/definitions"; + +export const generatedWidgetCatalogIcons = { + downloads: IconDownload, + weather: IconCloud, +} satisfies Partial>; 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..a316bb7e6 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,7 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { - featuredIntegrations, getIntegrationName, getWidgetKindsForIntegration, - hiddenFromOnboarding, integrationDefs, integrationKinds, } from "@homarr/definitions"; @@ -56,7 +54,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 && integrationDefs[kind].features.onboarding.hidden) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; }) @@ -67,11 +65,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 = integrationDefs[left.kind].features.onboarding.featuredOrder; + const rightFeaturedOrder = integrationDefs[right.kind].features.onboarding.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: generatedWeatherIcon, + 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: generatedDownloadsIcon, + "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, + ...additionalGeneratedWidgetIcons, +}; diff --git a/packages/widgets/src/air-quality/index.ts b/packages/widgets/src/air-quality/index.ts index a9dfb28dd..07a20547a 100644 --- a/packages/widgets/src/air-quality/index.ts +++ b/packages/widgets/src/air-quality/index.ts @@ -6,8 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("airQuality", { icon: IconWind, supportsAdvancedFocus: true, - queryKey: [["widget", "airQuality", "atLocation"]], - refetchInterval: 900, queryMatcher: ({ input }, scope) => { const location = scope.options.location; if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) { diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts index 4eac1a441..ded855dc5 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -6,9 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("anchorNote", { icon: IconNotes, supportsAdvancedFocus: true, - queryKey: [["widget", "anchorNotes"]], - maxIntegrations: 1, - supportedIntegrations: ["anchor"], createOptions() { return optionsBuilder.from((factory) => ({ noteId: factory.anchorNote(), diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts index ea99e0c24..07b3edb94 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -16,7 +16,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("app", { icon: IconApps, supportsAdvancedFocus: false, - queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]], createOptions(settings) { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index 38a3eef36..25c91c6f7 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", { icon: IconArchive, supportsAdvancedFocus: true, - maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showBroadcastMessage: factory.switch({ @@ -14,5 +13,4 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe }), })); }, - supportedIntegrations: ["archiveTeamWarrior"], }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index 6fb3b2014..bfbc8d577 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -14,11 +14,7 @@ const hideUnlessAudiobookshelf = { export const { definition, componentLoader } = createWidgetDefinition("audioStats", { icon: IconHeadphones, supportsAdvancedFocus: true, - queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]], queryMatcher: matchesWidgetRuntimeQuery, - supportedIntegrations: ["navidrome", "audiobookshelf"], - integrationsRequired: true, - maxIntegrations: 1, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index ff5bb0d85..7e5724b1a 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -6,9 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { supportsAdvancedFocus: true, icon: IconBadgeCc, - supportedIntegrations: ["bazarr"], - integrationsRequired: true, - maxIntegrations: 1, 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..29d1da289 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, - queryKey: [["widget", "beszel", "getAlerts"]], queryMatcher(query, scope) { const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts"); if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope); @@ -17,8 +16,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle maxHistoryItems: scope.options.maxHistoryItems, }); }, - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, 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..b73ee2ddc 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -6,9 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, - queryKey: [["widget", "beszel", "getSystems"]], - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, 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..c11d9cacc 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -17,7 +17,6 @@ const timePeriodOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", { icon: IconChartAreaLine, - queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]], queryMatcher(query, scope) { if (query.path.at(-1) === "getSystems") { return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds }); @@ -40,8 +39,6 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys (dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true })) ); }, - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, 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..d2ba29240 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -28,13 +28,10 @@ const sortDirectionOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", { icon: IconTable, - queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]], queryMatcher: (query, scope) => query.path.at(-1) === "getSystems" ? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds }) : matchesWidgetRuntimeQuery(query, scope), - supportedIntegrations: ["beszel", "mock"], - integrationsRequired: true, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index 82d171e9d..ec4736ddd 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -12,7 +12,6 @@ import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } fro export const { definition, componentLoader } = createWidgetDefinition("bookmarks", { icon: IconBookmark, supportsAdvancedFocus: true, - queryKey: [["app", "byIds"]], createOptions() { return optionsBuilder.from((factory) => ({ title: factory.text(), diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts index 471c50f17..81dd7d3c1 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -1,7 +1,6 @@ import { IconCalendar } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; @@ -10,7 +9,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("calendar", { icon: IconCalendar, supportsAdvancedFocus: true, - queryKey: [["widget", "calendar", "findAllEvents"]], queryMatcher: matchesWidgetRuntimeQuery, createOptions() { return optionsBuilder.from((factory) => ({ @@ -34,6 +32,4 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar" }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("calendar"), - integrationsRequired: false, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index 3287a15d5..31eb37e9e 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -1,119 +1 @@ -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, -}; +export { widgetCatalogIcons } from "@homarr/ui/widget-icons"; diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 8f3fdd06f..195327b81 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -15,7 +15,6 @@ const timeZoneOptions = getTimeZoneOptions(); export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, supportsAdvancedFocus: true, - queryKey: [["widget", "weather", "atLocation"]], queryMatcher: ({ input }, scope) => { const location = scope.options.weatherLocation; if ( diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts index cfc96bc9a..0ba35a5e4 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -19,7 +19,6 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify", }), })); }, - supportedIntegrations: ["coolify"], errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 6ef443999..1b7eb141c 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -4,13 +4,14 @@ 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"; -import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; +import type { IntegrationKind, NativeFeatureCapabilityDescriptor, WidgetKind } from "@homarr/definitions"; +import { widgetFeatureCatalog } from "@homarr/definitions"; import type { ServerSettings } from "@homarr/server-settings"; 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 { @@ -73,13 +74,86 @@ export interface WidgetContextActionProps { widgetRuntimeRef: WidgetRuntimeRef; } +type WidgetFeatureCapability = (typeof widgetFeatureCatalog)[TKind] extends { + capability: infer TCapability extends NativeFeatureCapabilityDescriptor; +} + ? TCapability + : never; + +type WidgetFeatureIntegrationDefinition = [WidgetFeatureCapability] extends [never] + ? object + : { + supportedIntegrations: WidgetFeatureCapability["integrations"][number][]; + integrationsRequired: WidgetFeatureCapability extends { connectionOptional: true } ? false : true; + } & (WidgetFeatureCapability extends { serverMaxIntegrations: infer TMax extends number } + ? { maxIntegrations: TMax } + : object); + +type CatalogOwnedWidgetDefinitionKey = + | "kind" + | "supportedIntegrations" + | "integrationsRequired" + | "maxIntegrations" + | "queryKey" + | "queryKeys" + | "refetchInterval"; + +type WidgetDefinitionInput = Omit; + +type WidgetDefinitionInputWithoutCatalogMetadata = TDefinition & { + [TKey in CatalogOwnedWidgetDefinitionKey]?: never; +}; + +type ResolvedWidgetDefinition = TDefinition & + WidgetFeatureIntegrationDefinition & { + kind: TKind; + queryKey?: QueryKey; + queryKeys?: readonly QueryKey[]; + refetchInterval?: number | null; + }; + +const resolveWidgetDefinition = ( + kind: TKind, + definition: WidgetDefinitionInputWithoutCatalogMetadata, +): ResolvedWidgetDefinition => { + const descriptor = widgetFeatureCatalog[kind]; + const resolvedDefinition: WidgetDefinition = { + ...definition, + kind, + }; + + if ("capability" in descriptor) { + const capability = descriptor.capability; + const connectionOptional = "connectionOptional" in capability && capability.connectionOptional === true; + resolvedDefinition.supportedIntegrations = [...capability.integrations]; + resolvedDefinition.integrationsRequired = !connectionOptional; + if ("serverMaxIntegrations" in capability && capability.serverMaxIntegrations !== undefined) { + resolvedDefinition.maxIntegrations = capability.serverMaxIntegrations; + } + } + + if ("query" in descriptor) { + const queryKeys: QueryKey[] = descriptor.query.paths.map((path) => [path]); + if (queryKeys.length === 1) { + resolvedDefinition.queryKey = queryKeys[0]; + } else { + resolvedDefinition.queryKeys = queryKeys; + } + if ("refetchIntervalSeconds" in descriptor.query) { + resolvedDefinition.refetchInterval = descriptor.query.refetchIntervalSeconds; + } + } + + return resolvedDefinition as ResolvedWidgetDefinition; +}; + const createWithDynamicImport = - (kind: TKind, definition: TDefinition) => + ( + kind: TKind, + definition: ResolvedWidgetDefinition, + ) => (componentLoader: () => LoaderComponent>) => ({ - definition: { - ...definition, - kind, - }, + definition, kind, componentLoader, }); @@ -93,14 +167,18 @@ export type Prefetch = ( }[], ) => Promise; -export const createWidgetDefinition = ( +export const createWidgetDefinition = ( kind: TKind, - definition: TDefinition, -) => ({ - withDynamicImport: createWithDynamicImport(kind, definition), -}); + definition: WidgetDefinitionInputWithoutCatalogMetadata, +) => { + const resolvedDefinition = resolveWidgetDefinition(kind, definition); + return { + withDynamicImport: createWithDynamicImport(kind, resolvedDefinition), + }; +}; export interface WidgetDefinition { + kind?: WidgetKind; icon: TablerIcon; supportsAdvancedFocus?: boolean; queryKey?: QueryKey; diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 5c3170c15..78fe6be04 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -1,6 +1,5 @@ import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -10,8 +9,6 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconDeviceGamepad, - queryKey: [["widget", "dnsHole"]], - refetchInterval: 10, createOptions() { return optionsBuilder.from((factory) => ({ showToggleAllButtons: factory.switch({ @@ -19,7 +16,6 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), 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..ec4e81981 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -1,6 +1,5 @@ import { IconAd, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -10,8 +9,6 @@ export const widgetKind = "dnsHoleSummary"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconAd, - queryKey: [["widget", "dnsHole"]], - refetchInterval: 10, createOptions() { return optionsBuilder.from((factory) => ({ usePiHoleColors: factory.switch({ @@ -30,7 +27,6 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("dnsHole"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index acedadfdb..94687c9db 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -27,8 +27,6 @@ const columnTranslationKeyMap = { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { icon: IconBrandDocker, supportsAdvancedFocus: true, - queryKey: [["docker", "getContainers"]], - refetchInterval: 30, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/downloads/docs/img/advanced-view.png b/packages/widgets/src/downloads/docs/img/advanced-view.png new file mode 100644 index 000000000..042e713c3 Binary files /dev/null and b/packages/widgets/src/downloads/docs/img/advanced-view.png differ diff --git a/packages/widgets/src/downloads/docs/img/context-menu.png b/packages/widgets/src/downloads/docs/img/context-menu.png new file mode 100644 index 000000000..350051d00 Binary files /dev/null and b/packages/widgets/src/downloads/docs/img/context-menu.png differ diff --git a/packages/widgets/src/downloads/docs/img/filter-panel.png b/packages/widgets/src/downloads/docs/img/filter-panel.png new file mode 100644 index 000000000..e819e80f9 Binary files /dev/null and b/packages/widgets/src/downloads/docs/img/filter-panel.png differ diff --git a/packages/widgets/src/downloads/docs/img/screenshot.png b/packages/widgets/src/downloads/docs/img/screenshot.png new file mode 100644 index 000000000..8ef3a1701 Binary files /dev/null and b/packages/widgets/src/downloads/docs/img/screenshot.png differ diff --git a/packages/widgets/src/downloads/docs/index.mdx b/packages/widgets/src/downloads/docs/index.mdx new file mode 100644 index 000000000..fefd416c1 --- /dev/null +++ b/packages/widgets/src/downloads/docs/index.mdx @@ -0,0 +1,152 @@ +--- +title: "Download Client" +description: "Allows you to view and manage your Downloads from both Torrent and Usenet clients." +hide_title: true +--- + +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { aria2Integration } from "@site/docs/integrations/aria2"; +import { delugeIntegration } from "@site/docs/integrations/deluge"; +import { nzbgetIntegration } from "@site/docs/integrations/nzbget"; +import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent"; +import { sabnzbdIntegration } from "@site/docs/integrations/sabnzbd"; +import { transmissionIntegration } from "@site/docs/integrations/transmission"; +import { slskdIntegration } from "@site/docs/integrations/slskd"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { downloadsWidget } from "."; + + + +This widget displays torrent, Usenet, and miscellaneous download jobs, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. + +### Screenshots + +import downloadsCard from "./img/screenshot.png"; +import contextMenuImg from "./img/context-menu.png"; +import advancedViewImg from "./img/advanced-view.png"; +import filterPanelImg from "./img/filter-panel.png"; + +
+ Downloads widget showing torrent and usenet items +
+ +#### Download-row context menu + +
+ Downloads row context menu with pause, delete, and copy actions +
+ +#### Advanced view + +
+ Downloads widget in advanced view with stats, sortable columns, and expanded details +
+ +#### Advanced filter panel + +
+ Downloads advanced view with the status filter panel open +
+ +### Features + +- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column +- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction +- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget +- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration +- **Row expansion** — click any row to see full details inline without opening a separate modal +- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode +- **Global stats bar** — show queue progress, downloaded/total size, transfer speeds, global torrent ratio, and per-state counts in compact mode; advanced view always includes it +- **Client filtering** — filter downloads by integration or status using the footer filter panel +- **Global controls** — pause/resume actionable queues from the footer bar, limited to selected integrations where you have Interact access +- **Status indicators** — color-coded badges show download state at a glance +- **Speed overview** — total download and upload speeds for the selected clients are displayed in the footer + +:::note + +Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state +columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also +hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions, +keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available +from the footer. + +The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and +state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the +selected torrent clients. The configured category/label filter can optionally be applied to the global torrent ratio +through **Use category filter for global torrent ratio**; footer client/status filters do not change that ratio. + +The per-item **Ratio** is sent bytes divided by the item's total size. **Global Ratio** is total uploaded bytes divided +by total downloaded bytes. + +Mutation controls depend on the integration's Interact permission. Users without that permission can still view rows and +use the row menu's copy actions. `slskd` is list-only: its pause, resume, and delete operations are no-ops, so those +controls have no effect for that client even when Interact permission exposes them. + +The screenshot above is a Downloads row menu, separate from the shared widget context menu. When board right-click menus +are enabled, the shared menu provides widget-level actions such as advanced view, widget options, refresh, and settings. + +::: + +### Supported Integrations + + + +### Interactions + +- **Right-click** any download to copy its name or ID; pause, resume, and delete are available only with Interact access +- **Click** any row to expand it and see full download details inline +- **Hover** a download name for a quick stats tooltip +- **Stats toggle** — show or hide the global stats bar in compact mode; advanced view always shows the stats bar without + changing the compact choice +- **Pause / Resume** actionable queues using the footer controls +- **Filter** rows by integration or status using the filter button in the footer +- **Sort** by clicking sortable column headers; the default sort is configured per widget, while header changes apply to the current view + + + +### Adding the widget + + + +### Configuration + + diff --git a/packages/widgets/src/downloads/docs/index.ts b/packages/widgets/src/downloads/docs/index.ts new file mode 100644 index 000000000..f4e1945b1 --- /dev/null +++ b/packages/widgets/src/downloads/docs/index.ts @@ -0,0 +1,102 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconDownload } from "@tabler/icons-react"; + +const columnsList = [ + "name", + "progress", + "size", + "downSpeed", + "upSpeed", + "time", + "state", + "added", + "ratio", + "received", + "sent", + "category", + "integration", + "index", + "type", +]; +const sortColumns = columnsList.filter((c) => !["state", "category", "integration"].includes(c)); + +export const downloadsWidget: WidgetDefinition = { + icon: IconDownload, + name: "Download Client", + description: "Allows you to view and manage your Downloads from both Torrent and Usenet clients.", + path: "../../widgets/downloads", + configuration: { + items: [ + { + name: "Columns to show", + description: + "Select the columns you want to display in the widget. Compact layouts automatically hide/show selected columns based on widget width and height; advanced view keeps the selected columns visible.", + values: `List of columns: ${columnsList.join(", ")}`, + defaultValue: ["name", "progress", "downSpeed", "time", "state"].join(", "), + }, + { + name: "Default sort column", + description: + "The column used for initial sorting when the widget loads. Clicking a column header changes the current view only.", + defaultValue: "progress", + values: `List of columns: ${sortColumns.join(", ")}`, + }, + { + name: "Invert sorting", + description: "This sets the initial sorting direction.", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Show usenet entries marked as completed", + description: "This will show entries that have been completed in your Usenet client.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show torrent entries marked as completed", + description: "This will show entries that have been completed in your Torrent client.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Show Miscellaneous entries marked as completed", + description: "This will show entries that have been completed in your Miscellaneous client.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Hide completed torrent under this threshold (in kiB/s)", + description: "This will hide completed torrent entries whose upload speed is below the specified threshold.", + values: "Any number above 0, 0 to disable", + defaultValue: "0", + }, + { + name: "Categories/labels to filter", + description: "You can filter the items by categories or labels. Use a comma to separate multiple values.", + values: "Comma-separated list of categories or labels", + defaultValue: "-", + }, + { + name: "Filter as a whitelist", + description: + "If enabled, only items that match the filter will be shown. If disabled, items that do not match the filter will be hidden.", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Use category filter for global torrent ratio", + description: + "When enabled, the configured category/label filter is applied to the global torrent ratio. Footer client/status filters do not change this ratio.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + { + name: "Limit items per integration", + description: "This will limit the number of items shown per integration, not globally.", + values: "Any whole number from 1 upward", + defaultValue: "50", + }, + ], + }, +}; diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index cad34eac4..bf09f66c2 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -1,4 +1,3 @@ -import { IconDownload } from "@tabler/icons-react"; import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; @@ -6,6 +5,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; import { DOWNLOAD_COLUMN_ACCESSORS } from "./helpers"; +import { widgetIcon } from "./module.generated"; const sortColumns = [ "name", @@ -23,15 +23,13 @@ const sortColumns = [ ] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][]; export const { definition, componentLoader } = createWidgetDefinition("downloads", { - icon: IconDownload, + icon: widgetIcon, supportsAdvancedFocus: true, - queryKey: [["widget", "downloads", "getJobsAndStatuses"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { integrationIds: scope.integrationIds, limitPerIntegration: scope.options.limitPerIntegration, }), - refetchInterval: 10, createOptions() { return optionsBuilder.from( (factory) => ({ @@ -111,5 +109,4 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("downloadClient"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/downloads/module.generated.ts b/packages/widgets/src/downloads/module.generated.ts new file mode 100644 index 000000000..ae685736f --- /dev/null +++ b/packages/widgets/src/downloads/module.generated.ts @@ -0,0 +1,3 @@ +// This file is generated by pnpm widgets:sync. Do not edit it directly. + +export { IconDownload as widgetIcon } from "@tabler/icons-react"; diff --git a/packages/widgets/src/downloads/module.ts b/packages/widgets/src/downloads/module.ts new file mode 100644 index 000000000..16732da31 --- /dev/null +++ b/packages/widgets/src/downloads/module.ts @@ -0,0 +1,38 @@ +import { defineWidgetModule } from "@homarr/definitions"; + +export default defineWidgetModule({ + kind: "downloads", + icon: "IconDownload", + clientEntry: ".", + documentation: { + slug: "downloads", + sourceDirectory: "docs", + }, + integration: { + categories: ["downloadClient"], + }, + operations: [ + { + name: "getJobsAndStatuses", + path: ["widget", "downloads", "getJobsAndStatuses"], + client: { + refetchIntervalSeconds: 10, + staleTimeSeconds: 10, + persist: true, + }, + serverCache: { + namespace: "downloads:jobs-and-status", + ttlMs: 10 * 1000, + scope: "integration", + }, + }, + ], + routers: [ + { + namespace: "downloads", + module: "./downloads", + exportName: "downloadsRouter", + mcp: true, + }, + ], +}); diff --git a/packages/widgets/src/errors/base-component.tsx b/packages/widgets/src/errors/base-component.tsx index c50979b0c..de319a1ef 100644 --- a/packages/widgets/src/errors/base-component.tsx +++ b/packages/widgets/src/errors/base-component.tsx @@ -12,7 +12,7 @@ export interface BaseWidgetErrorProps { icon: TablerIcon; message: stringOrTranslation; showLogsLink?: boolean; - onRetry: () => void; + onRetry?: () => void; } export const BaseWidgetError = (props: BaseWidgetErrorProps) => { @@ -39,9 +39,11 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => { )} - + {props.onRetry && ( + + )} ); }; diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index 7a013f07f..8a5fffe05 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -1,6 +1,5 @@ import { IconWall, IconWallOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -8,11 +7,9 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("firewall", { icon: IconWall, supportsAdvancedFocus: true, - refetchInterval: 10, createOptions() { return optionsBuilder.from(() => ({})); }, - supportedIntegrations: getIntegrationKindsByCategory("firewall"), errors: { INTERNAL_SERVER_ERROR: { icon: IconWallOff, diff --git a/packages/widgets/src/generated/widget-modules.ts b/packages/widgets/src/generated/widget-modules.ts new file mode 100644 index 000000000..11f45ddd2 --- /dev/null +++ b/packages/widgets/src/generated/widget-modules.ts @@ -0,0 +1,14 @@ +// This file is generated by pnpm widgets:sync. Do not edit it directly. + +import type * as widgetModule0 from "../downloads"; +import type * as widgetModule1 from "../weather"; + +export const generatedWidgetModuleLoaders = { + downloads: () => import("../downloads"), + weather: () => import("../weather"), +}; + +export interface GeneratedWidgetImports { + downloads: typeof widgetModule0; + weather: typeof widgetModule1; +} diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index 7cfcdbcd6..ee1545d2e 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -1,6 +1,5 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -9,8 +8,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", { icon: IconHeartRateMonitor, supportsAdvancedFocus: true, - queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]], - refetchInterval: 10, createOptions() { return optionsBuilder.from( (factory) => ({ @@ -114,9 +111,6 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon }, ); }, - supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter( - (kind) => kind !== "patchmon" && kind !== "wud", - ), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts index d95c4d029..7ced5ae5b 100644 --- a/packages/widgets/src/immich/album-carousel/index.ts +++ b/packages/widgets/src/immich/album-carousel/index.ts @@ -52,7 +52,6 @@ const createOptions = () => export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", { icon: IconPhoto, supportsAdvancedFocus: true, - queryKey: [["widget", "immich", "getAlbum"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { integrationId: scope.integrationIds[0] ?? "", @@ -61,10 +60,6 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al ? scope.options.albumId : undefined, }), - refetchInterval: null, - supportedIntegrations: ["immich"], - integrationsRequired: true, - maxIntegrations: 1, 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 dedd420fe..8565f615f 100644 --- a/packages/widgets/src/immich/server-stats/index.ts +++ b/packages/widgets/src/immich/server-stats/index.ts @@ -6,12 +6,7 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", { icon: IconGraphFilled, supportsAdvancedFocus: true, - queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]], queryMatcher: matchesWidgetRuntimeQuery, - refetchInterval: null, - supportedIntegrations: ["immich"], - integrationsRequired: true, - maxIntegrations: 1, 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..746923b8d 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -1,6 +1,5 @@ import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -28,9 +27,7 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa }, }, ], - refetchInterval: null, createOptions, - supportedIntegrations: getIntegrationKindsByCategory("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..0d9b66947 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, @@ -58,7 +58,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 () => { @@ -98,7 +98,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..8704e32d9 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -2,156 +2,30 @@ 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"; - -type WidgetModule = { - definition: WidgetDefinition; - componentLoader: () => Promise<{ default: ComponentType> }>; -}; +import { widgetModuleLoaders } from "./registry"; type WidgetComponentModule = { default: ComponentType>; }; +type WidgetModule = { + definition: WidgetDefinition; + componentLoader: () => Promise; +}; + 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,17 +46,29 @@ export const createRetryableLoader = (loaders: ReadonlyMap Promise][]), +const loadRegisteredWidgetModule = createRetryableLoader( + new Map(Object.entries(widgetModuleLoaders) as [WidgetKind, () => Promise][]), ); -export const loadWidgetComponent = createRetryableLoader( - new Map(Object.entries(componentLoaders) as [WidgetKind, () => Promise][]), -); +export const loadWidgetModule = (kind: WidgetKind) => loadRegisteredWidgetModule(kind); -export const loadWidgetResources = createRetryableLoader( +const loadRegisteredWidgetComponent = createRetryableLoader( new Map( - (Object.keys(moduleLoaders) as WidgetKind[]).map((kind) => [ + widgetKinds.map((kind) => [ + kind, + async () => { + const widgetModule = await loadWidgetModule(kind); + return widgetModule.componentLoader(); + }, + ]), + ), +); + +export const loadWidgetComponent = (kind: WidgetKind) => loadRegisteredWidgetComponent(kind); + +const loadRegisteredWidgetResources = createRetryableLoader( + new Map( + widgetKinds.map((kind) => [ kind, async () => { const [{ definition }, { default: Component }] = await Promise.all([ @@ -195,6 +81,8 @@ export const loadWidgetResources = createRetryableLoader( ), ); +export const loadWidgetResources = (kind: WidgetKind) => loadRegisteredWidgetResources(kind); + export const loadWidgetDefinition = (kind: WidgetKind) => { const existing = definitionPromises.get(kind); if (existing) return existing; diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts index 38ac8cd68..aed456c2c 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -1,6 +1,5 @@ import { IconMovie } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -10,7 +9,6 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", { icon: IconMovie, supportsAdvancedFocus: true, - queryKey: [["widget", "mediaOrganizer", "getData"]], queryMatcher: matchesWidgetRuntimeQuery, createOptions() { return optionsBuilder.from((factory) => ({ @@ -25,5 +23,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-releases/index.ts b/packages/widgets/src/media-releases/index.ts index 55eff84e5..01596cdc4 100644 --- a/packages/widgets/src/media-releases/index.ts +++ b/packages/widgets/src/media-releases/index.ts @@ -6,8 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", { icon: IconTicket, supportsAdvancedFocus: true, - queryKey: [["widget", "mediaRelease"]], - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ layout: factory.select({ @@ -34,5 +32,4 @@ export const { definition, componentLoader } = createWidgetDefinition("mediaRele }), })); }, - supportedIntegrations: ["mock", "emby", "jellyfin", "plex"], }).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..a31d43cc9 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,7 +1,6 @@ import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition"; @@ -29,7 +28,6 @@ const createOptions = () => export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { supportsAdvancedFocus: false, icon: IconZoomQuestion, - queryKey: [["widget", "mediaRequests", "getLatestRequests"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { integrationIds: scope.integrationIds, @@ -50,5 +48,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }, }, ], - supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }).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..c0ee3503d 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -1,6 +1,5 @@ import { IconChartBar, IconSearch } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; @@ -10,7 +9,6 @@ const createOptions = () => ({}); export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { supportsAdvancedFocus: false, icon: IconChartBar, - queryKey: [["widget", "mediaRequests", "getStats"]], createOptions, contextActions: ({ integrationIds }) => [ { @@ -22,5 +20,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }, }, ], - supportedIntegrations: getIntegrationKindsByCategory("mediaRequest"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 5dfac266d..b6ea47160 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -1,6 +1,5 @@ import { IconVideo } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -8,13 +7,11 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("mediaServer", { icon: IconVideo, supportsAdvancedFocus: true, - queryKey: [["widget", "mediaServer", "getCurrentStreams"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { integrationIds: scope.integrationIds, showOnlyPlaying: scope.options.showOnlyPlaying, }), - refetchInterval: 10, createOptions() { return optionsBuilder.from((factory) => ({ showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }), @@ -22,5 +19,4 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ showLocation: factory.switch({ defaultValue: true, withDescription: true }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("mediaService"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts index bff9c1ea3..a06de507c 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -2,7 +2,6 @@ import { IconTransform } from "@tabler/icons-react"; import { z } from "zod/v4"; import { capitalize } from "@homarr/common"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -12,10 +11,7 @@ export const views = ["workers", "queue", "statistics"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { icon: IconTransform, supportsAdvancedFocus: true, - queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], queryMatcher: matchesWidgetRuntimeQuery, - maxIntegrations: 1, - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ defaultView: factory.select({ @@ -25,5 +21,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/minecraft/server-status/index.ts b/packages/widgets/src/minecraft/server-status/index.ts index 91f3e6cdc..7110fae81 100644 --- a/packages/widgets/src/minecraft/server-status/index.ts +++ b/packages/widgets/src/minecraft/server-status/index.ts @@ -7,14 +7,12 @@ import { optionsBuilder } from "../../options"; export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", { supportsAdvancedFocus: false, icon: IconBrandMinecraft, - queryKey: [["widget", "minecraft", "getServerStatus"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { title: scope.options.title, domain: scope.options.domain, isBedrockServer: scope.options.isBedrockServer, }), - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ title: factory.text({ defaultValue: "" }), diff --git a/packages/widgets/src/network-controller/network-status/index.ts b/packages/widgets/src/network-controller/network-status/index.ts index e3741d8e1..45099b0bd 100644 --- a/packages/widgets/src/network-controller/network-status/index.ts +++ b/packages/widgets/src/network-controller/network-status/index.ts @@ -1,6 +1,5 @@ import { IconServerOff, IconTopologyFull } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -8,8 +7,6 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("networkControllerStatus", { icon: IconTopologyFull, supportsAdvancedFocus: true, - queryKey: [["widget", "networkController"]], - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ content: factory.select({ @@ -21,7 +18,6 @@ export const { definition, componentLoader } = createWidgetDefinition("networkCo }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("networkController"), 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..725da0e96 100644 --- a/packages/widgets/src/network-controller/summary/index.ts +++ b/packages/widgets/src/network-controller/summary/index.ts @@ -1,6 +1,5 @@ import { IconServerOff, IconTopologyFull } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -8,12 +7,9 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", { supportsAdvancedFocus: true, icon: IconTopologyFull, - queryKey: [["widget", "networkController"]], - refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({})); }, - supportedIntegrations: getIntegrationKindsByCategory("networkController"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 60bdc8758..337911efc 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -1,6 +1,5 @@ import { IconMessage } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -8,7 +7,6 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("notifications", { supportsAdvancedFocus: true, icon: IconMessage, - queryKey: [["widget", "notifications", "getNotifications"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { hideLogos: scope.options.hideLogos, @@ -19,5 +17,4 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat hideLogos: factory.switch({ defaultValue: false }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("notifications"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts index 600ee4652..5341d6f8f 100644 --- a/packages/widgets/src/paperless-ngx/index.ts +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -6,10 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { supportsAdvancedFocus: false, icon: IconFileText, - refetchInterval: null, - supportedIntegrations: ["paperlessNgx"], - integrationsRequired: true, - maxIntegrations: 1, 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 d715e90ac..802ad3f4c 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -9,9 +9,6 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { supportsAdvancedFocus: true, icon: IconShieldCheck, - supportedIntegrations: ["patchmon"], - integrationsRequired: true, - maxIntegrations: 1, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/prefetch.ts b/packages/widgets/src/prefetch.ts index 36e2f3415..1f0220893 100644 --- a/packages/widgets/src/prefetch.ts +++ b/packages/widgets/src/prefetch.ts @@ -5,17 +5,24 @@ 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 { PrefetchLoader, WidgetOptionsRecordOf } from "./definition"; import type { inferOptionsFromCreator } from "./options"; -const prefetchCallbacks: Partial<{ - [TKind in WidgetKind]: Prefetch; -}> = { - bookmarks: prefetchForBookmarks, - app: prefetchForApps, -}; +const definePrefetchLoaders = }>>( + loaders: TLoaders, +) => loaders; + +// Keep these imports explicit so Next.js can trace each optional prefetch +// module without loading its database-specific implementation eagerly. +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: TKind, @@ -25,12 +32,15 @@ export const prefetchForKindAsync = async ( 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, diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index 7f60c3154..a43f30d4e 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -1,38 +1,61 @@ +import { generatedWidgetOperationPolicies, widgetFeatureCatalog } from "@homarr/definitions"; + +interface WidgetQueryRefetchInterval { + queryKey: readonly [readonly string[]]; + intervalSeconds: number | null; + staleTimeSeconds?: number; +} + +const createWidgetQueryRefetchIntervals = (): WidgetQueryRefetchInterval[] => { + const policies = new Map(); + + for (const descriptor of Object.values(widgetFeatureCatalog)) { + if (!("query" in descriptor) || !("refetchIntervalSeconds" in descriptor.query)) continue; + + for (const path of descriptor.query.paths) { + if (path[0] !== "widget" && path[0] !== "docker") continue; + + const queryKey = [path] as const; + const serializedQueryKey = JSON.stringify(queryKey); + const intervalSeconds = descriptor.query.refetchIntervalSeconds; + const existing = policies.get(serializedQueryKey); + if (existing && existing.intervalSeconds !== intervalSeconds) { + throw new Error(`Conflicting widget refetch intervals for ${serializedQueryKey}`); + } + policies.set(serializedQueryKey, { queryKey, intervalSeconds }); + } + } + + for (const policy of generatedWidgetOperationPolicies) { + if (policy.kind !== "query") continue; + if (policy.path[0] !== "widget" && policy.path[0] !== "docker") continue; + + const queryKey = [policy.path] as const; + const serializedQueryKey = JSON.stringify(queryKey); + let existing = policies.get(serializedQueryKey); + if (!existing) { + if (policy.refetchIntervalSeconds === undefined) continue; + existing = { queryKey, intervalSeconds: policy.refetchIntervalSeconds }; + } + if ( + existing.intervalSeconds !== undefined && + policy.refetchIntervalSeconds !== undefined && + existing.intervalSeconds !== policy.refetchIntervalSeconds + ) { + throw new Error(`Conflicting generated widget refetch interval for ${serializedQueryKey}`); + } + if (policy.refetchIntervalSeconds !== undefined) { + existing.intervalSeconds = policy.refetchIntervalSeconds; + } + if (policy.staleTimeSeconds !== undefined) existing.staleTimeSeconds = policy.staleTimeSeconds; + policies.set(serializedQueryKey, existing); + } + + return [...policies.values()]; +}; + /** - * Query policy used by 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. + * Query policy used by the root client provider. This derives from the + * server-safe feature catalog and never imports widget definitions or code. */ -export const widgetQueryRefetchIntervals = [ - { queryKey: [["docker", "getContainers"]], intervalSeconds: 30 }, - { queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 }, - { queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 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", "airQuality", "atLocation"]], intervalSeconds: 900 }, - { queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null }, - { queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null }, - { queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null }, - { queryKey: [["widget", "indexerManager"]], intervalSeconds: null }, - { queryKey: [["widget", "mediaRelease"]], intervalSeconds: null }, - { queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], intervalSeconds: null }, - { queryKey: [["widget", "minecraft", "getServerStatus"]], intervalSeconds: null }, - { queryKey: [["widget", "networkController"]], intervalSeconds: null }, - { queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null }, - { queryKey: [["widget", "releases", "getLatest"]], intervalSeconds: null }, - { queryKey: [["widget", "rssFeed", "getFeeds"]], intervalSeconds: null }, - { queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null }, - { queryKey: [["widget", "stockPrice", "getPriceHistory"]], intervalSeconds: null }, - { queryKey: [["widget", "umami", "getVisitorStats"]], intervalSeconds: null }, - { queryKey: [["widget", "umami", "getActiveVisitors"]], intervalSeconds: null }, - { queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null }, - { queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null }, - { queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null }, - { queryKey: [["widget", "vpn"]], intervalSeconds: null }, - { queryKey: [["widget", "wud"]], intervalSeconds: null }, -] as const; +export const widgetQueryRefetchIntervals = createWidgetQueryRefetchIntervals(); diff --git a/packages/widgets/src/registry.ts b/packages/widgets/src/registry.ts new file mode 100644 index 000000000..d44603313 --- /dev/null +++ b/packages/widgets/src/registry.ts @@ -0,0 +1,203 @@ +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 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 wud from "./wud"; +import { generatedWidgetModuleLoaders } from "./generated/widget-modules"; +import type { GeneratedWidgetImports } from "./generated/widget-modules"; + +type WidgetModule = { + definition: unknown; + componentLoader: () => Promise; +}; + +// 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"), + "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"), + wud: () => import("./wud"), + ...generatedWidgetModuleLoaders, +} satisfies Record 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; + "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; + wud: typeof wud; +} & GeneratedWidgetImports; + +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/releases/index.ts b/packages/widgets/src/releases/index.ts index 75a42f203..5b126af43 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -15,9 +15,7 @@ const relativeDateSchema = z export const { definition, componentLoader } = createWidgetDefinition("releases", { supportsAdvancedFocus: true, icon: IconRocket, - queryKey: [["widget", "releases", "getLatest"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }), - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ newReleaseWithin: factory.text({ diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index abf728aa0..978368864 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -13,13 +13,11 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { supportsAdvancedFocus: true, icon: IconRss, - queryKey: [["widget", "rssFeed", "getFeeds"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { urls: scope.options.feedUrls, maximumAmountPosts: scope.options.maximumAmountPosts, }), - refetchInterval: null, errors: { BAD_GATEWAY: { icon: IconServerOff, diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts index 551fa42de..24fd371f6 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -1,6 +1,5 @@ import { IconBinaryTree, IconServerOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -8,8 +7,6 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", { icon: IconBinaryTree, supportsAdvancedFocus: true, - queryKey: [["widget", "smartHome"]], - maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ entityId: factory.text({ @@ -22,7 +19,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..ed53719fb 100644 --- a/packages/widgets/src/smart-home/execute-automation/index.ts +++ b/packages/widgets/src/smart-home/execute-automation/index.ts @@ -1,6 +1,5 @@ import { IconBinaryTree } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -8,12 +7,10 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", { icon: IconBinaryTree, supportsAdvancedFocus: true, - maxIntegrations: 1, 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 7de41100f..edf492cab 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { icon: IconSpeedboat, supportsAdvancedFocus: true, - refetchInterval: null, createOptions() { return optionsBuilder.from( (factory) => ({ @@ -30,7 +29,6 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest }, ); }, - supportedIntegrations: ["speedtestTracker"], errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts index 8769bce4a..da6c85410 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -14,14 +14,12 @@ const timeIntervalOptions = stockPriceTimeFrames.interval; export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { supportsAdvancedFocus: true, icon: IconBuildingBank, - queryKey: [["widget", "stockPrice", "getPriceHistory"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { stock: scope.options.stock, timeRange: scope.options.timeRange, timeInterval: scope.options.timeInterval, }), - refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ stock: factory.text({ diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts index 0780d1e6d..5ce96246a 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -7,9 +7,6 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { supportsAdvancedFocus: true, icon: IconServer2, - queryKey: [["widget", "healthMonitoring"]], - refetchInterval: 10, - supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index 03d7a83db..fa367a40a 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -22,9 +22,6 @@ const getChartOptionLabel = (key: "cpu" | "memory" | "gpu" | "network", t: (key: export const { definition, componentLoader } = createWidgetDefinition("systemResources", { icon: IconGraphFilled, supportsAdvancedFocus: true, - queryKey: [["widget", "healthMonitoring"]], - refetchInterval: 10, - supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], 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 61f88cf4f..fba382602 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("tracearr", { icon: IconActivityHeartbeat, supportsAdvancedFocus: true, - refetchInterval: 10, createOptions() { return optionsBuilder.from((factory) => ({ showStreams: factory.switch({ @@ -24,7 +23,6 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr" }), })); }, - supportedIntegrations: ["tracearr"], errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts index 95392a152..def3d64ed 100644 --- a/packages/widgets/src/traefik/index.ts +++ b/packages/widgets/src/traefik/index.ts @@ -19,7 +19,6 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik", }), })); }, - supportedIntegrations: ["traefik"], errors: { INTERNAL_SERVER_ERROR: { icon: IconRouteOff, diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts index 9c96e4a8e..d0e34b73b 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -9,18 +9,7 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth export const { definition, componentLoader } = createWidgetDefinition("umami", { icon: IconChartBar, supportsAdvancedFocus: true, - queryKeys: [ - [["widget", "umami", "getVisitorStats"]], - [["widget", "umami", "getActiveVisitors"]], - [["widget", "umami", "getMultiEventTimeSeries"]], - [["widget", "umami", "getTopPages"]], - [["widget", "umami", "getTopReferrers"]], - ], queryMatcher: matchesWidgetRuntimeQuery, - refetchInterval: null, - supportedIntegrations: ["umami"], - integrationsRequired: true, - maxIntegrations: 1, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/ups/index.ts b/packages/widgets/src/ups/index.ts index 0ee8c70d5..209feacb7 100644 --- a/packages/widgets/src/ups/index.ts +++ b/packages/widgets/src/ups/index.ts @@ -1,6 +1,5 @@ import { IconBatteryCharging, IconBatteryOff } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -21,7 +20,6 @@ export const { definition, componentLoader } = createWidgetDefinition("ups", { }), })); }, - supportedIntegrations: getIntegrationKindsByCategory("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 0b0cff5ad..06f9a15fb 100644 --- a/packages/widgets/src/uptime-kuma/index.ts +++ b/packages/widgets/src/uptime-kuma/index.ts @@ -34,7 +34,6 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum }), })); }, - supportedIntegrations: ["uptimeKuma"], errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts index f8418d86f..e17c8239a 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -1,6 +1,5 @@ import { IconShieldLock } from "@tabler/icons-react"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -8,9 +7,7 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("vpn", { supportsAdvancedFocus: false, icon: IconShieldLock, - refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({})); }, - supportedIntegrations: getIntegrationKindsByCategory("vpn"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/weather/docs/img/advanced-view.png b/packages/widgets/src/weather/docs/img/advanced-view.png new file mode 100644 index 000000000..96e37daf4 Binary files /dev/null and b/packages/widgets/src/weather/docs/img/advanced-view.png differ diff --git a/packages/widgets/src/weather/docs/img/current.png b/packages/widgets/src/weather/docs/img/current.png new file mode 100644 index 000000000..45cc08169 Binary files /dev/null and b/packages/widgets/src/weather/docs/img/current.png differ diff --git a/packages/widgets/src/weather/docs/img/forecast.png b/packages/widgets/src/weather/docs/img/forecast.png new file mode 100644 index 000000000..2de9b1224 Binary files /dev/null and b/packages/widgets/src/weather/docs/img/forecast.png differ diff --git a/packages/widgets/src/weather/docs/index.mdx b/packages/widgets/src/weather/docs/index.mdx new file mode 100644 index 000000000..c03357f87 --- /dev/null +++ b/packages/widgets/src/weather/docs/index.mdx @@ -0,0 +1,50 @@ +--- +title: "Weather" +description: "Displays the current weather information of a set location." +hide_title: true +--- + +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { weatherWidget } from "."; + + + +The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start +with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and +temperature readable before removing secondary details or forecast days. + +Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and +daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour +temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for +assistive technologies. + +The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly +details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on +the standard desktop surface and fewer columns as the surface narrows. + +Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's +reduced-motion preference always disables it. + + + +### Adding the widget + + + +### Configuration + + + +### Data source + +We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while +identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the +same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an +error indicator. + +Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. +If you need to whitelist the api, please use the following URL: `https://open-meteo.com` diff --git a/packages/widgets/src/weather/docs/index.tsx b/packages/widgets/src/weather/docs/index.tsx new file mode 100644 index 000000000..836b70ca5 --- /dev/null +++ b/packages/widgets/src/weather/docs/index.tsx @@ -0,0 +1,96 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconCloud } from "@tabler/icons-react"; + +export const weatherWidget: WidgetDefinition = { + icon: IconCloud, + name: "Weather", + description: "Displays the current weather information of a set location.", + path: "../../widgets/weather", + configuration: { + items: [ + { + name: "Temperature in Fahrenheit", + description: "Display temperature in Fahrenheit instead of Celsius", + values: { type: "boolean" }, + defaultValue: "no", + }, + { + name: "Disable temperature decimals", + description: "Show temperature as full number", + values: { + type: "boolean", + }, + defaultValue: "no", + }, + { + name: "Show current wind speed", + description: "Display current wind speed, only for current weather", + values: { + type: "boolean", + }, + defaultValue: "no", + }, + { + name: "Show humidity", + description: "Display humidity in current conditions, hourly details, and daily forecasts", + values: { + type: "boolean", + }, + defaultValue: "yes", + }, + { + name: "Animate weather icons", + description: "Animate condition icons unless the operating system requests reduced motion", + values: { + type: "boolean", + }, + defaultValue: "no", + }, + { + name: "Use imperial speed units", + description: "Display wind speed in mph instead of km/h", + values: { + type: "boolean", + }, + defaultValue: "no", + }, + { + name: "Weather location", + description: "Location for which to display the weather information", + values: "Select location through search or longitude/latitude", + defaultValue: "Paris / 48.85341, 2.3488", + }, + { + name: "Date format", + description: "Format for displaying the date in the widget", + values: { + type: "select", + options: ["dddd, MMMM D", "dddd, D MMMM", "MMM D", "D MMM", "DD/MM/YYYY", "MM/DD/YYYY", "DD/MM", "MM/DD"], + }, + defaultValue: "dddd, MMMM D", + }, + { + name: "Show city", + description: "Display the city name in the widget", + values: { + type: "boolean", + }, + defaultValue: "yes", + }, + { + name: "Has forecast", + description: "Display a forecast for the next few days", + values: { + type: "boolean", + }, + defaultValue: "yes", + }, + { + name: "Amount of forecast days", + description: "Number of days to show in the forecast", + values: "1-7", + defaultValue: "3", + }, + ], + }, +}; diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index 53c890b4c..f70a21bf8 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -1,15 +1,13 @@ -import { IconCloud } from "@tabler/icons-react"; import dayjs from "dayjs"; import { z } from "zod/v4"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; +import { widgetIcon } from "./module.generated"; export const { definition, componentLoader } = createWidgetDefinition("weather", { - icon: IconCloud, + icon: widgetIcon, supportsAdvancedFocus: true, - queryKey: [["widget", "weather", "atLocation"]], - refetchInterval: 600, queryMatcher: ({ input }, scope) => { const location = scope.options.location; if (location === null || typeof location !== "object" || !("latitude" in location) || !("longitude" in location)) { diff --git a/packages/widgets/src/weather/module.generated.ts b/packages/widgets/src/weather/module.generated.ts new file mode 100644 index 000000000..ee7ba69aa --- /dev/null +++ b/packages/widgets/src/weather/module.generated.ts @@ -0,0 +1,3 @@ +// This file is generated by pnpm widgets:sync. Do not edit it directly. + +export { IconCloud as widgetIcon } from "@tabler/icons-react"; diff --git a/packages/widgets/src/weather/module.ts b/packages/widgets/src/weather/module.ts new file mode 100644 index 000000000..51964c312 --- /dev/null +++ b/packages/widgets/src/weather/module.ts @@ -0,0 +1,36 @@ +import { defineWidgetModule } from "@homarr/definitions"; + +export default defineWidgetModule({ + kind: "weather", + icon: "IconCloud", + clientEntry: ".", + documentation: { + slug: "weather", + sourceDirectory: "docs", + }, + defaultSize: { width: 2, height: 1 }, + operations: [ + { + name: "atLocation", + path: ["widget", "weather", "atLocation"], + client: { + refetchIntervalSeconds: 600, + staleTimeSeconds: 300, + persist: true, + }, + serverCache: { + namespace: "weather:at-location", + ttlMs: 5 * 60 * 1000, + scope: "shared", + }, + }, + ], + routers: [ + { + namespace: "weather", + module: "./weather", + exportName: "weatherRouter", + additionalWidgetKinds: ["clock"], + }, + ], +}); diff --git a/packages/widgets/src/wud/index.ts b/packages/widgets/src/wud/index.ts index d7816b480..2e15ac169 100644 --- a/packages/widgets/src/wud/index.ts +++ b/packages/widgets/src/wud/index.ts @@ -6,10 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("wud", { icon: IconBrandDocker, supportsAdvancedFocus: true, - refetchInterval: null, - supportedIntegrations: ["wud"], - integrationsRequired: true, - maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showTitle: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/tsconfig.json b/packages/widgets/tsconfig.json index e205abceb..1709270b6 100644 --- a/packages/widgets/tsconfig.json +++ b/packages/widgets/tsconfig.json @@ -4,5 +4,5 @@ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" }, "include": ["*.ts", "src", "*.tsx"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "src/**/docs/**"] } diff --git a/scripts/integrations/sync-integration-modules.mts b/scripts/integrations/sync-integration-modules.mts new file mode 100644 index 000000000..959798356 --- /dev/null +++ b/scripts/integrations/sync-integration-modules.mts @@ -0,0 +1,364 @@ +import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { format } from "oxfmt"; + +import type { IntegrationModuleDefinition } from "../../packages/definitions/src/integration-module"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const integrationsRoot = path.join(repositoryRoot, "packages/integrations/src"); +const generatedRoot = path.join(integrationsRoot, "generated"); +const docsRoot = path.join(repositoryRoot, "apps/docs/docs/integrations"); +const integrationDefinitionsSource = path.join(repositoryRoot, "packages/definitions/src/integration.ts"); +const integrationCreatorsSource = path.join(integrationsRoot, "base/creator.ts"); +const checkOnly = process.argv.includes("--check"); +const generatedHeader = "// This file is generated by pnpm integrations:sync. Do not edit it directly.\n\n"; +const generatedDocsMarker = ".integration-module-generated"; + +interface LoadedIntegrationModule { + definition: IntegrationModuleDefinition; + directory: string; +} + +interface GeneratedFile { + path: string; + content: Buffer; +} + +const toPosixPath = (value: string) => value.split(path.sep).join("/"); + +const pathExistsAsync = async (target: string) => { + try { + await stat(target); + return true; + } catch { + return false; + } +}; + +const assertPathInside = (parent: string, target: string, label: string) => { + const relative = path.relative(parent, target); + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; + throw new Error(`${label} must stay inside ${parent}`); +}; + +const findSourceEntryAsync = async (target: string) => { + const candidates = [ + target, + `${target}.ts`, + `${target}.tsx`, + path.join(target, "index.ts"), + path.join(target, "index.tsx"), + ]; + for (const candidate of candidates) { + if (await pathExistsAsync(candidate)) return candidate; + } + return null; +}; + +const findIntegrationModuleFilesAsync = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name === "generated" || entry.name === "docs" || entry.name.startsWith(".")) continue; + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await findIntegrationModuleFilesAsync(target))); + continue; + } + if (entry.isFile() && entry.name === "module.ts") files.push(target); + } + + return files; +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const assertNonEmptyString = (value: unknown, label: string): asserts value is string => { + if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`); +}; + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const validateExportAsync = async (sourcePath: string, exportName: string, kind: string) => { + const source = await readFile(sourcePath, "utf8"); + const escapedName = escapeRegExp(exportName); + const declaration = new RegExp( + `export\\s+(?:declare\\s+)?(?:abstract\\s+)?(?:class|const|function)\\s+${escapedName}\\b`, + ); + const exportList = new RegExp(`export\\s*\\{[^}]*\\b${escapedName}\\b[^}]*\\}`, "s"); + if (declaration.test(source) || exportList.test(source)) return; + throw new Error(`Integration ${kind} creator export ${exportName} was not found in ${sourcePath}`); +}; + +const validateIntegrationModuleAsync = async (module: LoadedIntegrationModule) => { + const { definition } = module; + assertNonEmptyString(definition.kind, "Integration kind"); + if (!/^[A-Za-z][A-Za-z0-9]*$/.test(definition.kind)) { + throw new Error(`Integration kind ${definition.kind} contains unsupported characters`); + } + assertNonEmptyString(definition.name, `Integration ${definition.kind} name`); + assertNonEmptyString(definition.iconUrl, `Integration ${definition.kind} icon URL`); + if (!URL.canParse(definition.iconUrl)) throw new Error(`Integration ${definition.kind} icon URL is invalid`); + if (!Array.isArray(definition.secretKinds) || definition.secretKinds.length === 0) { + throw new Error(`Integration ${definition.kind} must declare at least one secret alternative`); + } + if (!Array.isArray(definition.categories) || definition.categories.length === 0) { + throw new Error(`Integration ${definition.kind} must declare at least one category`); + } + if (!/^[a-z0-9][a-z0-9-]*$/.test(definition.documentation.slug)) { + throw new Error(`Integration ${definition.kind} has invalid documentation slug ${definition.documentation.slug}`); + } + if ( + definition.defaultPort !== undefined && + (!Number.isSafeInteger(definition.defaultPort) || definition.defaultPort < 1) + ) { + throw new Error(`Integration ${definition.kind} default port must be a positive integer`); + } + + const docsDirectory = path.resolve(module.directory, definition.documentation.sourceDirectory); + assertPathInside(module.directory, docsDirectory, `Integration ${definition.kind} documentation`); + if (!(await pathExistsAsync(path.join(docsDirectory, "index.mdx")))) { + throw new Error(`Integration ${definition.kind} documentation must contain index.mdx: ${docsDirectory}`); + } + if (!(await pathExistsAsync(path.join(docsDirectory, "index.ts")))) { + throw new Error(`Integration ${definition.kind} documentation must contain index.ts: ${docsDirectory}`); + } + + const creatorTarget = path.resolve(module.directory, definition.creator.module); + assertPathInside(module.directory, creatorTarget, `Integration ${definition.kind} creator`); + const creatorSource = await findSourceEntryAsync(creatorTarget); + if (!creatorSource) { + throw new Error(`Integration ${definition.kind} creator module does not exist: ${definition.creator.module}`); + } + assertNonEmptyString(definition.creator.exportName, `Integration ${definition.kind} creator export`); + await validateExportAsync(creatorSource, definition.creator.exportName, definition.kind); +}; + +const loadHandAuthoredRegistryEntriesAsync = async () => { + const definitionsSource = await readFile(integrationDefinitionsSource, "utf8"); + const startMarker = "createIntegrationDefinitions({"; + const endMarker = "} as const satisfies Record);"; + const start = definitionsSource.indexOf(startMarker); + const end = definitionsSource.indexOf(endMarker, start); + if (start < 0 || end < 0) throw new Error("Unable to locate the hand-authored integration registry"); + + const registry = definitionsSource.slice(start + startMarker.length, end); + const kinds = new Set([...registry.matchAll(/^ ([A-Za-z][A-Za-z0-9]*): \{/gm)].map((match) => match[1])); + const docsSlugs = new Set( + [...registry.matchAll(/^ documentationSlug: "([a-z0-9][a-z0-9-]*)",/gm)].map((match) => match[1]), + ); + + const creatorsSource = await readFile(integrationCreatorsSource, "utf8"); + const creatorStartMarker = "const integrationCreatorLoaders = {"; + const creatorEndMarker = "\n};\n\ntype AssertIntegrationCreatorLoaders"; + const creatorStart = creatorsSource.indexOf(creatorStartMarker); + const creatorEnd = creatorsSource.indexOf(creatorEndMarker, creatorStart); + if (creatorStart < 0 || creatorEnd < 0) throw new Error("Unable to locate the hand-authored creator registry"); + const creatorRegistry = creatorsSource.slice(creatorStart + creatorStartMarker.length, creatorEnd); + const creatorKinds = new Set( + [...creatorRegistry.matchAll(/^ ([A-Za-z][A-Za-z0-9]*): async /gm)].map((match) => match[1]), + ); + return { kinds, docsSlugs, creatorKinds }; +}; + +const loadIntegrationModulesAsync = async () => { + const moduleFiles = (await findIntegrationModuleFilesAsync(integrationsRoot)).toSorted(); + const handAuthored = await loadHandAuthoredRegistryEntriesAsync(); + const modules: LoadedIntegrationModule[] = []; + const kinds = new Set(); + const docsSlugs = new Set(); + + for (const moduleFile of moduleFiles) { + const loaded: unknown = await import(`${pathToFileURL(moduleFile).href}?sync=${Date.now()}`); + if (!isRecord(loaded) || !("default" in loaded) || !isRecord(loaded.default)) { + throw new Error(`${moduleFile} must default-export defineIntegrationModule(...)`); + } + + const directory = path.dirname(moduleFile); + const module: LoadedIntegrationModule = { + definition: loaded.default as unknown as IntegrationModuleDefinition, + directory, + }; + await validateIntegrationModuleAsync(module); + if (handAuthored.kinds.has(module.definition.kind)) { + throw new Error(`Integration kind ${module.definition.kind} exists in both a module and the central registry`); + } + if (handAuthored.creatorKinds.has(module.definition.kind)) { + throw new Error(`Integration kind ${module.definition.kind} exists in both a module and the central creator`); + } + if (handAuthored.docsSlugs.has(module.definition.documentation.slug)) { + throw new Error( + `Integration documentation slug ${module.definition.documentation.slug} exists in both a module and the central registry`, + ); + } + if (kinds.has(module.definition.kind)) throw new Error(`Duplicate integration kind ${module.definition.kind}`); + kinds.add(module.definition.kind); + if (docsSlugs.has(module.definition.documentation.slug)) { + throw new Error(`Duplicate integration documentation slug ${module.definition.documentation.slug}`); + } + docsSlugs.add(module.definition.documentation.slug); + modules.push(module); + } + + return modules.toSorted((left, right) => left.definition.kind.localeCompare(right.definition.kind)); +}; + +const renderDefinition = (definition: IntegrationModuleDefinition) => { + const projected: Record = { + name: definition.name, + secretKinds: definition.secretKinds, + iconUrl: definition.iconUrl, + category: definition.categories, + documentationSlug: definition.documentation.slug, + }; + if (definition.defaultUrl !== undefined) projected.defaultUrl = definition.defaultUrl; + if (definition.defaultPort !== undefined) projected.defaultPort = definition.defaultPort; + if (definition.apiKeySettingsPath !== undefined) projected.apiKeySettingsPath = definition.apiKeySettingsPath; + if (definition.docker || definition.onboarding) { + const features: Record = {}; + if (definition.docker) features.docker = definition.docker; + if (definition.onboarding) features.onboarding = definition.onboarding; + projected.features = features; + } + return JSON.stringify(projected, null, 2); +}; + +const renderDefinitionsRegistry = (modules: LoadedIntegrationModule[]) => { + const entries = modules + .map((module) => ` ${JSON.stringify(module.definition.kind)}: ${renderDefinition(module.definition)},`) + .join("\n"); + return `${generatedHeader}export const generatedIntegrationDefinitions = {\n${entries}\n} as const;\n`; +}; + +const getCreatorImportPath = (module: LoadedIntegrationModule) => { + const target = path.resolve(module.directory, module.definition.creator.module); + const relative = toPosixPath(path.relative(generatedRoot, target)); + if (relative.startsWith(".")) return relative; + return `./${relative}`; +}; + +const renderCreatorLoader = (module: LoadedIntegrationModule) => { + const { creator, kind } = module.definition; + const importPath = getCreatorImportPath(module); + if (creator.type === "factory") { + return ` ${JSON.stringify(kind)}: async () => (await import(${JSON.stringify(importPath)})).${creator.exportName},`; + } + return ` ${JSON.stringify(kind)}: async () => {\n const IntegrationClass = (await import(${JSON.stringify(importPath)})).${creator.exportName};\n return async (input: IntegrationInput) => new IntegrationClass(input);\n },`; +}; + +const renderCreatorRegistry = (modules: LoadedIntegrationModule[]) => { + const entries = modules.map(renderCreatorLoader).join("\n"); + return `${generatedHeader}import type { Integration, IntegrationInput } from "../base/integration";\n\ntype GeneratedIntegrationCreator = (input: IntegrationInput) => Promise;\n\nexport const generatedIntegrationCreatorLoaders = {\n${entries}\n} satisfies Record Promise>;\n`; +}; + +const collectFilesAsync = async (directory: string, relativeDirectory = ""): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const files: GeneratedFile[] = []; + for (const entry of entries) { + const relativePath = path.join(relativeDirectory, entry.name); + const sourcePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFilesAsync(sourcePath, relativePath))); + continue; + } + if (entry.isFile()) files.push({ path: relativePath, content: await readFile(sourcePath) }); + } + return files; +}; + +const createDocsOutputsAsync = async (modules: LoadedIntegrationModule[]) => { + const outputs: GeneratedFile[] = []; + for (const module of modules) { + const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory); + const target = path.join(docsRoot, module.definition.documentation.slug); + for (const file of await collectFilesAsync(source)) { + outputs.push({ path: path.join(target, file.path), content: file.content }); + } + outputs.push({ + path: path.join(target, generatedDocsMarker), + content: Buffer.from(`${toPosixPath(path.relative(repositoryRoot, source))}\n`), + }); + } + return outputs; +}; + +const readOrEmptyAsync = async (target: string) => { + try { + return await readFile(target); + } catch { + return null; + } +}; + +const createGeneratedTypeScriptAsync = async (target: string, source: string): Promise => { + const result = await format(target, source, { printWidth: 120, endOfLine: "lf" }); + const errors = result.errors.filter((error) => error.severity === "Error"); + if (errors.length > 0) { + throw new Error(`Failed to format ${target}: ${errors.map((error) => error.message).join(", ")}`); + } + return { path: target, content: Buffer.from(result.code) }; +}; + +const synchronizeFileAsync = async (file: GeneratedFile) => { + const current = await readOrEmptyAsync(file.path); + if (current?.equals(file.content)) return false; + if (checkOnly) return true; + await mkdir(path.dirname(file.path), { recursive: true }); + await writeFile(file.path, file.content); + return true; +}; + +const removeStaleGeneratedDocsAsync = async (outputs: GeneratedFile[]) => { + const expected = new Set(outputs.map((output) => path.resolve(output.path))); + const stale: string[] = []; + const entries = await readdir(docsRoot, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const target = path.join(docsRoot, entry.name); + if (!(await pathExistsAsync(path.join(target, generatedDocsMarker)))) continue; + for (const file of await collectFilesAsync(target)) { + const absolutePath = path.resolve(target, file.path); + if (!expected.has(absolutePath)) stale.push(absolutePath); + } + } + if (!checkOnly) { + for (const file of stale) await rm(file); + } + return stale; +}; + +const mainAsync = async () => { + const modules = await loadIntegrationModulesAsync(); + if (modules.length === 0) throw new Error("No vertical integration modules were found"); + + const definitionsTarget = path.join(repositoryRoot, "packages/definitions/src/generated/integration-modules.ts"); + const creatorsTarget = path.join(generatedRoot, "integration-creators.ts"); + const outputs: GeneratedFile[] = [ + await createGeneratedTypeScriptAsync(definitionsTarget, renderDefinitionsRegistry(modules)), + await createGeneratedTypeScriptAsync(creatorsTarget, renderCreatorRegistry(modules)), + ...(await createDocsOutputsAsync(modules)), + ]; + + const staleDocs = await removeStaleGeneratedDocsAsync(outputs); + const drifted: string[] = []; + for (const output of outputs) { + if (await synchronizeFileAsync(output)) drifted.push(path.relative(repositoryRoot, output.path)); + } + drifted.push(...staleDocs.map((file) => path.relative(repositoryRoot, file))); + + if (checkOnly && drifted.length > 0) { + throw new Error( + `Integration module artifacts are out of date:\n${drifted.map((file) => ` - ${file}`).join("\n")}\nRun pnpm integrations:sync.`, + ); + } + + let action = "synchronized"; + if (checkOnly) action = "verified"; + console.log(`Integration modules ${action}: ${modules.map((module) => module.definition.kind).join(", ")}`); +}; + +await mainAsync(); diff --git a/scripts/widgets/create-widget-module.mts b/scripts/widgets/create-widget-module.mts new file mode 100644 index 000000000..0909c6d95 --- /dev/null +++ b/scripts/widgets/create-widget-module.mts @@ -0,0 +1,82 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const widgetsRoot = path.join(repositoryRoot, "packages/widgets/src"); + +const readOption = (name: string) => { + const optionIndex = process.argv.indexOf(name); + if (optionIndex < 0) return undefined; + return process.argv[optionIndex + 1]; +}; + +const kind = process.argv[2]; +if (!kind || kind.startsWith("--")) { + throw new Error("Usage: pnpm widgets:new [--slug ] [--icon ]"); +} +if (!/^[a-z][A-Za-z0-9]*$/.test(kind)) { + throw new Error("Widget kind must be a stable lower-camel-case identifier"); +} + +const defaultSlug = kind.replace(/([a-z0-9])([A-Z])/gu, "$1-$2").toLowerCase(); +const slug = readOption("--slug") ?? defaultSlug; +const icon = readOption("--icon") ?? "IconLayoutGrid"; +if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error("Widget slug must use lower-case kebab-case"); +if (!/^Icon[A-Za-z0-9]+$/.test(icon)) throw new Error("Widget icon must be a named Tabler Icon export"); + +const title = slug + .split("-") + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "); +const directory = path.join(widgetsRoot, slug); +await mkdir(directory); +await mkdir(path.join(directory, "docs")); + +const files = { + "module.ts": `import { defineWidgetModule } from "@homarr/definitions"; + +export default defineWidgetModule({ + kind: ${JSON.stringify(kind)}, + icon: ${JSON.stringify(icon)}, + clientEntry: ".", + documentation: { + slug: ${JSON.stringify(slug)}, + sourceDirectory: "docs", + }, +}); +`, + "index.ts": `import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; +import { widgetIcon } from "./module.generated"; + +export const { definition, componentLoader } = createWidgetDefinition(${JSON.stringify(kind)}, { + icon: widgetIcon, + createOptions() { + return optionsBuilder.from(() => ({})); + }, +}).withDynamicImport(() => import("./component")); +`, + "component.tsx": `"use client"; + +import type { WidgetComponentProps } from "../definition"; + +const Component = (_props: WidgetComponentProps<${JSON.stringify(kind)}>) => null; + +export default Component; +`, + "docs/index.mdx": `--- +title: ${JSON.stringify(title)} +description: ${JSON.stringify(`Documentation for the ${title} widget.`)} +--- + +Describe the widget, its configuration, data source, permissions, and interactions here. +`, +} as const; + +for (const [relativePath, content] of Object.entries(files)) { + await writeFile(path.join(directory, relativePath), content, { flag: "wx" }); +} + +await import("./sync-widget-modules.mts"); +console.log(`Created widget module ${kind} in ${path.relative(repositoryRoot, directory)}`); diff --git a/scripts/widgets/sync-widget-modules.mts b/scripts/widgets/sync-widget-modules.mts new file mode 100644 index 000000000..c89f945b6 --- /dev/null +++ b/scripts/widgets/sync-widget-modules.mts @@ -0,0 +1,475 @@ +import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { format } from "oxfmt"; + +import { getIntegrationKindsByCategory } from "../../packages/definitions/src/integration"; +import type { + WidgetModuleDefinition, + WidgetModuleIntegrationCapability, + WidgetModuleOperation, + WidgetModuleRouterRegistration, +} from "../../packages/definitions/src/widget-module"; + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const widgetsRoot = path.join(repositoryRoot, "packages/widgets/src"); +const apiRoutersRoot = path.join(repositoryRoot, "packages/api/src/router/widgets"); +const checkOnly = process.argv.includes("--check"); +const generatedHeader = "// This file is generated by pnpm widgets:sync. Do not edit it directly.\n\n"; +const generatedDocsMarker = ".widget-module-generated"; + +interface LoadedWidgetModule { + definition: WidgetModuleDefinition; + directory: string; + relativeDirectory: string; +} + +interface GeneratedFile { + path: string; + content: Buffer; +} + +interface GroupedRouter extends WidgetModuleRouterRegistration { + widgetKinds: string[]; +} + +const toPosixPath = (value: string) => value.split(path.sep).join("/"); +const quote = (value: string) => JSON.stringify(value); +const indent = (value: string, spaces: number) => { + const prefix = " ".repeat(spaces); + return value + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n"); +}; + +const pathExists = async (target: string) => { + try { + await stat(target); + return true; + } catch { + return false; + } +}; + +const assertPathInside = (parent: string, target: string, label: string) => { + const relative = path.relative(parent, target); + if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return; + throw new Error(`${label} must stay inside ${parent}`); +}; + +const findSourceEntryAsync = async (target: string) => { + const candidates = [ + target, + `${target}.ts`, + `${target}.tsx`, + path.join(target, "index.ts"), + path.join(target, "index.tsx"), + ]; + for (const candidate of candidates) { + if (await pathExists(candidate)) return candidate; + } + return null; +}; + +const findWidgetModuleFilesAsync = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name === "generated" || entry.name === "docs" || entry.name.startsWith(".")) continue; + const target = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await findWidgetModuleFilesAsync(target))); + continue; + } + if (entry.isFile() && entry.name === "module.ts") files.push(target); + } + + return files; +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + +const assertNonEmptyString = (value: unknown, label: string): asserts value is string => { + if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`); +}; + +const validateOperation = (module: LoadedWidgetModule, operation: WidgetModuleOperation, names: Set) => { + assertNonEmptyString(operation.name, `${module.definition.kind} operation name`); + if (names.has(operation.name)) { + throw new Error(`Widget ${module.definition.kind} declares operation ${operation.name} more than once`); + } + names.add(operation.name); + + if (!Array.isArray(operation.path) || operation.path.length === 0) { + throw new Error(`Widget ${module.definition.kind} operation ${operation.name} must declare a non-empty path`); + } + for (const part of operation.path) { + assertNonEmptyString(part, `${module.definition.kind}.${operation.name} path segment`); + } + if (operation.client?.staleTimeSeconds !== undefined && operation.client.refetchIntervalSeconds === undefined) { + throw new Error( + `Widget ${module.definition.kind} operation ${operation.name} must declare a refetch interval with stale time`, + ); + } + + const cache = operation.serverCache; + if (!cache) return; + if (!/^[a-z0-9][a-z0-9:-]*$/.test(cache.namespace)) { + throw new Error(`Widget ${module.definition.kind} has invalid cache namespace ${cache.namespace}`); + } + if (!Number.isSafeInteger(cache.ttlMs) || cache.ttlMs < 0) { + throw new Error(`Widget ${module.definition.kind} cache TTL must be a non-negative integer`); + } +}; + +const validateWidgetModuleAsync = async (module: LoadedWidgetModule) => { + const { definition } = module; + if ("enabled" in definition || "disabled" in definition) { + throw new Error(`Widget ${definition.kind} must not declare a feature availability flag`); + } + assertNonEmptyString(definition.kind, "Widget kind"); + if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(definition.kind)) { + throw new Error(`Widget kind ${definition.kind} contains unsupported characters`); + } + if (!/^Icon[A-Za-z0-9]+$/.test(definition.icon)) { + throw new Error(`Widget ${definition.kind} has invalid Tabler icon export ${definition.icon}`); + } + if (!/^[a-z0-9][a-z0-9-]*$/.test(definition.documentation.slug)) { + throw new Error(`Widget ${definition.kind} has invalid documentation slug ${definition.documentation.slug}`); + } + + const clientTarget = path.resolve(module.directory, definition.clientEntry); + assertPathInside(module.directory, clientTarget, `Widget ${definition.kind} client entry`); + if (!(await findSourceEntryAsync(clientTarget))) { + throw new Error(`Widget ${definition.kind} client entry does not exist: ${definition.clientEntry}`); + } + + const docsDirectory = path.resolve(module.directory, definition.documentation.sourceDirectory); + assertPathInside(module.directory, docsDirectory, `Widget ${definition.kind} documentation`); + if (!(await pathExists(path.join(docsDirectory, "index.mdx")))) { + throw new Error(`Widget ${definition.kind} documentation must contain index.mdx: ${docsDirectory}`); + } + + const operationNames = new Set(); + for (const operation of definition.operations ?? []) validateOperation(module, operation, operationNames); + + for (const router of definition.routers ?? []) { + assertNonEmptyString(router.namespace, `${definition.kind} router namespace`); + assertNonEmptyString(router.module, `${definition.kind} router module`); + assertNonEmptyString(router.exportName, `${definition.kind} router export`); + const routerTarget = path.resolve(apiRoutersRoot, router.module); + assertPathInside(apiRoutersRoot, routerTarget, `Widget ${definition.kind} router module`); + if (!(await findSourceEntryAsync(routerTarget))) { + throw new Error(`Widget ${definition.kind} router module does not exist: ${router.module}`); + } + } +}; + +const loadWidgetModulesAsync = async () => { + const moduleFiles = (await findWidgetModuleFilesAsync(widgetsRoot)).toSorted(); + const modules: LoadedWidgetModule[] = []; + const kinds = new Set(); + const docsSlugs = new Set(); + const cacheNamespaces = new Set(); + + for (const moduleFile of moduleFiles) { + const loaded: unknown = await import(`${pathToFileURL(moduleFile).href}?sync=${Date.now()}`); + if (!isRecord(loaded) || !("default" in loaded) || !isRecord(loaded.default)) { + throw new Error(`${moduleFile} must default-export defineWidgetModule(...)`); + } + + const directory = path.dirname(moduleFile); + const module: LoadedWidgetModule = { + definition: loaded.default as unknown as WidgetModuleDefinition, + directory, + relativeDirectory: toPosixPath(path.relative(widgetsRoot, directory)), + }; + await validateWidgetModuleAsync(module); + + if (kinds.has(module.definition.kind)) throw new Error(`Duplicate widget kind ${module.definition.kind}`); + kinds.add(module.definition.kind); + if (docsSlugs.has(module.definition.documentation.slug)) { + throw new Error(`Duplicate widget documentation slug ${module.definition.documentation.slug}`); + } + docsSlugs.add(module.definition.documentation.slug); + + for (const operation of module.definition.operations ?? []) { + const namespace = operation.serverCache?.namespace; + if (!namespace) continue; + if (cacheNamespaces.has(namespace)) throw new Error(`Duplicate widget cache namespace ${namespace}`); + cacheNamespaces.add(namespace); + } + modules.push(module); + } + + return modules.toSorted((left, right) => left.definition.kind.localeCompare(right.definition.kind)); +}; + +const resolveIntegrationKinds = (capability: WidgetModuleIntegrationCapability) => { + const kinds = new Set(capability.kinds ?? []); + for (const category of capability.categories ?? []) { + for (const kind of getIntegrationKindsByCategory(category)) kinds.add(kind); + } + for (const excludedKind of capability.excludeKinds ?? []) kinds.delete(excludedKind); + if (kinds.size === 0) throw new Error("A widget integration capability must resolve to at least one kind"); + return [...kinds]; +}; + +const renderFeatureDescriptor = (module: LoadedWidgetModule) => { + const { definition } = module; + const fields = [`documentationSlug: ${quote(definition.documentation.slug)}`]; + if (definition.defaultSize) { + fields.push(`defaultSize: { width: ${definition.defaultSize.width}, height: ${definition.defaultSize.height} }`); + } + if (definition.displayNameFromIntegration) fields.push("displayNameFromIntegration: true"); + if (definition.integration) { + const capability = [`integrations: ${JSON.stringify(resolveIntegrationKinds(definition.integration))} as const`]; + if (definition.integration.connectionOptional) capability.push("connectionOptional: true"); + if (definition.integration.serverMaxIntegrations !== undefined) { + capability.push(`serverMaxIntegrations: ${definition.integration.serverMaxIntegrations}`); + } + fields.push(`capability: { ${capability.join(", ")} }`); + } + + const queryOperations = (definition.operations ?? []).filter((operation) => (operation.kind ?? "query") === "query"); + if (queryOperations.length > 0) { + const intervals = new Set( + queryOperations + .map((operation) => operation.client?.refetchIntervalSeconds) + .filter((interval) => interval !== undefined), + ); + if (intervals.size > 1) { + throw new Error(`Widget ${definition.kind} has conflicting root refetch intervals; split the router path policy`); + } + const queryFields = [`paths: ${JSON.stringify(queryOperations.map((operation) => operation.path))} as const`]; + const interval = intervals.values().next().value; + if (interval !== undefined) queryFields.push(`refetchIntervalSeconds: ${String(interval)}`); + fields.push(`query: { ${queryFields.join(", ")} }`); + } + return `{\n${indent(fields.map((field) => `${field},`).join("\n"), 4)}\n }`; +}; + +const renderDefinitionsRegistry = (modules: LoadedWidgetModule[]) => { + const catalogEntries = modules + .map((module) => ` ${quote(module.definition.kind)}: ${renderFeatureDescriptor(module)},`) + .join("\n"); + const cacheEntries = modules + .filter((module) => (module.definition.operations ?? []).some((operation) => operation.serverCache)) + .map((module) => { + const operations = (module.definition.operations ?? []) + .filter((operation) => operation.serverCache) + .map((operation) => ` ${quote(operation.name)}: ${JSON.stringify(operation.serverCache)},`) + .join("\n"); + return ` ${quote(module.definition.kind)}: {\n${operations}\n },`; + }) + .join("\n"); + const operationPolicies = modules.flatMap((module) => + (module.definition.operations ?? []).map((operation) => ({ + widgetKind: module.definition.kind, + operation: operation.name, + kind: operation.kind ?? "query", + path: operation.path, + refetchIntervalSeconds: operation.client?.refetchIntervalSeconds, + staleTimeSeconds: operation.client?.staleTimeSeconds, + persist: operation.client?.persist ?? false, + })), + ); + const documentationSources = Object.fromEntries( + modules.map((module) => [ + module.definition.documentation.slug, + toPosixPath( + path.join("packages/widgets/src", module.relativeDirectory, module.definition.documentation.sourceDirectory), + ), + ]), + ); + + return `${generatedHeader}export const generatedWidgetFeatureCatalog = {\n${catalogEntries}\n} as const;\n\nexport const widgetServerCachePolicies = {\n${cacheEntries}\n} as const;\n\nexport const generatedWidgetOperationPolicies = ${JSON.stringify(operationPolicies, null, 2)} as const;\n\nexport const generatedWidgetDocumentationSources = ${JSON.stringify(documentationSources, null, 2)} as const;\n\nexport const getWidgetServerCachePolicy = <\n TKind extends keyof typeof widgetServerCachePolicies,\n TOperation extends keyof (typeof widgetServerCachePolicies)[TKind],\n>(\n kind: TKind,\n operation: TOperation,\n): (typeof widgetServerCachePolicies)[TKind][TOperation] => widgetServerCachePolicies[kind][operation];\n`; +}; + +const getClientImportPath = (module: LoadedWidgetModule) => { + const base = `../${module.relativeDirectory}`; + if (module.definition.clientEntry === ".") return base; + return `${base}/${module.definition.clientEntry.replace(/^\.\//, "")}`; +}; + +const renderWidgetRegistry = (modules: LoadedWidgetModule[]) => { + const typeImports = modules + .map((module, index) => `import type * as widgetModule${index} from ${quote(getClientImportPath(module))};`) + .join("\n"); + const loaders = modules + .map((module) => ` ${quote(module.definition.kind)}: () => import(${quote(getClientImportPath(module))}),`) + .join("\n"); + const imports = modules + .map((module, index) => ` ${quote(module.definition.kind)}: typeof widgetModule${index};`) + .join("\n"); + return `${generatedHeader}${typeImports}\n\nexport const generatedWidgetModuleLoaders = {\n${loaders}\n};\n\nexport interface GeneratedWidgetImports {\n${imports}\n}\n`; +}; + +const renderIconRegistry = (modules: LoadedWidgetModule[]) => { + const icons = [...new Set(modules.map((module) => module.definition.icon))].toSorted(); + const entries = modules.map((module) => ` ${quote(module.definition.kind)}: ${module.definition.icon},`).join("\n"); + return `${generatedHeader}import { ${icons.join(", ")} } from "@tabler/icons-react";\nimport type { TablerIcon } from "@tabler/icons-react";\n\nimport type { WidgetKind } from "@homarr/definitions";\n\nexport const generatedWidgetCatalogIcons = {\n${entries}\n} satisfies Partial>;\n`; +}; + +const renderLocalWidgetProjection = (module: LoadedWidgetModule) => + `${generatedHeader}export { ${module.definition.icon} as widgetIcon } from "@tabler/icons-react";\n`; + +const groupRouters = (modules: LoadedWidgetModule[]) => { + const routers = new Map(); + for (const module of modules) { + for (const registration of module.definition.routers ?? []) { + const widgetKinds = [...(registration.additionalWidgetKinds ?? []), module.definition.kind]; + const existing = routers.get(registration.namespace); + if (!existing) { + routers.set(registration.namespace, { ...registration, widgetKinds }); + continue; + } + if ( + existing.module !== registration.module || + existing.exportName !== registration.exportName || + existing.mcp !== registration.mcp + ) { + throw new Error(`Router namespace ${registration.namespace} has conflicting registrations`); + } + existing.widgetKinds = [...new Set([...existing.widgetKinds, ...widgetKinds])]; + } + } + return [...routers.values()].toSorted((left, right) => left.namespace.localeCompare(right.namespace)); +}; + +const renderApiRegistry = (modules: LoadedWidgetModule[]) => { + const entries = groupRouters(modules) + .map((router) => { + const mcp = router.mcp === undefined ? "false" : JSON.stringify(router.mcp); + return ` ${quote(router.namespace)}: registerGeneratedWidgetRouter(\n ${JSON.stringify(router.widgetKinds)},\n () => import(${quote(router.module)}).then((module) => module.${router.exportName}),\n ${mcp},\n ),`; + }) + .join("\n"); + return `${generatedHeader}import type { AnyTRPCRouter } from "@trpc/server";\n\nimport type { WidgetKind } from "@homarr/definitions";\n\nconst registerGeneratedWidgetRouter = (\n widgetKinds: readonly [WidgetKind, ...WidgetKind[]],\n loadRouter: () => Promise,\n mcp: false | true | string,\n) => ({ widgetKinds, loadRouter, mcp });\n\nexport const generatedWidgetRouterRegistry = {\n${entries}\n};\n`; +}; + +const collectFilesAsync = async (directory: string, relativeDirectory = ""): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const files: GeneratedFile[] = []; + for (const entry of entries) { + const relativePath = path.join(relativeDirectory, entry.name); + const sourcePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectFilesAsync(sourcePath, relativePath))); + continue; + } + if (entry.isFile()) files.push({ path: relativePath, content: await readFile(sourcePath) }); + } + return files; +}; + +const createDocsOutputsAsync = async (modules: LoadedWidgetModule[]) => { + const outputs: GeneratedFile[] = []; + for (const module of modules) { + const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory); + const target = path.join(repositoryRoot, "apps/docs/docs/widgets", module.definition.documentation.slug); + const sourceFiles = await collectFilesAsync(source); + for (const file of sourceFiles) { + outputs.push({ path: path.join(target, file.path), content: file.content }); + } + outputs.push({ + path: path.join(target, generatedDocsMarker), + content: Buffer.from(`${toPosixPath(path.relative(repositoryRoot, source))}\n`), + }); + } + return outputs; +}; + +const readOrEmptyAsync = async (target: string) => { + try { + return await readFile(target); + } catch { + return null; + } +}; + +const createGeneratedTypeScriptAsync = async (target: string, source: string): Promise => { + const result = await format(target, source, { printWidth: 120, endOfLine: "lf" }); + const errors = result.errors.filter((error) => error.severity === "Error"); + if (errors.length > 0) { + throw new Error(`Failed to format ${target}: ${errors.map((error) => error.message).join(", ")}`); + } + return { path: target, content: Buffer.from(result.code) }; +}; + +const synchronizeFileAsync = async (file: GeneratedFile) => { + const current = await readOrEmptyAsync(file.path); + if (current?.equals(file.content)) return false; + if (checkOnly) return true; + await mkdir(path.dirname(file.path), { recursive: true }); + await writeFile(file.path, file.content); + return true; +}; + +const removeStaleGeneratedDocsAsync = async (modules: LoadedWidgetModule[], outputs: GeneratedFile[]) => { + const expected = new Set(outputs.map((output) => path.resolve(output.path))); + const stale: string[] = []; + const docsRoot = path.join(repositoryRoot, "apps/docs/docs/widgets"); + const targetDirectories = new Set(modules.map((module) => path.join(docsRoot, module.definition.documentation.slug))); + for (const entry of await readdir(docsRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const target = path.join(docsRoot, entry.name); + if (await pathExists(path.join(target, generatedDocsMarker))) targetDirectories.add(target); + } + + for (const target of targetDirectories) { + if (!(await pathExists(path.join(target, generatedDocsMarker)))) continue; + for (const file of await collectFilesAsync(target)) { + const absolutePath = path.resolve(target, file.path); + if (!expected.has(absolutePath)) stale.push(absolutePath); + } + } + if (!checkOnly) { + for (const file of stale) await rm(file); + } + return stale; +}; + +const mainAsync = async () => { + const modules = await loadWidgetModulesAsync(); + if (modules.length === 0) throw new Error("No vertical widget modules were found"); + + const definitionsTarget = path.join(repositoryRoot, "packages/definitions/src/generated/widget-modules.ts"); + const widgetsTarget = path.join(repositoryRoot, "packages/widgets/src/generated/widget-modules.ts"); + const iconsTarget = path.join(repositoryRoot, "packages/ui/src/generated/widget-icons.ts"); + const apiTarget = path.join(repositoryRoot, "packages/api/src/router/widgets/generated-registry.ts"); + const outputs: GeneratedFile[] = [ + await createGeneratedTypeScriptAsync(definitionsTarget, renderDefinitionsRegistry(modules)), + await createGeneratedTypeScriptAsync(widgetsTarget, renderWidgetRegistry(modules)), + await createGeneratedTypeScriptAsync(iconsTarget, renderIconRegistry(modules)), + await createGeneratedTypeScriptAsync(apiTarget, renderApiRegistry(modules)), + ...(await Promise.all( + modules.map(async (module) => { + const target = path.join(module.directory, "module.generated.ts"); + return await createGeneratedTypeScriptAsync(target, renderLocalWidgetProjection(module)); + }), + )), + ...(await createDocsOutputsAsync(modules)), + ]; + + const staleDocs = await removeStaleGeneratedDocsAsync(modules, outputs); + const drifted: string[] = []; + for (const output of outputs) { + if (await synchronizeFileAsync(output)) drifted.push(path.relative(repositoryRoot, output.path)); + } + drifted.push(...staleDocs.map((file) => path.relative(repositoryRoot, file))); + + if (checkOnly && drifted.length > 0) { + throw new Error( + `Widget module artifacts are out of date:\n${drifted.map((file) => ` - ${file}`).join("\n")}\nRun pnpm widgets:sync.`, + ); + } + + const action = checkOnly ? "verified" : "synchronized"; + console.log(`Widget modules ${action}: ${modules.map((module) => module.definition.kind).join(", ")}`); +}; + +await mainAsync();