diff --git a/apps/docs/docs/advanced/development/widgets-and-integrations.mdx b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx index 03111897c..2fff7e63f 100644 --- a/apps/docs/docs/advanced/development/widgets-and-integrations.mdx +++ b/apps/docs/docs/advanced/development/widgets-and-integrations.mdx @@ -8,206 +8,58 @@ tags: 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. +Homarr keeps feature registration explicit and typed. There is no generator or generated registry to run after a +change. A new feature is available when its typed metadata and lazy loader are registered. -Registration means availability. There is no separate feature-enable list or runtime kill switch. +## Add a widget -## Current status +Keep the UI, options, helpers, and tests together in `packages/widgets/src//`. The usual folder only needs an +`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed. -Weather and Downloads are the reference widget modules. Their folders now own registration and canonical docs: +1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in + `packages/definitions/src/docs/widget-doc-slugs.ts`. +2. Export `definition` and `componentLoader` from `packages/widgets/src//index.ts` with + `createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and + errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard. +3. For an integration-backed widget, add its integration kinds and selection rules once to + `packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local + definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without + duplicating widget metadata. +4. Register the module in the explicit lazy-loader and type-only maps in `packages/widgets/src/registry.ts`, then add + its icon to `packages/ui/src/widget-icons.ts`. The two registry entries preserve exact option/component inference; + keep them in the same order so drift is obvious in review. +5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit + lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in + `@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures. +6. Add canonical documentation in `apps/docs/docs/widgets//`. -```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 -``` +Weather is the small integration-free example. Downloads shows integration selection, polling, server caching, +mutations, partial upstream failures, and a larger options surface. -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. +## Add an integration -Beszel is the reference integration module. Its folder owns the manifest, implementation, capability helpers, tests, -and canonical docs. The generator projects its metadata into definitions and its constructor into the lazy integration -factory without eagerly importing the implementation. +Keep the HTTP client and its capability-specific types in `packages/integrations/src//`. -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. +1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives, + categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant. +2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the + implementation out of the initial server bundle and makes the registration easy to find. +3. Implement only the interfaces required by the widgets that use the service. +4. Add canonical documentation in `apps/docs/docs/integrations//`. Its `index.ts` must satisfy + `IntegrationDefinition` so invalid metadata fails type checking. -## The module manifest +Beszel is a compact example with several widgets sharing one integration client. -`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. +## Data and errors -```ts -import { defineWidgetModule } from "@homarr/definitions"; +- TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching. +- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys + include the integration identity and are invalidated after mutations. +- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards. +- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log + secrets or full upstream payloads. +- Multi-integration reads should return successful providers when one service fails and surface a clear error when all + providers fail. -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. - -Use `pnpm features:sync` or `pnpm features:sync:check` when a change spans widgets and integrations. - -## 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. - -Generation reads are process-cached for one second and concurrent misses are deduplicated, so a hot L1 hit does not add -a Redis round trip. Redis failure backs off for five seconds instead of producing a request storm; local invalidations -take effect immediately and other replicas converge within the one-second generation window. - -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. - -## Integration modules - -Create an integration module with an explicit icon URL: - -```sh -pnpm integrations:new example --icon-url https://example.com/example.svg -pnpm integrations:sync:check -``` - -The scaffold creates a compileable connection test, pure `module.ts`, and canonical docs in -`packages/integrations/src/example/`. Add capability interfaces and service methods beside that implementation, then -connect them to widget request handlers. Declare real credential alternatives, categories, Docker discovery aliases, -ports, and onboarding metadata in the local module instead of editing the central definitions and creator registries. - -The integration synchronizer validates kinds, credential kinds, categories, ports, URLs, documentation, creator -exports, and collisions. It generates the definitions projection, a typed lazy creator registry with literal imports, -and Docusaurus copies whose edit links point back to the canonical module docs. - -Beszel is migrated to this path. Other integrations still use the compatibility registry and can move independently -without changing persisted `IntegrationKind` values or widget capability checks. Keep integrations server-only, -implement only their capability interfaces, and use narrow schema/interface exports instead of the broad runtime -barrel while migrating them. +Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact +TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable. diff --git a/apps/docs/docs/integrations/beszel/.integration-module-generated b/apps/docs/docs/integrations/beszel/.integration-module-generated deleted file mode 100644 index 975231da4..000000000 --- a/apps/docs/docs/integrations/beszel/.integration-module-generated +++ /dev/null @@ -1 +0,0 @@ -packages/integrations/src/beszel/docs diff --git a/apps/docs/docs/integrations/beszel/index.ts b/apps/docs/docs/integrations/beszel/index.ts index c67aae6bf..5b7db0d8c 100644 --- a/apps/docs/docs/integrations/beszel/index.ts +++ b/apps/docs/docs/integrations/beszel/index.ts @@ -1,7 +1,9 @@ +import type { IntegrationDefinition } from "@site/src/types"; + 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; +} satisfies IntegrationDefinition; diff --git a/apps/docs/docs/widgets/downloads/.widget-module-generated b/apps/docs/docs/widgets/downloads/.widget-module-generated deleted file mode 100644 index 73d20026f..000000000 --- a/apps/docs/docs/widgets/downloads/.widget-module-generated +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index c2fc7efdc..000000000 --- a/apps/docs/docs/widgets/weather/.widget-module-generated +++ /dev/null @@ -1 +0,0 @@ -packages/widgets/src/weather/docs diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 85a3e550f..cf7dcc354 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -2,37 +2,8 @@ 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 { generatedIntegrationDocumentationSources, 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 integrationPath = /^integrations\/([^/]+)\/(.+)$/u.exec(docPath); - const integrationSlug = integrationPath?.[1]; - const integrationRelativePath = integrationPath?.[2]; - if ( - integrationSlug && - integrationRelativePath && - Object.hasOwn(generatedIntegrationDocumentationSources, integrationSlug) - ) { - const source = - generatedIntegrationDocumentationSources[ - integrationSlug as keyof typeof generatedIntegrationDocumentationSources - ]; - return `${githubDocsEditRoot}/${source}/${integrationRelativePath}`; - } - - 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."); @@ -94,7 +65,7 @@ const config: Config = { { docs: { sidebarPath: require.resolve("./sidebars.js"), - editUrl: ({ docPath }) => getDocumentationEditUrl(docPath), + editUrl: ({ docPath }) => `https://github.com/homarr-labs/homarr/edit/dev/apps/docs/docs/${docPath}`, remarkPlugins: [a11yEmoji], exclude: [], showLastUpdateAuthor: false, diff --git a/apps/docs/plugins/validate-docs-coverage.ts b/apps/docs/plugins/validate-docs-coverage.ts index a3e077fea..d677a22f7 100644 --- a/apps/docs/plugins/validate-docs-coverage.ts +++ b/apps/docs/plugins/validate-docs-coverage.ts @@ -2,13 +2,17 @@ import type { LoadContext, Plugin } from "@docusaurus/types"; import fs from "node:fs"; import path from "node:path"; -import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs"; import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs"; +import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration"; -const slugMaps = { - Integration: { slugs: integrationDocSlugs, folder: "integrations" }, - Widget: { slugs: widgetDocSlugs, folder: "widgets" }, -} as const; +const documentationEntries = [ + { + label: "Integration", + folder: "integrations", + entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const), + }, + { label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) }, +] as const; export default function validateDocsCoveragePlugin(context: LoadContext): Plugin { return { @@ -17,8 +21,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin const docsDir = path.resolve(context.siteDir, "docs"); const missing: string[] = []; - for (const [label, { slugs, folder }] of Object.entries(slugMaps)) { - for (const [kind, slug] of Object.entries(slugs)) { + for (const { label, folder, entries } of documentationEntries) { + for (const [kind, slug] of entries) { if (!slug) continue; if (!fs.existsSync(path.join(docsDir, folder, slug))) { missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index d4a0bc453..e46a1a3d8 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -141,12 +141,14 @@ const ScopedTRPCReactProvider = ({ refetchOnWindowFocus: false, refetchOnReconnect: false, }); - for (const { queryKey, intervalSeconds, staleTimeSeconds } of widgetQueryRefetchIntervals) { + for (const queryDefaults 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); + if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false; + if (typeof queryDefaults.intervalSeconds === "number") { + policy.refetchInterval = queryDefaults.intervalSeconds * 1000; + } + if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000; + client.setQueryDefaults(queryDefaults.queryKey, policy); } for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) { client.setQueryDefaults(queryKey, policy); diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index cfd71ad79..3f7666dab 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -10,26 +10,25 @@ import { getI18n } from "@homarr/translation/server"; import { createMetaTitle } from "~/metadata"; import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; -import { getMcpRuntimeAsync } from "~/app/api/mcp/_extract-tools"; +import { extractMcpTools } 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"; -async function getMcpToolGroupsAsync(): Promise { - const { tools, procedureTypes } = await getMcpRuntimeAsync(); +function getMcpToolGroups(): McpToolGroup[] { + const tools = extractMcpTools(); const groups = new Map(); for (const tool of tools) { const namespace = tool.pathInRouter[0] ?? "other"; if (!groups.has(namespace)) { groups.set(namespace, []); } - const procedureKey = tool.pathInRouter.join("."); groups.get(namespace)?.push({ name: tool.name, description: tool.description, - type: procedureTypes.get(procedureKey) ?? "query", + type: tool.type, }); } return Array.from(groups.entries()).map(([namespace, items]) => ({ @@ -60,7 +59,7 @@ export default async function ApiPage() { headers(), api.apiKeys.getAll(), getI18n("management.page.tool.api.tab"), - getMcpToolGroupsAsync(), + getMcpToolGroups(), ]); const baseUrl = extractBaseUrlFromHeaders(requestHeaders); const document = openApiDocument(baseUrl); diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index f56e51c6d..f871add83 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 } from "@homarr/api/mcp"; +import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; import { createAssistantGenerationAccessToken, getAssistantContextEntitiesAsync, @@ -16,7 +16,6 @@ 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"; @@ -36,7 +35,7 @@ import type { AssistantWebSearchSource, } from "~/components/assistant/assistant-message-metadata"; -import { getMcpRuntimeAsync } from "../../mcp/_extract-tools"; +import { extractMcpTools } from "../../mcp/_extract-tools"; import { buildAssistantRequestContext, sanitizeAttachmentFilename } from "./assistant-chat-input"; import { getAssistantModelLookupStatus } from "./assistant-model-lookup"; import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion"; @@ -493,24 +492,14 @@ export async function POST(request: Request) { } const canAuthorCustomWidgets = session.user.permissions.includes("admin"); const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets); - 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 caller = mcpRouter.createCaller(context); const omittedCustomWidgetTools = new Set(customWidgetAuthoringContext.omittedToolNames); - const mcpTools = mcpRuntime.tools.filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name)); + const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name)); const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget(); const homarrTools = Object.fromEntries( mcpTools.map((mcpTool) => { - const requiresApproval = mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"; + const requiresApproval = mcpTool.type === "mutation"; return [ mcpTool.name, tool({ @@ -558,11 +547,9 @@ export async function POST(request: Request) { }), ) satisfies ToolSet; const toolApproval = Object.fromEntries( - mcpTools.flatMap((mcpTool) => - mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation" - ? [[mcpTool.name, "user-approval" as const]] - : [], - ), + mcpTools + .filter((mcpTool) => mcpTool.type === "mutation") + .map((mcpTool) => [mcpTool.name, "user-approval" as const]), ); const frontendTools = Object.fromEntries( diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index 67a1bade2..70bcfa4a4 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -16,6 +16,7 @@ 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", @@ -41,30 +42,29 @@ vi.mock("@homarr/custom-widgets/core", () => ({ vi.mock("@homarr/db", () => ({ db: {} })); vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" })); vi.mock("../_extract-tools", () => ({ - 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: {} }, - }, - ], - }), + extractMcpTools: () => [ + { + name: "board_getAllBoards", + description: "List boards", + type: "query", + pathInRouter: ["board", "getAllBoards"], + inputSchema: { type: "object", properties: {} }, + }, + { + name: "customWidget_list", + description: "List Custom Widgets", + type: "query", + pathInRouter: ["customWidget", "list"], + inputSchema: { type: "object", properties: {} }, + }, + { + name: "customWidget_workshopSearch", + description: "Search Workshop", + type: "query", + pathInRouter: ["customWidget", "workshopSearch"], + inputSchema: { type: "object", properties: {} }, + }, + ], })); 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 2b5b684c8..5b90d2963 100644 --- a/apps/nextjs/src/app/api/mcp/_extract-tools.ts +++ b/apps/nextjs/src/app/api/mcp/_extract-tools.ts @@ -1,55 +1,11 @@ -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"; +import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp"; -const logger = createLogger({ module: "mcpTools" }); +let cache: ReturnType | null = null; -export type McpProcedureType = "query" | "mutation"; +export function extractMcpTools() { + if (cache) 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; + cache = extractMcpToolsFromProcedures(mcpRouter); + + return cache; } diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts index 74936f347..d736c1baf 100644 --- a/apps/nextjs/src/app/api/mcp/_handler.ts +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -5,12 +5,11 @@ import { ResourceTemplate } from "@modelcontextprotocol/server"; import { z } from "zod/v4"; import type { McpTool } from "@homarr/api/mcp"; -import { createTRPCContext } from "@homarr/api/mcp"; +import { createTRPCContext, mcpRouter } 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, @@ -23,7 +22,7 @@ import { db } from "@homarr/db"; import { getPackageVersion } from "~/versions/package-reader"; import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error"; -import { getMcpRuntimeAsync } from "./_extract-tools"; +import { extractMcpTools } from "./_extract-tools"; import { createMcpProtocolHandler } from "./_protocol"; const logger = createLogger({ module: "mcpRoute" }); @@ -81,8 +80,10 @@ function clearAuthFailures(ip: string | null) { authFailures.delete(ip ?? "unknown"); } -const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => - isAdmin ? tools : tools.filter((tool) => !tool.name.startsWith("customWidget_")); +const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => { + if (isAdmin) return tools; + return tools.filter((tool) => !tool.name.startsWith("customWidget_")); +}; function registerCustomWidgetAuthoring(server: McpServer) { server.registerPrompt( @@ -322,23 +323,12 @@ 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 = mcpRuntime.router.createCaller(ctx); + const caller = mcpRouter.createCaller(ctx); const isAdmin = session.user.permissions.includes("admin"); const protocolHandler = createMcpProtocolHandler({ caller, - tools: getVisibleTools(mcpRuntime.tools, isAdmin), + tools: getVisibleTools(extractMcpTools(), isAdmin), version: getPackageVersion(), instructions: SERVER_INSTRUCTIONS, configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined, diff --git a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts index e29baf6c8..b5c8882f8 100644 --- a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts +++ b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts @@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() }); const echoTool: McpTool = { name: "echo", description: "Echo a message", + type: "query", pathInRouter: ["echo"], inputValidator: echoValidator, inputSchema: z.toJSONSchema(echoValidator), diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx index 024854c01..f33fe5810 100644 --- a/apps/nextjs/src/components/assistant/assistant-gate.tsx +++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx @@ -74,6 +74,7 @@ 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 7f947c0a2..f74aea66d 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 unavailable path stays cheap. + * assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap. */ export const useRegisterAssistantSpotlightPlaceholder = (description: string) => { const t = useI18n("assistant"); diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index 3b052d35c..452c22699 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -63,14 +63,14 @@ describe("item actions create-item", () => { { layoutId, height: 1, - width: 1, + width: 2, ...emptyPosition, sectionId: firstSectionId, }, ], }), ); - expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 }); + expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 }); }); test("should correctly pass containers and items to getFirstEmptyPosition", () => { // Arrange @@ -118,14 +118,14 @@ describe("item actions create-item", () => { expect(createdItem).toEqual( expect.objectContaining({ kind: itemKind, - layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }], + layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }], }), ); expect(spy).toHaveBeenCalledWith( [expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)], layout.columnCount, undefined, - { height: 1, width: 1 }, + { height: 1, width: 2 }, ); }); diff --git a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts index 032bf9a4d..dee1edd21 100644 --- a/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts +++ b/apps/nextjs/src/components/board/items/board-recipe-recommendations.ts @@ -1,5 +1,5 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions"; +import { widgetIntegrationConfigs, widgetKinds } from "@homarr/definitions"; export interface BoardRecipeRecommendation { widgetKind: WidgetKind; @@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({ return widgetKinds .filter((widgetKind) => !existingKinds.has(widgetKind)) .flatMap((widgetKind) => { - const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities]; + const capability = widgetIntegrationConfigs[widgetKind as keyof typeof widgetIntegrationConfigs]; if (!capability) return []; - const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind)); + const matches = capability.supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind)); if (matches.length === 0) return []; const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind); @@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({ widgetKind, integrationKind, isNewlyAvailable: Boolean(preferredMatch), - score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length, + score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.supportedIntegrations.length, }, ]; }) - .toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind)) + .sort((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 7dbb9abc6..24dee4e5e 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -91,7 +91,6 @@ const ItemSelectModalContent = ({ initialSearch = "", }: ItemSelectModalContentProps) => { const [search, setSearch] = useState(initialSearch); - const canBrowseCustomWidgets = isAdmin; const t = useI18n(); const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); @@ -480,7 +479,7 @@ const ItemSelectModalContent = ({ /> ))} - {canBrowseCustomWidgets && ( + {isAdmin && ( <> - {filteredItems.length === 0 && (!canBrowseCustomWidgets || filteredCustomWidgets.length === 0) && ( + {filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
{t("common.noResults")}
@@ -654,13 +653,12 @@ 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: canBrowseCustomWidgets }, + { enabled: isAdmin }, ); return ( diff --git a/package.json b/package.json index 0e18e96d2..347e76aa0 100644 --- a/package.json +++ b/package.json @@ -40,11 +40,6 @@ "docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis", "format": "turbo format --continue -- --check", "format:fix": "turbo format --continue", - "features:sync": "pnpm widgets:sync && pnpm integrations:sync", - "features:sync:check": "pnpm widgets:sync:check && pnpm integrations:sync:check", - "integrations:new": "tsx ./scripts/integrations/create-integration-module.mts", - "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", @@ -59,9 +54,6 @@ "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-tools.ts b/packages/api/src/mcp-tools.ts index b3e2d058e..f821c8280 100644 --- a/packages/api/src/mcp-tools.ts +++ b/packages/api/src/mcp-tools.ts @@ -1,4 +1,4 @@ -import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import"; +import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import"; import { z } from "zod/v4"; export interface McpMeta { @@ -12,6 +12,7 @@ export interface McpMeta { export interface McpTool { name: string; description: string; + type: "query" | "mutation"; pathInRouter: string[]; inputSchema: z.core.JSONSchema.JSONSchema; inputValidator: z.ZodObject; @@ -19,8 +20,32 @@ export interface McpTool { const emptyInput = z.object({}); +const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function"; + const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput); +const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => { + if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined; + + const metadata = meta.mcp; + if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined; + if (typeof metadata.enabled !== "boolean") return undefined; + + const result: NonNullable = { enabled: metadata.enabled }; + if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name; + if ("description" in metadata && typeof metadata.description === "string") { + result.description = metadata.description; + } + return result; +}; + +const getProcedureInput = (inputs: z.ZodType[]) => { + if (inputs.length === 0) return emptyInput; + if (inputs.length === 1) return inputs[0] ?? emptyInput; + if (inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)) return mergeInputs(inputs); + return null; +}; + const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => { const properties = (schema.properties ?? {}) as Record>; const required = ((schema.required as string[] | undefined) ?? []).filter( @@ -41,22 +66,15 @@ export function extractMcpToolsFromProcedures input !== undefined); - const procedureInput = - inputs.length === 0 - ? emptyInput - : inputs.length === 1 - ? (inputs[0] ?? emptyInput) - : inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject) - ? mergeInputs(inputs) - : null; + const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType); + const procedureInput = getProcedureInput(inputs); if ( procedureInput === null || @@ -69,6 +87,7 @@ export function extractMcpToolsFromProcedures { - 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; - } -} +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, +}); diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts index 7fa6d0a48..e4f04f192 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, middlewareProcedure } from "../trpc"; +import { isDemoMode, publicProcedure } from "../trpc"; export const dockerMiddleware = () => { - return middlewareProcedure.use(async ({ next }) => { + return publicProcedure.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 317ec498e..953671263 100644 --- a/packages/api/src/middlewares/integration.ts +++ b/packages/api/src/middlewares/integration.ts @@ -10,10 +10,10 @@ import type { Database } from "@homarr/db"; import { and, eq, inArray } from "@homarr/db"; import { integrations } from "@homarr/db/schema"; import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions"; -import { getWidgetFeatureIntegrationKinds } from "@homarr/definitions"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { middlewareProcedure } from "../trpc"; +import { publicProcedure } from "../trpc"; export type IntegrationAction = "query" | "interact"; @@ -32,51 +32,22 @@ interface IntegrationAccessMetadata { allowedIntegrationKinds: readonly IntegrationKind[]; } -interface WidgetIntegrationAvailability { - widgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]]; -} - -const getCompatibleIntegrationKindsOrThrow = ( - kinds: readonly TKind[], - widgetAvailability?: WidgetIntegrationAvailability, -) => { - 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); + return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => { const accessMetadata: IntegrationAccessMetadata = { action, cardinality: "one", userId: ctx.session?.user.id, requestedIntegrationIds: [input.integrationId], - allowedIntegrationKinds: compatibleKinds, + allowedIntegrationKinds: kinds, }; logger.debug("Resolving integration access", accessMetadata); const integration = await ctx.db.query.integrations.findFirst({ - where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, compatibleKinds)), + where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)), with: { app: true, secrets: true, @@ -94,7 +65,7 @@ const createOneIntegrationMiddlewareForKinds = ( 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 ${compatibleKinds.join(",")}`, + message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`, }); } @@ -148,80 +119,76 @@ export const createOneIntegrationMiddleware = ( 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); + return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => { + const accessMetadata: IntegrationAccessMetadata = { + action, + cardinality: "many", + userId: ctx.session?.user.id, + requestedIntegrationIds: input.integrationIds, + allowedIntegrationKinds: kinds, + }; + logger.debug("Resolving integration access", accessMetadata); - const findIntegrations = async () => { - if (input.integrationIds.length === 0) return []; + 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, - }, + return await ctx.db.query.integrations.findMany({ + where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)), + with: { + app: true, + secrets: true, + items: { + with: { + item: true, }, - userPermissions: true, - groupPermissions: true, }, - }); - }; - const dbIntegrations = await findIntegrations(); - - const offset = input.integrationIds.length - dbIntegrations.length; - if (offset !== 0) { - logger.warn("Integration access targets were not found", { - ...accessMetadata, - missingIntegrationCount: offset, - resolvedIntegrationIds: dbIntegrations.map(({ id }) => id), - resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind), - }); - throw new TRPCError({ - code: "NOT_FOUND", - message: `${offset} of the specified integrations not found or not of kinds ${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), - })), - }), - ), + userPermissions: true, + groupPermissions: true, }, }); + }; + const dbIntegrations = await findIntegrations(); + + const offset = input.integrationIds.length - dbIntegrations.length; + if (offset !== 0) { + logger.warn("Integration access targets were not found", { + ...accessMetadata, + missingIntegrationCount: offset, + resolvedIntegrationIds: dbIntegrations.map(({ id }) => id), + resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind), + }); + throw new TRPCError({ + code: "NOT_FOUND", + message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`, + }); + } + + if (dbIntegrations.length >= 1) { + await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session); + } + + logger.debug("Integration access granted", { + ...accessMetadata, + resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind), }); + + return next({ + ctx: { + integrations: dbIntegrations.map( + ({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({ + ...rest, + externalUrl: rest.app?.href ?? null, + kind: kind as TKind, + decryptedSecrets: secrets.map((secret) => ({ + ...secret, + value: decryptSecret(secret.value), + })), + }), + ), + }, + }); + }); }; /** @@ -241,111 +208,17 @@ export const createManyIntegrationMiddleware = ( 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( +export const 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] }); -} +): ReturnType>> => + createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations); -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( +export const 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], - }); -} +): ReturnType>> => + createManyIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations); /** * Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations diff --git a/packages/api/src/middlewares/item.ts b/packages/api/src/middlewares/item.ts index 815e94f86..375e21607 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 { middlewareProcedure } from "../trpc"; +import { publicProcedure } from "../trpc"; export const createOneItemMiddleware = (kind: WidgetKind) => { - return middlewareProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => { + return publicProcedure.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 9468274e4..8cefc0c35 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 { middlewareProcedure } from "../trpc"; +import { publicProcedure } from "../trpc"; export const kubernetesMiddleware = () => { - return middlewareProcedure.use(async ({ next }) => { + return publicProcedure.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 e2ca9aafe..a1cb201ea 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -1,7 +1,5 @@ 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; @@ -13,15 +11,8 @@ 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 d84a13a4b..03540ef0a 100644 --- a/packages/api/src/router/assistant.ts +++ b/packages/api/src/router/assistant.ts @@ -25,9 +25,7 @@ import { assistantProviderPresets, assistantProviderRequiresApiKey, getIconUrl, - integrationKinds, resolveAssistantModelId, - widgetKinds, } from "@homarr/definitions"; import type { createTRPCContext } from "../trpc"; @@ -338,7 +336,6 @@ 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), @@ -367,12 +364,9 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P ? [] : await ctx.db.query.items.findMany({ columns: { id: true, boardId: true, kind: true }, - where: and( - inArray( - items.boardId, - availableBoards.map((board) => board.id), - ), - inArray(items.kind, widgetKinds), + where: inArray( + items.boardId, + availableBoards.map((board) => board.id), ), orderBy: asc(items.kind), limit: 500, diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index b57e6c37b..67448945c 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -120,27 +120,22 @@ const findFirstAvailableBoardItemPosition = ( return null; }; -interface WidgetPlacementRecord { +interface WidgetConfiguration { 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 haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => { + if (left.length !== right.length) return false; + const sortedRight = right.toSorted(); + return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]); +}; const validateWidgetConfigurationsAsync = async ( ctx: Parameters[0], - submittedItems: readonly WidgetConfigurationRecord[], - storedItems: readonly WidgetConfigurationRecord[], + submittedItems: readonly WidgetConfiguration[], + storedItems: readonly WidgetConfiguration[] = [], ) => { const storedItemsById = new Map(storedItems.map((item) => [item.id, item])); const changedItems = submittedItems.filter((item) => { @@ -151,7 +146,7 @@ const validateWidgetConfigurationsAsync = async ( if (changedItems.length === 0) return; const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))]; - let integrationRecords: IntegrationKindRecord[] = []; + let integrationRecords: { id: string; kind: IntegrationKind }[] = []; if (selectedIntegrationIds.length > 0) { integrationRecords = await ctx.db.query.integrations.findMany({ columns: { id: true, kind: true }, @@ -181,10 +176,9 @@ const validateWidgetConfigurationsAsync = async ( 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 selectedIntegrationKinds = item.integrationIds.flatMap( + (integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [], + ); const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds); if (!integrationIssue) continue; @@ -705,7 +699,7 @@ export const boardRouter = createTRPCRouter({ mcp: { enabled: true, description: - "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 }", + "Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }", }, }) .input(boardDuplicateSchema) @@ -749,7 +743,6 @@ export const boardRouter = createTRPCRouter({ kind: item.kind, integrationIds: item.integrations.map(({ integrationId }) => integrationId), })), - [], ); const newBoardId = createId(); @@ -1563,11 +1556,10 @@ 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); + let previousOptions: Record | undefined; + if (previousItem?.kind === "timetable") previousOptions = previousItem.options; const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation"); - await validateTimetableOptionsChangeAsync( - item.options, - previousItem?.kind === "timetable" ? previousItem.options : undefined, - ); + await validateTimetableOptionsChangeAsync(item.options, previousOptions); } await handleTransactionsAsync(ctx.db, { @@ -2142,11 +2134,9 @@ export const boardRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); - await validateWidgetConfigurationsAsync( - ctx, - [{ id: "", kind: input.kind, integrationIds: input.integrationIds }], - [], - ); + 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 }], diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts index 910d16131..a9cf5f1b8 100644 --- a/packages/api/src/router/docker/docker-router.ts +++ b/packages/api/src/router/docker/docker-router.ts @@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove"; export const performDockerContainerActionsAsync = async ( targets: DockerContainerTarget[], action: DockerContainerAction, -) => - await Promise.all( +) => { + const results = await Promise.all( targets.map(async (target) => { try { const capability = action === "remove" ? "remove" : "lifecycle"; @@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async ( }), ); + if (results.some(({ success }) => success)) { + dockerContainersRequestHandler.invalidateCache(); + } + + return results; +}; + const assertDockerEndpointCapability = ( endpointId: string, capability: "inventory" | "logs" | "lifecycle" | "remove", diff --git a/packages/api/src/router/home.ts b/packages/api/src/router/home.ts index 96a24df2c..b94cbc18c 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 (database: Database, userId: string) => { - const groupsOfCurrentUser = await database.query.groupMembers.findMany({ +const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => { + const groupsOfCurrentUser = await db.query.groupMembers.findMany({ where: eq(groupMembers.userId, userId), }); - const accessibleIntegrations = await database.query.integrations.findMany({ + const accessibleIntegrations = await db.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 d8e76273c..a44f982fb 100644 --- a/packages/api/src/router/integration/integration-access.ts +++ b/packages/api/src/router/integration/integration-access.ts @@ -1,89 +1,37 @@ import { TRPCError } from "@trpc/server"; import type { Session } from "@homarr/auth"; +import type { IntegrationPermissionsProps } from "@homarr/auth/shared"; import { constructIntegrationPermissions } from "@homarr/auth/shared"; import type { Database, SQL } from "@homarr/db"; import { eq, inArray } from "@homarr/db"; import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema"; import type { IntegrationPermission } from "@homarr/definitions"; -interface IntegrationPermissionRecord { - userPermissions: { permission: IntegrationPermission }[]; - groupPermissions: { permission: IntegrationPermission }[]; -} - const hasIntegrationPermission = ( - integration: IntegrationPermissionRecord, + integration: IntegrationPermissionsProps, session: Session | null, permission: IntegrationPermission, ) => { const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session); if (hasFullAccess) return true; if (permission === "interact") return hasInteractAccess; - if (permission === "use") return hasUseAccess; + if (permission === "use") return hasUseAccess || hasInteractAccess; return false; }; -/** - * Throws NOT_FOUND if user is not allowed to perform action on integration - * @param ctx trpc router context - * @param integrationWhere where clause for the integration - * @param permission permission required to perform action on integration - */ -export const throwIfActionForbiddenAsync = async ( +const throwIfActionsForbiddenAsync = async ( ctx: { db: Database; session: Session | null }, integrationWhere: SQL, + expectedCount: number, permission: IntegrationPermission, ) => { - const { db, session } = ctx; - const groupsOfCurrentUser = await db.query.groupMembers.findMany({ - where: eq(groupMembers.userId, session?.user.id ?? ""), - }); - const integration = await db.query.integrations.findFirst({ - where: integrationWhere, - columns: { - id: true, - }, - with: { - userPermissions: { - where: eq(integrationUserPermissions.userId, session?.user.id ?? ""), - }, - groupPermissions: { - where: inArray( - integrationGroupPermissions.groupId, - groupsOfCurrentUser.map((group) => group.groupId).concat(""), - ), - }, - }, - }); - - if (!integration) { - notAllowed(); - } - - 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), + where: integrationWhere, columns: { id: true }, with: { userPermissions: { @@ -99,13 +47,43 @@ export const throwIfIntegrationActionsForbiddenAsync = async ( }); if ( - integrationRecords.length !== uniqueIntegrationIds.length || + integrationRecords.length !== expectedCount || !integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission)) ) { notAllowed(); } }; +/** + * Throws NOT_FOUND if user is not allowed to perform action on integration + * @param ctx trpc router context + * @param integrationWhere where clause for the integration + * @param permission permission required to perform action on integration + */ +export const throwIfActionForbiddenAsync = async ( + ctx: { db: Database; session: Session | null }, + integrationWhere: SQL, + permission: IntegrationPermission, +) => { + await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission); +}; + +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; + + await throwIfActionsForbiddenAsync( + ctx, + inArray(integrations.id, uniqueIntegrationIds), + uniqueIntegrationIds.length, + permission, + ); +}; + /** * 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 2c09e585b..0add5f517 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -29,6 +29,8 @@ import { } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations/factory"; import { invalidateIntegrationCacheAsync } from "@homarr/redis"; +import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list"; +import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { byIdSchema } from "@homarr/validation/common"; import { integrationCreateSchema, @@ -712,7 +714,10 @@ export const integrationRouter = createTRPCRouter({ .input(mediaRequestRequestSchema) .mutation(async ({ ctx, input }) => { const integration = await createIntegrationAsync(ctx.integration); - return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons); + mediaRequestListRequestHandler.invalidateCache(); + await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]); + return result; }), }); diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index b8411ca1f..21d9cdd1b 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -41,6 +41,14 @@ const defaultSession = { expires: new Date().toISOString(), } satisfies Session; +const integrationUseSession = { + ...defaultSession, + user: { + ...defaultSession.user, + permissions: ["integration-use-all"], + }, +} satisfies Session; + // Mock the auth module to return an empty session vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session })); @@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => { }, }; const caller = boardRouter.createCaller({ db, deviceType: undefined, session }); - const { boardId } = await createFullBoardAsync(db, "source"); + const { boardId, integrationId } = await createFullBoardAsync(db, "source"); + await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId)); const result = await caller.duplicateBoard({ id: boardId, name: "copy" }); @@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => { it("should remove integration reference when not present in input", async () => { const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession }); const anotherIntegration = { id: createId(), kind: "adGuardHome", @@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => { items: [ { id: itemId, - kind: "clock", - options: { is24HourFormat: true }, + kind: "dnsHoleSummary", + options: { usePiHoleColors: true, layout: "grid" }, integrationIds: [anotherIntegration.id], layouts: [ { @@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => { it("should add integration reference when present in input", async () => { const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); + const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession }); const integration = { id: createId(), kind: "plex", @@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => { items: [ { id: itemId, - kind: "clock", - options: { is24HourFormat: true }, + kind: "mediaServer", + options: { showOnlyPlaying: true, showBitrate: true, showLocation: true }, integrationIds: [integration.id], layouts: [ { diff --git a/packages/api/src/router/timetable-options-validation.ts b/packages/api/src/router/timetable-options-validation.ts index 755f5e722..b4fee95b5 100644 --- a/packages/api/src/router/timetable-options-validation.ts +++ b/packages/api/src/router/timetable-options-validation.ts @@ -9,8 +9,7 @@ import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/r const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough(); -const blockedTimetableIpv4Addresses = new BlockList(); -const blockedTimetableIpv6Addresses = new BlockList(); +const blockedTimetableAddresses = new BlockList(); const blockedTimetableSubnets = [ ["0.0.0.0", 8, "ipv4"], ["10.0.0.0", 8, "ipv4"], @@ -45,8 +44,7 @@ const blockedTimetableSubnets = [ ] as const; for (const [network, prefix, family] of blockedTimetableSubnets) { - const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses; - blockList.addSubnet(network, prefix, family); + blockedTimetableAddresses.addSubnet(network, prefix, family); } export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => { @@ -61,39 +59,28 @@ export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => { } }; -const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Timetable widget configuration is invalid", - cause, - }); -}; - -export const parseSavedTimetableOptions = (serializedOptions: string) => { +export const getSavedTimetableBaseUrl = (serializedOptions: string) => { try { - return timetableOptionsSchema.parse(parse(serializedOptions)); + const options = timetableOptionsSchema.parse(parse(serializedOptions)); + return normalizeTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); } catch (cause) { - return throwInvalidSavedTimetableConfiguration(cause); - } -}; - -export const normalizeSavedTimetableBaseUrl = (baseUrl: string) => { - try { - return normalizeTimetableBaseUrl(baseUrl); - } catch (cause) { - return throwInvalidSavedTimetableConfiguration(cause); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Timetable widget configuration is invalid", + cause, + }); } }; const normalizeHostname = (hostname: string) => { - const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; + let withoutBrackets = hostname; + if (hostname.startsWith("[") && hostname.endsWith("]")) withoutBrackets = hostname.slice(1, -1); return withoutBrackets.toLowerCase().replace(/\.$/, ""); }; const isBlockedTimetableAddress = (address: string, family: number) => { - return family === 6 - ? blockedTimetableIpv6Addresses.check(address, "ipv6") - : blockedTimetableIpv4Addresses.check(address, "ipv4"); + if (family === 6) return blockedTimetableAddresses.check(address, "ipv6"); + return blockedTimetableAddresses.check(address, "ipv4"); }; export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => { @@ -115,10 +102,8 @@ export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Pro let addresses: TimetableResolvedAddress[]; try { - const resolvedAddresses = - hostnameFamily === 0 - ? await lookup(hostname, { all: true, verbatim: true }) - : [{ address: hostname, family: hostnameFamily }]; + let resolvedAddresses = [{ address: hostname, family: hostnameFamily }]; + if (hostnameFamily === 0) resolvedAddresses = await lookup(hostname, { all: true, verbatim: true }); addresses = resolvedAddresses.filter( (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6, ); diff --git a/packages/api/src/router/widgets/anchor-notes.ts b/packages/api/src/router/widgets/anchor-notes.ts index 3075d294d..eed778841 100644 --- a/packages/api/src/router/widgets/anchor-notes.ts +++ b/packages/api/src/router/widgets/anchor-notes.ts @@ -75,6 +75,8 @@ export const anchorNotesRouter = createTRPCRouter({ ...(input.title !== undefined ? { title: input.title } : {}), ...(normalizedContent !== undefined ? { content: normalizedContent } : {}), }); + anchorNotesListRequestHandler.invalidateCache(); + await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]); return updatedNote; } catch (error) { diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 8cbb8ee0b..b61e9843f 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -11,10 +11,7 @@ import { } from "@homarr/request-handler/beszel"; import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; -import { - createManySharedWidgetIntegrationMiddleware, - createManyWidgetIntegrationMiddleware, -} from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -30,12 +27,7 @@ export const beszelRouter = createTRPCRouter({ "Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)", }, }) - .concat( - createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [ - "beszelSystemTable", - "beszelSystemStats", - ]), - ) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid")) .query(async ({ ctx }) => { const integrationIds = ctx.integrations.map((i) => i.id); logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds }); @@ -153,12 +145,7 @@ export const beszelRouter = createTRPCRouter({ "Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)", }, }) - .concat( - createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [ - "beszelSystemTable", - "beszelSystemGrid", - ]), - ) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats")) .input( z.object({ systemId: z.string(), @@ -209,12 +196,7 @@ export const beszelRouter = createTRPCRouter({ }), subscribeSystemStats: publicProcedure - .concat( - createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [ - "beszelSystemTable", - "beszelSystemGrid", - ]), - ) + .concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats")) .input( z.object({ systemId: z.string(), diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts index 34d4ff327..ef8a9f683 100644 --- a/packages/api/src/router/widgets/dns-hole.ts +++ b/packages/api/src/router/widgets/dns-hole.ts @@ -6,7 +6,7 @@ import type { DnsHoleSummary } from "@homarr/integrations/types"; import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole"; import { - createManySharedWidgetIntegrationMiddleware, + createManyWidgetIntegrationMiddleware, createOneWidgetIntegrationMiddleware, } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; @@ -34,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(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"])) + .concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary")) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>( ctx.integrations, @@ -72,6 +72,7 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration } }) => { const client = await createIntegrationAsync(integration); await client.enableAsync(); + await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]); }), disable: protectedProcedure @@ -91,5 +92,6 @@ export const dnsHoleRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); await client.disableAsync(input.duration); + await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]); }), }); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 089bf7603..d7e0825fc 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -38,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase & } ); +const runDownloadMutationsAsync = async (mutations: Promise[], integrationIds: string[]) => { + const results = await Promise.allSettled(mutations); + await downloadClientRequestHandler.invalidateCacheAsync(integrationIds); + + const failure = results.find((result) => result.status === "rejected"); + if (failure) throw failure.reason; +}; + export const downloadsRouter = createTRPCRouter({ getJobsAndStatuses: publicProcedure .meta({ @@ -84,25 +92,25 @@ export const downloadsRouter = createTRPCRouter({ }) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .mutation(async ({ ctx }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.pauseQueueAsync(); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), pauseItem: protectedProcedure .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema })) .mutation(async ({ ctx, input }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.pauseItemAsync(input.item); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), resume: protectedProcedure .meta({ @@ -114,36 +122,36 @@ export const downloadsRouter = createTRPCRouter({ }) .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .mutation(async ({ ctx }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.resumeQueueAsync(); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), resumeItem: protectedProcedure .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema })) .mutation(async ({ ctx, input }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.resumeItemAsync(input.item); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), deleteItem: protectedProcedure .concat(createManyWidgetIntegrationMiddleware("interact", "downloads")) .input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() })) .mutation(async ({ ctx, input }) => { - await Promise.all( + await runDownloadMutationsAsync( ctx.integrations.map(async (integration) => { const integrationInstance = await createIntegrationAsync(integration); await integrationInstance.deleteItemAsync(input.item, input.fromDisk); }), + ctx.integrations.map(({ id }) => id), ); - downloadClientRequestHandler.invalidateCache(); }), }); diff --git a/packages/api/src/router/widgets/generated-registry.ts b/packages/api/src/router/widgets/generated-registry.ts deleted file mode 100644 index 88b39e970..000000000 --- a/packages/api/src/router/widgets/generated-registry.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 7d490cbd9..aa18fdb31 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -2,11 +2,7 @@ import { createIntegrationAsync } from "@homarr/integrations/factory"; import type { SystemHealthMonitoring } from "@homarr/integrations"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; -import { - createManySharedWidgetIntegrationMiddleware, - createOneSharedWidgetIntegrationMiddleware, - createOneWidgetIntegrationMiddleware, -} from "../../middlewares/integration"; +import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -39,14 +35,7 @@ export const healthMonitoringRouter = createTRPCRouter({ "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)", }, }) - .concat( - createManySharedWidgetIntegrationMiddleware( - "query", - "healthMonitoring", - ["systemResources", "systemDisks"], - healthMonitoringIntegrationKinds, - ), - ) + .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>( ctx.integrations, @@ -78,7 +67,7 @@ export const healthMonitoringRouter = createTRPCRouter({ "List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)", }, }) - .concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["synology"])) + .concat(createOneIntegrationMiddleware("query", "synology")) .query(async ({ ctx }) => { const integrationInstance = await createIntegrationAsync(ctx.integration); return await integrationInstance.listStorageVolumesAsync(); @@ -91,7 +80,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(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"])) + .concat(createOneIntegrationMiddleware("query", "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 871de5521..9e7f0371b 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -6,10 +6,7 @@ import { immichStatsRequestHandler, } from "@homarr/request-handler/immich"; -import { - createOneSharedWidgetIntegrationMiddleware, - createOneWidgetIntegrationMiddleware, -} from "../../middlewares/integration"; +import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const immichRouter = createTRPCRouter({ @@ -40,7 +37,7 @@ export const immichRouter = createTRPCRouter({ limit: z.number().int().min(1).max(500).optional(), }), ) - .concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"])) + .concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel")) .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 d4ab56234..01edcbd3b 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -1,30 +1,49 @@ -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({ - ...lazyWidgetRouters, + 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)), 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/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index b8b9a28e8..f0ac9cce0 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -152,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); + mediaRequestListRequestHandler.invalidateCache(); + await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]); return { success: true }; }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index b85d9a72a..619988e1b 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -2,7 +2,7 @@ import { z } from "zod/v4"; import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"; -import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -15,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(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"])) + .concat(createManyWidgetIntegrationMiddleware("query", "mediaServer")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { return await settleIntegrationQueries( diff --git a/packages/api/src/router/widgets/network-controller.ts b/packages/api/src/router/widgets/network-controller.ts index a9e60bcbd..2a39f4b42 100644 --- a/packages/api/src/router/widgets/network-controller.ts +++ b/packages/api/src/router/widgets/network-controller.ts @@ -2,7 +2,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { NetworkControllerSummary } from "@homarr/integrations/types"; import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller"; -import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration"; +import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration"; import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; @@ -21,9 +21,7 @@ interface NetworkControllerQueryResult { export const networkControllerRouter = createTRPCRouter({ summary: publicProcedure - .concat( - createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]), - ) + .concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary")) .query(async ({ ctx }) => { return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>( ctx.integrations, diff --git a/packages/api/src/router/widgets/registry.ts b/packages/api/src/router/widgets/registry.ts deleted file mode 100644 index eea618a08..000000000 --- a/packages/api/src/router/widgets/registry.ts +++ /dev/null @@ -1,225 +0,0 @@ -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 28917f23a..e9a83d890 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -57,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({ if (!success) { throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" }); } + await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]); return true; }), executeAutomation: protectedProcedure @@ -75,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({ if (!success) { throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" }); } + await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]); return true; }), }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index acdb19266..9b77ab76b 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -15,9 +15,8 @@ import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-ur import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; import { - normalizeSavedTimetableBaseUrl, + getSavedTimetableBaseUrl, normalizeTimetableBaseUrlOrThrowBadRequest, - parseSavedTimetableOptions, resolvePublicTimetableAddressesAsync, } from "../timetable-options-validation"; @@ -27,11 +26,6 @@ const timetableSourceSchema = z.object({ itemId: z.string().max(100).optional(), boardId: z.string().max(100).optional(), }); -const getSavedTimetableBaseUrl = (serializedOptions: string) => { - const options = parseSavedTimetableOptions(serializedOptions); - return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL); -}; - interface ResolvedTimetableSource { baseUrl: string; pinnedAddresses?: TimetableResolvedAddress[]; diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts index ac9bc1e00..a708701b5 100644 --- a/packages/api/src/test/mcp.spec.ts +++ b/packages/api/src/test/mcp.spec.ts @@ -2,13 +2,11 @@ import type { TRPCError } from "@trpc/server"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod/v4"; -import { getMcpRouterAsync } from "../mcp"; +import { mcpRouter } 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 9d7389304..2c0936dbb 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -16,7 +16,6 @@ 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"; @@ -106,13 +105,6 @@ 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 @@ -129,47 +121,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => { return next({ ctx }); }); -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); -} +const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure; /** * Public (unauthed) procedure @@ -193,7 +145,7 @@ const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => { export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess); -export const internalProcedure = t.procedure.use(observeProcedure); +export const internalProcedure = t.procedure; 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 3036250f8..c3a246b2f 100644 --- a/packages/db/queries/widget-placement.ts +++ b/packages/db/queries/widget-placement.ts @@ -124,7 +124,6 @@ export const placeAllWidgetsAsync = async ( } } - let placedAppCount = 0; for (const app of allApps) { await placeWidgetAsync( db, @@ -134,7 +133,6 @@ 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)); @@ -155,5 +153,5 @@ export const placeAllWidgetsAsync = async ( }); } - return placedWidgets.size + placedAppCount; + return placedWidgets.size + allApps.length; }; diff --git a/packages/definitions/package.json b/packages/definitions/package.json index dff0deb5e..0152ff0ea 100644 --- a/packages/definitions/package.json +++ b/packages/definitions/package.json @@ -14,6 +14,7 @@ "exports": { ".": "./index.ts", "./cookie": "./src/cookie.ts", + "./integration": "./src/integration.ts", "./docs/*": "./src/docs/*.ts" }, "scripts": { diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts index becadf00c..e58f18c3f 100644 --- a/packages/definitions/src/docker-integration-match.ts +++ b/packages/definitions/src/docker-integration-match.ts @@ -1,6 +1,6 @@ import { objectKeys } from "@homarr/common"; -import { integrationDefs } from "./integration"; +import { getIntegrationDockerMetadata, integrationDefs } from "./integration"; import type { IntegrationKind } from "./integration"; export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? ""; @@ -28,7 +28,7 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null => } for (const kind of objectKeys(integrationDefs)) { - if (integrationDefs[kind].features.docker.aliases.some((alias) => alias === normalized)) return kind; + if (getIntegrationDockerMetadata(kind).aliases.some((alias) => alias === normalized)) return kind; } for (const kind of objectKeys(integrationDefs)) { @@ -47,10 +47,10 @@ interface ContainerMatchInput { export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => { const imageName = extractContainerImageName(container.image); const fromImage = matchIntegrationKind(imageName); - if (fromImage && integrationDefs[fromImage].features.docker.discoverable) return fromImage; + if (fromImage && getIntegrationDockerMetadata(fromImage).discoverable) return fromImage; const fromName = matchIntegrationKind(container.name); - if (fromName && integrationDefs[fromName].features.docker.discoverable) return fromName; + if (fromName && getIntegrationDockerMetadata(fromName).discoverable) return fromName; return null; }; diff --git a/packages/definitions/src/docs/codegen.ts b/packages/definitions/src/docs/codegen.ts index d9185e59c..39d9d79da 100644 --- a/packages/definitions/src/docs/codegen.ts +++ b/packages/definitions/src/docs/codegen.ts @@ -5,8 +5,8 @@ import { XMLParser } from "fast-xml-parser"; import { z } from "zod/v4"; import { createDocumentationLink } from "./index"; -import { integrationDocSlugs } from "./integration-doc-slugs"; import { widgetDocSlugs } from "./widget-doc-slugs"; +import { getIntegrationDocumentationSlug, integrationKinds } from "../integration"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -60,7 +60,8 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => { }; const slugMapPaths = [ - ...Object.values(integrationDocSlugs) + ...integrationKinds + .map(getIntegrationDocumentationSlug) .filter(Boolean) .map((slug) => `/docs/integrations/${slug}`), ...Object.values(widgetDocSlugs) diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts deleted file mode 100644 index aca3a949e..000000000 --- a/packages/definitions/src/docs/integration-doc-slugs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { objectEntries } from "@homarr/common"; - -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 f5cc6a57a..0a47b3537 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -1,6 +1,63 @@ import type { WidgetKind } from "../widget"; -import { widgetFeatureCatalog, widgetKinds } from "../widget"; -export const widgetDocSlugs = Object.fromEntries( - widgetKinds.map((kind) => [kind, widgetFeatureCatalog[kind].documentationSlug]), -) as Record; +export const widgetDocSlugs = { + 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", +} satisfies Record; diff --git a/packages/definitions/src/generated/integration-modules.ts b/packages/definitions/src/generated/integration-modules.ts deleted file mode 100644 index ad43793f9..000000000 --- a/packages/definitions/src/generated/integration-modules.ts +++ /dev/null @@ -1,16 +0,0 @@ -// 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; - -export const generatedIntegrationDocumentationSources = { - beszel: "packages/integrations/src/beszel/docs", -} as const; diff --git a/packages/definitions/src/generated/widget-modules.ts b/packages/definitions/src/generated/widget-modules.ts deleted file mode 100644 index 32af57a67..000000000 --- a/packages/definitions/src/generated/widget-modules.ts +++ /dev/null @@ -1,59 +0,0 @@ -// 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 504d3236e..b8d094c27 100644 --- a/packages/definitions/src/index.ts +++ b/packages/definitions/src/index.ts @@ -1,7 +1,5 @@ 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"; @@ -16,8 +14,6 @@ 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 deleted file mode 100644 index bbb0a481e..000000000 --- a/packages/definitions/src/integration-module.ts +++ /dev/null @@ -1,53 +0,0 @@ -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 3eae126d7..c22f25db7 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,12 +1,12 @@ -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; +type IntegrationDocumentationSlug = IntegrationDocumentationPath extends `/docs/integrations/${infer TSlug}` + ? TSlug + : never; export const integrationSecretKindObject = { apiKey: { isPublic: false, multiline: false }, @@ -27,7 +27,7 @@ export const integrationSecretKindObject = { slug: { isPublic: true, multiline: false }, } satisfies Record; -export const integrationSecretKinds = objectKeys(integrationSecretKindObject); +export const integrationSecretKinds = Object.keys(integrationSecretKindObject) as IntegrationSecretKind[]; interface IntegrationFeatureMetadata { docker?: { @@ -40,17 +40,6 @@ interface IntegrationFeatureMetadata { }; } -interface ResolvedIntegrationFeatureMetadata { - docker: { - aliases: readonly string[]; - discoverable: boolean; - }; - onboarding: { - featuredOrder: number | null; - hidden: boolean; - }; -} - interface integrationDefinition { name: string; iconUrl: string; @@ -63,45 +52,7 @@ interface integrationDefinition { apiKeySettingsPath?: string; } -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({ +export const integrationDefs = { sabNzbd: { name: "SABnzbd", secretKinds: [["apiKey"]], @@ -551,7 +502,14 @@ export const integrationDefs = createIntegrationDefinitions({ documentationSlug: "peanut", defaultPort: 8080, }, - ...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, + }, gluetun: { name: "Gluetun", secretKinds: [["username", "password"], ["apiKey"], []], @@ -612,9 +570,9 @@ export const integrationDefs = createIntegrationDefinitions({ onboarding: { hidden: true }, }, }, -} as const satisfies Record); +} as const satisfies Record; -export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf; +export const integrationKinds = Object.keys(integrationDefs) as AtLeastOneOf; export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl; @@ -624,10 +582,10 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration ...integrationDefs[integration].secretKinds[0], ]; -export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf => - integrationDefs[integration].secretKinds.map((secretKinds) => [...secretKinds]) as AtLeastOneOf< - IntegrationSecretKind[] - >; +export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf => { + const [first, ...rest] = integrationDefs[integration].secretKinds; + return [[...first], ...rest.map((secretKinds) => [...secretKinds])]; +}; export const getIntegrationDefaultUrl = (integration: IntegrationKind) => { const definition = integrationDefs[integration]; @@ -639,8 +597,30 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef return "defaultPort" in definition ? definition.defaultPort : undefined; }; -export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => - integrationDefs[kind].documentationUrl; +export const getIntegrationDocumentationSlug = (kind: IntegrationKind): IntegrationDocumentationSlug | null => + integrationDefs[kind].documentationSlug; + +export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => { + const slug = getIntegrationDocumentationSlug(kind); + if (slug === null) return null; + return createDocumentationLink(`/docs/integrations/${slug}`); +}; + +export const getIntegrationDockerMetadata = (kind: IntegrationKind) => { + const definition: integrationDefinition = integrationDefs[kind]; + return { + aliases: definition.features?.docker?.aliases ?? [], + discoverable: definition.features?.docker?.discoverable ?? true, + }; +}; + +export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => { + const definition: integrationDefinition = integrationDefs[kind]; + return { + featuredOrder: definition.features?.onboarding?.featuredOrder ?? null, + hidden: definition.features?.onboarding?.hidden ?? false, + }; +}; export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { const definition = integrationDefs[kind]; @@ -666,7 +646,7 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio * @returns Partial list of integration kinds */ export const getIntegrationKindsByCategory = (category: TCategory) => { - return objectKeys(integrationDefs).filter((integration) => + return (Object.keys(integrationDefs) as IntegrationKind[]).filter((integration) => integrationDefs[integration].category.some((defCategory) => defCategory === category), ) as AtLeastOneOf>; }; diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts index ed176be66..6e7cb9e30 100644 --- a/packages/definitions/src/test/api-key-url.spec.ts +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from "vitest"; -import { getIntegrationApiKeyUrl, integrationDefs } from "../integration"; +import { objectEntries } from "@homarr/common"; + +import { getIntegrationApiKeyUrl, getIntegrationDocumentationUrl, integrationDefs } from "../integration"; describe("getIntegrationApiKeyUrl", () => { it("links every user-facing integration to its Homarr setup guide", () => { expect( - Object.entries(integrationDefs) - .filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl) + objectEntries(integrationDefs) + .filter(([kind]) => kind !== "mock" && !getIntegrationDocumentationUrl(kind)) .map(([kind]) => kind), ).toEqual([]); }); diff --git a/packages/definitions/src/test/widget-integration-map.spec.ts b/packages/definitions/src/test/widget-integration-map.spec.ts index 33407069e..ff3b1a74c 100644 --- a/packages/definitions/src/test/widget-integration-map.spec.ts +++ b/packages/definitions/src/test/widget-integration-map.spec.ts @@ -4,19 +4,19 @@ import { integrationKinds } from "../integration"; import { getWidgetKindsForIntegration, integrationWidgetSupport, - nativeFeatureCapabilities, + widgetIntegrationConfigs, widgetIntegrationLimits, widgetIntegrationSupport, widgetKindsWithOptionalIntegrations, } from "../widget-integration-map"; -describe("native feature capability descriptor", () => { +describe("widget integration config", () => { test("derives forward and reverse support without losing relationships", () => { - for (const [widgetKind, capability] of Object.entries(nativeFeatureCapabilities)) { + for (const [widgetKind, capability] of Object.entries(widgetIntegrationConfigs)) { expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual( - capability.integrations, + capability.supportedIntegrations, ); - for (const integrationKind of capability.integrations) { + for (const integrationKind of capability.supportedIntegrations) { expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind); } } diff --git a/packages/definitions/src/widget-feature-catalog.ts b/packages/definitions/src/widget-feature-catalog.ts deleted file mode 100644 index 3a45ff968..000000000 --- a/packages/definitions/src/widget-feature-catalog.ts +++ /dev/null @@ -1,419 +0,0 @@ -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 529dba2fc..bc91628f1 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -1,31 +1,91 @@ +import { objectEntries } from "@homarr/common"; + import { createDocumentationLink } from "./docs"; import type { IntegrationKind } from "./integration"; -import { integrationKinds } from "./integration"; +import { getIntegrationKindsByCategory, integrationKinds } from "./integration"; import type { WidgetKind } from "./widget"; -import { widgetDefaultSizes, widgetFeatureCatalog, widgetKinds } from "./widget"; -import type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog"; +import { widgetDefaultSizes } from "./widget"; -export type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog"; +/** Integration selection shared by the widget UI and API trust boundary. */ +export interface WidgetIntegrationConfig { + supportedIntegrations: IntegrationKind[]; + integrationsRequired?: false; + maxIntegrations?: number; +} -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 widgetIntegrationConfigs = { + dnsHoleSummary: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") }, + dnsHoleControls: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") }, + "smartHome-entityState": { + supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), + maxIntegrations: 1, + }, + "smartHome-executeAutomation": { + supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), + maxIntegrations: 1, + }, + mediaServer: { supportedIntegrations: getIntegrationKindsByCategory("mediaService") }, + calendar: { supportedIntegrations: getIntegrationKindsByCategory("calendar"), integrationsRequired: false }, + downloads: { supportedIntegrations: getIntegrationKindsByCategory("downloadClient") }, + "mediaRequests-requestList": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") }, + "mediaRequests-requestStats": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") }, + mediaTranscoding: { + supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"), + maxIntegrations: 1, + }, + mediaMissing: { supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer") }, + networkControllerSummary: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, + networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") }, + indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") }, + healthMonitoring: { + supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter( + (kind) => kind !== "patchmon" && kind !== "wud", + ), + }, + firewall: { supportedIntegrations: getIntegrationKindsByCategory("firewall") }, + notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") }, + mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] }, + systemResources: { + supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"], + }, + systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] }, + beszelAlerts: { supportedIntegrations: ["beszel", "mock"] }, + beszelSystemGrid: { supportedIntegrations: ["beszel", "mock"] }, + beszelSystemStats: { supportedIntegrations: ["beszel", "mock"] }, + beszelSystemTable: { supportedIntegrations: ["beszel", "mock"] }, + coolify: { supportedIntegrations: ["coolify"] }, + "immich-serverStats": { supportedIntegrations: ["immich"], maxIntegrations: 1 }, + "immich-albumCarousel": { supportedIntegrations: ["immich"], maxIntegrations: 1 }, + paperlessNgx: { supportedIntegrations: ["paperlessNgx"], maxIntegrations: 1 }, + patchmon: { supportedIntegrations: ["patchmon"], maxIntegrations: 1 }, + bazarr: { supportedIntegrations: ["bazarr"], maxIntegrations: 1 }, + tracearr: { supportedIntegrations: ["tracearr"] }, + speedtestTracker: { supportedIntegrations: ["speedtestTracker"] }, + uptimeKuma: { supportedIntegrations: ["uptimeKuma"] }, + audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf"], maxIntegrations: 1 }, + umami: { supportedIntegrations: ["umami"], maxIntegrations: 1 }, + vpn: { supportedIntegrations: getIntegrationKindsByCategory("vpn") }, + ups: { supportedIntegrations: getIntegrationKindsByCategory("ups") }, + archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior"], maxIntegrations: 1 }, + anchorNote: { supportedIntegrations: ["anchor"], maxIntegrations: 1 }, + traefik: { supportedIntegrations: ["traefik"] }, + wud: { supportedIntegrations: ["wud"], maxIntegrations: 1 }, +} satisfies Partial>; -export const nativeFeatureCapabilities = createNativeFeatureCapabilities(); +export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs; -const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [ - WidgetKind, - NativeFeatureCapabilityDescriptor, -][]; +export type WidgetIntegrationKind = + (typeof widgetIntegrationConfigs)[TKind]["supportedIntegrations"][number]; + +export const getWidgetIntegrationConfig = ( + kind: TKind, +): (typeof widgetIntegrationConfigs)[TKind] => widgetIntegrationConfigs[kind]; + +const widgetIntegrationConfigEntries = objectEntries(widgetIntegrationConfigs); const createWidgetIntegrationSupport = () => { const result: Partial> = {}; - for (const [kind, capability] of nativeFeatureCapabilityEntries) result[kind] = capability.integrations; + for (const [kind, config] of widgetIntegrationConfigEntries) result[kind] = config.supportedIntegrations; return result; }; @@ -33,16 +93,15 @@ export const widgetIntegrationSupport = createWidgetIntegrationSupport(); /** Widgets that remain useful without a configured integration. */ export const widgetKindsWithOptionalIntegrations = new Set( - nativeFeatureCapabilityEntries - .filter(([, capability]) => capability.connectionOptional === true) + widgetIntegrationConfigEntries + .filter(([, config]) => "integrationsRequired" in config && config.integrationsRequired === false) .map(([kind]) => kind), ); -/** Widgets that support fewer integrations than the default unlimited selection. */ const createWidgetIntegrationLimits = () => { const result: Partial> = {}; - for (const [kind, capability] of nativeFeatureCapabilityEntries) { - if (capability.serverMaxIntegrations !== undefined) result[kind] = capability.serverMaxIntegrations; + for (const [kind, config] of widgetIntegrationConfigEntries) { + if ("maxIntegrations" in config) result[kind] = config.maxIntegrations; } return result; }; @@ -75,7 +134,8 @@ export const getWidgetIntegrationIssue = ( return { code: "integration-required" }; } const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind)); - return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null; + if (incompatibleKinds.length === 0) return null; + return { code: "incompatible-integration", incompatibleKinds }; }; export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => { @@ -87,17 +147,19 @@ export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`; }; -/** Reverse capability index generated from nativeFeatureCapabilities. */ const createIntegrationWidgetSupport = () => { const result = {} as Record; for (const integrationKind of integrationKinds) { - result[integrationKind] = nativeFeatureCapabilityEntries - .filter(([, capability]) => capability.integrations.includes(integrationKind)) + result[integrationKind] = widgetIntegrationConfigEntries + .filter(([, config]) => supportsIntegration(config, integrationKind)) .map(([widgetKind]) => widgetKind); } return result; }; +const supportsIntegration = (config: WidgetIntegrationConfig, integrationKind: IntegrationKind) => + config.supportedIntegrations.includes(integrationKind); + export const integrationWidgetSupport = createIntegrationWidgetSupport(); export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => { @@ -112,28 +174,12 @@ export interface DefaultWidgetConfig { skip?: boolean; } -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[] = [ + { 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 } }, ]; -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 => diff --git a/packages/definitions/src/widget-module.ts b/packages/definitions/src/widget-module.ts deleted file mode 100644 index ef41330d8..000000000 --- a/packages/definitions/src/widget-module.ts +++ /dev/null @@ -1,139 +0,0 @@ -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 ee544ae7a..1db43a479 100644 --- a/packages/definitions/src/widget-name.ts +++ b/packages/definitions/src/widget-name.ts @@ -1,11 +1,21 @@ +import type { IntegrationKind } from "./integration"; import { getIntegrationName } from "./integration"; import type { WidgetKind } from "./widget"; -import { widgetFeatureCatalog } from "./widget-feature-catalog"; + +const widgetIntegrationKinds: Partial> = { + coolify: "coolify", + tracearr: "tracearr", + paperlessNgx: "paperlessNgx", + patchmon: "patchmon", + bazarr: "bazarr", + uptimeKuma: "uptimeKuma", + traefik: "traefik", +}; export const getWidgetIntegrationName = (kind: WidgetKind): string | undefined => { - const descriptor = widgetFeatureCatalog[kind]; - if (!("displayNameFromIntegration" in descriptor) || !descriptor.displayNameFromIntegration) return undefined; - return getIntegrationName(descriptor.capability.integrations[0]); + const integrationKind = widgetIntegrationKinds[kind]; + if (!integrationKind) return undefined; + return getIntegrationName(integrationKind); }; type WidgetNameTranslator = (key: never) => string; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index d438697bb..662ad5e57 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -1,19 +1,79 @@ -import { widgetFeatureCatalog } from "./widget-feature-catalog"; +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 * from "./widget-feature-catalog"; -export type { WidgetFeatureDescriptor } from "./widget-feature-catalog"; +export type WidgetKind = (typeof widgetKinds)[number]; -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: Partial> = { + clock: { width: 2, height: 1 }, + weather: { width: 2, height: 1 }, + airQuality: { width: 2, height: 1 }, + countdown: { width: 2, height: 1 }, + timer: { width: 2, height: 1 }, + bookmarks: { width: 2, height: 2 }, + uptimeKuma: { width: 2, height: 3 }, + audioStats: { width: 2, height: 2 }, + paperlessNgx: { width: 2, height: 2 }, + patchmon: { width: 2, height: 2 }, + mediaMissing: { width: 4, height: 3 }, + bazarr: { width: 2, height: 2 }, + assistant: { width: 10, height: 4 }, }; - -export const widgetDefaultSizes = createWidgetDefaultSizes(); diff --git a/packages/integrations/index.ts b/packages/integrations/index.ts index 0b5335443..3bd16e178 100644 --- a/packages/integrations/index.ts +++ b/packages/integrations/index.ts @@ -1,6 +1 @@ -/** - * 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"; +export * from "./src"; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 82c0f5bbc..37144edc8 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -17,7 +17,6 @@ "./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 bbdbf9b6c..df62678c5 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -2,187 +2,128 @@ import type { IntegrationKind } from "@homarr/definitions"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { generatedIntegrationCreatorLoaders } from "../generated/integration-creators"; import type { Integration, IntegrationInput } from "./integration"; 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), -}; +const integrationCreators = { + anchor: async (input: IntegrationInput) => + new (await import("../anchor/anchor-integration")).AnchorIntegration(input), + piHole: async (input: IntegrationInput) => + (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input), + adGuardHome: async (input: IntegrationInput) => + new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input), + technitiumDns: async (input: IntegrationInput) => + (await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input), + homeAssistant: async (input: IntegrationInput) => + new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input), + jellyfin: async (input: IntegrationInput) => + new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input), + plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input), + sonarr: async (input: IntegrationInput) => + new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input), + radarr: async (input: IntegrationInput) => + new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input), + sabNzbd: async (input: IntegrationInput) => + new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input), + nzbGet: async (input: IntegrationInput) => + new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input), + qBittorrent: async (input: IntegrationInput) => + new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input), + deluge: async (input: IntegrationInput) => + new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input), + transmission: async (input: IntegrationInput) => + new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input), + slskd: async (input: IntegrationInput) => + new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input), + aria2: async (input: IntegrationInput) => + new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input), + jellyseerr: async (input: IntegrationInput) => + new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input), + seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input), + overseerr: async (input: IntegrationInput) => + new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input), + prowlarr: async (input: IntegrationInput) => + new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input), + openmediavault: async (input: IntegrationInput) => + new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input), + lidarr: async (input: IntegrationInput) => + new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input), + readarr: async (input: IntegrationInput) => + new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input), + dashDot: async (input: IntegrationInput) => + new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input), + tdarr: async (input: IntegrationInput) => + new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input), + proxmox: async (input: IntegrationInput) => + new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input), + emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input), + nextcloud: async (input: IntegrationInput) => + new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input), + unifiController: async (input: IntegrationInput) => + new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input), + opnsense: async (input: IntegrationInput) => + new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input), + ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input), + ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input), + gotify: async (input: IntegrationInput) => + new (await import("../gotify/gotify-integration")).GotifyIntegration(input), + mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input), + truenas: async (input: IntegrationInput) => + new (await import("../truenas/truenas-integration")).TrueNasIntegration(input), + synology: async (input: IntegrationInput) => + new (await import("../synology/synology-integration")).SynologyIntegration(input), + unraid: async (input: IntegrationInput) => + new (await import("../unraid/unraid-integration")).UnraidIntegration(input), + coolify: async (input: IntegrationInput) => + new (await import("../coolify/coolify-integration")).CoolifyIntegration(input), + tracearr: async (input: IntegrationInput) => + new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input), + glances: async (input: IntegrationInput) => + new (await import("../glances/glances-integration")).GlancesIntegration(input), + immich: async (input: IntegrationInput) => + new (await import("../immich/immich-integration")).ImmichIntegration(input), + paperlessNgx: async (input: IntegrationInput) => + new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input), + patchmon: async (input: IntegrationInput) => + new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input), + speedtestTracker: async (input: IntegrationInput) => + new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input), + audiobookshelf: async (input: IntegrationInput) => + new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input), + navidrome: async (input: IntegrationInput) => + new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input), + umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input), + gluetun: async (input: IntegrationInput) => + new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input), + archiveTeamWarrior: async (input: IntegrationInput) => + new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input), + uptimeKuma: async (input: IntegrationInput) => + new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input), + peaNut: async (input: IntegrationInput) => + new (await import("../peanut/peanut-integration")).PeaNutIntegration(input), + beszel: async (input: IntegrationInput) => + new (await import("../beszel/beszel-integration")).BeszelIntegration(input), + bazarr: async (input: IntegrationInput) => + new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input), + traefik: async (input: IntegrationInput) => + new (await import("../traefik/traefik-integration")).TraefikIntegration(input), + wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input), +} satisfies Record Promise>; -type AssertIntegrationCreatorLoaders Promise>> = - TLoaders; -type AssertNoUnexpectedIntegrationKinds = TKind; -type IntegrationCreatorLoadersCheck = AssertIntegrationCreatorLoaders; -type UnexpectedIntegrationKindsCheck = AssertNoUnexpectedIntegrationKinds< - Exclude +type IntegrationCreators = typeof integrationCreators; +type IntegrationInstanceOfKind = Awaited< + ReturnType >; -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 ( +export function createIntegrationAsync( integration: IntegrationInput & { kind: TKind }, -): Promise> => { - if (!Object.hasOwn(integrationCreatorLoaders, integration.kind)) { +): Promise>; +export async function createIntegrationAsync( + integration: IntegrationInput & { kind: IntegrationKind }, +): Promise { + if (!Object.hasOwn(integrationCreators, integration.kind)) { logger.warn("Integration client creation refused", { integrationId: integration.id, integrationKind: integration.kind, @@ -193,6 +134,29 @@ export const createIntegrationAsync = async ; -}; + const startedAt = Date.now(); + logger.debug("Creating integration client", { integrationId: integration.id, integrationKind: integration.kind }); + + try { + const instance = await integrationCreators[integration.kind](integration); + logger.debug("Integration client created", { + integrationId: integration.id, + integrationKind: integration.kind, + durationMs: Date.now() - startedAt, + }); + return instance; + } catch (error) { + logger.warn( + new ErrorWithMetadata( + "Integration client creation failed", + { + integrationId: integration.id, + integrationKind: integration.kind, + durationMs: Date.now() - startedAt, + }, + { cause: error }, + ), + ); + throw error; + } +} diff --git a/packages/integrations/src/base/errors/decorator.ts b/packages/integrations/src/base/errors/decorator.ts index 69966dde8..9b9148da7 100644 --- a/packages/integrations/src/base/errors/decorator.ts +++ b/packages/integrations/src/base/errors/decorator.ts @@ -42,6 +42,12 @@ const createSafeErrorCause = (error: unknown, depth = 0): Error => { if (error instanceof Error && safeErrorNameRegex.test(error.name)) { safeError.name = error.name; } + if (error instanceof Error && error.stack) { + const originalHeader = Error.prototype.toString.call(error); + if (error.stack.startsWith(originalHeader)) { + safeError.stack = `${safeError.name}: ${safeError.message}${error.stack.slice(originalHeader.length)}`; + } + } return safeError; }; diff --git a/packages/integrations/src/base/session-store.ts b/packages/integrations/src/base/session-store.ts index 5c3ef0ce0..0233bb471 100644 --- a/packages/integrations/src/base/session-store.ts +++ b/packages/integrations/src/base/session-store.ts @@ -3,12 +3,12 @@ import superjson from "superjson"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { createGetSetChannel } from "@homarr/redis"; +import { createGetSetChannel, getIntegrationSessionStoreKey } from "@homarr/redis"; const logger = createLogger({ module: "sessionStore" }); export const createSessionStore = (integration: { id: string }) => { - const channelName = `session-store:${integration.id}`; + const channelName = getIntegrationSessionStoreKey(integration.id); const channel = createGetSetChannel<`${string}.${string}`>(channelName); return { diff --git a/packages/integrations/src/beszel/docs/img/beszel-dashboard.png b/packages/integrations/src/beszel/docs/img/beszel-dashboard.png deleted file mode 100644 index 38344a54d..000000000 Binary files a/packages/integrations/src/beszel/docs/img/beszel-dashboard.png and /dev/null differ diff --git a/packages/integrations/src/beszel/docs/index.mdx b/packages/integrations/src/beszel/docs/index.mdx deleted file mode 100644 index 8ddda77c6..000000000 --- a/packages/integrations/src/beszel/docs/index.mdx +++ /dev/null @@ -1,58 +0,0 @@ ---- -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 deleted file mode 100644 index c67aae6bf..000000000 --- a/packages/integrations/src/beszel/docs/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index c9cc6f951..000000000 --- a/packages/integrations/src/beszel/module.ts +++ /dev/null @@ -1,19 +0,0 @@ -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/generated/integration-creators.ts b/packages/integrations/src/generated/integration-creators.ts deleted file mode 100644 index 0071e883d..000000000 --- a/packages/integrations/src/generated/integration-creators.ts +++ /dev/null @@ -1,12 +0,0 @@ -// 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 21fdfa443..6869835bb 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -1,6 +1,53 @@ -// Type-only compatibility surface. Runtime capabilities have explicit subpaths. +// 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 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"; @@ -12,11 +59,8 @@ export type { FirewallMemorySummary, } from "./interfaces/firewall-summary/firewall-summary-types"; export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types"; -export type { - MediaRequestList, - MediaRequestStats, - UpstreamMediaRequestStatus, -} from "./interfaces/media-requests/media-request-types"; +export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types"; +export type { MediaRequestList, MediaRequestStats } 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 { @@ -51,4 +95,11 @@ export type { } from "./archive-team-warrior/archive-team-warrior-types"; export type { WudStats, WudContainerUpdate } from "./wud/wud-types"; +// Schemas +export { anchorNotesListInputSchema, 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 049c21f65..a204d7ccf 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/download-client/aria2/aria2-integration"; +import { Aria2Integration } from "../src"; 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 35996a744..91952b37e 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/homeassistant/homeassistant-integration"; +import { HomeAssistantIntegration } from "../src"; 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 d31891e18..f9b4860bb 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/nextcloud/nextcloud.integration"; +import { NextcloudIntegration } from "../src"; // 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 21a19756b..6b485e71d 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/download-client/nzbget/nzbget-integration"; +import { NzbGetIntegration } from "../src"; 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 41b8719a4..78c272689 100644 --- a/packages/integrations/test/pi-hole.spec.ts +++ b/packages/integrations/test/pi-hole.spec.ts @@ -4,8 +4,7 @@ import { describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; -import { PiHoleIntegrationV5 } from "../src/pi-hole/v5/pi-hole-integration-v5"; -import { PiHoleIntegrationV6 } from "../src/pi-hole/v6/pi-hole-integration-v6"; +import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src"; 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 fd3ef994f..8603f04a0 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/download-client/sabnzbd/sabnzbd-integration"; +import { SabnzbdIntegration } from "../src"; 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 fc72f54b1..ad33dc44c 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -1940,12 +1940,9 @@ const getPreviewWidgetKinds = (props: StudioSectionProps) => [ ]), ]; -const getPreviewAppCount = (props: StudioSectionProps) => { - return ( - props.drafts.filter(isIntegrationDraftComplete).length + - props.discoveredApps.filter((app) => props.selectedAppIds.includes(app.sourceId)).length - ); -}; +const getPreviewAppCount = (props: StudioSectionProps) => + 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 d470c9c03..d05c833ea 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -16,10 +16,12 @@ export { handshakeAsync, createSubPubChannel, createGetSetChannel, + getIntegrationSessionStoreKey, createLockChannel, getIntegrationCacheGenerationAsync, getWidgetCacheGenerationAsync, invalidateIntegrationCacheAsync, + invalidateIntegrationResponseCacheAsync, invalidateWidgetCache, } from "./lib/channel"; export type { IntegrationCacheGeneration, WidgetCacheGeneration } from "./lib/channel"; diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index ec49e82f4..942599e89 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -168,7 +168,13 @@ export const createLockChannel = (name: string) => { }; const integrationCacheGenerationKey = (integrationId: string) => `integration-cache:generation:${integrationId}`; -const pendingIntegrationCacheInvalidations = new Map(); +export const getIntegrationSessionStoreKey = (integrationId: string) => `session-store:${integrationId}`; +interface PendingIntegrationCacheInvalidation { + generation: string; + clearSession: boolean; +} + +const pendingIntegrationCacheInvalidations = new Map(); const MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS = 1000; const CACHE_GENERATION_REDIS_TIMEOUT_MS = 500; const CACHE_GENERATION_SUCCESS_TTL_MS = 1000; @@ -284,7 +290,11 @@ const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => if (!client) return null; const results = await withCacheGenerationRedisTimeoutAsync( - client.multi().incr(integrationCacheGenerationKey(integrationId)).del(`session-store:${integrationId}`).exec(), + client + .multi() + .incr(integrationCacheGenerationKey(integrationId)) + .del(getIntegrationSessionStoreKey(integrationId)) + .exec(), ); const generationResult = results?.[0]; const sessionResult = results?.[1]; @@ -293,25 +303,54 @@ const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => return String(generationResult[1]); }; +const advanceIntegrationResponseCacheGenerationAsync = async (integrationId: string) => { + const client = getSetClient as typeof getSetClient | null; + if (!client) return null; + + return String(await withCacheGenerationRedisTimeoutAsync(client.incr(integrationCacheGenerationKey(integrationId)))); +}; + +const getIntegrationCacheInvalidationOperation = (clearSession: boolean) => { + if (clearSession) { + return { + advanceAsync: advanceIntegrationCacheGenerationAsync, + operation: "generation-advance-and-session-delete", + failureMessage: "Failed to invalidate integration and session caches in Redis", + }; + } + return { + advanceAsync: advanceIntegrationResponseCacheGenerationAsync, + operation: "generation-advance", + failureMessage: "Failed to invalidate integration response caches in Redis", + }; +}; + export const getIntegrationCacheGenerationAsync = async ( integrationId: string, ): Promise => { const cacheKey = integrationCacheGenerationKey(integrationId); - const pendingGeneration = pendingIntegrationCacheInvalidations.get(integrationId); - if (pendingGeneration) { + const pendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId); + if (pendingInvalidation) { + const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession); const generation = await getCachedGenerationAsync({ cacheKey, - readAsync: async () => await advanceIntegrationCacheGenerationAsync(integrationId), - fallback: { value: pendingGeneration, isShared: false }, + readAsync: async () => await invalidationOperation.advanceAsync(integrationId), + fallback: { value: pendingInvalidation.generation, isShared: false }, errorMessage: "Failed to retry integration cache invalidation", - errorMetadata: { integrationId, operation: "generation-advance" }, + errorMetadata: { + integrationId, + operation: invalidationOperation.operation, + }, }); - if (generation.isShared && pendingIntegrationCacheInvalidations.get(integrationId) === pendingGeneration) { + if ( + generation.isShared && + pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation + ) { pendingIntegrationCacheInvalidations.delete(integrationId); return generation; } - const latestPendingGeneration = pendingIntegrationCacheInvalidations.get(integrationId); - if (latestPendingGeneration) return { value: latestPendingGeneration, isShared: false }; + const latestPendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId); + if (latestPendingInvalidation) return { value: latestPendingInvalidation.generation, isShared: false }; return generation; } @@ -328,29 +367,52 @@ export const getIntegrationCacheGenerationAsync = async ( }); }; -export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { - const pendingGeneration = `local-${createId()}`; +const invalidateIntegrationCacheGenerationAsync = async ( + integrationId: string, + clearSession: boolean, +): Promise => { + const previousInvalidation = pendingIntegrationCacheInvalidations.get(integrationId); + const pendingInvalidation: PendingIntegrationCacheInvalidation = { + generation: `local-${createId()}`, + clearSession: clearSession || previousInvalidation?.clearSession === true, + }; if (pendingIntegrationCacheInvalidations.size >= MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS) { const oldestIntegrationId = pendingIntegrationCacheInvalidations.keys().next().value; if (oldestIntegrationId) pendingIntegrationCacheInvalidations.delete(oldestIntegrationId); } - pendingIntegrationCacheInvalidations.set(integrationId, pendingGeneration); + pendingIntegrationCacheInvalidations.set(integrationId, pendingInvalidation); const cacheKey = integrationCacheGenerationKey(integrationId); - beginCacheGenerationInvalidation(cacheKey, pendingGeneration); + beginCacheGenerationInvalidation(cacheKey, pendingInvalidation.generation); + const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession); const generation = await getCachedGenerationAsync({ cacheKey, - readAsync: async () => await advanceIntegrationCacheGenerationAsync(integrationId), - fallback: { value: pendingGeneration, isShared: false }, - errorMessage: "Failed to invalidate integration caches in Redis", - errorMetadata: { integrationId, operation: "generation-advance-and-session-delete" }, + readAsync: async () => await invalidationOperation.advanceAsync(integrationId), + fallback: { value: pendingInvalidation.generation, isShared: false }, + errorMessage: invalidationOperation.failureMessage, + errorMetadata: { + integrationId, + operation: invalidationOperation.operation, + }, force: true, }); - if (generation.isShared && pendingIntegrationCacheInvalidations.get(integrationId) === pendingGeneration) { + if ( + generation.isShared && + pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation + ) { pendingIntegrationCacheInvalidations.delete(integrationId); } }; +export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise => { + await invalidateIntegrationCacheGenerationAsync(integrationId, true); +}; + +/** Advances response-cache generation without evicting cached integration credentials. */ +export const invalidateIntegrationResponseCacheAsync = async (integrationId: string): Promise => { + await invalidateIntegrationCacheGenerationAsync(integrationId, false); +}; + const widgetCacheGenerationKey = (namespace: string) => `widget-cache:generation:${namespace}`; const pendingWidgetCacheInvalidations = new Map(); const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000; diff --git a/packages/request-handler/src/downloads.ts b/packages/request-handler/src/downloads.ts index a96f21136..1c2b9b900 100644 --- a/packages/request-handler/src/downloads.ts +++ b/packages/request-handler/src/downloads.ts @@ -1,18 +1,16 @@ -import { getWidgetServerCachePolicy } from "@homarr/definitions"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import type { DownloadClientJobsAndStatus } 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, + cacheNamespace: "downloads:jobs-and-status", + cacheTtlMs: 10 * 1000, async requestAsync(integration, input) { const integrationInstance = await createIntegrationAsync(integration); return await integrationInstance.getClientJobsAndStatusAsync(input); diff --git a/packages/request-handler/src/lib/integration-request-handler.ts b/packages/request-handler/src/lib/integration-request-handler.ts index 67b8bbaeb..130691e69 100644 --- a/packages/request-handler/src/lib/integration-request-handler.ts +++ b/packages/request-handler/src/lib/integration-request-handler.ts @@ -1,8 +1,9 @@ import type { Modify } from "@homarr/common/types"; import type { Integration, IntegrationSecret } from "@homarr/db/schema"; -import type { IntegrationKind, WidgetModuleServerCachePolicy } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; +import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis"; -import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./integration-shared-cache"; +import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./shared-cache"; import { createRequestHandler } from "./request-handler"; import type { SharedCacheAdapter } from "./request-handler"; @@ -18,7 +19,6 @@ interface Options( options: Options, ) => { - const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace; + const cacheNamespace = options.cacheNamespace; let getSharedCacheAsync: | ((input: { integration: IntegrationOfKind; options: TInput }) => Promise>) | undefined; @@ -45,14 +45,24 @@ export const createIntegrationRequestHandler = < requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions), getCacheKey: ({ integration, options: itemOptions }) => `${integration.id}:${hashIntegrationCacheOptions(itemOptions)}`, - cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs, - fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError, - staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs, + cacheTtlMs: options.cacheTtlMs, + fallbackToStaleOnError: options.fallbackToStaleOnError, + staleIfErrorTtlMs: options.staleIfErrorTtlMs, getSharedCacheAsync, }); return { invalidateCache: inner.invalidateCache, + /** Clears this handler's L1 and every shared response-cache namespace for these integrations. */ + invalidateCacheAsync: async (integrationIds: readonly string[]) => { + inner.invalidateCache(); + if (!cacheNamespace) return; + await Promise.all( + [...new Set(integrationIds)].map(async (integrationId) => { + await invalidateIntegrationResponseCacheAsync(integrationId); + }), + ); + }, handler: (integration: IntegrationOfKind, itemOptions: TInput) => inner.handler({ integration, options: itemOptions }), }; diff --git a/packages/request-handler/src/lib/request-handler.spec.ts b/packages/request-handler/src/lib/request-handler.spec.ts index 85ee34339..dddaf781d 100644 --- a/packages/request-handler/src/lib/request-handler.spec.ts +++ b/packages/request-handler/src/lib/request-handler.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { Integration, IntegrationSecret } from "@homarr/db/schema"; import { createIntegrationRequestHandler } from "./integration-request-handler"; +import type { SharedCacheAdapter } from "./request-handler"; import { createRequestHandler } from "./request-handler"; import { createWidgetRequestHandler } from "./widget-request-handler"; @@ -52,6 +53,62 @@ describe("createRequestHandler", () => { expect(calls).toBe(1); }); + it("uses a fresh shared cache entry before calling the upstream service", async () => { + const timestamp = new Date(); + const requestAsync = vi.fn(async () => "upstream"); + const sharedCache = { + generation: "shared-1", + isShared: true, + getAsync: vi.fn(async () => ({ + data: "shared", + timestamp, + expiresAt: Date.now() + 1_000, + staleUntil: Date.now() + 1_000, + })), + setAsync: vi.fn(async () => undefined), + acquireRefreshLockAsync: vi.fn(async () => "lock"), + releaseRefreshLockAsync: vi.fn(async () => undefined), + }; + const handler = createRequestHandler({ + cacheTtlMs: 1_000, + requestAsync, + getSharedCacheAsync: async () => sharedCache, + }); + + await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp }); + expect(requestAsync).not.toHaveBeenCalled(); + expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled(); + }); + + it("deduplicates while the shared cache is being resolved", async () => { + const sharedCacheRequest = deferred>(); + const upstreamRequest = deferred(); + const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise); + const requestAsync = vi.fn(async () => await upstreamRequest.promise); + const handler = createRequestHandler({ + cacheTtlMs: 1_000, + requestAsync, + getSharedCacheAsync, + }); + + const first = handler.handler({ id: "same" }).getDataAsync(); + const second = handler.handler({ id: "same" }).getDataAsync(); + expect(getSharedCacheAsync).toHaveBeenCalledTimes(1); + + sharedCacheRequest.resolve({ + generation: "shared-1", + isShared: false, + getAsync: async () => undefined, + setAsync: async () => undefined, + acquireRefreshLockAsync: async () => undefined, + releaseRefreshLockAsync: async () => undefined, + }); + await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1)); + upstreamRequest.resolve("value"); + + await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]); + }); + it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => { let calls = 0; const request = deferred<{ call: number }>(); diff --git a/packages/request-handler/src/lib/request-handler.ts b/packages/request-handler/src/lib/request-handler.ts index a945587c0..4c5db9734 100644 --- a/packages/request-handler/src/lib/request-handler.ts +++ b/packages/request-handler/src/lib/request-handler.ts @@ -95,26 +95,27 @@ export const createRequestHandler = { const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; 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(); - if (cached && now < cached.expiresAt) { - return { data: cached.data, timestamp: cached.timestamp }; - } - if (cached && now >= cached.staleUntil) { - cache.delete(key); - scheduleExpiry(); - cached = undefined; - } - - const existing = inflight.get(key); + const existing = inflight.get(baseKey); if (existing) return existing; const requestGeneration = generation; let promise: Promise>; promise = (async () => { + let sharedCache: SharedCacheAdapter | undefined; + if (ttl > 0 && options.getSharedCacheAsync) { + sharedCache = await options.getSharedCacheAsync(input); + } + const key = `${sharedCache?.generation ?? "local"}:${baseKey}`; + + let cached = cache.get(key); + const now = Date.now(); + if (cached && now < cached.expiresAt) return cached; + if (cached && now >= cached.staleUntil) { + cache.delete(key); + scheduleExpiry(); + cached = undefined; + } + let stale = cached; let lockToken: string | null | undefined; @@ -179,10 +180,10 @@ export const createRequestHandler = { - if (inflight.get(key) === promise) inflight.delete(key); + if (inflight.get(baseKey) === promise) inflight.delete(baseKey); }); - inflight.set(key, promise); + inflight.set(baseKey, promise); return promise; }, }), diff --git a/packages/request-handler/src/lib/integration-shared-cache.ts b/packages/request-handler/src/lib/shared-cache.ts similarity index 95% rename from packages/request-handler/src/lib/integration-shared-cache.ts rename to packages/request-handler/src/lib/shared-cache.ts index 7062e3294..d90032924 100644 --- a/packages/request-handler/src/lib/integration-shared-cache.ts +++ b/packages/request-handler/src/lib/shared-cache.ts @@ -75,7 +75,7 @@ interface RedisSharedCacheOptions { const logRedisFailure = (operation: string, metadata: Record, error: unknown) => { logger.warn( new ErrorWithMetadata( - "Redis widget cache operation failed", + "Redis response cache operation failed", { ...metadata, operation, @@ -121,7 +121,7 @@ const createRedisSharedCache = ({ if (value === undefined || value === null) return value; if (isCacheEntry(value)) return value as CacheEntry; - logger.warn("Ignored invalid Redis widget cache entry", { + logger.warn("Ignored invalid Redis response cache entry", { ...logMetadata, operation: "payload-validate", }); @@ -164,7 +164,7 @@ export const createWidgetSharedCacheAsync = async ({ const metadata = { cacheNamespace: namespace }; let generation: WidgetCacheGeneration; try { - generation = await withTimeoutAsync(getWidgetCacheGenerationAsync(namespace), REDIS_OPERATION_TIMEOUT_MS); + generation = await getWidgetCacheGenerationAsync(namespace); } catch (error) { logRedisFailure("generation-read", metadata, error); generation = { value: "redis-unavailable", isShared: false }; @@ -190,7 +190,7 @@ export const createIntegrationSharedCacheAsync = async ({ const metadata = { cacheNamespace: namespace, integrationId }; let generation: IntegrationCacheGeneration; try { - generation = await withTimeoutAsync(getIntegrationCacheGenerationAsync(integrationId), REDIS_OPERATION_TIMEOUT_MS); + generation = await getIntegrationCacheGenerationAsync(integrationId); } catch (error) { logRedisFailure("generation-read", metadata, error); generation = { value: "redis-unavailable", isShared: false }; diff --git a/packages/request-handler/src/lib/widget-request-handler.ts b/packages/request-handler/src/lib/widget-request-handler.ts index 553ac6443..09edc0b20 100644 --- a/packages/request-handler/src/lib/widget-request-handler.ts +++ b/packages/request-handler/src/lib/widget-request-handler.ts @@ -1,7 +1,6 @@ import { invalidateWidgetCache } from "@homarr/redis"; -import type { WidgetModuleServerCachePolicy } from "@homarr/definitions"; -import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./integration-shared-cache"; +import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache"; import { createRequestHandler } from "./request-handler"; import type { SharedCacheAdapter } from "./request-handler"; @@ -11,13 +10,12 @@ interface Options> { fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; cacheNamespace?: string; - cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" }; } export const createWidgetRequestHandler = >( options: Options, ) => { - const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace; + const cacheNamespace = options.cacheNamespace; let getSharedCacheAsync: ((input: TInput) => Promise>) | undefined; if (cacheNamespace) { getSharedCacheAsync = async (input) => @@ -29,9 +27,9 @@ export const createWidgetRequestHandler = ({ ...options, - cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs, - fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError, - staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs, + cacheTtlMs: options.cacheTtlMs, + fallbackToStaleOnError: options.fallbackToStaleOnError, + staleIfErrorTtlMs: options.staleIfErrorTtlMs, getCacheKey: hashSharedCacheInput, getSharedCacheAsync, }); diff --git a/packages/request-handler/src/media-organizer.ts b/packages/request-handler/src/media-organizer.ts index 4f51ccc75..174c59307 100644 --- a/packages/request-handler/src/media-organizer.ts +++ b/packages/request-handler/src/media-organizer.ts @@ -1,6 +1,6 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations/factory"; -import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types"; +import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; @@ -18,7 +18,7 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler< >({ cacheNamespace: "media-organizer:summary", async requestAsync(integration, input) { - const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration; + const integrationInstance = await createIntegrationAsync(integration); const [missingResult, queueResult] = await Promise.all([ integrationInstance.getMissingAsync(input.pageSize), integrationInstance.getMediaQueueAsync(input.pageSize), diff --git a/packages/request-handler/src/weather.ts b/packages/request-handler/src/weather.ts index 8ad60a95b..b56f0bebd 100644 --- a/packages/request-handler/src/weather.ts +++ b/packages/request-handler/src/weather.ts @@ -6,15 +6,12 @@ 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; @@ -143,7 +140,8 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number }; export const weatherRequestHandler = createWidgetRequestHandler({ - cachePolicy: weatherCachePolicy, + cacheNamespace: "weather:at-location", + cacheTtlMs: 5 * 60 * 1000, requestAsync: requestWeatherAsync, }); diff --git a/packages/spotlight/src/modes/command/children/new-integration.tsx b/packages/spotlight/src/modes/command/children/new-integration.tsx index d5bd668aa..aebb6ede1 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)) - .toSorted( + .sort( ([, definitionA], [, definitionB]) => definitionA.name.toLowerCase().indexOf(normalizedQuery) - definitionB.name.toLowerCase().indexOf(normalizedQuery), diff --git a/packages/ui/src/generated/widget-icons.ts b/packages/ui/src/generated/widget-icons.ts deleted file mode 100644 index 05b9001a9..000000000 --- a/packages/ui/src/generated/widget-icons.ts +++ /dev/null @@ -1,11 +0,0 @@ -// 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.ts b/packages/ui/src/integration-select-grid/integration-grid-shared.ts index a316bb7e6..d36230f7a 100644 --- a/packages/ui/src/integration-select-grid/integration-grid-shared.ts +++ b/packages/ui/src/integration-select-grid/integration-grid-shared.ts @@ -1,6 +1,7 @@ import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { getIntegrationName, + getIntegrationOnboardingMetadata, getWidgetKindsForIntegration, integrationDefs, integrationKinds, @@ -54,7 +55,7 @@ export const buildSortedIntegrations = ( integrationKinds .filter((kind) => { if (options.allowedKinds && !options.allowedKinds.includes(kind)) return false; - if (options.onboarding && integrationDefs[kind].features.onboarding.hidden) return false; + if (options.onboarding && getIntegrationOnboardingMetadata(kind).hidden) return false; if (!options.enableMockIntegration && kind === "mock") return false; return true; }) @@ -65,8 +66,8 @@ export const buildSortedIntegrations = ( widgets: getWidgetKindsForIntegration(kind), })) .toSorted((left, right) => { - const leftFeaturedOrder = integrationDefs[left.kind].features.onboarding.featuredOrder; - const rightFeaturedOrder = integrationDefs[right.kind].features.onboarding.featuredOrder; + const leftFeaturedOrder = getIntegrationOnboardingMetadata(left.kind).featuredOrder; + const rightFeaturedOrder = getIntegrationOnboardingMetadata(right.kind).featuredOrder; if (leftFeaturedOrder !== null && rightFeaturedOrder !== null) return leftFeaturedOrder - rightFeaturedOrder; if (leftFeaturedOrder !== null) return -1; if (rightFeaturedOrder !== null) return 1; diff --git a/packages/ui/src/widget-icons.ts b/packages/ui/src/widget-icons.ts index b30a42cc3..f3d21a7bf 100644 --- a/packages/ui/src/widget-icons.ts +++ b/packages/ui/src/widget-icons.ts @@ -22,6 +22,7 @@ import { IconCloud, IconDeviceCctv, IconDeviceGamepad, + IconDownload, IconFileText, IconGraphFilled, IconHeadphones, @@ -55,17 +56,9 @@ import type { TablerIcon } from "@tabler/icons-react"; import type { WidgetKind } from "@homarr/definitions"; -import { generatedWidgetCatalogIcons } from "./generated/widget-icons"; - -const { - weather: generatedWeatherIcon, - downloads: generatedDownloadsIcon, - ...additionalGeneratedWidgetIcons -} = generatedWidgetCatalogIcons; - export const widgetCatalogIcons: Record = { clock: IconClock, - weather: generatedWeatherIcon, + weather: IconCloud, airQuality: IconWind, countdown: IconHourglass, timer: IconClockPlay, @@ -82,7 +75,7 @@ export const widgetCatalogIcons: Record = { stockPrice: IconBuildingBank, mediaServer: IconVideo, calendar: IconCalendar, - downloads: generatedDownloadsIcon, + downloads: IconDownload, "mediaRequests-requestList": IconZoomQuestion, "mediaRequests-requestStats": IconChartBar, mediaTranscoding: IconTransform, @@ -123,5 +116,4 @@ export const widgetCatalogIcons: Record = { 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 07a20547a..a9dfb28dd 100644 --- a/packages/widgets/src/air-quality/index.ts +++ b/packages/widgets/src/air-quality/index.ts @@ -6,6 +6,8 @@ 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 ded855dc5..0cd71a333 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -1,11 +1,15 @@ import { IconNotes } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("anchorNote", { icon: IconNotes, supportsAdvancedFocus: true, + queryKey: [["widget", "anchorNotes"]], + ...getWidgetIntegrationConfig("anchorNote"), 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 07b3edb94..ea99e0c24 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -16,6 +16,7 @@ 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 25c91c6f7..99163abe9 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -1,11 +1,14 @@ import { IconArchive } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", { icon: IconArchive, supportsAdvancedFocus: true, + ...getWidgetIntegrationConfig("archiveTeamWarrior"), createOptions() { return optionsBuilder.from((factory) => ({ showBroadcastMessage: factory.switch({ diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index bfbc8d577..5f462f4c2 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -1,5 +1,7 @@ import { IconHeadphones, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -14,7 +16,9 @@ const hideUnlessAudiobookshelf = { export const { definition, componentLoader } = createWidgetDefinition("audioStats", { icon: IconHeadphones, supportsAdvancedFocus: true, + queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]], queryMatcher: matchesWidgetRuntimeQuery, + ...getWidgetIntegrationConfig("audioStats"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index 7e5724b1a..ff47fc3de 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -1,11 +1,14 @@ import { IconBadgeCc, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { supportsAdvancedFocus: true, icon: IconBadgeCc, + ...getWidgetIntegrationConfig("bazarr"), createOptions() { return optionsBuilder.from((factory) => ({ showMissingEpisodes: factory.switch({ diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index 29d1da289..9a98c733a 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,11 +1,14 @@ import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; 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); @@ -16,6 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelAle maxHistoryItems: scope.options.maxHistoryItems, }); }, + ...getWidgetIntegrationConfig("beszelAlerts"), createOptions() { return optionsBuilder.from((factory) => ({ showHistory: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index b73ee2ddc..f908ac400 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -1,11 +1,15 @@ import { IconLayoutGrid, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { statusOptions } from "../beszel/_shared/options"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, + queryKey: [["widget", "beszel", "getSystems"]], + ...getWidgetIntegrationConfig("beszelSystemGrid"), createOptions() { return optionsBuilder.from((factory) => ({ statusFilter: factory.select({ diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts index c11d9cacc..4479bd274 100644 --- a/packages/widgets/src/beszel-system-stats/index.ts +++ b/packages/widgets/src/beszel-system-stats/index.ts @@ -1,6 +1,7 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -17,6 +18,7 @@ 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 }); @@ -39,6 +41,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys (dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true })) ); }, + ...getWidgetIntegrationConfig("beszelSystemStats"), createOptions() { return optionsBuilder.from((factory) => ({ systemId: factory.integrationSelect({ diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index d2ba29240..4d96c7895 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -1,6 +1,6 @@ import { IconServerOff, IconTable } from "@tabler/icons-react"; -import { invariantTechnicalLabels } from "@homarr/definitions"; +import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import type { SelectOption } from "../_inputs/widget-select-input"; @@ -28,10 +28,12 @@ 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), + ...getWidgetIntegrationConfig("beszelSystemTable"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx index ec4736ddd..82d171e9d 100644 --- a/packages/widgets/src/bookmarks/index.tsx +++ b/packages/widgets/src/bookmarks/index.tsx @@ -12,6 +12,7 @@ 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 81dd7d3c1..25873188b 100644 --- a/packages/widgets/src/calendar/index.ts +++ b/packages/widgets/src/calendar/index.ts @@ -1,6 +1,7 @@ import { IconCalendar } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; @@ -9,6 +10,7 @@ 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) => ({ @@ -32,4 +34,5 @@ export const { definition, componentLoader } = createWidgetDefinition("calendar" }), })); }, + ...getWidgetIntegrationConfig("calendar"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 195327b81..8f3fdd06f 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -15,6 +15,7 @@ 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 0ba35a5e4..58b16e725 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -1,5 +1,7 @@ import { IconCloud, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -19,6 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("coolify", }), })); }, + ...getWidgetIntegrationConfig("coolify"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index 1b7eb141c..6a308254b 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,11 +1,9 @@ import type React from "react"; -import type { LoaderComponent } from "next/dynamic"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; import { hashKey } from "@tanstack/react-query"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; -import type { IntegrationKind, NativeFeatureCapabilityDescriptor, WidgetKind } from "@homarr/definitions"; -import { widgetFeatureCatalog } from "@homarr/definitions"; +import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import type { ServerSettings } from "@homarr/server-settings"; import type { SettingsContextProps } from "@homarr/settings/creator"; import type { stringOrTranslation } from "@homarr/translation"; @@ -74,86 +72,13 @@ 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: ResolvedWidgetDefinition, - ) => - (componentLoader: () => LoaderComponent>) => ({ - definition, + (kind: TKind, definition: TDefinition) => + (componentLoader: () => Promise<{ default: React.ComponentType> }>) => ({ + definition: { + ...definition, + kind, + }, kind, componentLoader, }); @@ -167,18 +92,14 @@ export type Prefetch = ( }[], ) => Promise; -export const createWidgetDefinition = ( +export const createWidgetDefinition = ( kind: TKind, - definition: WidgetDefinitionInputWithoutCatalogMetadata, -) => { - const resolvedDefinition = resolveWidgetDefinition(kind, definition); - return { - withDynamicImport: createWithDynamicImport(kind, resolvedDefinition), - }; -}; + definition: TDefinition, +) => ({ + withDynamicImport: createWithDynamicImport(kind, definition), +}); 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 78fe6be04..6d01ed1e5 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -1,5 +1,6 @@ import { IconDeviceGamepad, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -9,6 +10,8 @@ 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({ @@ -16,6 +19,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind }), })); }, + ...getWidgetIntegrationConfig("dnsHoleControls"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index ec4e81981..7ff85a714 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -1,5 +1,6 @@ import { IconAd, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -9,6 +10,8 @@ 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({ @@ -27,6 +30,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind }), })); }, + ...getWidgetIntegrationConfig("dnsHoleSummary"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index 94687c9db..acedadfdb 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -27,6 +27,8 @@ 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 deleted file mode 100644 index 042e713c3..000000000 Binary files a/packages/widgets/src/downloads/docs/img/advanced-view.png and /dev/null differ diff --git a/packages/widgets/src/downloads/docs/img/context-menu.png b/packages/widgets/src/downloads/docs/img/context-menu.png deleted file mode 100644 index 350051d00..000000000 Binary files a/packages/widgets/src/downloads/docs/img/context-menu.png and /dev/null differ diff --git a/packages/widgets/src/downloads/docs/img/filter-panel.png b/packages/widgets/src/downloads/docs/img/filter-panel.png deleted file mode 100644 index e819e80f9..000000000 Binary files a/packages/widgets/src/downloads/docs/img/filter-panel.png and /dev/null differ diff --git a/packages/widgets/src/downloads/docs/img/screenshot.png b/packages/widgets/src/downloads/docs/img/screenshot.png deleted file mode 100644 index 8ef3a1701..000000000 Binary files a/packages/widgets/src/downloads/docs/img/screenshot.png and /dev/null differ diff --git a/packages/widgets/src/downloads/docs/index.mdx b/packages/widgets/src/downloads/docs/index.mdx deleted file mode 100644 index fefd416c1..000000000 --- a/packages/widgets/src/downloads/docs/index.mdx +++ /dev/null @@ -1,152 +0,0 @@ ---- -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 deleted file mode 100644 index f4e1945b1..000000000 --- a/packages/widgets/src/downloads/docs/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -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 bf09f66c2..31e0b66f7 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -1,11 +1,11 @@ +import { IconDownload } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { getIntegrationKindsByCategory, getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; import { DOWNLOAD_COLUMN_ACCESSORS } from "./helpers"; -import { widgetIcon } from "./module.generated"; const sortColumns = [ "name", @@ -23,13 +23,15 @@ const sortColumns = [ ] as const satisfies readonly (typeof DOWNLOAD_COLUMN_ACCESSORS)[number][]; export const { definition, componentLoader } = createWidgetDefinition("downloads", { - icon: widgetIcon, + icon: IconDownload, 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) => ({ @@ -109,4 +111,5 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads }, ); }, + ...getWidgetIntegrationConfig("downloads"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/downloads/module.generated.ts b/packages/widgets/src/downloads/module.generated.ts deleted file mode 100644 index ae685736f..000000000 --- a/packages/widgets/src/downloads/module.generated.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 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 deleted file mode 100644 index 16732da31..000000000 --- a/packages/widgets/src/downloads/module.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 de319a1ef..c50979b0c 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,11 +39,9 @@ export const BaseWidgetError = (props: BaseWidgetErrorProps) => { )} - {props.onRetry && ( - - )} + ); }; diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index 8a5fffe05..6a22c10e5 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -1,5 +1,6 @@ import { IconWall, IconWallOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,9 +8,11 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("firewall", { icon: IconWall, supportsAdvancedFocus: true, + refetchInterval: 10, createOptions() { return optionsBuilder.from(() => ({})); }, + ...getWidgetIntegrationConfig("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 deleted file mode 100644 index 11f45ddd2..000000000 --- a/packages/widgets/src/generated/widget-modules.ts +++ /dev/null @@ -1,14 +0,0 @@ -// 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 ee1545d2e..09b090818 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -1,5 +1,6 @@ import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -8,6 +9,8 @@ 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) => ({ @@ -111,6 +114,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon }, ); }, + ...getWidgetIntegrationConfig("healthMonitoring"), 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 7ced5ae5b..3dbb322e3 100644 --- a/packages/widgets/src/immich/album-carousel/index.ts +++ b/packages/widgets/src/immich/album-carousel/index.ts @@ -2,6 +2,7 @@ import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@ import z from "zod"; import { clientApi } from "@homarr/api/client"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { useI18n } from "@homarr/translation/client"; import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition"; @@ -52,6 +53,7 @@ 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] ?? "", @@ -60,6 +62,8 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al ? scope.options.albumId : undefined, }), + refetchInterval: null, + ...getWidgetIntegrationConfig("immich-albumCarousel"), contextActions: ({ widgetRuntimeRef }) => { const actions = widgetRuntimeRef.current.actions; return [ diff --git a/packages/widgets/src/immich/server-stats/index.ts b/packages/widgets/src/immich/server-stats/index.ts index 8565f615f..ebb40aa2e 100644 --- a/packages/widgets/src/immich/server-stats/index.ts +++ b/packages/widgets/src/immich/server-stats/index.ts @@ -1,12 +1,17 @@ import { IconGraphFilled } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../../definition"; 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, + ...getWidgetIntegrationConfig("immich-serverStats"), createOptions() { return optionsBuilder.from((factory) => ({ showUsers: factory.switch({ diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index 746923b8d..dfe25faba 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -1,5 +1,6 @@ import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -27,7 +28,9 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa }, }, ], + refetchInterval: null, createOptions, + ...getWidgetIntegrationConfig("indexerManager"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/manifest.spec.ts b/packages/widgets/src/manifest.spec.ts index 0d9b66947..f15fa4b0f 100644 --- a/packages/widgets/src/manifest.spec.ts +++ b/packages/widgets/src/manifest.spec.ts @@ -17,7 +17,13 @@ import { widgetQueryRefetchIntervals } from "./refetch-intervals"; import { widgetCatalogIcons } from "./catalog"; import { getWidgetQueryKeys } from "./definition"; -const serializePollingPolicy = (entry: { queryKey: QueryKey; intervalSeconds: number | null }) => JSON.stringify(entry); +const serializePollingPolicy = ({ + queryKey, + intervalSeconds, +}: { + queryKey: QueryKey; + intervalSeconds: number | null; +}) => JSON.stringify({ queryKey, intervalSeconds }); describe("widget manifest promise stability", () => { it("returns the same module promise for every render", () => { @@ -70,6 +76,7 @@ describe("widget manifest promise stability", () => { const canonicalComponent = await module.componentLoader(); const component = await loadWidgetComponent(kind); const resources = await loadWidgetResources(kind); + expect(module.definition.kind).toBe(kind); expect(definitions.get(kind)).toBe(module.definition); expect(component.default).toBe(canonicalComponent.default); expect(component.default).toBeDefined(); diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index 8704e32d9..c2e365f6c 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -7,20 +7,44 @@ import type { SettingsContextProps } from "@homarr/settings/creator"; import type { WidgetComponentProps, WidgetDefinition } from "./definition"; import type { WidgetOptionDefinition } from "./options"; +import type { WidgetImports } from "./registry"; import { widgetModuleLoaders } from "./registry"; -type WidgetComponentModule = { - default: ComponentType>; +type RegisteredWidgetModule = WidgetImports[WidgetKind]; +type RegisteredWidgetComponentModule = Awaited>; +type WidgetDefinitionFor = WidgetImports[TKind]["definition"] & WidgetDefinition; + +type WidgetComponentModule = { + default: ComponentType>; }; -type WidgetModule = { - definition: WidgetDefinition; - componentLoader: () => Promise; +export type WidgetModule = { + definition: WidgetDefinitionFor; + componentLoader: () => Promise>; }; -export type WidgetResources = { +export type WidgetResources = { + definition: WidgetDefinitionFor; + Component: ComponentType>; +}; + +type RegisteredWidgetResources = { + definition: RegisteredWidgetModule["definition"]; + Component: RegisteredWidgetComponentModule["default"]; +}; + +type WidgetManifestValue = { + module: WidgetModule; + component: WidgetComponentModule; + resources: WidgetResources; + definition: WidgetDefinitionFor; +}; + +type RegisteredWidgetManifestValue = { + module: RegisteredWidgetModule; + component: RegisteredWidgetComponentModule; + resources: RegisteredWidgetResources; definition: WidgetDefinition; - Component: ComponentType>; }; const definitionPromises = new Map>(); @@ -47,52 +71,67 @@ export const createRetryableLoader = (loaders: ReadonlyMap Promise][]), + new Map Promise>(objectEntries(widgetModuleLoaders)), ); -export const loadWidgetModule = (kind: WidgetKind) => loadRegisteredWidgetModule(kind); +// ponytail: TypeScript cannot express a Map whose value depends on its key. This +// private assertion restores only the fixed shapes above; the manifest alignment +// test checks module.definition.kind. Its ceiling is runtime-only mismatch +// detection, so remove it if TypeScript gains dependent Map value types. +const correlateWidgetPromise = >( + _kind: TKind, + _type: TType, + promise: Promise, +) => promise as Promise[TType]>; + +export const loadWidgetModule = (kind: TKind) => + correlateWidgetPromise(kind, "module", loadRegisteredWidgetModule(kind)); const loadRegisteredWidgetComponent = createRetryableLoader( - new Map( + new Map Promise>( widgetKinds.map((kind) => [ kind, async () => { - const widgetModule = await loadWidgetModule(kind); + const widgetModule = await loadRegisteredWidgetModule(kind); return widgetModule.componentLoader(); }, ]), ), ); -export const loadWidgetComponent = (kind: WidgetKind) => loadRegisteredWidgetComponent(kind); +export const loadWidgetComponent = (kind: TKind) => + correlateWidgetPromise(kind, "component", loadRegisteredWidgetComponent(kind)); const loadRegisteredWidgetResources = createRetryableLoader( - new Map( + new Map Promise>( widgetKinds.map((kind) => [ kind, async () => { const [{ definition }, { default: Component }] = await Promise.all([ - loadWidgetModule(kind), - loadWidgetComponent(kind), + loadRegisteredWidgetModule(kind), + loadRegisteredWidgetComponent(kind), ]); - return { definition, Component } satisfies WidgetResources; + return { definition, Component }; }, ]), ), ); -export const loadWidgetResources = (kind: WidgetKind) => loadRegisteredWidgetResources(kind); +export const loadWidgetResources = (kind: TKind) => + correlateWidgetPromise(kind, "resources", loadRegisteredWidgetResources(kind)); -export const loadWidgetDefinition = (kind: WidgetKind) => { +export const loadWidgetDefinition = (kind: TKind) => { const existing = definitionPromises.get(kind); - if (existing) return existing; + if (existing) { + return correlateWidgetPromise(kind, "definition", existing); + } - const promise = loadWidgetModule(kind).then(({ definition }) => definition); + const promise = loadRegisteredWidgetModule(kind).then(({ definition }) => definition); definitionPromises.set(kind, promise); void promise.catch(() => { if (definitionPromises.get(kind) === promise) definitionPromises.delete(kind); }); - return promise; + return correlateWidgetPromise(kind, "definition", promise); }; let allWidgetDefinitionsPromise: Promise> | undefined; diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts index aed456c2c..72316791d 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -1,5 +1,6 @@ import { IconMovie } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -9,6 +10,7 @@ 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) => ({ @@ -23,4 +25,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaMiss }), })); }, + ...getWidgetIntegrationConfig("mediaMissing"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-releases/index.ts b/packages/widgets/src/media-releases/index.ts index 01596cdc4..26e06452a 100644 --- a/packages/widgets/src/media-releases/index.ts +++ b/packages/widgets/src/media-releases/index.ts @@ -1,11 +1,15 @@ import { IconTicket } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; 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({ @@ -32,4 +36,5 @@ export const { definition, componentLoader } = createWidgetDefinition("mediaRele }), })); }, + ...getWidgetIntegrationConfig("mediaReleases"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index a31d43cc9..febf07955 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -1,6 +1,7 @@ import { IconSearch, IconZoomQuestion } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition"; @@ -28,6 +29,7 @@ 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, @@ -48,4 +50,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }, }, ], + ...getWidgetIntegrationConfig("mediaRequests-requestList"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index c0ee3503d..d5b9b01a0 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -1,5 +1,6 @@ import { IconChartBar, IconSearch } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; import { createWidgetDefinition } from "../../definition"; @@ -9,6 +10,7 @@ const createOptions = () => ({}); export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { supportsAdvancedFocus: false, icon: IconChartBar, + queryKey: [["widget", "mediaRequests", "getStats"]], createOptions, contextActions: ({ integrationIds }) => [ { @@ -20,4 +22,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaRequ }, }, ], + ...getWidgetIntegrationConfig("mediaRequests-requestStats"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index b6ea47160..f6ed833ce 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -1,5 +1,6 @@ import { IconVideo } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,11 +8,13 @@ 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 }), @@ -19,4 +22,5 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ showLocation: factory.switch({ defaultValue: true, withDescription: true }), })); }, + ...getWidgetIntegrationConfig("mediaServer"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts index a06de507c..4e9961b31 100644 --- a/packages/widgets/src/media-transcoding/index.ts +++ b/packages/widgets/src/media-transcoding/index.ts @@ -2,6 +2,7 @@ import { IconTransform } from "@tabler/icons-react"; import { z } from "zod/v4"; import { capitalize } from "@homarr/common"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -11,7 +12,10 @@ export const views = ["workers", "queue", "statistics"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", { icon: IconTransform, supportsAdvancedFocus: true, + queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], queryMatcher: matchesWidgetRuntimeQuery, + ...getWidgetIntegrationConfig("mediaTranscoding"), + refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ defaultView: factory.select({ diff --git a/packages/widgets/src/minecraft/server-status/index.ts b/packages/widgets/src/minecraft/server-status/index.ts index 7110fae81..91f3e6cdc 100644 --- a/packages/widgets/src/minecraft/server-status/index.ts +++ b/packages/widgets/src/minecraft/server-status/index.ts @@ -7,12 +7,14 @@ 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 45099b0bd..735037ac0 100644 --- a/packages/widgets/src/network-controller/network-status/index.ts +++ b/packages/widgets/src/network-controller/network-status/index.ts @@ -1,5 +1,6 @@ import { IconServerOff, IconTopologyFull } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -7,6 +8,8 @@ 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({ @@ -18,6 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("networkCo }), })); }, + ...getWidgetIntegrationConfig("networkControllerStatus"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/network-controller/summary/index.ts b/packages/widgets/src/network-controller/summary/index.ts index 725da0e96..ffa1c2f9e 100644 --- a/packages/widgets/src/network-controller/summary/index.ts +++ b/packages/widgets/src/network-controller/summary/index.ts @@ -1,5 +1,6 @@ import { IconServerOff, IconTopologyFull } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -7,9 +8,12 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("networkControllerSummary", { supportsAdvancedFocus: true, icon: IconTopologyFull, + queryKey: [["widget", "networkController"]], + refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({})); }, + ...getWidgetIntegrationConfig("networkControllerSummary"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 337911efc..c97e3462c 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -1,5 +1,6 @@ import { IconMessage } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,6 +8,7 @@ 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, @@ -17,4 +19,5 @@ export const { componentLoader, definition } = createWidgetDefinition("notificat hideLogos: factory.switch({ defaultValue: false }), })); }, + ...getWidgetIntegrationConfig("notifications"), }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts index 5341d6f8f..ce851e7f9 100644 --- a/packages/widgets/src/paperless-ngx/index.ts +++ b/packages/widgets/src/paperless-ngx/index.ts @@ -1,11 +1,15 @@ import { IconFileText, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", { supportsAdvancedFocus: false, icon: IconFileText, + refetchInterval: null, + ...getWidgetIntegrationConfig("paperlessNgx"), createOptions() { return optionsBuilder.from((factory) => ({ showInboxRatio: factory.switch({ diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts index 802ad3f4c..3953f7852 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -1,6 +1,8 @@ import { IconServerOff, IconShieldCheck } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; import { PATCHMON_STAT_COLOR_PRESETS } from "./stat-colors"; @@ -9,6 +11,7 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation"; export const { definition, componentLoader } = createWidgetDefinition("patchmon", { supportsAdvancedFocus: true, icon: IconShieldCheck, + ...getWidgetIntegrationConfig("patchmon"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index a43f30d4e..926087703 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -1,61 +1,45 @@ -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. This derives from the - * server-safe feature catalog and never imports widget definitions or code. + * Lightweight query defaults for the root client provider. + * + * Keep this list free of widget imports: importing widget definitions here + * would put every widget in the initial client bundle. */ -export const widgetQueryRefetchIntervals = createWidgetQueryRefetchIntervals(); +export const widgetQueryRefetchIntervals = [ + { queryKey: [["docker", "getContainers"]], intervalSeconds: 30 }, + { queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 }, + { + queryKey: [["widget", "downloads", "getJobsAndStatuses"]], + intervalSeconds: 10, + staleTimeSeconds: 10, + }, + { queryKey: [["widget", "firewall"]], intervalSeconds: 10 }, + { queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 }, + { queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 }, + { queryKey: [["widget", "tracearr"]], intervalSeconds: 10 }, + { + queryKey: [["widget", "weather", "atLocation"]], + intervalSeconds: 600, + staleTimeSeconds: 300, + }, + { 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; diff --git a/packages/widgets/src/registry.ts b/packages/widgets/src/registry.ts index d44603313..be5ab761f 100644 --- a/packages/widgets/src/registry.ts +++ b/packages/widgets/src/registry.ts @@ -20,6 +20,7 @@ import type * as customApi from "./custom-api"; import type * as dnsHoleControls from "./dns-hole/controls"; import type * as dnsHoleSummary from "./dns-hole/summary"; import type * as dockerContainers from "./docker"; +import type * as downloads from "./downloads"; import type * as firewall from "./firewall"; import type * as healthMonitoring from "./health-monitoring"; import type * as iframe from "./iframe"; @@ -56,9 +57,8 @@ import type * as ups from "./ups"; import type * as uptimeKuma from "./uptime-kuma"; import type * as video from "./video"; import type * as vpn from "./vpn"; +import type * as weather from "./weather"; import type * as wud from "./wud"; -import { generatedWidgetModuleLoaders } from "./generated/widget-modules"; -import type { GeneratedWidgetImports } from "./generated/widget-modules"; type WidgetModule = { definition: unknown; @@ -86,6 +86,7 @@ export const widgetModuleLoaders = { stockPrice: () => import("./stocks"), mediaServer: () => import("./media-server"), calendar: () => import("./calendar"), + downloads: () => import("./downloads"), "mediaRequests-requestList": () => import("./media-requests/list"), "mediaRequests-requestStats": () => import("./media-requests/stats"), mediaMissing: () => import("./media-missing"), @@ -124,8 +125,8 @@ export const widgetModuleLoaders = { beszelSystemStats: () => import("./beszel-system-stats"), traefik: () => import("./traefik"), customApi: () => import("./custom-api"), + weather: () => import("./weather"), wud: () => import("./wud"), - ...generatedWidgetModuleLoaders, } satisfies Record Promise>; export type WidgetImports = { @@ -147,6 +148,7 @@ export type WidgetImports = { stockPrice: typeof stockPrice; mediaServer: typeof mediaServer; calendar: typeof calendar; + downloads: typeof downloads; "mediaRequests-requestList": typeof mediaRequestsList; "mediaRequests-requestStats": typeof mediaRequestsStats; mediaMissing: typeof mediaMissing; @@ -185,8 +187,9 @@ export type WidgetImports = { beszelSystemStats: typeof beszelSystemStats; traefik: typeof traefik; customApi: typeof customApi; + weather: typeof weather; wud: typeof wud; -} & GeneratedWidgetImports; +}; export type WidgetImportKey = keyof WidgetImports; diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts index 5b126af43..75a42f203 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -15,7 +15,9 @@ 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 978368864..abf728aa0 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -13,11 +13,13 @@ 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 24fd371f6..65a6102c3 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -1,5 +1,6 @@ import { IconBinaryTree, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -7,6 +8,8 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", { icon: IconBinaryTree, supportsAdvancedFocus: true, + queryKey: [["widget", "smartHome"]], + ...getWidgetIntegrationConfig("smartHome-entityState"), createOptions() { return optionsBuilder.from((factory) => ({ entityId: factory.text({ diff --git a/packages/widgets/src/smart-home/execute-automation/index.ts b/packages/widgets/src/smart-home/execute-automation/index.ts index ed53719fb..4f962c666 100644 --- a/packages/widgets/src/smart-home/execute-automation/index.ts +++ b/packages/widgets/src/smart-home/execute-automation/index.ts @@ -1,5 +1,6 @@ import { IconBinaryTree } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../../definition"; import { optionsBuilder } from "../../options"; @@ -7,6 +8,7 @@ import { optionsBuilder } from "../../options"; export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", { icon: IconBinaryTree, supportsAdvancedFocus: true, + ...getWidgetIntegrationConfig("smartHome-executeAutomation"), createOptions() { return optionsBuilder.from((factory) => ({ displayName: factory.text(), diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts index edf492cab..dbc989d99 100644 --- a/packages/widgets/src/speedtest-tracker/index.ts +++ b/packages/widgets/src/speedtest-tracker/index.ts @@ -1,11 +1,14 @@ import { IconServerOff, IconSpeedboat } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", { icon: IconSpeedboat, supportsAdvancedFocus: true, + refetchInterval: null, createOptions() { return optionsBuilder.from( (factory) => ({ @@ -29,6 +32,7 @@ export const { definition, componentLoader } = createWidgetDefinition("speedtest }, ); }, + ...getWidgetIntegrationConfig("speedtestTracker"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts index da6c85410..8769bce4a 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -14,12 +14,14 @@ 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 5ce96246a..059649719 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -1,5 +1,7 @@ import { IconServer2 } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options"; @@ -7,6 +9,9 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { supportsAdvancedFocus: true, icon: IconServer2, + queryKey: [["widget", "healthMonitoring"]], + refetchInterval: 10, + ...getWidgetIntegrationConfig("systemDisks"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index fa367a40a..80f3cc1b7 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -1,7 +1,7 @@ import { IconAlignLeft, IconEyeOff, IconGraphFilled, IconListDetails, IconPhoto } from "@tabler/icons-react"; import { objectEntries } from "@homarr/common"; -import { invariantTechnicalLabels } from "@homarr/definitions"; +import { getWidgetIntegrationConfig, invariantTechnicalLabels } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -22,6 +22,9 @@ const getChartOptionLabel = (key: "cpu" | "memory" | "gpu" | "network", t: (key: export const { definition, componentLoader } = createWidgetDefinition("systemResources", { icon: IconGraphFilled, supportsAdvancedFocus: true, + queryKey: [["widget", "healthMonitoring"]], + refetchInterval: 10, + ...getWidgetIntegrationConfig("systemResources"), createOptions() { return optionsBuilder.from((factory) => ({ hasShadow: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index fba382602..074a34bab 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -1,11 +1,14 @@ import { IconActivityHeartbeat, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("tracearr", { icon: IconActivityHeartbeat, supportsAdvancedFocus: true, + refetchInterval: 10, createOptions() { return optionsBuilder.from((factory) => ({ showStreams: factory.switch({ @@ -23,6 +26,7 @@ export const { definition, componentLoader } = createWidgetDefinition("tracearr" }), })); }, + ...getWidgetIntegrationConfig("tracearr"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts index def3d64ed..f679b0c9c 100644 --- a/packages/widgets/src/traefik/index.ts +++ b/packages/widgets/src/traefik/index.ts @@ -1,5 +1,7 @@ import { IconRoute, IconRouteOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -19,6 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("traefik", }), })); }, + ...getWidgetIntegrationConfig("traefik"), errors: { INTERNAL_SERVER_ERROR: { icon: IconRouteOff, diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts index d0e34b73b..9d92cbada 100644 --- a/packages/widgets/src/umami/index.ts +++ b/packages/widgets/src/umami/index.ts @@ -1,6 +1,8 @@ import { IconChartBar, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; @@ -9,7 +11,16 @@ 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, + ...getWidgetIntegrationConfig("umami"), createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/ups/index.ts b/packages/widgets/src/ups/index.ts index 209feacb7..e0b8e0739 100644 --- a/packages/widgets/src/ups/index.ts +++ b/packages/widgets/src/ups/index.ts @@ -1,5 +1,6 @@ import { IconBatteryCharging, IconBatteryOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -20,6 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("ups", { }), })); }, + ...getWidgetIntegrationConfig("ups"), errors: { INTERNAL_SERVER_ERROR: { icon: IconBatteryOff, diff --git a/packages/widgets/src/uptime-kuma/index.ts b/packages/widgets/src/uptime-kuma/index.ts index 06f9a15fb..ad67a4601 100644 --- a/packages/widgets/src/uptime-kuma/index.ts +++ b/packages/widgets/src/uptime-kuma/index.ts @@ -1,5 +1,7 @@ import { IconHeartbeat, IconServerOff } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -34,6 +36,7 @@ export const { definition, componentLoader } = createWidgetDefinition("uptimeKum }), })); }, + ...getWidgetIntegrationConfig("uptimeKuma"), errors: { INTERNAL_SERVER_ERROR: { icon: IconServerOff, diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts index e17c8239a..e22fe6a3a 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -1,5 +1,6 @@ import { IconShieldLock } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,7 +8,9 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("vpn", { supportsAdvancedFocus: false, icon: IconShieldLock, + refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({})); }, + ...getWidgetIntegrationConfig("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 deleted file mode 100644 index 96e37daf4..000000000 Binary files a/packages/widgets/src/weather/docs/img/advanced-view.png and /dev/null differ diff --git a/packages/widgets/src/weather/docs/img/current.png b/packages/widgets/src/weather/docs/img/current.png deleted file mode 100644 index 45cc08169..000000000 Binary files a/packages/widgets/src/weather/docs/img/current.png and /dev/null differ diff --git a/packages/widgets/src/weather/docs/img/forecast.png b/packages/widgets/src/weather/docs/img/forecast.png deleted file mode 100644 index 2de9b1224..000000000 Binary files a/packages/widgets/src/weather/docs/img/forecast.png and /dev/null differ diff --git a/packages/widgets/src/weather/docs/index.mdx b/packages/widgets/src/weather/docs/index.mdx deleted file mode 100644 index c03357f87..000000000 --- a/packages/widgets/src/weather/docs/index.mdx +++ /dev/null @@ -1,50 +0,0 @@ ---- -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 deleted file mode 100644 index 836b70ca5..000000000 --- a/packages/widgets/src/weather/docs/index.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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 f70a21bf8..53c890b4c 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -1,13 +1,15 @@ +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: widgetIcon, + icon: IconCloud, 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 deleted file mode 100644 index ee7ba69aa..000000000 --- a/packages/widgets/src/weather/module.generated.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 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 deleted file mode 100644 index 51964c312..000000000 --- a/packages/widgets/src/weather/module.ts +++ /dev/null @@ -1,36 +0,0 @@ -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 2e15ac169..d5bcae3e6 100644 --- a/packages/widgets/src/wud/index.ts +++ b/packages/widgets/src/wud/index.ts @@ -1,11 +1,15 @@ import { IconBrandDocker } from "@tabler/icons-react"; +import { getWidgetIntegrationConfig } from "@homarr/definitions"; + import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("wud", { icon: IconBrandDocker, supportsAdvancedFocus: true, + refetchInterval: null, + ...getWidgetIntegrationConfig("wud"), createOptions() { return optionsBuilder.from((factory) => ({ showTitle: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/tsconfig.json b/packages/widgets/tsconfig.json index 1709270b6..e205abceb 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", "src/**/docs/**"] + "exclude": ["node_modules"] } diff --git a/scripts/integrations/create-integration-module.mts b/scripts/integrations/create-integration-module.mts deleted file mode 100644 index 5f725ac92..000000000 --- a/scripts/integrations/create-integration-module.mts +++ /dev/null @@ -1,120 +0,0 @@ -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 integrationsRoot = path.join(repositoryRoot, "packages/integrations/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 integrations:new --icon-url [--slug ] [--name ] [--category ] [--default-port ]", - ); -} -if (!/^[a-z][A-Za-z0-9]*$/.test(kind)) { - throw new Error("Integration 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 name = - readOption("--name") ?? - slug - .split("-") - .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) - .join(" "); -const category = readOption("--category") ?? "miscellaneous"; -const iconUrl = readOption("--icon-url"); -const defaultPortInput = readOption("--default-port"); - -if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error("Integration slug must use lower-case kebab-case"); -if (!iconUrl || !URL.canParse(iconUrl)) throw new Error("--icon-url must be a valid absolute URL"); -if (!/^[a-z][A-Za-z0-9]*$/.test(category)) throw new Error("Integration category must be a stable identifier"); - -let defaultPort: number | undefined; -if (defaultPortInput !== undefined) { - defaultPort = Number(defaultPortInput); - if (!Number.isSafeInteger(defaultPort) || defaultPort < 1 || defaultPort > 65_535) { - throw new Error("--default-port must be an integer between 1 and 65535"); - } -} - -const className = `${kind.charAt(0).toUpperCase()}${kind.slice(1)}Integration`; -const metadataExportName = `${kind}Integration`; -const directory = path.join(integrationsRoot, slug); -await mkdir(directory); -await mkdir(path.join(directory, "docs")); - -const defaultPortLine = defaultPort === undefined ? "" : ` defaultPort: ${defaultPort},\n`; -const files = { - "module.ts": `import { defineIntegrationModule } from "@homarr/definitions"; - -export default defineIntegrationModule({ - kind: ${JSON.stringify(kind)}, - name: ${JSON.stringify(name)}, - iconUrl: ${JSON.stringify(iconUrl)}, - secretKinds: [[]], - categories: [${JSON.stringify(category)}], -${defaultPortLine} documentation: { - slug: ${JSON.stringify(slug)}, - sourceDirectory: "docs", - }, - creator: { - type: "constructor", - module: "./${slug}-integration", - exportName: ${JSON.stringify(className)}, - }, -}); -`, - [`${slug}-integration.ts`]: `import type { IntegrationTestingInput } from "../base/integration"; -import { Integration } from "../base/integration"; -import { TestConnectionError } from "../base/test-connection/test-connection-error"; -import type { TestingResult } from "../base/test-connection/test-connection-service"; - -export class ${className} extends Integration { - protected async testingAsync(input: IntegrationTestingInput): Promise { - const response = await input.fetchAsync(this.url("/")); - if (!response.ok) return TestConnectionError.StatusResult(response); - return { success: true }; - } -} -`, - "docs/index.ts": `export const ${metadataExportName} = { - name: ${JSON.stringify(name)}, - description: ${JSON.stringify(`Connect Homarr to ${name}.`)}, - iconUrl: ${JSON.stringify(iconUrl)}, - path: "../../integrations/${slug}", -} as const; -`, - "docs/index.mdx": `--- -title: ${JSON.stringify(name)} -description: ${JSON.stringify(`Connect Homarr to ${name}.`)} -hide_title: true ---- - -import { IntegrationHeader } from "@site/src/components/integrations/header"; -import { AddingIntegration } from "@site/src/components/integrations/adding"; -import { ${metadataExportName} } from "."; - - - -Describe the service, supported Homarr widgets, required permissions, and setup prerequisites here. - -### Adding the integration - - -`, -} as const; - -for (const [relativePath, content] of Object.entries(files)) { - await writeFile(path.join(directory, relativePath), content, { flag: "wx" }); -} - -await import("./sync-integration-modules.mts"); -console.log(`Created integration module ${kind} in ${path.relative(repositoryRoot, directory)}`); diff --git a/scripts/integrations/sync-integration-modules.mts b/scripts/integrations/sync-integration-modules.mts deleted file mode 100644 index 4be477ea0..000000000 --- a/scripts/integrations/sync-integration-modules.mts +++ /dev/null @@ -1,392 +0,0 @@ -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"; -import { integrationCategories, integrationSecretKinds } from "../../packages/definitions/src/integration"; - -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"; -const integrationCategorySet = new Set(integrationCategories); -const integrationSecretKindSet = new Set(integrationSecretKinds); - -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`); - } - for (const secretAlternative of definition.secretKinds) { - if (!Array.isArray(secretAlternative)) { - throw new Error(`Integration ${definition.kind} secret alternatives must be arrays`); - } - for (const secretKind of secretAlternative) { - if (!integrationSecretKindSet.has(secretKind)) { - throw new Error(`Integration ${definition.kind} has unknown secret kind ${secretKind}`); - } - } - } - if (!Array.isArray(definition.categories) || definition.categories.length === 0) { - throw new Error(`Integration ${definition.kind} must declare at least one category`); - } - for (const category of definition.categories) { - if (!integrationCategorySet.has(category)) { - throw new Error(`Integration ${definition.kind} has unknown category ${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 || definition.defaultPort > 65_535) - ) { - throw new Error(`Integration ${definition.kind} default port must be an integer between 1 and 65535`); - } - if (definition.defaultUrl !== undefined && !URL.canParse(definition.defaultUrl)) { - throw new Error(`Integration ${definition.kind} default URL is invalid`); - } - - 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"); - const documentationSources = modules - .map((module) => { - const source = path.resolve(module.directory, module.definition.documentation.sourceDirectory); - const relativeSource = toPosixPath(path.relative(repositoryRoot, source)); - return ` ${JSON.stringify(module.definition.documentation.slug)}: ${JSON.stringify(relativeSource)},`; - }) - .join("\n"); - return `${generatedHeader}export const generatedIntegrationDefinitions = {\n${entries}\n} as const;\n\nexport const generatedIntegrationDocumentationSources = {\n${documentationSources}\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 deleted file mode 100644 index 0909c6d95..000000000 --- a/scripts/widgets/create-widget-module.mts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index c89f945b6..000000000 --- a/scripts/widgets/sync-widget-modules.mts +++ /dev/null @@ -1,475 +0,0 @@ -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();