refactor: streamline widget and integration architecture
This commit is contained in:
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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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>>;
|
||||
@@ -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";
|
||||
@@ -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
Reference in new issue
Block a user