refactor: streamline widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-23 22:33:29 +02:00
1 parent ec295fe26e
commit 425cb653a1
243 files changed
+5722 -2255

No files matched your search

@@ -0,0 +1,188 @@
---
title: Widgets and integrations
tags:
- Developer
- Technical documentation
- Widgets
- Integrations
sidebar_position: 2
---
Homarr is moving native widgets and integrations to vertical feature modules: the stable identity, lightweight metadata,
client entry, server registration, cache policy, and documentation source live beside the implementation. Generated
files preserve static imports and package boundaries for Next.js, tRPC, and Docusaurus.
Registration means availability. There is no separate feature-enable list or runtime kill switch.
## Current status
Weather and Downloads are the reference widget modules. Their folders now own registration and canonical docs:
```text
packages/widgets/src/weather/
├── module.ts # authored manifest and client/server data policy
├── index.ts # options and presentation-specific definition
├── component.tsx # client UI
├── module.generated.ts # generated icon projection; do not edit
└── docs/ # canonical MDX, metadata, and assets
```
The remaining widgets still use the compatibility registries and can be migrated one folder at a time without changing
their persisted `WidgetKind` or tRPC paths. The generated and legacy entries are merged behind the same runtime loaders.
The server router implementation is not physically inside these first two widget folders yet. `@homarr/widgets`
currently imports `@homarr/api/client`; making `@homarr/api` import widget server files would create a package cycle and
risk shipping server code to the browser. The module already owns the lazy router registration, and the neutral server
operation types are the cycle-breaking seam for the next migration stage.
## The module manifest
`module.ts` is pure and server-safe. It must not import React, tRPC, an integration implementation, secrets, or a request
handler. It describes import locations as strings so the generator can emit literal imports into the correct package.
```ts
import { defineWidgetModule } from "@homarr/definitions";
export default defineWidgetModule({
kind: "example",
icon: "IconSparkles",
clientEntry: ".",
documentation: {
slug: "example",
sourceDirectory: "docs",
},
integration: {
categories: ["downloadClient"],
serverMaxIntegrations: 1,
},
operations: [
{
name: "getData",
path: ["widget", "example", "getData"],
client: {
refetchIntervalSeconds: 30,
persist: true,
},
serverCache: {
namespace: "example:data",
ttlMs: 30_000,
scope: "integration",
},
},
],
routers: [
{
namespace: "example",
module: "./example",
exportName: "exampleRouter",
},
],
});
```
Run the synchronizer after adding or changing a module:
```sh
pnpm widgets:new example --icon IconSparkles
pnpm widgets:sync
pnpm widgets:sync:check
```
`widgets:new` creates a minimal client-only folder and immediately runs the synchronizer. Replace the empty component,
add options, and extend the manifest with operations or router registrations as needed; it never edits a hand-written
central feature list.
The check command is read-only and reports drift. The synchronizer rejects duplicate widget kinds, docs slugs, router
namespaces with conflicting targets, operation names, and cache namespaces. It also verifies client entries, router
modules, and local docs before producing anything.
It generates these compatibility projections:
| Consumer | Generated projection |
| ---------------------- | -------------------------------------------------------------------- |
| Definitions and policy | `packages/definitions/src/generated/widget-modules.ts` |
| Client lazy loading | `packages/widgets/src/generated/widget-modules.ts` |
| Lightweight icons | `packages/ui/src/generated/widget-icons.ts` |
| Lazy tRPC routers | `packages/api/src/router/widgets/generated-registry.ts` |
| Docusaurus | `apps/docs/docs/widgets/<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.
## Client and server data caching
The operation is the shared identity for both cache layers:
- The tRPC path drives the TanStack Query key.
- `refetchIntervalSeconds` configures the root Query Client without importing widget UI code.
- `persist` controls session-scoped browser persistence. Persisted payloads are size-bounded, sanitized, and separated
by the authenticated user and permission scope.
- `serverCache` is imported by the request handler through `getWidgetServerCachePolicy`, so its namespace and TTL are
not copied into another file.
Server reads use bounded in-process L1 caching and a shared Redis L2. Inputs are canonically serialized and SHA-256
hashed; raw URLs, options, and secrets never appear in Redis keys or cache logs. Redis operations and lock waits are
bounded, refreshes use token-safe distributed locks, and stale data can be retained for the declared error window.
Redis failure degrades to local caching and produces structured logs with safe metadata and the original cause.
Integration-backed cache keys include the integration ID and its Redis generation. Mutations advance that generation,
which invalidates all replicas without scanning keys. Integration-free widget handlers use a namespace generation;
manual refreshes such as Docker inventory invalidation therefore clear both local and shared values. A zero TTL keeps
only overlapping in-flight request deduplication and never stores a Redis payload, which is appropriate for realtime
or subscription data.
TanStack Query remains the browser cache and Redis remains the server cache. A browser persistence hit never bypasses
server authorization, and Redis never caches permission decisions or decrypted credentials.
## Server operations and tRPC
The neutral contract includes helpers for queries, mutations, subscriptions, and routes:
- `defineWidgetServerOperation`
- `defineWidgetServerSubscription`
- `defineWidgetServerRoute`
- `defineWidgetServerModule`
They describe validated input, authorization intent, cache policy, an `AbortSignal`, and a framework-neutral resolver.
The future API adapter will inject tRPC procedure builders, integration middleware, logging, and error normalization.
This dependency direction lets a local `server.ts` be consumed by API code without the widget client importing API
runtime code.
Until that adapter replaces the current client dependency, keep actual upstream calls in `@homarr/request-handler` and
the tRPC compatibility router in `packages/api/src/router/widgets/`. Put the router registration in the local manifest;
do not add it to the hand-written registry.
An integration-backed procedure must use the widget-aware middleware. It derives compatible kinds and maximum
selection limits from the module/catalog, resolves only the requested records, decrypts secrets on the server, and
checks direct user and group permissions before the procedure runs. Use `query` permission for reads and `interact`
permission for mutations.
## Errors and logs
- Root tRPC instrumentation records the procedure path, operation type, user, duration, and outcome.
- Integration middleware records safe requested/resolved IDs and kinds plus denial reasons; it never logs credentials.
- Integration calls are timed and normalized into public errors while preserving a structured server-side cause.
- Multi-integration reads use `settleIntegrationQueries` so one failed service does not erase successful providers.
- Widget components read through the shared usable-data helper: stale data remains visible after a refresh error, while
first-load failures reach the common widget error boundary and logs link.
- Lazy client, router, and integration imports cache successful and in-flight loads, evict failed promises, and log
load duration so a transient chunk failure can retry.
## Complex modules
The manifest is the common path, not a limit on implementation size.
- Beszel can group multiple stable widget kinds behind one router and keep live statistics on an SSE subscription with
Redis payload caching disabled.
- Assistant can use a route/stream adapter for chat while keeping client runtime state and tools in subfolders.
- A proxy-only widget can expose a server route without inventing a polling query.
- A widget can add custom query matchers, context actions, prefetching, or nested client components while retaining the
same registration, permissions, logging, error, and cache contracts.
## Legacy integration path
Integration metadata is currently derived from `integrationDefs`, while implementation constructors are loaded lazily
through `@homarr/integrations/factory`. Keep integrations server-only, implement only their capability interfaces, and
use narrow schema/interface exports instead of the broad runtime barrel. The same local-manifest and generated-registry
shape is being introduced for integrations so metadata, constructor registration, and canonical docs can move beside
each implementation without changing `IntegrationKind` or widget capability checks.
@@ -0,0 +1 @@
packages/integrations/src/beszel/docs
+2 -4
View File
@@ -1,9 +1,7 @@
import { IntegrationDefinition } from "@site/src/types";
export const beszelIntegration: IntegrationDefinition = {
export const beszelIntegration = {
name: "Beszel",
description:
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
path: "../../integrations/beszel",
};
} as const;
@@ -0,0 +1 @@
packages/widgets/src/downloads/docs
@@ -0,0 +1 @@
packages/widgets/src/weather/docs
+15 -1
View File
@@ -2,8 +2,22 @@ import type * as Preset from "@docusaurus/preset-classic";
import type { Config } from "@docusaurus/types";
import { themes as prismThemes } from "prism-react-renderer";
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
import { generatedWidgetDocumentationSources } from "@homarr/definitions";
const a11yEmoji = require("@fec/remark-a11y-emoji");
const githubDocsEditRoot = "https://github.com/homarr-labs/homarr/edit/dev";
const getDocumentationEditUrl = (docPath: string) => {
const widgetPath = /^widgets\/([^/]+)\/(.+)$/u.exec(docPath);
const slug = widgetPath?.[1];
const relativePath = widgetPath?.[2];
if (slug && relativePath && Object.hasOwn(generatedWidgetDocumentationSources, slug)) {
const source = generatedWidgetDocumentationSources[slug as keyof typeof generatedWidgetDocumentationSources];
return `${githubDocsEditRoot}/${source}/${relativePath}`;
}
return `${githubDocsEditRoot}/apps/docs/docs/${docPath}`;
};
const deprecatedWorkshopUrl = process.env.WORKSHOP_URL;
if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead.");
@@ -65,7 +79,7 @@ const config: Config = {
{
docs: {
sidebarPath: require.resolve("./sidebars.js"),
editUrl: ({ docPath }) => `https://github.com/homarr-labs/homarr/edit/dev/apps/docs/docs/${docPath}`,
editUrl: ({ docPath }) => getDocumentationEditUrl(docPath),
remarkPlugins: [a11yEmoji],
exclude: [],
showLastUpdateAuthor: false,
@@ -141,10 +141,12 @@ const ScopedTRPCReactProvider = ({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
for (const { queryKey, intervalSeconds, staleTimeSeconds } of widgetQueryRefetchIntervals) {
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
if (intervalSeconds === null) policy.refetchInterval = false;
if (typeof intervalSeconds === "number") policy.refetchInterval = intervalSeconds * 1000;
if (staleTimeSeconds !== undefined) policy.staleTime = staleTimeSeconds * 1000;
client.setQueryDefaults(queryKey, policy);
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
+10 -8
View File
@@ -102,14 +102,16 @@ export default async function Layout(props: {
})
: null,
);
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) =>
!session
? "unauthenticated"
: api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error"),
);
const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise<AssistantAvailability> => {
if (!session) return "unauthenticated";
try {
const availability = await api.assistant.getAvailability();
return availability.enabled ? "enabled" : "unconfigured";
} catch {
return "error";
}
});
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
userPromise,
@@ -2,7 +2,6 @@ import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { Stack } from "@mantine/core";
import { mcpRouter } from "@homarr/api/mcp";
import { openApiDocument } from "@homarr/api/open-api";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
@@ -11,29 +10,15 @@ import { getI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { getMcpRuntimeAsync } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs";
import { ScalarApiReference } from "./components/scalar-api-reference";
import type { McpToolGroup } from "./components/api-page-tabs";
let cachedToolGroups: McpToolGroup[] | null = null;
function getMcpToolGroups(): McpToolGroup[] {
if (cachedToolGroups) return cachedToolGroups;
const tools = extractMcpTools();
const procedures = (mcpRouter as any)._def.procedures as Record<string, { _def?: { type?: string } }>;
const procedureTypeMap = new Map<string, "query" | "mutation">();
for (const [key, proc] of Object.entries(procedures)) {
const type = proc?._def?.type;
if (type === "query" || type === "mutation") {
procedureTypeMap.set(key, type);
}
}
async function getMcpToolGroupsAsync(): Promise<McpToolGroup[]> {
const { tools, procedureTypes } = await getMcpRuntimeAsync();
const groups = new Map<string, McpToolGroup["tools"]>();
for (const tool of tools) {
const namespace = tool.pathInRouter[0] ?? "other";
@@ -44,14 +29,13 @@ function getMcpToolGroups(): McpToolGroup[] {
groups.get(namespace)?.push({
name: tool.name,
description: tool.description,
type: procedureTypeMap.get(procedureKey) ?? "query",
type: procedureTypes.get(procedureKey) ?? "query",
});
}
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
return Array.from(groups.entries()).map(([namespace, items]) => ({
namespace,
tools: items,
}));
return cachedToolGroups;
}
export async function generateMetadata() {
@@ -72,12 +56,14 @@ export default async function ApiPage() {
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const requestHeaders = await headers();
const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([
headers(),
api.apiKeys.getAll(),
getI18n("management.page.tool.api.tab"),
getMcpToolGroupsAsync(),
]);
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
const document = openApiDocument(baseUrl);
const apiKeys = await api.apiKeys.getAll();
const t = await getI18n("management.page.tool.api.tab");
const toolGroups = getMcpToolGroups();
return (
<>
+17 -21
View File
@@ -6,7 +6,7 @@ import type { ToolSet, UIMessage } from "ai";
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
import { z } from "zod/v4";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext } from "@homarr/api/mcp";
import {
createAssistantGenerationAccessToken,
getAssistantContextEntitiesAsync,
@@ -16,6 +16,7 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { decryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { and, eq } from "@homarr/db";
import { db } from "@homarr/db";
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
@@ -35,7 +36,7 @@ import type {
AssistantWebSearchSource,
} from "~/components/assistant/assistant-message-metadata";
import { extractMcpTools } from "../../mcp/_extract-tools";
import { getMcpRuntimeAsync } from "../../mcp/_extract-tools";
import { buildAssistantRequestContext, sanitizeAttachmentFilename } from "./assistant-chat-input";
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion";
@@ -322,20 +323,6 @@ const sumCompleteStepMetric = (
return (values as number[]).reduce((sum, value) => sum + value, 0);
};
const getProcedureTypeMap = () => {
const procedures = (
mcpRouter as unknown as {
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
}
)["_def"].procedures;
return new Map(
Object.entries(procedures).flatMap(([path, procedure]) => {
const type = procedure["_def"]?.type;
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
}),
);
};
const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
@@ -506,15 +493,24 @@ export async function POST(request: Request) {
}
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
const caller = mcpRouter.createCaller(context);
const procedureTypes = getProcedureTypeMap();
let mcpRuntime;
try {
mcpRuntime = await getMcpRuntimeAsync();
} catch (error) {
logger.error(new ErrorWithMetadata("Assistant tools are unavailable", { requestId }, { cause: error }));
return Response.json(
{ error: "Homarr tools could not be loaded. Check the Homarr logs and try again." },
{ status: 503 },
);
}
const caller = mcpRuntime.router.createCaller(context);
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const mcpTools = mcpRuntime.tools.filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
const homarrTools = Object.fromEntries(
mcpTools.map((mcpTool) => {
const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
const requiresApproval = mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
return [
mcpTool.name,
tool({
@@ -563,7 +559,7 @@ export async function POST(request: Request) {
) satisfies ToolSet;
const toolApproval = Object.fromEntries(
mcpTools.flatMap((mcpTool) =>
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
? [[mcpTool.name, "user-approval" as const]]
: [],
),
@@ -16,7 +16,6 @@ const mocks = vi.hoisted(() => ({
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
vi.mock("@homarr/api/mcp", () => ({
createTRPCContext: vi.fn(() => ({})),
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
}));
vi.mock("@homarr/auth/api-key", () => ({
API_KEY_HEADER_NAME: "ApiKey",
@@ -42,26 +41,30 @@ vi.mock("@homarr/custom-widgets/core", () => ({
vi.mock("@homarr/db", () => ({ db: {} }));
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
vi.mock("../_extract-tools", () => ({
extractMcpTools: () => [
{
name: "board_getAllBoards",
description: "List boards",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
],
getMcpRuntimeAsync: async () => ({
router: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
procedureTypes: new Map([["board.getAllBoards", "query"]]),
tools: [
{
name: "board_getAllBoards",
description: "List boards",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
],
}),
}));
import { POST } from "./route";
+51 -7
View File
@@ -1,11 +1,55 @@
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
import type { McpRouter, McpTool } from "@homarr/api/mcp";
import { extractMcpToolsFromProcedures, getMcpRouterAsync } from "@homarr/api/mcp";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
const logger = createLogger({ module: "mcpTools" });
export function extractMcpTools() {
if (cache) return cache;
export type McpProcedureType = "query" | "mutation";
cache = extractMcpToolsFromProcedures(mcpRouter);
return cache;
export interface McpRuntime {
router: McpRouter;
tools: McpTool[];
procedureTypes: ReadonlyMap<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;
}
+18 -16
View File
@@ -5,11 +5,12 @@ import { ResourceTemplate } from "@modelcontextprotocol/server";
import { z } from "zod/v4";
import type { McpTool } from "@homarr/api/mcp";
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
import { createTRPCContext } from "@homarr/api/mcp";
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
import { extractBaseUrlFromHeaders } from "@homarr/common";
import { ipAddressFromHeaders } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
import {
getCustomWidgetComponent,
@@ -22,7 +23,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
import { extractMcpTools } from "./_extract-tools";
import { getMcpRuntimeAsync } from "./_extract-tools";
import { createMcpProtocolHandler } from "./_protocol";
const logger = createLogger({ module: "mcpRoute" });
@@ -80,18 +81,8 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
let toolsCache: McpTool[] | null = null;
function getTools() {
if (!toolsCache) {
toolsCache = extractMcpTools();
logger.info(`Extracted ${toolsCache.length} MCP tools`);
}
return toolsCache;
}
const getVisibleTools = (isAdmin: boolean) =>
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) =>
isAdmin ? tools : tools.filter((tool) => !tool.name.startsWith("customWidget_"));
function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt(
@@ -331,12 +322,23 @@ export const handleMcpRequest = async (req: NextRequest) => {
clearAuthFailures(ipAddress);
logger.info("MCP request authenticated", { userId: session.user.id });
let mcpRuntime;
try {
mcpRuntime = await getMcpRuntimeAsync();
} catch (error) {
logger.error(new ErrorWithMetadata("MCP runtime is unavailable", {}, { cause: error }));
return jsonErrorResponse(503, {
error: "service_unavailable",
hint: "The MCP tool registry could not be loaded. Check the Homarr logs and try again.",
});
}
const ctx = createTRPCContext({ session, headers: req.headers });
const caller = mcpRouter.createCaller(ctx);
const caller = mcpRuntime.router.createCaller(ctx);
const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({
caller,
tools: getVisibleTools(isAdmin),
tools: getVisibleTools(mcpRuntime.tools, isAdmin),
version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
@@ -74,7 +74,6 @@ 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 disabled path stays cheap.
* assistant runtime. Lives apart from `assistant-provider` so the unavailable path stays cheap.
*/
export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
const t = useI18n("assistant");
@@ -2,25 +2,32 @@ import { describe, expect, test } from "vitest";
import type { WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport } from "@homarr/definitions";
import { widgetImports } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
const widgetDefinitions = await loadAllWidgetDefinitions();
const getWidgetDefinition = (kind: WidgetKind) => {
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
return definition;
};
describe("getAssistantWidgetConfiguration", () => {
test("keeps server-side integration support synchronized with widget definitions", () => {
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
const definition = widgetImport.definition;
for (const [kind, definition] of widgetDefinitions) {
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
const supportedIntegrations = widgetIntegrationSupport[kind];
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
expect(supportedIntegrations, kind).toEqual(expected);
}
});
test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.notebook.definition,
getWidgetDefinition("notebook"),
{
boardId: "board-1",
boardName: "Home",
@@ -42,7 +49,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration(
widgetImports.audioStats.definition,
getWidgetDefinition("audioStats"),
{
boardId: "board-1",
boardName: "Home",
@@ -90,13 +97,13 @@ describe("getAssistantWidgetConfiguration", () => {
test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration(
widgetImports.mediaServer.definition,
getWidgetDefinition("mediaServer"),
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings,
[],
);
const calendar = getAssistantWidgetConfiguration(
widgetImports.calendar.definition,
getWidgetDefinition("calendar"),
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings,
[],
@@ -55,6 +55,6 @@ export const getBoardRecipeRecommendations = ({
},
];
})
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -91,6 +91,7 @@ const ItemSelectModalContent = ({
initialSearch = "",
}: ItemSelectModalContentProps) => {
const [search, setSearch] = useState(initialSearch);
const canBrowseCustomWidgets = isAdmin;
const t = useI18n();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
@@ -479,7 +480,7 @@ const ItemSelectModalContent = ({
/>
))}
{isAdmin && (
{canBrowseCustomWidgets && (
<>
<Divider
label={
@@ -534,7 +535,7 @@ const ItemSelectModalContent = ({
)}
</SimpleGrid>
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
{filteredItems.length === 0 && (!canBrowseCustomWidgets || filteredCustomWidgets.length === 0) && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
@@ -653,12 +654,13 @@ const ItemSelectModalFrame = ({
const utils = clientApi.useUtils();
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const canBrowseCustomWidgets = isAdmin;
const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false;
const t = useI18n();
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
{ boardId: innerProps.boardId },
{ enabled: isAdmin },
{ enabled: canBrowseCustomWidgets },
);
return (
@@ -1,12 +1,14 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import type { WidgetKind } from "@homarr/definitions";
import type { NormalizedWidgetQuery, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys } from "@homarr/widgets";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { matchesWidgetItemQuery } from "./widget-query-scope";
type WidgetKind = keyof typeof widgetImports;
const widgetDefinitions = await loadAllWidgetDefinitions();
const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
@@ -19,7 +21,9 @@ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQu
});
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
};
+5
View File
@@ -40,6 +40,8 @@
"docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis",
"format": "turbo format --continue -- --check",
"format:fix": "turbo format --continue",
"integrations:sync": "tsx ./scripts/integrations/sync-integration-modules.mts",
"integrations:sync:check": "tsx ./scripts/integrations/sync-integration-modules.mts --check",
"lint": "turbo lint --continue",
"lint:fix": "turbo lint --continue -- --fix",
"lint:ws": "pnpm dlx sherif@latest",
@@ -54,6 +56,9 @@
"test:workshop-image": "sh apps/workshop/test-image.sh",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"widgets:sync": "tsx ./scripts/widgets/sync-widget-modules.mts",
"widgets:sync:check": "tsx ./scripts/widgets/sync-widget-modules.mts --check",
"widgets:new": "tsx ./scripts/widgets/create-widget-module.mts",
"with-env": "dotenv -e .env --"
},
"dependencies": {
+49 -36
View File
@@ -2,6 +2,9 @@ export { createTRPCContext } from "./trpc";
export { extractMcpToolsFromProcedures } from "./mcp-tools";
export type { McpTool } from "./mcp-tools";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createTRPCRouter } from "./trpc";
import { appRouter as appRouterForApps } from "./router/app";
import { apiKeysRouter } from "./router/apiKeys";
@@ -12,41 +15,51 @@ import { iconsRouter } from "./router/icons";
import { infoRouter } from "./router/info";
import { integrationRouter } from "./router/integration/integration-router";
import { inviteRouter } from "./router/invite";
import { airQualityRouter } from "./router/widgets/air-quality";
import { bazarrRouter } from "./router/widgets/bazarr";
import { serverSettingsRouter } from "./router/serverSettings";
import { beszelRouter } from "./router/widgets/beszel";
import { calendarRouter } from "./router/widgets/calendar";
import { dnsHoleRouter } from "./router/widgets/dns-hole";
import { downloadsRouter } from "./router/widgets/downloads";
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
import { mediaRequestsRouter } from "./router/widgets/media-requests";
import { mediaServerRouter } from "./router/widgets/media-server";
import { patchmonRouter } from "./router/widgets/patchmon";
import { smartHomeRouter } from "./router/widgets/smart-home";
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
import { loadMcpWidgetRoutersAsync } from "./router/widgets/registry";
export const mcpRouter = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
customWidget: customWidgetRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
airQuality: airQualityRouter,
bazarr: bazarrRouter,
serverSettings: serverSettingsRouter,
beszel: beszelRouter,
calendar: calendarRouter,
dnsHole: dnsHoleRouter,
downloads: downloadsRouter,
healthMonitoring: healthMonitoringRouter,
mediaRequests: mediaRequestsRouter,
mediaServer: mediaServerRouter,
patchmon: patchmonRouter,
smartHome: smartHomeRouter,
widgetSecrets: widgetSecretsRouter,
});
const logger = createLogger({ module: "mcpRouter" });
const createMcpRouterAsync = async () => {
const startedAt = Date.now();
const widgetRouters = await loadMcpWidgetRoutersAsync();
const router = createTRPCRouter({
app: appRouterForApps,
apiKeys: apiKeysRouter,
board: boardRouter,
customWidget: customWidgetRouter,
docker: dockerRouter,
icon: iconsRouter,
info: infoRouter,
integration: integrationRouter,
invite: inviteRouter,
serverSettings: serverSettingsRouter,
...widgetRouters,
});
logger.info("Materialized MCP router", {
durationMs: Date.now() - startedAt,
widgetRouters: Object.keys(widgetRouters),
});
return router;
};
export type McpRouter = Awaited<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;
}
}
+2 -2
View File
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { isDemoMode, publicProcedure } from "../trpc";
import { isDemoMode, middlewareProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
return middlewareProcedure.use(async ({ next }) => {
if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
+287 -67
View File
@@ -9,28 +9,74 @@ import type { AtLeastOneOf } from "@homarr/common/types";
import type { Database } from "@homarr/db";
import { and, eq, inArray } from "@homarr/db";
import { integrations } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
import { getWidgetFeatureIntegrationKinds } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { publicProcedure } from "../trpc";
import { middlewareProcedure } from "../trpc";
export type IntegrationAction = "query" | "interact";
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
*/
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
const logger = createLogger({ module: "integrationMiddleware" });
const getIntegrationKindForLog = (integration: object) => {
if ("kind" in integration && typeof integration.kind === "string") return integration.kind;
return "unknown";
};
interface IntegrationAccessMetadata {
action: IntegrationAction;
cardinality: "one" | "many";
userId: string | undefined;
requestedIntegrationIds: readonly string[];
allowedIntegrationKinds: readonly IntegrationKind[];
}
interface WidgetIntegrationAvailability {
widgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]];
}
const getCompatibleIntegrationKindsOrThrow = <TKind extends IntegrationKind>(
kinds: readonly TKind[],
widgetAvailability?: WidgetIntegrationAvailability,
) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
if (!widgetAvailability) return kinds;
const widgetIntegrationKinds = new Set(
widgetAvailability.widgetKinds.flatMap((widgetKind) => getWidgetFeatureIntegrationKinds(widgetKind)),
);
const compatibleKinds = kinds.filter((kind) => widgetIntegrationKinds.has(kind));
if (compatibleKinds.length >= 1) return compatibleKinds;
logger.warn("No compatible integration kinds for widget route", {
widgetKinds: widgetAvailability.widgetKinds,
configuredIntegrationKinds: kinds,
reason: "no-compatible-widget-integration-kinds",
});
throw new TRPCError({
code: "NOT_FOUND",
message: "No integration kind is compatible with the consuming widget",
});
};
const createOneIntegrationMiddlewareForKinds = <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);
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "one",
userId: ctx.session?.user.id,
requestedIntegrationIds: [input.integrationId],
allowedIntegrationKinds: compatibleKinds,
};
logger.debug("Resolving integration access", accessMetadata);
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, compatibleKinds)),
with: {
app: true,
secrets: true,
@@ -45,13 +91,18 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
});
if (!integration) {
logger.warn("Integration access target was not found", accessMetadata);
throw new TRPCError({
code: "NOT_FOUND",
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
message: `Integration with id ${input.integrationId} not found or not of kinds ${compatibleKinds.join(",")}`,
});
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: [integration.kind],
});
const {
secrets,
@@ -78,6 +129,101 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
});
};
/**
* Creates a middleware that provides the integration in the context that is of the specified kinds
* @param action query for showing data or interact for mutating data
* @param kinds kinds of integrations that are supported
* @returns middleware that can be used with trpc
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
* @throws TRPCError NOT_FOUND if the integration was not found
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
*/
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return createOneIntegrationMiddlewareForKinds(action, kinds);
};
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);
const findIntegrations = async () => {
if (input.integrationIds.length === 0) return [];
return await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, compatibleKinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: true,
},
});
};
const dbIntegrations = await findIntegrations();
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
logger.warn("Integration access targets were not found", {
...accessMetadata,
missingIntegrationCount: offset,
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${compatibleKinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
};
/**
* Creates a middleware that provides the integrations in the context that are of the specified kinds and have the specified item
* It also ensures that the user has permission to perform the specified action on the integrations
@@ -92,55 +238,115 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const dbIntegrations =
input.integrationIds.length >= 1
? await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: true,
},
})
: [];
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
}),
),
},
});
});
return createManyIntegrationMiddlewareForKinds(action, kinds);
};
/**
* Widget procedures should use these adapters so their server-side permission
* guards stay aligned with the feature catalog automatically.
*/
export function createOneWidgetIntegrationMiddleware<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] });
}
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>(
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],
});
}
/**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform
@@ -152,25 +358,39 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
const throwIfActionIsNotAllowedAsync = async (
action: IntegrationAction,
db: Database,
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
session: Session | null,
) => {
if (action === "interact") {
const haveAllInteractAccess = integrations
const haveAllInteractAccess = integrationRecords
.map((integration) => constructIntegrationPermissions(integration, session))
.every(({ hasInteractAccess }) => hasInteractAccess);
if (haveAllInteractAccess) return;
logger.warn("Integration access denied", {
action,
userId: session?.user.id,
integrationIds: integrationRecords.map(({ id }) => id),
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
});
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to interact with at least one of the specified integrations",
});
}
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session);
if (hasQueryAccess) return;
logger.warn("Integration access denied", {
action,
userId: session?.user.id,
integrationIds: integrationRecords.map(({ id }) => id),
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
});
throw new TRPCError({
code: "FORBIDDEN",
message: "User does not have permission to query at least one of the specified integration",
+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 { publicProcedure } from "../trpc";
import { middlewareProcedure } from "../trpc";
export const createOneItemMiddleware = (kind: WidgetKind) => {
return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
return middlewareProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
const item = await ctx.db.query.items.findFirst({
where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
});
+2 -2
View File
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
import { publicProcedure } from "../trpc";
import { middlewareProcedure } from "../trpc";
export const kubernetesMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
return middlewareProcedure.use(async ({ next }) => {
if (env.ENABLE_KUBERNETES) {
return await next();
}
+9
View File
@@ -1,5 +1,7 @@
import type { QueryKey } from "@tanstack/react-query";
import { generatedWidgetOperationPolicies } from "@homarr/definitions";
// RSC-prefetched data must survive the hydration pass without an immediate
// duplicate fetch. Widget-specific intervals still control live refreshes.
export const queryCacheDefaultStaleTimeMs = 30_000;
@@ -11,8 +13,15 @@ const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContai
// Beszel system stats are large time-series payloads and were deliberately kept out of
// the persisted cache in #6289; live stats come from an SSE subscription instead.
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
const generatedWidgetPersistencePolicies = new Map(
generatedWidgetOperationPolicies
.filter((policy) => policy.kind === "query")
.map((policy) => [policy.path.join("."), policy.persist] as const),
);
export const isWidgetDataTrpcPath = (path: string) => {
const generatedPolicy = generatedWidgetPersistencePolicies.get(path);
if (generatedPolicy !== undefined) return generatedPolicy;
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
return widgetDataQueryPaths.has(path);
};
+9 -3
View File
@@ -25,7 +25,9 @@ import {
assistantProviderPresets,
assistantProviderRequiresApiKey,
getIconUrl,
integrationKinds,
resolveAssistantModelId,
widgetKinds,
} from "@homarr/definitions";
import type { createTRPCContext } from "../trpc";
@@ -336,6 +338,7 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
boardRouter.createCaller(ctx).getAllBoards(),
ctx.db.query.integrations.findMany({
columns: { id: true, name: true, kind: true },
where: inArray(integrations.kind, integrationKinds),
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
@@ -364,9 +367,12 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
? []
: await ctx.db.query.items.findMany({
columns: { id: true, boardId: true, kind: true },
where: inArray(
items.boardId,
availableBoards.map((board) => board.id),
where: and(
inArray(
items.boardId,
availableBoards.map((board) => board.id),
),
inArray(items.kind, widgetKinds),
),
orderBy: asc(items.kind),
limit: 500,
+175 -122
View File
@@ -19,9 +19,7 @@ import {
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
integrations,
apps,
itemLayouts,
@@ -70,12 +68,11 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
interface BoardItemPlacementRectangle {
xOffset: number;
@@ -123,6 +120,81 @@ const findFirstAvailableBoardItemPosition = (
return null;
};
interface WidgetPlacementRecord {
id: string;
kind: WidgetKind;
}
interface WidgetConfigurationRecord extends WidgetPlacementRecord {
integrationIds: readonly string[];
}
interface IntegrationKindRecord {
id: string;
kind: IntegrationKind;
}
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) =>
left.length === right.length && left.every((integrationId) => right.includes(integrationId));
const validateWidgetConfigurationsAsync = async (
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
submittedItems: readonly WidgetConfigurationRecord[],
storedItems: readonly WidgetConfigurationRecord[],
) => {
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
const changedItems = submittedItems.filter((item) => {
const storedItem = storedItemsById.get(item.id);
if (!storedItem || storedItem.kind !== item.kind) return true;
return !haveSameIntegrationIds(item.integrationIds, storedItem.integrationIds);
});
if (changedItems.length === 0) return;
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
let integrationRecords: IntegrationKindRecord[] = [];
if (selectedIntegrationIds.length > 0) {
integrationRecords = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
where: inArray(integrations.id, selectedIntegrationIds),
});
}
const integrationRecordsById = new Map(integrationRecords.map((integration) => [integration.id, integration]));
const invalidIntegrationIds = selectedIntegrationIds.filter(
(integrationId) => !integrationRecordsById.has(integrationId),
);
if (invalidIntegrationIds.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Invalid integration IDs: ${invalidIntegrationIds.join(", ")}`,
});
}
const addedOrReconfiguredIntegrationIds = [
...new Set(
changedItems.flatMap((item) => {
const storedItem = storedItemsById.get(item.id);
if (!storedItem || storedItem.kind !== item.kind) return item.integrationIds;
return item.integrationIds.filter((integrationId) => !storedItem.integrationIds.includes(integrationId));
}),
),
];
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
for (const item of changedItems) {
const selectedIntegrationKinds = item.integrationIds.flatMap((integrationId) => {
const integration = integrationRecordsById.get(integrationId);
return integration ? [integration.kind] : [];
});
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
if (!integrationIssue) continue;
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue),
});
}
};
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
.requiresPermission("board-create")
@@ -633,7 +705,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Disabled or invalid widget configurations must be removed before duplication. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
@@ -670,6 +742,15 @@ export const boardRouter = createTRPCRouter({
const { sections: boardSections, items: boardItems, layouts: boardLayouts, ...boardProps } = board;
throwIfCustomWidgetBoardDuplicationForbidden(ctx.session.user.permissions.includes("admin"), boardItems);
await validateWidgetConfigurationsAsync(
ctx,
boardItems.map((item) => ({
id: item.id,
kind: item.kind,
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
})),
[],
);
const newBoardId = createId();
@@ -750,17 +831,19 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
.flatMap((section) =>
section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
: layoutSection.parentSectionId,
})),
section.layouts.map(
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
: layoutSection.parentSectionId,
}),
),
)
.concat(
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
@@ -777,11 +860,13 @@ export const boardRouter = createTRPCRouter({
);
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
(section) =>
section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
...collapseState,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(collapseState.sectionId)!,
})),
section.collapseStates.map(
(collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
...collapseState,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(collapseState.sectionId)!,
}),
),
);
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
@@ -796,15 +881,17 @@ export const boardRouter = createTRPCRouter({
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
})),
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
}),
),
)
.concat(
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
@@ -818,34 +905,12 @@ export const boardRouter = createTRPCRouter({
})),
);
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
const integrationIdsWithAccess = hasAccessForAll
? []
: await ctx.db
.selectDistinct({
id: integrationGroupPermissions.integrationId,
})
.from(integrationGroupPermissions)
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
.where(eq(groupMembers.userId, ctx.session.user.id))
.union(
ctx.db
.selectDistinct({ id: integrationUserPermissions.integrationId })
.from(integrationUserPermissions)
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
)
.then((result) => result.map((row) => row.id));
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
item.integrations
// Restrict integrations to only those the user has access to
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
item.integrations.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
);
await handleTransactionsAsync(ctx.db, {
@@ -1488,6 +1553,7 @@ export const boardRouter = createTRPCRouter({
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: input.items,
@@ -1497,6 +1563,7 @@ export const boardRouter = createTRPCRouter({
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation");
await validateTimetableOptionsChangeAsync(
item.options,
previousItem?.kind === "timetable" ? previousItem.options : undefined,
@@ -1524,15 +1591,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
})),
section.layouts.map(
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
@@ -1554,15 +1623,17 @@ export const boardRouter = createTRPCRouter({
);
await transaction.insert(schema.itemLayouts).values(
addedItems.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
})),
item.layouts.map(
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
);
}
@@ -1720,15 +1791,17 @@ export const boardRouter = createTRPCRouter({
const sectionLayoutsToInsert = addedSections
.filter((section) => section.kind === "container")
.flatMap((section) =>
section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
})),
section.layouts.map(
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
layoutId: sectionLayout.layoutId,
sectionId: section.id,
parentSectionId: sectionLayout.parentSectionId,
height: sectionLayout.height,
width: sectionLayout.width,
xOffset: sectionLayout.xOffset,
yOffset: sectionLayout.yOffset,
}),
),
);
if (sectionLayoutsToInsert.length > 0) {
@@ -1755,15 +1828,17 @@ export const boardRouter = createTRPCRouter({
.insert(itemLayouts)
.values(
addedItems.flatMap((item) =>
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
})),
item.layouts.map(
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
layoutId: layoutSection.layoutId,
sectionId: layoutSection.sectionId,
itemId: item.id,
height: layoutSection.height,
width: layoutSection.width,
xOffset: layoutSection.xOffset,
yOffset: layoutSection.yOffset,
}),
),
),
)
.run();
@@ -2066,6 +2141,12 @@ export const boardRouter = createTRPCRouter({
.output(z.object({ itemId: z.string() }))
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
await validateWidgetConfigurationsAsync(
ctx,
[{ id: "", kind: input.kind, integrationIds: input.integrationIds }],
[],
);
throwIfCustomWidgetPlacementChangeForbidden({
isAdmin: ctx.session.user.permissions.includes("admin"),
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
@@ -2073,38 +2154,10 @@ export const boardRouter = createTRPCRouter({
});
if (input.kind === "timetable") {
const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation");
await validateTimetableOptionsChangeAsync(input.options);
}
let selectedIntegrationKinds: IntegrationKind[] = [];
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true, kind: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
const invalid = input.integrationIds.filter((id) => !validIds.has(id));
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
selectedIntegrationKinds = existing.map((integration) => integration.kind);
await Promise.all(
input.integrationIds.map((integrationId) =>
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
),
);
}
const integrationIssue = getWidgetIntegrationIssue(input.kind, selectedIntegrationKinds);
if (integrationIssue) {
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(input.kind, integrationIssue),
});
}
return await serializeBoardItemPlacementAsync(input.boardId, async () => {
const board = await ctx.db.query.boards.findFirst({
where: eq(boards.id, input.boardId),
+3 -3
View File
@@ -19,12 +19,12 @@ import {
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
import { createTRPCRouter, publicProcedure } from "../trpc";
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
const getFullAccessIntegrationIdsAsync = async (database: Database, userId: string) => {
const groupsOfCurrentUser = await database.query.groupMembers.findMany({
where: eq(groupMembers.userId, userId),
});
const accessibleIntegrations = await db.query.integrations.findMany({
const accessibleIntegrations = await database.query.integrations.findMany({
columns: {
id: true,
},
@@ -4,9 +4,26 @@ import type { Session } from "@homarr/auth";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
interface IntegrationPermissionRecord {
userPermissions: { permission: IntegrationPermission }[];
groupPermissions: { permission: IntegrationPermission }[];
}
const hasIntegrationPermission = (
integration: IntegrationPermissionRecord,
session: Session | null,
permission: IntegrationPermission,
) => {
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) return true;
if (permission === "interact") return hasInteractAccess;
if (permission === "use") return hasUseAccess;
return false;
};
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
@@ -44,23 +61,51 @@ export const throwIfActionForbiddenAsync = async (
notAllowed();
}
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) {
return; // As full access is required and user has full access, allow
}
if (["interact", "use"].includes(permission) && hasInteractAccess) {
return; // As interact access is required and user has interact access, allow
}
if (permission === "use" && hasUseAccess) {
return; // As use access is required and user has use access, allow
}
if (hasIntegrationPermission(integration, session, permission)) return;
notAllowed();
};
/**
* Batched variant for feature configuration flows. It applies the same direct
* user, group, and global permission rules without issuing one query per ID.
*/
export const throwIfIntegrationActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationIds: readonly string[],
permission: IntegrationPermission,
) => {
const uniqueIntegrationIds = [...new Set(integrationIds)];
if (uniqueIntegrationIds.length === 0) return;
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrationRecords = await db.query.integrations.findMany({
where: inArray(integrations.id, uniqueIntegrationIds),
columns: { id: true },
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map(({ groupId }) => groupId).concat(""),
),
},
},
});
if (
integrationRecords.length !== uniqueIntegrationIds.length ||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
) {
notAllowed();
}
};
/**
* This method returns NOT_FOUND to prevent snooping on board existence
* A function is used to use the method without return statement
@@ -27,7 +27,7 @@ import {
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { byIdSchema } from "@homarr/validation/common";
import {
@@ -156,9 +156,21 @@ export const integrationRouter = createTRPCRouter({
})
.input(byIdSchema)
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integrationWhere = eq(integrations.id, input.id);
await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => {
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
logger.warn("Integration lookup refused", {
integrationId: input.id,
errorCode: error.code,
reason: "integration-not-found-or-forbidden",
});
}
throw error;
});
const integration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, input.id),
where: integrationWhere,
with: {
secrets: {
columns: {
@@ -179,6 +191,11 @@ export const integrationRouter = createTRPCRouter({
});
if (!integration) {
logger.warn("Integration lookup refused", {
integrationId: input.id,
errorCode: "NOT_FOUND",
reason: "integration-not-found",
});
throw new TRPCError({
code: "NOT_FOUND",
message: "Integration not found",
@@ -4,7 +4,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
const logger = createLogger({ module: "integrationTestConnection" });
@@ -218,6 +218,7 @@ export const onboardRouter = createTRPCRouter({
const discoveredApps: DiscoveredApp[] = [];
for (const service of discovery.services) {
const widgetKind = service.widgetKind;
if (service.integrationKind) {
discoveredIntegrations.push({
sourceId: service.sourceId,
@@ -231,7 +232,7 @@ export const onboardRouter = createTRPCRouter({
group: service.group,
boardName: service.boardName,
host: service.host,
widgetKind: service.widgetKind,
widgetKind,
description: service.description,
pingUrl: service.pingUrl,
});
@@ -249,7 +250,7 @@ export const onboardRouter = createTRPCRouter({
description: service.description,
pingUrl: service.pingUrl,
host: service.host,
widgetKind: service.widgetKind,
widgetKind,
});
}
}
@@ -1,7 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import * as homarrIntegrations from "@homarr/integrations";
import type * as homarrIntegrations from "@homarr/integrations";
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
import { testConnectionAsync } from "../../integration/integration-test-connection";
@@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
describe("testConnectionAsync should run test connection of integration", () => {
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -0,0 +1,169 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
const blockedTimetableIpv4Addresses = new BlockList();
const blockedTimetableIpv6Addresses = new BlockList();
const blockedTimetableSubnets = [
["0.0.0.0", 8, "ipv4"],
["10.0.0.0", 8, "ipv4"],
["100.64.0.0", 10, "ipv4"],
["127.0.0.0", 8, "ipv4"],
["169.254.0.0", 16, "ipv4"],
["172.16.0.0", 12, "ipv4"],
["192.0.0.0", 24, "ipv4"],
["192.0.2.0", 24, "ipv4"],
["192.88.99.0", 24, "ipv4"],
["192.168.0.0", 16, "ipv4"],
["198.18.0.0", 15, "ipv4"],
["198.51.100.0", 24, "ipv4"],
["203.0.113.0", 24, "ipv4"],
["224.0.0.0", 4, "ipv4"],
["240.0.0.0", 4, "ipv4"],
["::", 96, "ipv6"],
["::ffff:0:0", 96, "ipv6"],
["::ffff:0:0:0", 96, "ipv6"],
["64:ff9b::", 96, "ipv6"],
["64:ff9b:1::", 48, "ipv6"],
["100::", 64, "ipv6"],
["2001::", 32, "ipv6"],
["2001:2::", 48, "ipv6"],
["2001:10::", 28, "ipv6"],
["2001:db8::", 32, "ipv6"],
["2002::", 16, "ipv6"],
["fc00::", 7, "ipv6"],
["fe80::", 10, "ipv6"],
["fec0::", 10, "ipv6"],
["ff00::", 8, "ipv6"],
] as const;
for (const [network, prefix, family] of blockedTimetableSubnets) {
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
blockList.addSubnet(network, prefix, family);
}
export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
cause,
});
}
};
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
};
export const parseSavedTimetableOptions = (serializedOptions: string) => {
try {
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
export const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeHostname = (hostname: string) => {
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
return withoutBrackets.toLowerCase().replace(/\.$/, "");
};
const isBlockedTimetableAddress = (address: string, family: number) => {
return family === 6
? blockedTimetableIpv6Addresses.check(address, "ipv6")
: blockedTimetableIpv4Addresses.check(address, "ipv4");
};
export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
const hostname = normalizeHostname(new URL(baseUrl).hostname);
const hostnameFamily = isIP(hostname);
const isInternalHostname =
hostnameFamily === 0 &&
(!hostname.includes(".") ||
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname.endsWith(".home.arpa"));
if (isInternalHostname) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
}
let addresses: TimetableResolvedAddress[];
try {
const resolvedAddresses =
hostnameFamily === 0
? await lookup(hostname, { all: true, verbatim: true })
: [{ address: hostname, family: hostnameFamily }];
addresses = resolvedAddresses.filter(
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL host could not be resolved",
cause,
});
}
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL must not target a private or internal address",
});
}
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
};
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
const parsedOptions = timetableOptionsSchema.safeParse(options);
if (!parsedOptions.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" });
}
return normalizeTimetableBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
export const validateTimetableOptionsChangeAsync = async (
nextOptions: Record<string, unknown>,
previousOptions?: Record<string, unknown>,
) => {
const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions);
if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return;
let previousBaseUrl: string | undefined;
if (previousOptions) {
try {
previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions);
} catch {
// Invalid legacy options must not prevent replacing them with a valid configuration.
}
}
if (nextBaseUrl === previousBaseUrl) return;
await resolvePublicTimetableAddressesAsync(nextBaseUrl);
};
@@ -3,10 +3,11 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
@@ -40,7 +41,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
@@ -50,7 +51,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data;
}),
getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor"))
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
@@ -60,7 +61,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data;
}),
updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor"))
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
@@ -1,11 +1,11 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
@@ -1,12 +1,12 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
+2 -2
View File
@@ -1,6 +1,6 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({
@@ -12,7 +12,7 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneIntegrationMiddleware("query", "bazarr"))
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
+24 -6
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
@@ -11,7 +11,10 @@ import {
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import {
createManySharedWidgetIntegrationMiddleware,
createManyWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -27,7 +30,12 @@ export const beszelRouter = createTRPCRouter({
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [
"beszelSystemTable",
"beszelSystemStats",
]),
)
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
@@ -71,7 +79,7 @@ export const beszelRouter = createTRPCRouter({
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
.input(
z.object({
includeHistory: z.boolean().default(true),
@@ -145,7 +153,12 @@ export const beszelRouter = createTRPCRouter({
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
},
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
"beszelSystemTable",
"beszelSystemGrid",
]),
)
.input(
z.object({
systemId: z.string(),
@@ -196,7 +209,12 @@ export const beszelRouter = createTRPCRouter({
}),
subscribeSystemStats: publicProcedure
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.concat(
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
"beszelSystemTable",
"beszelSystemGrid",
]),
)
.input(
z.object({
systemId: z.string(),
+2 -3
View File
@@ -1,10 +1,9 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -25,7 +24,7 @@ export const calendarRouter = createTRPCRouter({
showUnmonitored: z.boolean(),
}),
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
ctx.integrations,
+2 -2
View File
@@ -1,7 +1,7 @@
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,7 +16,7 @@ interface CoolifyInstanceResult {
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify"))
.concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+8 -6
View File
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManySharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -32,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"]))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations,
@@ -66,7 +68,7 @@ export const dnsHoleRouter = createTRPCRouter({
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
@@ -85,7 +87,7 @@ export const dnsHoleRouter = createTRPCRouter({
duration: z.number().optional(),
}),
)
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
+9 -13
View File
@@ -1,19 +1,15 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
interface DownloadClientQueryResultBase {
integrationId: string;
integrationName: string;
@@ -51,7 +47,7 @@ export const downloadsRouter = createTRPCRouter({
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
},
})
.concat(createDownloadClientIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
@@ -86,7 +82,7 @@ export const downloadsRouter = createTRPCRouter({
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -97,7 +93,7 @@ export const downloadsRouter = createTRPCRouter({
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
@@ -116,7 +112,7 @@ export const downloadsRouter = createTRPCRouter({
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -127,7 +123,7 @@ export const downloadsRouter = createTRPCRouter({
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
@@ -139,7 +135,7 @@ export const downloadsRouter = createTRPCRouter({
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
+2 -3
View File
@@ -1,4 +1,3 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -6,11 +5,11 @@ import {
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
const queryFirewall = <
THandler extends {
@@ -0,0 +1,24 @@
// This file is generated by pnpm widgets:sync. Do not edit it directly.
import type { AnyTRPCRouter } from "@trpc/server";
import type { WidgetKind } from "@homarr/definitions";
const registerGeneratedWidgetRouter = <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,
),
};
@@ -1,12 +1,17 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManySharedWidgetIntegrationMiddleware,
createOneSharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
// This endpoint uses the generic system-info handler, so it intentionally excludes
// health-monitoring integrations with specialized data models such as Proxmox and Coolify.
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
@@ -34,7 +39,14 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.concat(
createManySharedWidgetIntegrationMiddleware(
"query",
"healthMonitoring",
["systemResources", "systemDisks"],
healthMonitoringIntegrationKinds,
),
)
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations,
@@ -66,13 +78,9 @@ export const healthMonitoringRouter = createTRPCRouter({
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
},
})
.concat(createOneIntegrationMiddleware("query", "synology"))
.concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["synology"]))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
throw new Error("Expected Synology integration");
}
return await integrationInstance.listStorageVolumesAsync();
}),
getClusterHealthStatus: publicProcedure
@@ -83,7 +91,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
},
})
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.concat(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"]))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+13 -13
View File
@@ -1,28 +1,28 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import {
immichAlbumRequestHandler,
immichAlbumsRequestHandler,
immichStatsRequestHandler,
} from "@homarr/request-handler/immich";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createOneSharedWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"));
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getServerStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
.query(async ({ ctx }) => {
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbum: publicProcedure
.concat(createImmichIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.input(
z.object({
albumId: z.string().optional(),
@@ -40,7 +40,7 @@ export const immichRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createImmichIntegrationMiddleware("query"))
.concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"]))
.query(async ({ ctx, input }) => {
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
const data = await innerHandler.getDataAsync();
+23 -42
View File
@@ -1,49 +1,30 @@
import type { AnyTRPCRouter } from "@trpc/server";
import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
import { widgetRouterRegistry } from "./registry";
import type { WidgetRouterName } from "./registry";
const createLazyWidgetRouter = <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({
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
stockPrice: lazy(() => import("./stocks").then((mod) => mod.stockPriceRouter)),
mediaServer: lazy(() => import("./media-server").then((mod) => mod.mediaServerRouter)),
mediaRelease: lazy(() => import("./media-release").then((mod) => mod.mediaReleaseRouter)),
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
mediaTranscoding: lazy(() => import("./media-transcoding").then((mod) => mod.mediaTranscodingRouter)),
minecraft: lazy(() => import("./minecraft").then((mod) => mod.minecraftRouter)),
...lazyWidgetRouters,
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
});
@@ -1,20 +1,15 @@
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -41,7 +36,7 @@ export const indexerManagerRouter = createTRPCRouter({
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
.mutation(async ({ ctx }) => {
await Promise.all(
ctx.integrations.map(async (integration) => {
@@ -1,9 +1,8 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -16,7 +15,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -1,13 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getMediaReleaseResponse } from "./media-release-result";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
.query(async ({ ctx }) => {
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
@@ -1,8 +1,7 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
@@ -11,7 +10,10 @@ import {
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -30,7 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
@@ -85,7 +87,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
.query(async ({ ctx }) => {
const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null;
@@ -134,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
},
})
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
.input(
z.object({
requestId: z.number(),
@@ -1,16 +1,11 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
.meta({
@@ -20,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
},
})
.concat(createMediaServerIntegrationMiddleware("query"))
.concat(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"]))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
.input(paginatedSchema.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
@@ -1,9 +1,8 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -22,7 +21,9 @@ interface NetworkControllerQueryResult {
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.concat(
createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]),
)
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations,
@@ -1,17 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+1 -1
View File
@@ -9,7 +9,6 @@ import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import { validateTimetableOptionsChangeAsync } from "./timetable";
import { throwIfCustomWidgetPlacementChangeForbidden } from "../board/custom-widget-placement-access";
export const optionsRouter = createTRPCRouter({
@@ -50,6 +49,7 @@ export const optionsRouter = createTRPCRouter({
const previousOptions = SuperJSON.parse<Record<string, unknown>>(item.options);
const updatedOptions = { ...previousOptions, ...input.newOptions };
if (item.kind === "timetable") {
const { validateTimetableOptionsChangeAsync } = await import("../timetable-options-validation");
await validateTimetableOptionsChangeAsync(updatedOptions, previousOptions);
}
throwIfCustomWidgetPlacementChangeForbidden({
@@ -1,12 +1,14 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
.query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({
@@ -12,7 +12,7 @@ export const patchmonRouter = createTRPCRouter({
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
.concat(createOneIntegrationMiddleware("query", "patchmon"))
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+225
View File
@@ -0,0 +1,225 @@
import type { AnyTRPCRouter } from "@trpc/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { WidgetKind } from "@homarr/definitions";
import { generatedWidgetRouterRegistry } from "./generated-registry";
const {
weather: generatedWeatherRouter,
downloads: generatedDownloadsRouter,
...additionalGeneratedWidgetRouters
} = generatedWidgetRouterRegistry;
const logger = createLogger({ module: "widgetRouterRegistry" });
type McpRouterNamespace = false | true | string;
type WidgetRouterRegistration<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;
}
+6 -11
View File
@@ -1,20 +1,15 @@
import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.meta({
@@ -25,7 +20,7 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
@@ -40,7 +35,7 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
@@ -54,7 +49,7 @@ export const smartHomeRouter = createTRPCRouter({
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
@@ -72,7 +67,7 @@ export const smartHomeRouter = createTRPCRouter({
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
},
})
.concat(createSmartHomeIntegrationMiddleware("interact"))
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
@@ -1,12 +1,12 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+8 -166
View File
@@ -1,7 +1,4 @@
import { lookup } from "node:dns/promises";
import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
import type { Session } from "@homarr/auth";
@@ -13,10 +10,16 @@ import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-url";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
import {
normalizeSavedTimetableBaseUrl,
normalizeTimetableBaseUrlOrThrowBadRequest,
parseSavedTimetableOptions,
resolvePublicTimetableAddressesAsync,
} from "../timetable-options-validation";
const baseUrlSchema = z.string().url().max(2_048);
const timetableSourceSchema = z.object({
@@ -24,167 +27,6 @@ const timetableSourceSchema = z.object({
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
const blockedTimetableIpv4Addresses = new BlockList();
const blockedTimetableIpv6Addresses = new BlockList();
const blockedTimetableSubnets = [
["0.0.0.0", 8, "ipv4"],
["10.0.0.0", 8, "ipv4"],
["100.64.0.0", 10, "ipv4"],
["127.0.0.0", 8, "ipv4"],
["169.254.0.0", 16, "ipv4"],
["172.16.0.0", 12, "ipv4"],
["192.0.0.0", 24, "ipv4"],
["192.0.2.0", 24, "ipv4"],
["192.88.99.0", 24, "ipv4"],
["192.168.0.0", 16, "ipv4"],
["198.18.0.0", 15, "ipv4"],
["198.51.100.0", 24, "ipv4"],
["203.0.113.0", 24, "ipv4"],
["224.0.0.0", 4, "ipv4"],
["240.0.0.0", 4, "ipv4"],
["::", 96, "ipv6"],
["::ffff:0:0", 96, "ipv6"],
["::ffff:0:0:0", 96, "ipv6"],
["64:ff9b::", 96, "ipv6"],
["64:ff9b:1::", 48, "ipv6"],
["100::", 64, "ipv6"],
["2001::", 32, "ipv6"],
["2001:2::", 48, "ipv6"],
["2001:10::", 28, "ipv6"],
["2001:db8::", 32, "ipv6"],
["2002::", 16, "ipv6"],
["fc00::", 7, "ipv6"],
["fe80::", 10, "ipv6"],
["fec0::", 10, "ipv6"],
["ff00::", 8, "ipv6"],
] as const;
for (const [network, prefix, family] of blockedTimetableSubnets) {
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
blockList.addSubnet(network, prefix, family);
}
const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
cause,
});
}
};
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Timetable widget configuration is invalid",
cause,
});
};
const parseSavedTimetableOptions = (serializedOptions: string) => {
try {
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
try {
return normalizeTimetableBaseUrl(baseUrl);
} catch (cause) {
return throwInvalidSavedTimetableConfiguration(cause);
}
};
const normalizeHostname = (hostname: string) => {
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
return withoutBrackets.toLowerCase().replace(/\.$/, "");
};
const isBlockedTimetableAddress = (address: string, family: number) => {
return family === 6
? blockedTimetableIpv6Addresses.check(address, "ipv6")
: blockedTimetableIpv4Addresses.check(address, "ipv4");
};
const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
const hostname = normalizeHostname(new URL(baseUrl).hostname);
const hostnameFamily = isIP(hostname);
const isInternalHostname =
hostnameFamily === 0 &&
(!hostname.includes(".") ||
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname.endsWith(".home.arpa"));
if (isInternalHostname) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
}
let addresses: TimetableResolvedAddress[];
try {
const resolvedAddresses =
hostnameFamily === 0
? await lookup(hostname, { all: true, verbatim: true })
: [{ address: hostname, family: hostnameFamily }];
addresses = resolvedAddresses.filter(
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
);
} catch (cause) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL host could not be resolved",
cause,
});
}
if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Timetable base URL must not target a private or internal address",
});
}
return addresses.toSorted((left, right) => left.family - right.family || left.address.localeCompare(right.address));
};
const normalizeTimetableOptionsBaseUrlOrThrowBadRequest = (options: Record<string, unknown>) => {
const parsedOptions = timetableOptionsSchema.safeParse(options);
if (!parsedOptions.success) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid timetable widget options" });
}
return normalizeBaseUrlOrThrowBadRequest(parsedOptions.data.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
};
export const validateTimetableOptionsChangeAsync = async (
nextOptions: Record<string, unknown>,
previousOptions?: Record<string, unknown>,
) => {
const nextBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(nextOptions);
if (nextBaseUrl === DEFAULT_TIMETABLE_BASE_URL) return;
let previousBaseUrl: string | undefined;
if (previousOptions) {
try {
previousBaseUrl = normalizeTimetableOptionsBaseUrlOrThrowBadRequest(previousOptions);
} catch {
// Invalid legacy options must not prevent replacing them with a valid configuration.
}
}
if (nextBaseUrl === previousBaseUrl) return;
await resolvePublicTimetableAddressesAsync(nextBaseUrl);
};
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
const options = parseSavedTimetableOptions(serializedOptions);
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
@@ -200,7 +42,7 @@ const resolveTimetableBaseUrlAsync = async (
input: z.infer<typeof timetableSourceSchema>,
allowBoardConfiguration = false,
): Promise<ResolvedTimetableSource> => {
const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
const requestedBaseUrl = normalizeTimetableBaseUrlOrThrowBadRequest(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
+31 -29
View File
@@ -1,37 +1,39 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
getDashboard: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+2 -2
View File
@@ -1,6 +1,6 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -13,7 +13,7 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyIntegrationMiddleware("query", "traefik"))
.concat(createManyWidgetIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+12 -9
View File
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
@@ -12,14 +12,17 @@ import {
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
getWebsites: publicProcedure.concat(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
try {
const instance = await createIntegrationAsync(ctx.integration);
return await instance.getWebsitesAsync();
@@ -37,7 +40,7 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
@@ -59,7 +62,7 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
@@ -81,7 +84,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
@@ -111,7 +114,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
@@ -141,7 +144,7 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const sortedNames = [...input.eventNames].toSorted();
@@ -166,7 +169,7 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami"))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
+29 -32
View File
@@ -1,40 +1,37 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: [],
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: [],
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
@@ -1,12 +1,12 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+2 -3
View File
@@ -1,13 +1,12 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+2 -2
View File
@@ -1,6 +1,6 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
@@ -12,7 +12,7 @@ export const wudRouter = createTRPCRouter({
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud"))
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
+3 -1
View File
@@ -2,11 +2,13 @@ import type { TRPCError } from "@trpc/server";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod/v4";
import { mcpRouter } from "../mcp";
import { getMcpRouterAsync } from "../mcp";
import { extractMcpToolsFromProcedures } from "../mcp-tools";
vi.mock("@homarr/auth", () => ({}));
const mcpRouter = await getMcpRouterAsync();
const MCP_TOOL_ALLOWLIST = {
query: [
"airQuality_atLocation",
+50 -2
View File
@@ -16,6 +16,7 @@ import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
import { userAgent } from "@homarr/common/server";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
@@ -105,6 +106,13 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
/**
* Unadorned procedure builder for middleware fragments that are concatenated
* onto a public, protected, or internal procedure. Global middleware belongs
* on the outer procedure so observability and policy checks run exactly once.
*/
export const middlewareProcedure = t.procedure;
export const isDemoMode = env.DEMO_MODE;
// The public "real demo" (demo + mock integrations) stays read-only, while preview
@@ -121,7 +129,47 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
return next({ ctx });
});
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
const observeProcedure = t.middleware(async ({ ctx, next, path, type }) => {
const startedAt = Date.now();
const metadata = {
path,
type,
userId: ctx.session?.user.id,
};
logger.debug("tRPC procedure started", metadata);
const result = await next();
const durationMs = Date.now() - startedAt;
if (result.ok) {
logger.debug("tRPC procedure completed", { ...metadata, durationMs });
return result;
}
const safeError = new Error(result.error.message);
safeError.name = result.error.name;
safeError.stack = result.error.stack;
logger.warn(
new ErrorWithMetadata(
"tRPC procedure failed",
{
...metadata,
durationMs,
errorCode: result.error.code,
causeType: result.error.cause instanceof Error ? result.error.cause.name : undefined,
},
{ cause: safeError },
),
);
return result;
});
let baseProcedure = t.procedure.use(observeProcedure);
if (isDemoReadOnly) {
baseProcedure = baseProcedure.use(enforceDemoModeReadOnly);
}
/**
* Public (unauthed) procedure
@@ -145,7 +193,7 @@ const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => {
export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess);
export const internalProcedure = t.procedure;
export const internalProcedure = t.procedure.use(observeProcedure);
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
+3 -1
View File
@@ -124,6 +124,7 @@ export const placeAllWidgetsAsync = async (
}
}
let placedAppCount = 0;
for (const app of allApps) {
await placeWidgetAsync(
db,
@@ -133,6 +134,7 @@ export const placeAllWidgetsAsync = async (
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
{ width: 1, height: 1 },
);
placedAppCount += 1;
}
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
@@ -153,5 +155,5 @@ export const placeAllWidgetsAsync = async (
});
}
return placedWidgets.size + allApps.length;
return placedWidgets.size + placedAppCount;
};
@@ -14,30 +14,6 @@ export const integrationIconSlugs: Record<IntegrationKind, string> = Object.from
objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]),
) as Record<IntegrationKind, string>;
const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> = {
piHole: ["pihole", "pi-hole"],
adGuardHome: ["adguardhome", "adguard-home", "adguard"],
homeAssistant: ["homeassistant", "home-assistant", "hass"],
openmediavault: ["omv"],
qBittorrent: ["qbittorrent"],
sabNzbd: ["sabnzbd"],
nzbGet: ["nzbget"],
unifiController: ["unifi", "unifi-controller"],
jellyseerr: ["jellyseerr"],
overseerr: ["overseerr"],
dashDot: ["dashdot", "dash-dot", "dash."],
speedtestTracker: ["speedtest-tracker"],
uptimeKuma: ["uptime-kuma"],
audiobookshelf: ["audiobookshelf"],
navidrome: ["navidrome"],
paperlessNgx: ["paperless-ngx", "paperless"],
patchmon: ["patchmon", "patch-mon"],
coolify: ["coolify"],
truenas: ["truenas"],
bazarr: ["bazarr"],
synology: ["synology", "diskstation"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
const normalized = search.toLowerCase().trim();
if (!normalized) return null;
@@ -51,8 +27,8 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
if (integrationIconSlugs[kind] === normalized) return kind;
}
for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) {
if (aliases.some((alias) => alias === normalized)) return kind;
for (const kind of objectKeys(integrationDefs)) {
if (integrationDefs[kind].features.docker.aliases.some((alias) => alias === normalized)) return kind;
}
for (const kind of objectKeys(integrationDefs)) {
@@ -63,8 +39,6 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
return null;
};
const notDockerDiscoverable = new Set<IntegrationKind>(["ical", "mock"]);
interface ContainerMatchInput {
image: string;
name: string;
@@ -73,10 +47,10 @@ interface ContainerMatchInput {
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
const imageName = extractContainerImageName(container.image);
const fromImage = matchIntegrationKind(imageName);
if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage;
if (fromImage && integrationDefs[fromImage].features.docker.discoverable) return fromImage;
const fromName = matchIntegrationKind(container.name);
if (fromName && !notDockerDiscoverable.has(fromName)) return fromName;
if (fromName && integrationDefs[fromName].features.docker.discoverable) return fromName;
return null;
};
@@ -1,59 +1,8 @@
import type { IntegrationKind } from "../integration";
import { objectEntries } from "@homarr/common";
export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
sabNzbd: "sabnzbd",
nzbGet: "nzbget",
deluge: "deluge",
transmission: "transmission",
qBittorrent: "q-bittorent",
aria2: "aria2",
slskd: "slskd",
sonarr: "sonarr",
radarr: "radarr",
lidarr: "lidarr",
readarr: "readarr",
prowlarr: "prowlarr",
bazarr: "bazarr",
jellyfin: "jellyfin",
emby: "emby",
plex: "plex",
jellyseerr: "jellyseerr",
seerr: "seerr",
overseerr: "overseerr",
piHole: "pi-hole",
adGuardHome: "adguard-home",
technitiumDns: "technitium-dns",
homeAssistant: "home-assistant",
openmediavault: "open-media-vault",
dashDot: "dash-dot",
glances: "glances",
tdarr: "tdarr",
proxmox: "proxmox",
nextcloud: "nextcloud",
unifiController: "unifi-controller",
opnsense: "opnsense",
ntfy: "ntfy",
gotify: "gotify",
ical: "ical",
anchor: "anchor",
truenas: "truenas",
synology: "synology",
unraid: "unraid",
coolify: "coolify",
immich: "immich",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: "archiveteam-warrior",
wud: "whats-up-docker",
mock: null,
};
import type { IntegrationKind } from "../integration";
import { integrationDefs } from "../integration";
export const integrationDocSlugs = Object.fromEntries(
objectEntries(integrationDefs).map(([kind, definition]) => [kind, definition.documentationSlug]),
) as Record<IntegrationKind, string | null>;
@@ -1,63 +1,6 @@
import type { WidgetKind } from "../widget";
import { widgetFeatureCatalog, widgetKinds } from "../widget";
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock",
weather: "weather",
airQuality: "air-quality",
countdown: "countdown",
timer: "timer",
app: "app",
iframe: "iframe",
video: "video",
notebook: "notebook",
anchorNote: "anchor-note",
dnsHoleSummary: "dns-hole-summary",
dnsHoleControls: "dns-hole-controls",
"smartHome-entityState": "smart-home-entity-state",
"smartHome-executeAutomation": "smart-home-execute-automation",
stockPrice: "stock-price",
mediaServer: "media-server",
calendar: "calendar",
downloads: "downloads",
"mediaRequests-requestList": "media-request-list",
"mediaRequests-requestStats": "media-request-stats",
mediaTranscoding: "media-transcoding",
mediaMissing: "media-missing",
minecraftServerStatus: "minecraft-server-status",
networkControllerSummary: "network-controller-summary",
networkControllerStatus: "network-controller-status",
rssFeed: "rss-feed",
bookmarks: "bookmarks",
indexerManager: "indexer-manager",
healthMonitoring: "health-monitoring",
releases: "releases",
mediaReleases: "media-releases",
dockerContainers: "docker-containers",
firewall: "firewall",
notifications: "notifications",
systemResources: "system-resources",
coolify: "coolify",
systemDisks: "system-disks",
timetable: "timetable",
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
paperlessNgx: "paperless-ngx",
patchmon: "patchmon",
bazarr: "bazarr",
tracearr: "tracearr",
speedtestTracker: "speedtest-tracker",
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
beszelSystemGrid: "beszel-system-grid",
beszelAlerts: "beszel-alerts",
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
assistant: "assistant",
wud: "whats-up-docker",
};
export const widgetDocSlugs = Object.fromEntries(
widgetKinds.map((kind) => [kind, widgetFeatureCatalog[kind].documentationSlug]),
) as Record<WidgetKind, string | null>;
@@ -0,0 +1,12 @@
// This file is generated by pnpm integrations:sync. Do not edit it directly.
export const generatedIntegrationDefinitions = {
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["beszel"],
documentationSlug: "beszel",
defaultPort: 8090,
},
} as const;
@@ -0,0 +1,59 @@
// This file is generated by pnpm widgets:sync. Do not edit it directly.
export const generatedWidgetFeatureCatalog = {
downloads: {
documentationSlug: "downloads",
capability: {
integrations: ["sabNzbd", "nzbGet", "deluge", "transmission", "qBittorrent", "aria2", "slskd", "mock"] as const,
},
query: { paths: [["widget", "downloads", "getJobsAndStatuses"]] as const, refetchIntervalSeconds: 10 },
},
weather: {
documentationSlug: "weather",
defaultSize: { width: 2, height: 1 },
query: { paths: [["widget", "weather", "atLocation"]] as const, refetchIntervalSeconds: 600 },
},
} as const;
export const widgetServerCachePolicies = {
downloads: {
getJobsAndStatuses: { namespace: "downloads:jobs-and-status", ttlMs: 10000, scope: "integration" },
},
weather: {
atLocation: { namespace: "weather:at-location", ttlMs: 300000, scope: "shared" },
},
} as const;
export const generatedWidgetOperationPolicies = [
{
widgetKind: "downloads",
operation: "getJobsAndStatuses",
kind: "query",
path: ["widget", "downloads", "getJobsAndStatuses"],
refetchIntervalSeconds: 10,
staleTimeSeconds: 10,
persist: true,
},
{
widgetKind: "weather",
operation: "atLocation",
kind: "query",
path: ["widget", "weather", "atLocation"],
refetchIntervalSeconds: 600,
staleTimeSeconds: 300,
persist: true,
},
] as const;
export const generatedWidgetDocumentationSources = {
downloads: "packages/widgets/src/downloads/docs",
weather: "packages/widgets/src/weather/docs",
} as const;
export const getWidgetServerCachePolicy = <
TKind extends keyof typeof widgetServerCachePolicies,
TOperation extends keyof (typeof widgetServerCachePolicies)[TKind],
>(
kind: TKind,
operation: TOperation,
): (typeof widgetServerCachePolicies)[TKind][TOperation] => widgetServerCachePolicies[kind][operation];
+4
View File
@@ -1,5 +1,7 @@
export * from "./board";
export * from "./integration";
export * from "./integration-module";
export * from "./generated/integration-modules";
export * from "./section";
export * from "./widget";
export * from "./widget-name";
@@ -14,6 +16,8 @@ export * from "./cookie";
export * from "./search-engine";
export * from "./onboarding";
export * from "./widget-integration-map";
export * from "./widget-module";
export * from "./generated/widget-modules";
export * from "./emptysuperjson";
export * from "./hotkeys";
export * from "./docker-integration-match";
@@ -0,0 +1,53 @@
import type { IntegrationCategory, IntegrationSecretKind } from "./integration";
export interface IntegrationModuleDocumentation {
slug: string;
sourceDirectory: string;
}
export interface IntegrationModuleDockerMetadata {
aliases?: readonly string[];
discoverable?: boolean;
}
export interface IntegrationModuleOnboardingMetadata {
featuredOrder?: number;
hidden?: boolean;
}
export type IntegrationModuleCreatorEntry =
| {
type: "constructor";
module: string;
exportName: string;
}
| {
type: "factory";
module: string;
exportName: string;
};
/**
* Pure, server-safe authoring contract for a vertical integration module.
*
* Runtime entry points are strings so reading metadata never imports an
* integration implementation or its transitive server dependencies.
*/
export interface IntegrationModuleDefinition {
kind: string;
name: string;
iconUrl: string;
secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
categories: readonly [IntegrationCategory, ...IntegrationCategory[]];
documentation: IntegrationModuleDocumentation;
creator: IntegrationModuleCreatorEntry;
defaultUrl?: string;
defaultPort?: number;
apiKeySettingsPath?: string;
docker?: IntegrationModuleDockerMetadata;
onboarding?: IntegrationModuleOnboardingMetadata;
}
export const defineIntegrationModule = <const TDefinition extends IntegrationModuleDefinition>(
definition: TDefinition,
) => definition;
+170 -71
View File
@@ -1,7 +1,12 @@
import { objectKeys } from "@homarr/common";
import { objectEntries, objectKeys } from "@homarr/common";
import type { AtLeastOneOf } from "@homarr/common/types";
import { createDocumentationLink } from "./docs";
import type { HomarrDocumentationPath } from "./docs/homarr-docs-sitemap";
import { generatedIntegrationDefinitions } from "./generated/integration-modules";
type IntegrationDocumentationPath = Extract<HomarrDocumentationPath, `/docs/integrations/${string}`>;
type IntegrationDocumentationSlug = string;
export const integrationSecretKindObject = {
apiKey: { isPublic: false, multiline: false },
@@ -24,41 +29,107 @@ export const integrationSecretKindObject = {
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
interface IntegrationFeatureMetadata {
docker?: {
aliases?: readonly string[];
discoverable?: boolean;
};
onboarding?: {
featuredOrder?: number;
hidden?: boolean;
};
}
interface ResolvedIntegrationFeatureMetadata {
docker: {
aliases: readonly string[];
discoverable: boolean;
};
onboarding: {
featuredOrder: number | null;
hidden: boolean;
};
}
interface integrationDefinition {
name: string;
iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
documentationUrl: string | null;
secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
category: readonly [IntegrationCategory, ...IntegrationCategory[]];
documentationSlug: IntegrationDocumentationSlug | null;
features?: IntegrationFeatureMetadata;
defaultUrl?: string;
defaultPort?: number;
apiKeySettingsPath?: string;
}
export const integrationDefs = {
type ResolvedIntegrationDefinitions<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({
sabNzbd: {
name: "SABnzbd",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"),
documentationSlug: "sabnzbd",
defaultPort: 8080,
apiKeySettingsPath: "/config/general/",
features: {
docker: { aliases: ["sabnzbd"] },
onboarding: { featuredOrder: 3 },
},
},
nzbGet: {
name: "NZBGet",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/nzbget"),
documentationSlug: "nzbget",
defaultPort: 6789,
features: { docker: { aliases: ["nzbget"] } },
},
deluge: {
name: "Deluge",
secretKinds: [["password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/deluge"),
documentationSlug: "deluge",
defaultPort: 8112,
},
transmission: {
@@ -66,7 +137,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/transmission"),
documentationSlug: "transmission",
defaultPort: 9091,
},
qBittorrent: {
@@ -74,16 +145,20 @@ export const integrationDefs = {
secretKinds: [["apiKey"], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"),
documentationSlug: "q-bittorent",
defaultPort: 8080,
apiKeySettingsPath: "/#/settings/webui",
features: {
docker: { aliases: ["qbittorrent"] },
onboarding: { featuredOrder: 4 },
},
},
aria2: {
name: "Aria2",
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
category: ["downloadClient", "torrent", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/aria2"),
documentationSlug: "aria2",
defaultPort: 6800,
},
slskd: {
@@ -91,7 +166,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
category: ["downloadClient", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/slskd"),
documentationSlug: "slskd",
defaultPort: 5030,
},
sonarr: {
@@ -99,25 +174,27 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
documentationSlug: "sonarr",
defaultPort: 8989,
apiKeySettingsPath: "/settings/general",
features: { onboarding: { featuredOrder: 0 } },
},
radarr: {
name: "Radarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar", "mediaOrganizer"],
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
documentationSlug: "radarr",
defaultPort: 7878,
apiKeySettingsPath: "/settings/general",
features: { onboarding: { featuredOrder: 1 } },
},
lidarr: {
name: "Lidarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"),
documentationSlug: "lidarr",
defaultPort: 8686,
apiKeySettingsPath: "/settings/general",
},
@@ -126,7 +203,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
documentationSlug: "readarr",
defaultPort: 8787,
apiKeySettingsPath: "/settings/general",
},
@@ -135,34 +212,37 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"],
documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"),
documentationSlug: "prowlarr",
defaultPort: 9696,
apiKeySettingsPath: "/settings/general",
features: { onboarding: { featuredOrder: 2 } },
},
bazarr: {
name: "Bazarr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg",
category: ["subtitleManager"],
documentationUrl: createDocumentationLink("/docs/integrations/bazarr"),
documentationSlug: "bazarr",
defaultPort: 6767,
apiKeySettingsPath: "/settings/general",
features: { docker: { aliases: ["bazarr"] } },
},
jellyfin: {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"),
documentationSlug: "jellyfin",
defaultPort: 8096,
apiKeySettingsPath: "/web/index.html#!/dashboard/keys",
features: { onboarding: { featuredOrder: 6 } },
},
emby: {
name: "Emby",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/emby"),
documentationSlug: "emby",
defaultPort: 8096,
},
plex: {
@@ -170,7 +250,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/plex"),
documentationSlug: "plex",
defaultPort: 32400,
},
jellyseerr: {
@@ -178,50 +258,55 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"),
documentationSlug: "jellyseerr",
defaultPort: 5055,
apiKeySettingsPath: "/settings",
features: { docker: { aliases: ["jellyseerr"] } },
},
seerr: {
name: "Seerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/seerr"),
documentationSlug: "seerr",
defaultPort: 5055,
apiKeySettingsPath: "/settings",
features: { onboarding: { featuredOrder: 5 } },
},
overseerr: {
name: "Overseerr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/overseerr"),
documentationSlug: "overseerr",
defaultPort: 5055,
apiKeySettingsPath: "/settings",
features: { docker: { aliases: ["overseerr"] } },
},
piHole: {
name: "Pi-hole",
secretKinds: [["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"),
documentationSlug: "pi-hole",
defaultPort: 80,
features: { docker: { aliases: ["pihole", "pi-hole"] } },
},
adGuardHome: {
name: "AdGuard Home",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"),
documentationSlug: "adguard-home",
defaultPort: 3000,
features: { docker: { aliases: ["adguardhome", "adguard-home", "adguard"] } },
},
technitiumDns: {
name: "Technitium DNS",
secretKinds: [["apiKey"], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"),
documentationSlug: "technitium-dns",
defaultPort: 5380,
},
homeAssistant: {
@@ -229,32 +314,35 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer", "calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
documentationSlug: "home-assistant",
defaultPort: 8123,
apiKeySettingsPath: "/profile/security",
features: { docker: { aliases: ["homeassistant", "home-assistant", "hass"] } },
},
openmediavault: {
name: "OpenMediaVault",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"),
documentationSlug: "open-media-vault",
defaultPort: 80,
features: { docker: { aliases: ["omv"] } },
},
dashDot: {
name: "Dash.",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"),
documentationSlug: "dash-dot",
defaultPort: 3001,
features: { docker: { aliases: ["dashdot", "dash-dot", "dash."] } },
},
glances: {
name: "Glances",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/glances"),
documentationSlug: "glances",
defaultPort: 61208,
},
tdarr: {
@@ -262,7 +350,7 @@ export const integrationDefs = {
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"],
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
documentationSlug: "tdarr",
defaultPort: 8265,
},
proxmox: {
@@ -270,7 +358,7 @@ export const integrationDefs = {
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"),
documentationSlug: "proxmox",
defaultPort: 8006,
},
nextcloud: {
@@ -278,7 +366,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
category: ["calendar", "notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"),
documentationSlug: "nextcloud",
defaultPort: 443,
},
unifiController: {
@@ -286,15 +374,16 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
documentationSlug: "unifi-controller",
defaultPort: 8443,
features: { docker: { aliases: ["unifi", "unifi-controller"] } },
},
opnsense: {
name: "OPNsense",
secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
category: ["firewall"],
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
documentationSlug: "opnsense",
defaultPort: 443,
apiKeySettingsPath: "/system_usermanager.php",
},
@@ -303,7 +392,7 @@ export const integrationDefs = {
secretKinds: [["topic"], ["topic", "apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
documentationSlug: "ntfy",
defaultPort: 80,
apiKeySettingsPath: "/account",
},
@@ -312,7 +401,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
documentationSlug: "gotify",
defaultPort: 80,
},
ical: {
@@ -320,14 +409,15 @@ export const integrationDefs = {
secretKinds: [["url"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
documentationSlug: "ical",
features: { docker: { discoverable: false } },
},
anchor: {
name: "Anchor",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"],
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
documentationSlug: "anchor",
defaultPort: 8080,
apiKeySettingsPath: "/settings",
},
@@ -336,23 +426,25 @@ export const integrationDefs = {
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
documentationSlug: "truenas",
defaultPort: 80,
features: { docker: { aliases: ["truenas"] } },
},
synology: {
name: "Synology DiskStation",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/synology"),
documentationSlug: "synology",
defaultPort: 5000,
features: { docker: { aliases: ["synology", "diskstation"] } },
},
unraid: {
name: "Unraid",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
documentationSlug: "unraid",
defaultPort: 80,
apiKeySettingsPath: "/Settings/ManagementAccess",
},
@@ -361,16 +453,17 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
documentationSlug: "coolify",
defaultPort: 8000,
apiKeySettingsPath: "/security/api-tokens",
features: { docker: { aliases: ["coolify"] } },
},
immich: {
name: "Immich",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg",
category: ["photoService"],
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
documentationSlug: "immich",
defaultPort: 2283,
apiKeySettingsPath: "/user-settings",
},
@@ -379,24 +472,26 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
category: ["documents"],
documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"),
documentationSlug: "paperless-ngx",
defaultPort: 8000,
features: { docker: { aliases: ["paperless-ngx", "paperless"] } },
},
patchmon: {
name: "PatchMon",
secretKinds: [["patchmonApiKey", "patchmonApiSecret"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
documentationSlug: "patchmon",
defaultPort: 3413,
apiKeySettingsPath: "/settings/integrations",
features: { docker: { aliases: ["patchmon", "patch-mon"] } },
},
tracearr: {
name: "Tracearr",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg",
category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
documentationSlug: "tracearr",
defaultPort: 7040,
apiKeySettingsPath: "/settings",
},
@@ -405,35 +500,39 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
documentationSlug: "speedtest-tracker",
defaultPort: 80,
apiKeySettingsPath: "/admin/api-tokens",
features: { docker: { aliases: ["speedtest-tracker"] } },
},
uptimeKuma: {
name: "Uptime Kuma",
secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
category: ["uptimeMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"),
documentationSlug: "uptime-kuma",
defaultPort: 3001,
apiKeySettingsPath: "/settings/api-keys",
features: { docker: { aliases: ["uptime-kuma"] } },
},
audiobookshelf: {
name: "Audiobookshelf",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
category: ["mediaLibrary"],
documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"),
documentationSlug: "audiobookshelf",
defaultPort: 13378,
apiKeySettingsPath: "/account",
features: { docker: { aliases: ["audiobookshelf"] } },
},
navidrome: {
name: "Navidrome",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
category: ["mediaLibrary", "mediaService"],
documentationUrl: createDocumentationLink("/docs/integrations/navidrome"),
documentationSlug: "navidrome",
defaultPort: 4533,
features: { docker: { aliases: ["navidrome"] } },
},
umami: {
name: "Umami",
@@ -441,7 +540,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg",
category: ["analytics"],
defaultUrl: "https://api.umami.is/v1",
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
documentationSlug: "umami",
defaultPort: 3000,
},
peaNut: {
@@ -449,23 +548,16 @@ export const integrationDefs = {
secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"],
documentationUrl: createDocumentationLink("/docs/integrations/peanut"),
documentationSlug: "peanut",
defaultPort: 8080,
},
beszel: {
name: "Beszel",
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["beszel"],
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
...generatedIntegrationDefinitions,
gluetun: {
name: "Gluetun",
secretKinds: [["username", "password"], ["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
documentationSlug: "gluetun",
defaultPort: 8000,
},
traefik: {
@@ -473,7 +565,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
category: ["reverseProxy"],
documentationUrl: createDocumentationLink("/docs/integrations/traefik"),
documentationSlug: "traefik",
defaultPort: 8080,
},
archiveTeamWarrior: {
@@ -481,7 +573,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
documentationSlug: "archiveteam-warrior",
defaultPort: 8001,
},
wud: {
@@ -489,7 +581,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
documentationSlug: "whats-up-docker",
defaultPort: 3000,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
@@ -514,9 +606,13 @@ export const integrationDefs = {
"uptimeMonitoring",
"mediaOrganizer",
],
documentationUrl: null,
documentationSlug: null,
features: {
docker: { discoverable: false },
onboarding: { hidden: true },
},
},
} as const satisfies Record<string, integrationDefinition>;
} as const satisfies Record<string, integrationDefinition>);
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
@@ -524,11 +620,14 @@ export const getIconUrl = (integration: IntegrationKind) => integrationDefs[inte
export const getIntegrationName = (integration: IntegrationKind) => integrationDefs[integration].name;
export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] =>
integrationDefs[integration].secretKinds[0];
export const getDefaultSecretKinds = (integration: IntegrationKind): IntegrationSecretKind[] => [
...integrationDefs[integration].secretKinds[0],
];
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds;
integrationDefs[integration].secretKinds.map((secretKinds) => [...secretKinds]) as AtLeastOneOf<
IntegrationSecretKind[]
>;
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
const definition = integrationDefs[integration];
@@ -0,0 +1,419 @@
import type { AtLeastOneOf } from "@homarr/common/types";
import type { IntegrationKind } from "./integration";
import { getIntegrationKindsByCategory } from "./integration";
import type { IntegrationKindByCategory } from "./integration";
import { generatedWidgetFeatureCatalog } from "./generated/widget-modules";
const {
weather: generatedWeatherFeature,
downloads: generatedDownloadsFeature,
...additionalGeneratedWidgetFeatures
} = generatedWidgetFeatureCatalog;
export interface NativeFeatureCapabilityDescriptor {
integrations: Readonly<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,73 +1,22 @@
import { createDocumentationLink } from "./docs";
import type { IntegrationKind } from "./integration";
import { getIntegrationKindsByCategory, integrationKinds } from "./integration";
import { integrationKinds } from "./integration";
import type { WidgetKind } from "./widget";
import { widgetDefaultSizes } from "./widget";
import { widgetDefaultSizes, widgetFeatureCatalog, widgetKinds } from "./widget";
import type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog";
/**
* Server-safe capability metadata shared by the API and widget catalog.
* Keep this intentionally small: presentation, options, and request contracts
* remain owned by their feature packages.
*/
export interface NativeFeatureCapabilityDescriptor {
integrations: readonly IntegrationKind[];
connectionOptional?: true;
serverMaxIntegrations?: number;
}
export type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog";
export const nativeFeatureCapabilities = {
dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") },
dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") },
"smartHome-entityState": {
integrations: getIntegrationKindsByCategory("smartHomeServer"),
serverMaxIntegrations: 1,
},
"smartHome-executeAutomation": {
integrations: getIntegrationKindsByCategory("smartHomeServer"),
serverMaxIntegrations: 1,
},
mediaServer: { integrations: getIntegrationKindsByCategory("mediaService") },
calendar: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true },
downloads: { integrations: getIntegrationKindsByCategory("downloadClient") },
"mediaRequests-requestList": { integrations: getIntegrationKindsByCategory("mediaRequest") },
"mediaRequests-requestStats": { integrations: getIntegrationKindsByCategory("mediaRequest") },
mediaTranscoding: { integrations: getIntegrationKindsByCategory("mediaTranscoding"), serverMaxIntegrations: 1 },
mediaMissing: { integrations: getIntegrationKindsByCategory("mediaOrganizer") },
networkControllerSummary: { integrations: getIntegrationKindsByCategory("networkController") },
networkControllerStatus: { integrations: getIntegrationKindsByCategory("networkController") },
indexerManager: { integrations: getIntegrationKindsByCategory("indexerManager") },
healthMonitoring: {
integrations: getIntegrationKindsByCategory("healthMonitoring").filter(
(kind) => kind !== "patchmon" && kind !== "wud",
),
},
firewall: { integrations: getIntegrationKindsByCategory("firewall") },
notifications: { integrations: getIntegrationKindsByCategory("notifications") },
mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] },
systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"] },
systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
beszelAlerts: { integrations: ["beszel", "mock"] },
beszelSystemGrid: { integrations: ["beszel", "mock"] },
beszelSystemStats: { integrations: ["beszel", "mock"] },
beszelSystemTable: { integrations: ["beszel", "mock"] },
coolify: { integrations: ["coolify"] },
"immich-serverStats": { integrations: ["immich"], serverMaxIntegrations: 1 },
"immich-albumCarousel": { integrations: ["immich"], serverMaxIntegrations: 1 },
paperlessNgx: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 },
patchmon: { integrations: ["patchmon"], serverMaxIntegrations: 1 },
bazarr: { integrations: ["bazarr"], serverMaxIntegrations: 1 },
tracearr: { integrations: ["tracearr"] },
speedtestTracker: { integrations: ["speedtestTracker"] },
uptimeKuma: { integrations: ["uptimeKuma"] },
audioStats: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 },
umami: { integrations: ["umami"], serverMaxIntegrations: 1 },
vpn: { integrations: getIntegrationKindsByCategory("vpn") },
ups: { integrations: getIntegrationKindsByCategory("ups") },
archiveTeamWarrior: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 },
anchorNote: { integrations: ["anchor"], serverMaxIntegrations: 1 },
traefik: { integrations: ["traefik"] },
wud: { integrations: ["wud"], serverMaxIntegrations: 1 },
} as const satisfies Partial<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
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 nativeFeatureCapabilities = createNativeFeatureCapabilities();
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
WidgetKind,
@@ -108,20 +57,24 @@ export type WidgetIntegrationIssue =
export const getWidgetIntegrationIssue = (
widgetKind: WidgetKind,
integrationKinds: readonly IntegrationKind[],
selectedIntegrationKinds: readonly IntegrationKind[],
): WidgetIntegrationIssue | null => {
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY;
if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit };
if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" };
if (selectedIntegrationKinds.length > integrationLimit) {
return { code: "integration-limit", limit: integrationLimit };
}
if (supportedIntegrations === undefined && selectedIntegrationKinds.length > 0) {
return { code: "integration-not-supported" };
}
if (
supportedIntegrations !== undefined &&
integrationKinds.length === 0 &&
selectedIntegrationKinds.length === 0 &&
!widgetKindsWithOptionalIntegrations.has(widgetKind)
) {
return { code: "integration-required" };
}
const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null;
};
@@ -147,9 +100,9 @@ const createIntegrationWidgetSupport = () => {
export const integrationWidgetSupport = createIntegrationWidgetSupport();
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => [
...integrationWidgetSupport[integrationKind],
];
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
return [...integrationWidgetSupport[integrationKind]];
};
export interface DefaultWidgetConfig {
kind: WidgetKind;
@@ -159,12 +112,28 @@ export interface DefaultWidgetConfig {
skip?: boolean;
}
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
{ kind: "clock", width: 2, height: 1 },
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
type WidgetKindWithDefaultSize = {
[TKind in WidgetKind]: (typeof widgetFeatureCatalog)[TKind] extends { defaultSize: Readonly<{ width: number }> }
? TKind
: never;
}[WidgetKind];
type DefaultWidgetConfigRecipe = Omit<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[] = defaultWidgetConfigDefinitions.map((recipe) => {
const defaultSize = widgetDefaultSizes[recipe.kind];
if (!defaultSize) throw new Error(`Default widget ${recipe.kind} must declare a default size`);
return { ...recipe, ...defaultSize };
});
const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
export const getDefaultWidgetConfig = (kind: WidgetKind): DefaultWidgetConfig =>
@@ -174,18 +143,6 @@ export const generalWidgets: WidgetKind[] = defaultWidgetConfigs
.filter((config) => !config.skip)
.map((config) => config.kind);
export const featuredIntegrations: readonly IntegrationKind[] = [
"sonarr",
"radarr",
"prowlarr",
"sabNzbd",
"qBittorrent",
"seerr",
"jellyfin",
];
export const hiddenFromOnboarding = new Set<IntegrationKind>(["mock"]);
export const defaultBookmarkApps = [
{
name: "Homarr Docs",
+139
View File
@@ -0,0 +1,139 @@
import type { AtLeastOneOf } from "@homarr/common/types";
import type { IntegrationCategory, IntegrationKind } from "./integration";
export type WidgetModuleIconName = `Icon${string}`;
export type WidgetModuleOperationKind = "query" | "mutation" | "subscription" | "route";
export interface WidgetModuleClientPolicy {
refetchIntervalSeconds?: number | null;
staleTimeSeconds?: number;
persist?: boolean;
}
export interface WidgetModuleServerCachePolicy {
namespace: string;
ttlMs: number;
staleIfErrorTtlMs?: number;
scope: "integration" | "shared" | "private";
}
export interface WidgetModuleOperation {
name: string;
kind?: WidgetModuleOperationKind;
path: readonly [string, ...string[]];
client?: WidgetModuleClientPolicy;
serverCache?: WidgetModuleServerCachePolicy;
}
type WidgetModuleIntegrationSelection =
| {
categories: Readonly<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;
+4 -14
View File
@@ -1,21 +1,11 @@
import type { IntegrationKind } from "./integration";
import { getIntegrationName } from "./integration";
import type { WidgetKind } from "./widget";
const widgetIntegrationKinds: Partial<Record<WidgetKind, IntegrationKind>> = {
coolify: "coolify",
tracearr: "tracearr",
paperlessNgx: "paperlessNgx",
patchmon: "patchmon",
bazarr: "bazarr",
uptimeKuma: "uptimeKuma",
traefik: "traefik",
};
import { widgetFeatureCatalog } from "./widget-feature-catalog";
export const getWidgetIntegrationName = (kind: WidgetKind): string | undefined => {
const integrationKind = widgetIntegrationKinds[kind];
if (!integrationKind) return undefined;
return getIntegrationName(integrationKind);
const descriptor = widgetFeatureCatalog[kind];
if (!("displayNameFromIntegration" in descriptor) || !descriptor.displayNameFromIntegration) return undefined;
return getIntegrationName(descriptor.capability.integrations[0]);
};
type WidgetNameTranslator = (key: never) => string;
+17 -73
View File
@@ -1,75 +1,19 @@
export const widgetKinds = [
"clock",
"weather",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
"notebook",
"anchorNote",
"dnsHoleSummary",
"dnsHoleControls",
"smartHome-entityState",
"smartHome-executeAutomation",
"stockPrice",
"mediaServer",
"calendar",
"downloads",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"mediaTranscoding",
"mediaMissing",
"minecraftServerStatus",
"networkControllerSummary",
"networkControllerStatus",
"rssFeed",
"bookmarks",
"indexerManager",
"healthMonitoring",
"releases",
"mediaReleases",
"dockerContainers",
"firewall",
"notifications",
"systemResources",
"coolify",
"systemDisks",
"timetable",
"immich-serverStats",
"immich-albumCarousel",
"paperlessNgx",
"patchmon",
"bazarr",
"tracearr",
"speedtestTracker",
"uptimeKuma",
"audioStats",
"umami",
"vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
"traefik",
"customApi",
"assistant",
"wud",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
import { widgetFeatureCatalog } from "./widget-feature-catalog";
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
airQuality: { width: 2, height: 1 },
countdown: { width: 2, height: 1 },
timer: { width: 2, height: 1 },
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
patchmon: { width: 2, height: 2 },
mediaMissing: { width: 4, height: 3 },
bazarr: { width: 2, height: 2 },
assistant: { width: 10, height: 4 },
export * from "./widget-feature-catalog";
export type { WidgetFeatureDescriptor } from "./widget-feature-catalog";
export type WidgetKind = keyof typeof widgetFeatureCatalog;
export const widgetKinds = Object.keys(widgetFeatureCatalog) as unknown as readonly [WidgetKind, ...WidgetKind[]];
const createWidgetDefaultSizes = () => {
const result: Partial<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 = createWidgetDefaultSizes();
+6 -1
View File
@@ -1 +1,6 @@
export * from "./src";
/**
* The package root is intentionally type-only. Runtime integrations must use
* a narrow export such as `@homarr/integrations/factory` so importing one
* capability cannot eagerly pull every concrete integration into the graph.
*/
export type * from "./src";
+4
View File
@@ -13,7 +13,11 @@
},
"exports": {
".": "./index.ts",
"./anchor": "./src/anchor/anchor-types.ts",
"./downloads": "./src/interfaces/downloads/download-client-items.ts",
"./factory": "./src/factory.ts",
"./response-contract": "./src/base/response-contract.ts",
"./synology": "./src/synology/synology-integration.ts",
"./test-connection": "./src/base/test-connection/index.ts",
"./types": "./src/types.ts"
},
+187 -135
View File
@@ -1,146 +1,198 @@
import type { IntegrationKind } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { BazarrIntegration } from "../bazarr/bazarr-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { SlskdIntegration } from "../download-client/slskd/slskd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GlancesIntegration } from "../glances/glances-integration";
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { ImmichIntegration } from "../immich/immich-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
import { PatchMonIntegration } from "../patchmon/patchmon-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TraefikIntegration } from "../traefik/traefik-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import { WudIntegration } from "../wud/wud-integration";
import { generatedIntegrationCreatorLoaders } from "../generated/integration-creators";
import type { Integration, IntegrationInput } from "./integration";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
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),
};
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 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>(
integration: IntegrationInput & { kind: TKind },
) => {
if (!(integration.kind in integrationCreators)) {
): Promise<IntegrationInstanceOfKind<TKind>> => {
if (!Object.hasOwn(integrationCreatorLoaders, integration.kind)) {
logger.warn("Integration client creation refused", {
integrationId: integration.id,
integrationKind: integration.kind,
reason: "integration-kind-unregistered",
});
throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
);
}
const creator = integrationCreators[integration.kind];
// factories are an array, to differentiate in js between class constructors and functions
if (Array.isArray(creator)) {
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
}
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
const creator = await loadIntegrationCreatorAsync(integration.kind);
return (await creator(integration)) as IntegrationInstanceOfKind<TKind>;
};
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = {
anchor: AnchorIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
technitiumDns: [createTechnitiumDnsIntegrationAsync],
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
plex: PlexIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
nzbGet: NzbGetIntegration,
qBittorrent: QBitTorrentIntegration,
deluge: DelugeIntegration,
transmission: TransmissionIntegration,
slskd: SlskdIntegration,
aria2: Aria2Integration,
jellyseerr: JellyseerrIntegration,
seerr: SeerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,
openmediavault: OpenMediaVaultIntegration,
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
ical: ICalIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
synology: SynologyIntegration,
unraid: UnraidIntegration,
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
wud: WudIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
: (typeof integrationCreators)[kind] extends IntegrationInstance
? InstanceType<(typeof integrationCreators)[kind]>
: never;
}[TKind];
@@ -1,14 +1,18 @@
import { isFunction } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { Integration } from "../integration";
import type { IIntegrationErrorHandler } from "./handler";
import { integrationFetchHttpErrorHandler } from "./http";
import { IntegrationError } from "./integration-error";
import { IntegrationUnknownError } from "./integration-unknown-error";
import { IntegrationRequestError } from "./http/integration-request-error";
import { IntegrationResponseError } from "./http/integration-response-error";
import { integrationJsonParseErrorHandler, integrationZodParseErrorHandler } from "./parse";
const logger = createLogger({ module: "handleIntegrationErrors" });
const loggedIntegrationErrors = new WeakSet<IntegrationError>();
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any
type AbstractConstructor<T = {}> = abstract new (...args: any[]) => T;
@@ -19,6 +23,62 @@ const defaultErrorHandlers: IIntegrationErrorHandler[] = [
integrationFetchHttpErrorHandler,
];
interface IntegrationOperationMetadata {
operation: string;
integrationId: string;
integrationKind: string;
integrationName: string;
}
const safeErrorNameRegex = /^(?:Error|[A-Za-z][A-Za-z0-9]*(?:Error|Err))$/;
const createSafeErrorCause = (error: unknown, depth = 0): Error => {
let options: ErrorOptions | undefined;
if (depth < 5 && error instanceof Error && error.cause !== undefined) {
options = { cause: createSafeErrorCause(error.cause, depth + 1) };
}
const safeError = new Error("Integration operation failed", options);
if (error instanceof Error && safeErrorNameRegex.test(error.name)) {
safeError.name = error.name;
}
return safeError;
};
const logFailure = (
error: IntegrationError,
failureType: "normalized" | "unknown",
metadata: IntegrationOperationMetadata,
durationMs: number,
) => {
if (loggedIntegrationErrors.has(error)) {
return;
}
loggedIntegrationErrors.add(error);
const errorMetadata: Record<string, unknown> = { errorType: error.name };
if (error instanceof IntegrationRequestError) {
errorMetadata.requestType = error.cause.type;
errorMetadata.requestReason = error.cause.reason;
errorMetadata.requestCode = error.cause.code;
}
if (error instanceof IntegrationResponseError) {
errorMetadata.statusCode = error.cause.statusCode;
}
logger.warn(
new ErrorWithMetadata(
"Integration operation failed",
{
...metadata,
durationMs,
failureType,
...errorMetadata,
},
{ cause: createSafeErrorCause(error) },
),
);
};
export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[]) => {
const combinedErrorHandlers = [...defaultErrorHandlers, ...errorHandlers];
return <T extends AbstractConstructor<Integration>>(IntegrationBaseClass: T): T => {
@@ -44,8 +104,27 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
processedProperties.add(propertyKey);
const wrapped = (...methodArgs: unknown[]) => {
const handleError = (error: unknown) => {
const startedAt = Date.now();
const operationMetadata: IntegrationOperationMetadata = {
operation: propertyKey,
integrationId: this.publicIntegration.id,
integrationKind: this.integration.kind ?? "unknown",
integrationName: this.publicIntegration.name,
};
logger.debug("Integration operation started", operationMetadata);
const logCompletion = () => {
logger.debug("Integration operation completed", {
...operationMetadata,
durationMs: Date.now() - startedAt,
});
};
const handleError = (error: unknown): never => {
if (error instanceof IntegrationError) {
const failureType = error instanceof IntegrationUnknownError ? "unknown" : "normalized";
logFailure(error, failureType, operationMetadata, Date.now() - startedAt);
throw error;
}
@@ -53,26 +132,29 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
const handledError = handler.handleError(error, this.publicIntegration);
if (!handledError) continue;
logFailure(handledError, "normalized", operationMetadata, Date.now() - startedAt);
throw handledError;
}
// If the error was handled and should be thrown again, throw it
logger.debug("Unhandled error in integration", {
error: error instanceof Error ? `${error.name}: ${error.message}` : undefined,
integrationName: this.publicIntegration.name,
});
throw new IntegrationUnknownError(this.publicIntegration, { cause: error });
const unknownError = new IntegrationUnknownError(this.publicIntegration, { cause: error });
logFailure(unknownError, "unknown", operationMetadata, Date.now() - startedAt);
throw unknownError;
};
try {
const result = original.apply(this, methodArgs);
if (result instanceof Promise) {
return result.catch((error: unknown) => {
handleError(error);
});
return result.then(
(value: unknown) => {
logCompletion();
return value;
},
(error: unknown) => handleError(error),
);
}
logCompletion();
return result;
} catch (error: unknown) {
handleError(error);
@@ -5,7 +5,7 @@ import { fetch as undiciFetch } from "undici";
import { removeTrailingSlash } from "@homarr/common";
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { HandleIntegrationErrors } from "./errors/decorator";
import { TestConnectionError } from "./test-connection/test-connection-error";
@@ -15,6 +15,7 @@ import type { IntegrationSecret } from "./types";
export interface IntegrationInput {
id: string;
kind?: IntegrationKind;
name: string;
url: string;
externalUrl: string | null;
Binary file not shown.

After

Width:  |  Height:  |  Size: 948 KiB

@@ -0,0 +1,58 @@
---
title: "Beszel"
description: "Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { beszelIntegration } from ".";
import { beszelSystemStatsWidget } from "@site/docs/widgets/beszel-system-stats";
import { beszelSystemGridWidget } from "@site/docs/widgets/beszel-system-grid";
import { beszelSystemTableWidget } from "@site/docs/widgets/beszel-system-table";
import { beszelAlertsWidget } from "@site/docs/widgets/beszel-alerts";
import beszelDashboard from "./img/beszel-dashboard.png";
<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.
:::
@@ -0,0 +1,7 @@
export const beszelIntegration = {
name: "Beszel",
description:
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
path: "../../integrations/beszel",
} as const;
@@ -0,0 +1,19 @@
import { defineIntegrationModule } from "@homarr/definitions";
export default defineIntegrationModule({
kind: "beszel",
name: "Beszel",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
secretKinds: [["username", "password"]],
categories: ["beszel"],
defaultPort: 8090,
documentation: {
slug: "beszel",
sourceDirectory: "docs",
},
creator: {
type: "constructor",
module: "./beszel-integration",
exportName: "BeszelIntegration",
},
});
+1
View File
@@ -0,0 +1 @@
export { createIntegrationAsync } from "./base/creator";
@@ -0,0 +1,12 @@
// This file is generated by pnpm integrations:sync. Do not edit it directly.
import type { Integration, IntegrationInput } from "../base/integration";
type GeneratedIntegrationCreator = (input: IntegrationInput) => Promise<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>>;
+7 -59
View File
@@ -1,53 +1,6 @@
// General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { SlskdIntegration } from "./download-client/slskd/slskd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { GotifyIntegration } from "./gotify/gotify-integration";
export { NTFYIntegration } from "./ntfy/ntfy-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { GlancesIntegration } from "./glances/glances-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { SeerrIntegration } from "./seerr/seerr-integration";
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration";
export { SynologyIntegration } from "./synology/synology-integration";
export { UnraidIntegration } from "./unraid/unraid-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration";
export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration";
export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
// Types
// Type-only compatibility surface. Runtime capabilities have explicit subpaths.
export type { IntegrationInput } from "./base/integration";
export type { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
@@ -59,8 +12,11 @@ export type {
FirewallMemorySummary,
} from "./interfaces/firewall-summary/firewall-summary-types";
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
export type {
MediaRequestList,
MediaRequestStats,
UpstreamMediaRequestStatus,
} from "./interfaces/media-requests/media-request-types";
export type { StreamSession } from "./interfaces/media-server/media-server-types";
export type { EntityState } from "./interfaces/smart-home/smart-home-types";
export type {
@@ -95,12 +51,4 @@ export type {
} from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
export { createIntegrationAsync } from "./base/creator";
export { simulateResponseContractAsync } from "./base/response-contract";
export type { ResponseContractFixture, ResponseContractParser } from "./base/response-contract";
+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";
import { Aria2Integration } from "../src/download-client/aria2/aria2-integration";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more