refactor: simplify widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-24 00:57:36 +02:00
1 parent c988c35b29
commit 8f9efd0074
179 files changed
+1509 -4187

No files matched your search

@@ -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/<widget>/`. 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/<widget>/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/<slug>/`.
```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/<integration>/`.
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/<slug>/`. 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/<slug>/` 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.
@@ -1 +0,0 @@
packages/integrations/src/beszel/docs
+3 -1
View File
@@ -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;
@@ -1 +0,0 @@
packages/widgets/src/downloads/docs
@@ -1 +0,0 @@
packages/widgets/src/weather/docs
+1 -30
View File
@@ -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,
+11 -7
View File
@@ -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}/`);
@@ -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);
@@ -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<McpToolGroup[]> {
const { tools, procedureTypes } = await getMcpRuntimeAsync();
function getMcpToolGroups(): McpToolGroup[] {
const tools = extractMcpTools();
const groups = new Map<string, McpToolGroup["tools"]>();
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);
@@ -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<string>(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(
@@ -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";
+7 -51
View File
@@ -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<typeof extractMcpToolsFromProcedures> | null = null;
export type McpProcedureType = "query" | "mutation";
export function extractMcpTools() {
if (cache) return cache;
export interface McpRuntime {
router: McpRouter;
tools: McpTool[];
procedureTypes: ReadonlyMap<string, McpProcedureType>;
}
const createMcpRuntimeAsync = async (): Promise<McpRuntime> => {
const startedAt = Date.now();
const router = await getMcpRouterAsync();
const tools = extractMcpToolsFromProcedures(router);
const procedures = router["_def"].procedures as unknown as Record<string, { ["_def"]?: { type?: McpProcedureType } }>;
const procedureTypes = new Map<string, McpProcedureType>();
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<McpRuntime> | null = null;
export async function getMcpRuntimeAsync(): Promise<McpRuntime> {
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<McpTool[]> {
const { tools } = await getMcpRuntimeAsync();
return tools;
cache = extractMcpToolsFromProcedures(mcpRouter);
return cache;
}
+8 -18
View File
@@ -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,
@@ -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),
@@ -74,6 +74,7 @@ const unavailableMessageKeys = {
export const AssistantGate = ({ availability, children }: AssistantGateProps) => {
const t = useI18n("assistant");
if (availability === "enabled") {
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
}
@@ -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");
@@ -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 },
);
});
@@ -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));
};
@@ -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 && (
<>
<Divider
label={
@@ -535,7 +534,7 @@ const ItemSelectModalContent = ({
)}
</SimpleGrid>
{filteredItems.length === 0 && (!canBrowseCustomWidgets || filteredCustomWidgets.length === 0) && (
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
@@ -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 (
-8
View File
@@ -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": {
+34 -15
View File
@@ -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<McpMeta["mcp"]> = { 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<string, Record<string, unknown>>;
const required = ((schema.required as string[] | undefined) ?? []).filter(
@@ -41,22 +66,15 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
const definition = procedure["_def"] as unknown as {
inputs?: z.ZodType[];
meta?: McpMeta;
};
const metadata = definition.meta?.mcp;
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
if (!metadata?.enabled) continue;
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => 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<TRoot extends AnyRootTypes, TRecor
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
+36 -49
View File
@@ -2,9 +2,6 @@ export { createTRPCContext } from "./trpc";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
import { apiKeysRouter } from "./router/apiKeys";
@@ -15,51 +12,41 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { airQualityRouter } from "./router/widgets/air-quality";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { loadMcpWidgetRoutersAsync } from "./router/widgets/registry";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
const logger = createLogger({ module: "mcpRouter" });
const createMcpRouterAsync = async () => {
const startedAt = Date.now();
const widgetRouters = await loadMcpWidgetRoutersAsync();
const router = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
customWidget: customWidgetRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
...widgetRouters,
});
logger.info("Materialized MCP router", {
durationMs: Date.now() - startedAt,
widgetRouters: Object.keys(widgetRouters),
});
return router;
};
export type McpRouter = Awaited<ReturnType<typeof createMcpRouterAsync>>;
let mcpRouterPromise: Promise<McpRouter> | null = null;
export async function getMcpRouterAsync(): Promise<McpRouter> {
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,
});
+2 -2
View File
@@ -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();
}
+74 -201
View File
@@ -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 = <TKind extends IntegrationKind>(
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 = <TKind extends IntegrationKind>(
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 = <TKind extends IntegrationKind>(
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 = <TKind extends IntegrationKind>(
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
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 = <TKind extends IntegrationKind>(
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<TWidgetKind extends WidgetKindWithIntegration>(
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
export function createOneWidgetIntegrationMiddleware<
TWidgetKind extends WidgetKindWithIntegration,
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
>(
action: IntegrationAction,
widgetKind: TWidgetKind,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
export function createOneWidgetIntegrationMiddleware(
action: IntegrationAction,
widgetKind: WidgetKindWithIntegration,
integrationKinds?: readonly IntegrationKind[],
) {
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
}
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
export function createOneSharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
export function createOneSharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
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<TWidgetKind extends WidgetKindWithIntegration>(
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
export function createManyWidgetIntegrationMiddleware<
TWidgetKind extends WidgetKindWithIntegration,
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
>(
action: IntegrationAction,
widgetKind: TWidgetKind,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
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<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
export function createManySharedWidgetIntegrationMiddleware<
TContractWidgetKind extends WidgetKindWithIntegration,
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
>(
action: IntegrationAction,
contractWidgetKind: TContractWidgetKind,
additionalWidgetKinds: TAdditionalWidgetKinds,
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
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<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
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
+2 -2
View File
@@ -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)),
});
+2 -2
View File
@@ -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();
}
-9
View File
@@ -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);
};
+3 -9
View File
@@ -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,
+19 -29
View File
@@ -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<typeof throwIfIntegrationActionsForbiddenAsync>[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<string, unknown> | 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 }],
@@ -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",
+3 -3
View File
@@ -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,
},
@@ -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<unknown>,
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<unknown>,
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
@@ -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;
}),
});
+16 -7
View File
@@ -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: [
{
@@ -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<unknown>(serializedOptions));
const options = timetableOptionsSchema.parse(parse<unknown>(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<TimetableResolvedAddress[]> => {
@@ -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,
);
@@ -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) {
+4 -22
View File
@@ -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(),
+4 -2
View File
@@ -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]);
}),
});
+18 -10
View File
@@ -38,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
}
);
const runDownloadMutationsAsync = async (mutations: Promise<void>[], 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();
}),
});
@@ -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 = <TRouter extends AnyTRPCRouter>(
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
loadRouter: () => Promise<TRouter>,
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,
),
};
@@ -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();
+2 -5
View File
@@ -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();
+42 -23
View File
@@ -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 = <TRouter extends AnyTRPCRouter>(loadRouter: () => Promise<TRouter>) => lazy(loadRouter);
const lazyWidgetRouters = Object.fromEntries(
Object.entries(widgetRouterRegistry).map(([name, registration]) => [
name,
createLazyWidgetRouter(registration.loadRouter as () => Promise<AnyTRPCRouter>),
]),
) as unknown as {
[TName in WidgetRouterName]: ReturnType<
typeof createLazyWidgetRouter<
(typeof widgetRouterRegistry)[TName] extends { loadRouter: () => Promise<infer TRouter> }
? 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)),
});
@@ -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 };
}),
});
@@ -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(
@@ -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,
-225
View File
@@ -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<TRouter extends AnyTRPCRouter, TMcpNamespace extends McpRouterNamespace> = {
widgetKinds: readonly [WidgetKind, ...WidgetKind[]];
loadRouter: () => Promise<TRouter>;
mcp: TMcpNamespace;
};
const registerWidgetRouter = <TRouter extends AnyTRPCRouter, const TMcpNamespace extends McpRouterNamespace = false>(
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
loadRouter: () => Promise<TRouter>,
options?: { mcp?: TMcpNamespace },
): WidgetRouterRegistration<TRouter, TMcpNamespace> => ({
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<ReturnType<(typeof widgetRouterRegistry)[TName]["loadRouter"]>>;
};
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<const TNames extends readonly WidgetRouterName[]>(
names: TNames,
consumer: string,
): Promise<Pick<LoadedWidgetRouterRecord, TNames[number]>> {
const loadedRouters: Partial<Record<WidgetRouterName, AnyTRPCRouter>> = {};
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<LoadedWidgetRouterRecord, TNames[number]>;
}
export async function loadMcpWidgetRoutersAsync(): Promise<Record<string, AnyTRPCRouter>> {
const loadedRouters = await loadWidgetRoutersAsync(mcpWidgetRouterNames, "mcp");
const mcpRouters: Record<string, AnyTRPCRouter> = {};
for (const selection of mcpWidgetRouterSelections) {
mcpRouters[selection.namespace] = loadedRouters[selection.name];
}
return mcpRouters;
}
@@ -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;
}),
});
+1 -7
View File
@@ -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[];
+1 -3
View File
@@ -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",
+2 -50
View File
@@ -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) {
+1 -3
View File
@@ -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;
};
+1
View File
@@ -14,6 +14,7 @@
"exports": {
".": "./index.ts",
"./cookie": "./src/cookie.ts",
"./integration": "./src/integration.ts",
"./docs/*": "./src/docs/*.ts"
},
"scripts": {
@@ -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;
};
+3 -2
View File
@@ -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)
@@ -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<IntegrationKind, string | null>;
@@ -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<WidgetKind, string | null>;
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<WidgetKind, string | 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;
@@ -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];
-4
View File
@@ -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";
@@ -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 = <const TDefinition extends IntegrationModuleDefinition>(
definition: TDefinition,
) => definition;
+44 -64
View File
@@ -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<HomarrDocumentationPath, `/docs/integrations/${string}`>;
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<string, { isPublic: boolean; multiline: boolean }>;
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<TDefinitions extends Record<string, integrationDefinition>> = {
[TKind in keyof TDefinitions]: TDefinitions[TKind] & {
documentationUrl: string | null;
features: ResolvedIntegrationFeatureMetadata;
};
};
const createIntegrationDefinitions = <const TDefinitions extends Record<string, integrationDefinition>>(
definitions: TDefinitions,
): ResolvedIntegrationDefinitions<TDefinitions> => {
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<TDefinitions>;
};
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<string, integrationDefinition>);
} as const satisfies Record<string, integrationDefinition>;
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
export const integrationKinds = Object.keys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
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<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds.map((secretKinds) => [...secretKinds]) as AtLeastOneOf<
IntegrationSecretKind[]
>;
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> => {
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 = <TCategory extends IntegrationCategory>(category: TCategory) => {
return objectKeys(integrationDefs).filter((integration) =>
return (Object.keys(integrationDefs) as IntegrationKind[]).filter((integration) =>
integrationDefs[integration].category.some((defCategory) => defCategory === category),
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
};
@@ -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([]);
});
@@ -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);
}
}
@@ -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<AtLeastOneOf<IntegrationKind>>;
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<NativeFeatureCapabilityDescriptor, "integrations"> & {
integrations: readonly [IntegrationKind];
};
}
export type WidgetFeatureDescriptor =
| WidgetFeatureDescriptorWithDefaultName
| WidgetFeatureDescriptorWithIntegrationName;
const defineWidgetFeatureCatalog = <const TCatalog extends Record<string, WidgetFeatureDescriptor>>(
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<GeneralHealthMonitoringIntegrationKind>;
/**
* 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<TKind extends WidgetFeatureKind> = (typeof widgetFeatureCatalog)[TKind] extends {
capability: { integrations: readonly (infer TIntegrationKind extends IntegrationKind)[] };
}
? TIntegrationKind
: never;
export type WidgetKindWithIntegration = {
[TKind in WidgetFeatureKind]: WidgetIntegrationKind<TKind> extends never ? never : TKind;
}[WidgetFeatureKind];
export const getWidgetFeatureIntegrationKinds = <TKind extends WidgetKindWithIntegration>(
kind: TKind,
): AtLeastOneOf<WidgetIntegrationKind<TKind>> => {
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<WidgetIntegrationKind<TKind>>;
};
@@ -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<Record<WidgetKind, NativeFeatureCapabilityDescriptor>> = {};
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<Record<WidgetKind, WidgetIntegrationConfig>>;
export const nativeFeatureCapabilities = createNativeFeatureCapabilities();
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
WidgetKind,
NativeFeatureCapabilityDescriptor,
][];
export type WidgetIntegrationKind<TKind extends WidgetKindWithIntegration> =
(typeof widgetIntegrationConfigs)[TKind]["supportedIntegrations"][number];
export const getWidgetIntegrationConfig = <TKind extends WidgetKindWithIntegration>(
kind: TKind,
): (typeof widgetIntegrationConfigs)[TKind] => widgetIntegrationConfigs[kind];
const widgetIntegrationConfigEntries = objectEntries(widgetIntegrationConfigs);
const createWidgetIntegrationSupport = () => {
const result: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {};
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<WidgetKind>(
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<Record<WidgetKind, number>> = {};
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<IntegrationKind, readonly WidgetKind[]>;
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<DefaultWidgetConfig, "kind" | "width" | "height"> & {
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 =>
-139
View File
@@ -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<AtLeastOneOf<IntegrationCategory>>;
kinds?: readonly IntegrationKind[];
}
| {
categories?: readonly IntegrationCategory[];
kinds: Readonly<AtLeastOneOf<IntegrationKind>>;
};
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 = <const TDefinition extends WidgetModuleDefinition>(definition: TDefinition) =>
definition;
export type WidgetServerPermission =
| "public"
| "authenticated"
| "integration-query"
| "integration-interact"
| "custom";
export interface WidgetServerInputSchema<TInput> {
parseAsync: (input: unknown) => Promise<TInput>;
}
export interface WidgetServerContext<TServices = unknown, TAuthorization = unknown> {
services: TServices;
authorization: TAuthorization;
signal: AbortSignal;
}
export interface WidgetServerOperationDefinition<TInput, TOutput, TContext = WidgetServerContext> {
kind: "query" | "mutation";
permission: WidgetServerPermission;
input: WidgetServerInputSchema<TInput>;
cache?: WidgetModuleServerCachePolicy;
resolve: (context: TContext, input: TInput) => Promise<TOutput>;
}
export interface WidgetServerSubscriptionDefinition<TInput, TOutput, TContext = WidgetServerContext> {
kind: "subscription";
permission: WidgetServerPermission;
input: WidgetServerInputSchema<TInput>;
transport: "sse" | "websocket";
subscribe: (context: TContext, input: TInput) => AsyncIterable<TOutput>;
}
export interface WidgetServerRouteDefinition<TRequest = unknown, TResponse = unknown, TContext = WidgetServerContext> {
kind: "route";
permission: WidgetServerPermission;
handle: (context: TContext, request: TRequest) => Promise<TResponse>;
}
export const defineWidgetServerOperation = <TInput, TOutput, TContext = WidgetServerContext>(
operation: WidgetServerOperationDefinition<TInput, TOutput, TContext>,
) => operation;
export const defineWidgetServerSubscription = <TInput, TOutput, TContext = WidgetServerContext>(
subscription: WidgetServerSubscriptionDefinition<TInput, TOutput, TContext>,
) => subscription;
export const defineWidgetServerRoute = <TRequest, TResponse, TContext = WidgetServerContext>(
route: WidgetServerRouteDefinition<TRequest, TResponse, TContext>,
) => route;
export const defineWidgetServerModule = <
const TModule extends {
operations?: Readonly<Record<string, object>>;
subscriptions?: Readonly<Record<string, object>>;
routes?: Readonly<Record<string, object>>;
},
>(
module: TModule,
) => module;
+14 -4
View File
@@ -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<Record<WidgetKind, IntegrationKind>> = {
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;
+76 -16
View File
@@ -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<Record<WidgetKind, { width: number; height: number }>> = {};
for (const kind of widgetKinds) {
const descriptor = widgetFeatureCatalog[kind];
if ("defaultSize" in descriptor) result[kind] = descriptor.defaultSize;
}
return result;
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
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();
+1 -6
View File
@@ -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";
-1
View File
@@ -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"
},
+139 -175
View File
@@ -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<TIntegration extends Integration = Integration> = (
input: IntegrationInput,
) => Promise<TIntegration>;
type IntegrationConstructor<TIntegration extends Integration = Integration> = new (
input: IntegrationInput,
) => TIntegration;
const createConstructorCreator =
<TIntegration extends Integration>(
IntegrationClass: IntegrationConstructor<TIntegration>,
): IntegrationCreator<TIntegration> =>
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<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
type AssertIntegrationCreatorLoaders<TLoaders extends Record<IntegrationKind, () => Promise<IntegrationCreator>>> =
TLoaders;
type AssertNoUnexpectedIntegrationKinds<TKind extends never> = TKind;
type IntegrationCreatorLoadersCheck = AssertIntegrationCreatorLoaders<typeof integrationCreatorLoaders>;
type UnexpectedIntegrationKindsCheck = AssertNoUnexpectedIntegrationKinds<
Exclude<keyof typeof integrationCreatorLoaders, IntegrationKind>
type IntegrationCreators = typeof integrationCreators;
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreators> = Awaited<
ReturnType<IntegrationCreators[TKind]>
>;
type IntegrationCreatorLoaders = IntegrationCreatorLoadersCheck & Record<UnexpectedIntegrationKindsCheck, never>;
type IntegrationCreatorOfKind<TKind extends keyof IntegrationCreatorLoaders> = Awaited<
ReturnType<IntegrationCreatorLoaders[TKind]>
>;
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreatorLoaders> = Awaited<
ReturnType<IntegrationCreatorOfKind<TKind>>
>;
const integrationCreatorCache = new Map<IntegrationKind, Promise<IntegrationCreator>>();
const loadIntegrationCreatorAsync = async <TKind extends keyof IntegrationCreatorLoaders>(
kind: TKind,
): Promise<IntegrationCreatorOfKind<TKind>> => {
const cachedCreator = integrationCreatorCache.get(kind);
if (cachedCreator) {
return (await cachedCreator) as IntegrationCreatorOfKind<TKind>;
}
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<TKind>;
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 <TKind extends keyof IntegrationCreatorLoaders>(
export function createIntegrationAsync<TKind extends keyof IntegrationCreators>(
integration: IntegrationInput & { kind: TKind },
): Promise<IntegrationInstanceOfKind<TKind>> => {
if (!Object.hasOwn(integrationCreatorLoaders, integration.kind)) {
): Promise<IntegrationInstanceOfKind<TKind>>;
export async function createIntegrationAsync(
integration: IntegrationInput & { kind: IntegrationKind },
): Promise<Integration> {
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 <TKind extends keyof IntegrationCrea
);
}
const creator = await loadIntegrationCreatorAsync(integration.kind);
return (await creator(integration)) as IntegrationInstanceOfKind<TKind>;
};
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;
}
}
@@ -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;
};
@@ -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 = <TValue>(integration: { id: string }) => {
const channelName = `session-store:${integration.id}`;
const channelName = getIntegrationSessionStoreKey(integration.id);
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
return {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 KiB

@@ -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";
<IntegrationHeader integration={beszelIntegration} categories={["System Monitoring"]} />
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
<img src={beszelDashboard} alt="Beszel dashboard overview showing all four widgets" />
### Widgets & Capabilities
<IntegrationCapabilites
items={[
{ widget: beszelSystemStatsWidget },
{ widget: beszelSystemGridWidget },
{ widget: beszelSystemTableWidget },
{ widget: beszelAlertsWidget },
]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["username", "password"],
steps: [
"Open your Beszel hub instance (default port 8090)",
"Use the same credentials you use to log in to the Beszel web UI",
],
},
]}
/>
:::tip
Beszel uses PocketBase under the hood. The default admin credentials are set during the initial Beszel setup.
:::
@@ -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;
@@ -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",
},
});
@@ -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<Integration>;
export const generatedIntegrationCreatorLoaders = {
beszel: async () => {
const IntegrationClass = (await import("../beszel/beszel-integration")).BeszelIntegration;
return async (input: IntegrationInput) => new IntegrationClass(input);
},
} satisfies Record<string, () => Promise<GeneratedIntegrationCreator>>;
+58 -7
View File
@@ -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";
+1 -1
View File
@@ -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
@@ -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) => {
+1 -1
View File
@@ -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) => {
+1 -1
View File
@@ -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) => {
+1 -2
View File
@@ -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";
+1 -1
View File
@@ -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";
+3 -6
View File
@@ -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,
+2
View File
@@ -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";
+81 -19
View File
@@ -168,7 +168,13 @@ export const createLockChannel = (name: string) => {
};
const integrationCacheGenerationKey = (integrationId: string) => `integration-cache:generation:${integrationId}`;
const pendingIntegrationCacheInvalidations = new Map<string, string>();
export const getIntegrationSessionStoreKey = (integrationId: string) => `session-store:${integrationId}`;
interface PendingIntegrationCacheInvalidation {
generation: string;
clearSession: boolean;
}
const pendingIntegrationCacheInvalidations = new Map<string, PendingIntegrationCacheInvalidation>();
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<IntegrationCacheGeneration> => {
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<void> => {
const pendingGeneration = `local-${createId()}`;
const invalidateIntegrationCacheGenerationAsync = async (
integrationId: string,
clearSession: boolean,
): Promise<void> => {
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<void> => {
await invalidateIntegrationCacheGenerationAsync(integrationId, true);
};
/** Advances response-cache generation without evicting cached integration credentials. */
export const invalidateIntegrationResponseCacheAsync = async (integrationId: string): Promise<void> => {
await invalidateIntegrationCacheGenerationAsync(integrationId, false);
};
const widgetCacheGenerationKey = (namespace: string) => `widget-cache:generation:${namespace}`;
const pendingWidgetCacheInvalidations = new Map<string, string>();
const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000;
+2 -4
View File
@@ -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);
@@ -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<TData, TKind extends IntegrationKind, TInput extends Record<st
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
cacheNamespace?: string;
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "integration" };
}
export const createIntegrationRequestHandler = <
@@ -28,7 +28,7 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
const cacheNamespace = options.cacheNamespace;
let getSharedCacheAsync:
| ((input: { integration: IntegrationOfKind<TKind>; options: TInput }) => Promise<SharedCacheAdapter<TData>>)
| 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<TKind>, itemOptions: TInput) =>
inner.handler({ integration, options: itemOptions }),
};
@@ -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<SharedCacheAdapter<string>>();
const upstreamRequest = deferred<string>();
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 }>();
@@ -95,26 +95,27 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
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<CacheEntry<TData>>;
promise = (async () => {
let sharedCache: SharedCacheAdapter<TData> | 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 = <TData, TInput extends Record<string, unknow
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
}
})().finally(() => {
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;
},
}),
@@ -75,7 +75,7 @@ interface RedisSharedCacheOptions {
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
logger.warn(
new ErrorWithMetadata(
"Redis widget cache operation failed",
"Redis response cache operation failed",
{
...metadata,
operation,
@@ -121,7 +121,7 @@ const createRedisSharedCache = <TData>({
if (value === undefined || value === null) return value;
if (isCacheEntry(value)) return value as CacheEntry<TData>;
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 <TData>({
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 <TData>({
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 };
@@ -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<TData, TInput extends Record<string, unknown>> {
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
cacheNamespace?: string;
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" };
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
const cacheNamespace = options.cacheNamespace;
let getSharedCacheAsync: ((input: TInput) => Promise<SharedCacheAdapter<TData>>) | undefined;
if (cacheNamespace) {
getSharedCacheAsync = async (input) =>
@@ -29,9 +27,9 @@ export const createWidgetRequestHandler = <TData, TInput extends Record<string,
const inner = createRequestHandler<TData, TInput>({
...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,
});
@@ -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),
+2 -4
View File
@@ -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,
});
@@ -13,7 +13,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions<Record<string
const normalizedQuery = query.trim().toLowerCase();
return objectEntries(integrationDefs)
.filter(([, integrationDef]) => integrationDef.name.toLowerCase().includes(normalizedQuery))
.toSorted(
.sort(
([, definitionA], [, definitionB]) =>
definitionA.name.toLowerCase().indexOf(normalizedQuery) -
definitionB.name.toLowerCase().indexOf(normalizedQuery),
-11
View File
@@ -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<Record<WidgetKind, TablerIcon>>;
@@ -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;
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more