Compare commits

..
Author SHA1 Message Date
ajnart 1f8d12c19e fix(widgets): use readable onboarding sizes 2026-08-29 06:30:19 +02:00
223 changed files with 2053 additions and 4041 deletions

No files matched your search

+6 -10
View File
@@ -40,7 +40,7 @@ homarr/
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
│ ├── core/ # Env validation, DB/Redis driver factories, logging
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry
│ ├── widgets/ # All 39 dashboard widgets (definitions + components)
│ ├── integrations/ # Integration classes (HTTP clients to external apps)
│ ├── redis/ # Redis pub/sub channels, caching abstractions
│ ├── translation/ # next-intl setup, locale configs, lang JSON files
@@ -118,15 +118,11 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System
Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in
`packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and
`componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`.
Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal
module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so
`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while
keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected
entries so later loads can retry. Do not restore an eager root `widgetImports` map.
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic`
- Fetches data via tRPC client hooks (backed by cron job caches)
### Integration System
Binary file not shown.

Before

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 649 KiB

@@ -1,102 +0,0 @@
---
title: Widgets and integrations
tags:
- Developer
- Technical documentation
- Widgets
- Integrations
sidebar_position: 2
---
Homarr keeps feature registration explicit and typed. Feature registration has no generator or generated registry.
A new feature is available when its typed metadata and lazy loader are registered.
## Add a widget
Keep the UI, options, helpers, and tests together in `packages/widgets/src/<widget>/`. The usual folder only needs an
`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed.
1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in
`packages/definitions/src/docs/widget-doc-slugs.ts`.
2. Export `definition` and `componentLoader` from `packages/widgets/src/<widget>/index.ts` with
`createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and
errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard.
3. For an integration-backed widget, add its integration kinds and selection rules once to
`packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local
definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without
duplicating widget metadata.
4. Register the widget in both explicit literal loader maps and the type-only module map in
`packages/widgets/src/registry.ts`, then add its icon to `packages/ui/src/widget-icons.ts`. The module and component
loaders must both use literal import paths. Keeping them separate lets a cold widget load request its definition
and component chunks in parallel; the type-only map preserves exact option and component inference. Keep all three
maps in the same order so drift is obvious in review.
5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit
lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in
`@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures.
6. Add canonical documentation in `apps/docs/docs/widgets/<slug>/`.
Weather is the small integration-free example. Downloads shows integration selection, polling, server caching,
mutations, partial upstream failures, and a larger options surface.
## Add an integration
Keep the HTTP client and its capability-specific types in `packages/integrations/src/<integration>/`.
1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives,
categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant.
2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the
implementation out of the initial server bundle and makes the registration easy to find.
3. Implement only the interfaces required by the widgets that use the service.
4. Add canonical documentation in `apps/docs/docs/integrations/<slug>/`. Its `index.ts` must satisfy
`IntegrationDefinition` so invalid metadata fails type checking without widening the object. Use a type-only import
and `satisfies IntegrationDefinition`; do not cast the object.
Beszel is a compact example with several widgets sharing one integration client.
## Write user documentation
Write for technically capable self-hosters. Keep each page short and operational.
- Start with one sentence describing what the integration or widget adds to Homarr.
- Include prerequisites, credentials, permissions, destructive actions, and non-obvious limits only when they matter.
- Use numbered steps only when order matters. Prefer the exact UI labels a user must select.
- Do not add marketing, implementation history, architecture internals, or explanations of basic self-hosting concepts.
- End when the user can configure and verify the feature; do not repeat the same information in a summary.
The usual page needs only an introduction and a `Configuration` section. Add `Notes` or `Troubleshooting` only for
real caveats.
## Data and errors
- TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching for bounded key
spaces. Public handlers whose keys come from arbitrary URLs, domains, symbols, or coordinates stay in the bounded
process-local cache.
- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys
include keyed credential fingerprints and the complete integration identity, and are invalidated after mutations.
The fingerprints change with credentials without exposing their values in Redis keys.
- Give upstream requests a finite deadline and pass the request handler's `AbortSignal` into clients that support it.
Admission is bounded per handler. Request deduplication and distributed lock ownership remain active until the
upstream settles or a bounded cleanup grace expires, while unresolved upstream work stays separately capped.
- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards.
- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log
secrets or full upstream payloads.
- Multi-integration reads should return successful providers when one service fails and surface a clear error when all
providers fail.
Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact
TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable.
## Registry imports
Use `@homarr/widgets/manifest` to load registered widgets. `loadWidgetDefinition` is the metadata-only path;
`loadWidgetComponent` loads only the UI module; and `loadWidgetResources` requests both paths in parallel. These
loaders cache in-flight and successful promises and remove rejected promises so a later request can retry.
The former root `widgetImports` and `loadWidgetDynamic` exports are intentionally not compatibility aliases.
`widgetImports` synchronously imported every widget module, and `loadWidgetDynamic` depended on that eager map.
Restoring them at the package root would defeat lazy definition chunks or add client-only runtime dependencies to
every root consumer. Migrate metadata consumers to `loadWidgetDefinition`, renderers to `loadWidgetResources`, and
type-only registry consumers from the removed internal `WidgetImportRecord` helper to the root `WidgetImports` type.
`@homarr/widgets/catalog` remains as a deprecated compatibility subpath for `widgetCatalogIcons`. New imports should
use `@homarr/ui/widget-icons`, which is the canonical server-safe icon catalog.
+3 -3
View File
@@ -1,9 +1,9 @@
import type { IntegrationDefinition } from "@site/src/types";
import { IntegrationDefinition } from "@site/src/types";
export const beszelIntegration = {
export const beszelIntegration: IntegrationDefinition = {
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",
} satisfies IntegrationDefinition;
};
+1 -4
View File
@@ -113,10 +113,7 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Set desktop and mobile home boards for the current user
- Configure global board defaults such as public home boards and status behavior
Creating, duplicating, or reconfiguring a widget validates its integration selection on the server. The caller needs
use access for each newly linked integration; unchanged links remain editable through normal board permissions.
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, and use a unique breakpoint for every layout. Base and custom layouts can appear in any breakpoint order. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
+1 -1
View File
@@ -18,7 +18,7 @@ Paste a URL and press Enter, paste several comma-separated or one-per-line URLs,
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. **Compact grid** keeps every card to a short row. Enable **Expand cards to fill row** when cards in an incomplete final row should use the remaining width. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
### Screenshots
@@ -56,12 +56,6 @@ export const bookmarksWidget: WidgetDefinition = {
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Expand cards to fill row",
description: "Whether cards in an incomplete final row expand to use the remaining width.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Bookmarks",
description: "URLs and existing Apps shown by the widget.",
+7 -11
View File
@@ -2,17 +2,13 @@ import type { LoadContext, Plugin } from "@docusaurus/types";
import fs from "node:fs";
import path from "node:path";
import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs";
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
const documentationEntries = [
{
label: "Integration",
folder: "integrations",
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
},
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
] as const;
const slugMaps = {
Integration: { slugs: integrationDocSlugs, folder: "integrations" },
Widget: { slugs: widgetDocSlugs, folder: "widgets" },
} as const;
export default function validateDocsCoveragePlugin(context: LoadContext): Plugin {
return {
@@ -21,8 +17,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin
const docsDir = path.resolve(context.siteDir, "docs");
const missing: string[] = [];
for (const { label, folder, entries } of documentationEntries) {
for (const [kind, slug] of entries) {
for (const [label, { slugs, folder }] of Object.entries(slugMaps)) {
for (const [kind, slug] of Object.entries(slugs)) {
if (!slug) continue;
if (!fs.existsSync(path.join(docsDir, folder, slug))) {
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`);
+2 -2
View File
@@ -1,5 +1,5 @@
import type { TablerIcon } from "@tabler/icons-react";
import type { ReactNode } from "react";
import { TablerIcon } from "@tabler/icons-react";
import { ReactNode } from "react";
export interface IntegrationDefinition {
name: string;
@@ -145,14 +145,10 @@ const ScopedTRPCReactProvider = ({
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
for (const queryDefaults of widgetQueryRefetchIntervals) {
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
if (typeof queryDefaults.intervalSeconds === "number") {
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
}
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
client.setQueryDefaults(queryDefaults.queryKey, policy);
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
client.setQueryDefaults(queryKey, {
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
});
}
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
client.setQueryDefaults(queryKey, policy);
@@ -9,7 +9,7 @@ import { getRepresentativeLayoutWidth, projectBoardLayout } from "@homarr/boards
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { Board } from "../../_types";
import classes from "./_layout-preview.module.css";
+5 -8
View File
@@ -114,15 +114,12 @@ export default async function Layout(props: {
})
: null,
);
const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise<AssistantAvailability> => {
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
if (!session) return "unauthenticated";
try {
const availability = await api.assistant.getAvailability();
return availability.enabled ? "enabled" : "unconfigured";
} catch {
return "error";
}
return api.assistant
.getAvailability()
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
.catch(() => "error");
});
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
sessionPromise,
@@ -50,8 +50,7 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
onChange={(next) => {
setDraft(next);
try {
const value: unknown = JSON.parse(next);
const parsed = customWidgetDefinitionSchema.safeParse(value);
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
if (!parsed.success) {
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
return;
@@ -76,8 +76,7 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
export function parseJson(value: string): unknown {
try {
const parsed: unknown = JSON.parse(value);
return parsed;
return JSON.parse(value) as unknown;
} catch {
return null;
}
@@ -129,9 +129,7 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
className={classes.paneSwitcher}
fullWidth
value={props.mobilePane}
onChange={(value) => {
if (value === "configure" || value === "preview") props.setMobilePane(value);
}}
onChange={(value) => props.setMobilePane(value as MobilePane)}
data={[
{ value: "configure", label: w("configure") },
{ value: "preview", label: w("section.preview") },
@@ -173,9 +173,7 @@ export function RequestBodyEditor({
onChange={(next) => {
setDraft(next);
try {
let parsed: unknown;
if (next.trim()) parsed = JSON.parse(next);
onChange(parsed);
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
setError(undefined);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("invalidJson"));
@@ -2,6 +2,7 @@ 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";
@@ -15,24 +16,40 @@ 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);
}
}
const groups = new Map<string, McpToolGroup["tools"]>();
for (const tool of tools) {
const namespace = tool.pathInRouter[0] ?? "other";
if (!groups.has(namespace)) {
groups.set(namespace, []);
}
const procedureKey = tool.pathInRouter.join(".");
groups.get(namespace)?.push({
name: tool.name,
description: tool.description,
type: tool.type,
type: procedureTypeMap.get(procedureKey) ?? "query",
});
}
return Array.from(groups.entries()).map(([namespace, items]) => ({
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
namespace,
tools: items,
}));
return cachedToolGroups;
}
export async function generateMetadata() {
@@ -53,14 +70,12 @@ export default async function ApiPage() {
if (!session?.user.permissions.includes("admin")) {
notFound();
}
const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([
headers(),
api.apiKeys.getAll(),
getI18n("management.page.tool.api.tab"),
getMcpToolGroups(),
]);
const requestHeaders = await headers();
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 (
<>
@@ -1,7 +1,7 @@
import type { PropsWithChildren } from "react";
import { widgetKinds } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { MainNavigation } from "~/components/layout/navigation";
import { ClientShell } from "~/components/layout/shell";
@@ -327,6 +327,20 @@ 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>) => {
@@ -371,8 +385,7 @@ export async function POST(request: Request) {
const parsed = requestSchema.safeParse(
(() => {
try {
const value: unknown = JSON.parse(requestBody);
return value;
return JSON.parse(requestBody) as unknown;
} catch {
return null;
}
@@ -503,13 +516,14 @@ 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();
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
const homarrTools = Object.fromEntries(
mcpTools.map((mcpTool) => {
const requiresApproval = mcpTool.type === "mutation";
const requiresApproval = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
return [
mcpTool.name,
tool({
@@ -557,9 +571,11 @@ export async function POST(request: Request) {
}),
) satisfies ToolSet;
const toolApproval = Object.fromEntries(
mcpTools
.filter((mcpTool) => mcpTool.type === "mutation")
.map((mcpTool) => [mcpTool.name, "user-approval" as const]),
mcpTools.flatMap((mcpTool) =>
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
? [[mcpTool.name, "user-approval" as const]]
: [],
),
);
const frontendTools = Object.fromEntries(
@@ -46,21 +46,18 @@ vi.mock("../_extract-tools", () => ({
{
name: "board_getAllBoards",
description: "List boards",
type: "query",
pathInRouter: ["board", "getAllBoards"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_list",
description: "List Custom Widgets",
type: "query",
pathInRouter: ["customWidget", "list"],
inputSchema: { type: "object", properties: {} },
},
{
name: "customWidget_workshopSearch",
description: "Search Workshop",
type: "query",
pathInRouter: ["customWidget", "workshopSearch"],
inputSchema: { type: "object", properties: {} },
},
+13 -5
View File
@@ -80,10 +80,18 @@ function clearAuthFailures(ip: string | null) {
authFailures.delete(ip ?? "unknown");
}
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
if (isAdmin) return tools;
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
};
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_"));
function registerCustomWidgetAuthoring(server: McpServer) {
server.registerPrompt(
@@ -328,7 +336,7 @@ export const handleMcpRequest = async (req: NextRequest) => {
const isAdmin = session.user.permissions.includes("admin");
const protocolHandler = createMcpProtocolHandler({
caller,
tools: getVisibleTools(extractMcpTools(), isAdmin),
tools: getVisibleTools(isAdmin),
version: getPackageVersion(),
instructions: SERVER_INSTRUCTIONS,
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
@@ -50,7 +50,6 @@ const echoValidator = z.object({ message: z.string() });
const echoTool: McpTool = {
name: "echo",
description: "Echo a message",
type: "query",
pathInRouter: ["echo"],
inputValidator: echoValidator,
inputSchema: z.toJSONSchema(echoValidator),
@@ -2,32 +2,25 @@ import { describe, expect, test } from "vitest";
import type { WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport } from "@homarr/definitions";
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
import { widgetImports } from "@homarr/widgets";
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, definition] of widgetDefinitions) {
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
const definition = widgetImport.definition;
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
const supportedIntegrations = widgetIntegrationSupport[kind];
expect(supportedIntegrations, kind).toEqual(expected);
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
}
});
test("preserves generated notebook content and fills its hidden defaults", () => {
const result = getAssistantWidgetConfiguration(
getWidgetDefinition("notebook"),
widgetImports.notebook.definition,
{
boardId: "board-1",
boardName: "Home",
@@ -49,7 +42,7 @@ describe("getAssistantWidgetConfiguration", () => {
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
const result = getAssistantWidgetConfiguration(
getWidgetDefinition("audioStats"),
widgetImports.audioStats.definition,
{
boardId: "board-1",
boardName: "Home",
@@ -97,13 +90,13 @@ describe("getAssistantWidgetConfiguration", () => {
test("treats integration-backed widgets as required unless explicitly optional", () => {
const mediaServer = getAssistantWidgetConfiguration(
getWidgetDefinition("mediaServer"),
widgetImports.mediaServer.definition,
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
settings,
[],
);
const calendar = getAssistantWidgetConfiguration(
getWidgetDefinition("calendar"),
widgetImports.calendar.definition,
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
settings,
[],
@@ -4,7 +4,7 @@ import { IconLayoutGrid } from "@tabler/icons-react";
import type { BoardPreviewData } from "@homarr/boards/layout-preview";
import { projectBoardLayout } from "@homarr/boards/layout-preview";
import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import classes from "./board-layout-thumbnail.module.css";
@@ -21,7 +21,7 @@ describe("item actions create-item", () => {
.build();
const baseLayout = board.layouts.find((layout) => layout.role === "base");
const result = createItemCallback({ kind: "downloads" })(board);
const result = createItemCallback({ kind: "mediaMissing" })(board);
const createdItem = result.items.at(0);
expect(createdItem?.layouts.find((layout) => layout.layoutId === "mobile")?.width).toBe(3);
@@ -63,14 +63,14 @@ describe("item actions create-item", () => {
{
layoutId,
height: 1,
width: 2,
width: 1,
...emptyPosition,
sectionId: firstSectionId,
},
],
}),
);
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
});
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
// Arrange
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
expect(createdItem).toEqual(
expect.objectContaining({
kind: itemKind,
layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
}),
);
expect(spy).toHaveBeenCalledWith(
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
layout.columnCount,
undefined,
{ height: 1, width: 2 },
{ height: 1, width: 1 },
);
});
@@ -165,7 +165,7 @@ describe("item actions create-item", () => {
const result = createItemCallback({ id: "calendar", kind: "mediaMissing", targetSectionId: "media" })(board);
expect(result.items.find((item) => item.id === "calendar")?.layouts).toEqual([
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 4 }),
]);
});
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
layoutId: layout.id,
sectionId: "canvas",
width: 1,
height: 2,
height: 3,
xOffset: 0,
yOffset: 1,
}),
@@ -1,5 +1,5 @@
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
export interface BoardRecipeRecommendation {
widgetKind: WidgetKind;
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
return widgetKinds
.filter((widgetKind) => !existingKinds.has(widgetKind))
.flatMap((widgetKind) => {
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
if (!supportedIntegrations) return [];
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
if (!capability) return [];
const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
if (matches.length === 0) return [];
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
widgetKind,
integrationKind,
isNewlyAvailable: Boolean(preferredMatch),
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
},
];
})
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
.slice(0, Math.max(0, limit));
};
@@ -42,7 +42,7 @@ import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectableCard } from "@homarr/ui";
import type { TablerIcon } from "@homarr/ui";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { EmptySection } from "~/app/[locale]/boards/_types";
@@ -1,14 +1,12 @@
import type { QueryKey } from "@tanstack/react-query";
import { describe, expect, test } from "vitest";
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 type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
import { matchesWidgetItemQuery } from "./widget-query-scope";
const widgetDefinitions = await loadAllWidgetDefinitions();
type WidgetKind = keyof typeof widgetImports;
const allPhotosAlbumId = "all";
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
@@ -21,9 +19,7 @@ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQu
});
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
const definition = widgetDefinitions.get(kind);
if (!definition) throw new Error(`No definition is registered for ${kind}`);
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
const queryKey: QueryKey = [path, { input, type: "query" }];
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
};
@@ -13,7 +13,7 @@
.board-grid-entry:has([data-board-widget-header]):hover,
.board-grid-entry:has([data-board-widget-header]):focus-within {
z-index: calc(var(--homarr-z-index-board-header) - 1);
z-index: var(--homarr-z-index-widget-focus);
}
.board-grid-entry[data-grid-preview="true"] {
@@ -22,7 +22,7 @@ import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getBoardLaneColumnCount, getRootSectionLane, getWidgetName } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
import { widgetCatalogIcons } from "@homarr/widgets/catalog";
import type { EmptySection } from "~/app/[locale]/boards/_types";
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
+19 -55
View File
@@ -1,4 +1,4 @@
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
import { z } from "zod/v4";
export interface McpMeta {
@@ -12,7 +12,6 @@ export interface McpMeta {
export interface McpTool {
name: string;
description: string;
type: "query" | "mutation";
pathInRouter: string[];
inputSchema: z.core.JSONSchema.JSONSchema;
inputValidator: z.ZodObject;
@@ -20,54 +19,13 @@ export interface McpTool {
const emptyInput = z.object({});
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
const getObjectInput = (input: z.ZodType) => {
if (input instanceof z.ZodObject) return input;
if (input instanceof z.ZodOptional) {
const unwrappedInput = input.unwrap();
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
}
return null;
};
const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined;
const metadata = meta.mcp;
if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined;
if (typeof metadata.enabled !== "boolean") return undefined;
const result: NonNullable<McpMeta["mcp"]> = { enabled: metadata.enabled };
if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name;
if ("description" in metadata && typeof metadata.description === "string") {
result.description = metadata.description;
}
return result;
};
const getProcedureInput = (inputs: z.ZodType[]) => {
if (inputs.length === 0) return emptyInput;
if (inputs.length === 1) {
const input = inputs[0] ?? emptyInput;
return getObjectInput(input) ?? input;
}
const objectInputs = inputs.map(getObjectInput);
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
return null;
};
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
const properties = schema.properties ?? {};
const required = (schema.required ?? []).filter((key) => {
const property = properties[key];
return !property || typeof property !== "object" || !("default" in property);
});
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
const required = ((schema.required as string[] | undefined) ?? []).filter(
(key) => !("default" in (properties[key] ?? {})),
);
return {
...schema,
@@ -83,15 +41,22 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
const tools: McpTool[] = [];
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
if (!isProcedure(procedure)) continue;
const definition = procedure["_def"];
if (definition.type === "subscription") continue;
const metadata = getMcpMetadata(definition.meta);
const definition = procedure["_def"] as unknown as {
inputs?: z.ZodType[];
meta?: McpMeta;
};
const metadata = definition.meta?.mcp;
if (!metadata?.enabled) continue;
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
const procedureInput = getProcedureInput(inputs);
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
const procedureInput =
inputs.length === 0
? emptyInput
: inputs.length === 1
? (inputs[0] ?? emptyInput)
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
? mergeInputs(inputs)
: null;
if (
procedureInput === null ||
@@ -104,7 +69,6 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
tools.push({
name: metadata.name ?? path.replaceAll(".", "_"),
description: metadata.description ?? "",
type: definition.type,
pathInRouter: path.split("."),
inputValidator,
inputSchema: normalizeJsonSchema(
+71 -180
View File
@@ -9,51 +9,26 @@ 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, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
import { getWidgetIntegrationConfig } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKind } from "@homarr/definitions";
import { publicProcedure } from "../trpc";
export type IntegrationAction = "query" | "interact";
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[];
}
const throwUnexpectedIntegrationKind = (kind: IntegrationKind, allowedKinds: readonly IntegrationKind[]): never => {
logger.error("Database returned an integration outside the requested kinds", {
integrationKind: kind,
allowedIntegrationKinds: allowedKinds,
});
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Integration kind validation failed" });
};
const createOneIntegrationMiddlewareForKinds = <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: readonly TKind[],
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "one",
userId: ctx.session?.user.id,
requestedIntegrationIds: [input.integrationId],
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
@@ -70,7 +45,6 @@ const createOneIntegrationMiddlewareForKinds = <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(",")}`,
@@ -78,24 +52,22 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
}
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: [integration.kind],
});
const narrowedKind = kinds.find((kind) => kind === integration.kind);
if (!narrowedKind) {
return throwUnexpectedIntegrationKind(integration.kind, kinds);
}
const { secrets, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest } = integration;
const {
secrets,
kind,
items: _ignore1,
groupPermissions: _ignore2,
userPermissions: _ignore3,
...rest
} = integration;
return next({
ctx: {
integration: {
...rest,
externalUrl: rest.app?.href ?? null,
kind: narrowedKind,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
@@ -106,101 +78,6 @@ const createOneIntegrationMiddlewareForKinds = <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[],
) => {
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
const accessMetadata: IntegrationAccessMetadata = {
action,
cardinality: "many",
userId: ctx.session?.user.id,
requestedIntegrationIds: input.integrationIds,
allowedIntegrationKinds: kinds,
};
logger.debug("Resolving integration access", accessMetadata);
const findIntegrations = async () => {
if (input.integrationIds.length === 0) return [];
return await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
item: true,
},
},
userPermissions: true,
groupPermissions: true,
},
});
};
const dbIntegrations = await findIntegrations();
const offset = input.integrationIds.length - dbIntegrations.length;
if (offset !== 0) {
logger.warn("Integration access targets were not found", {
...accessMetadata,
missingIntegrationCount: offset,
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
throw new TRPCError({
code: "NOT_FOUND",
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
});
}
if (dbIntegrations.length >= 1) {
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
}
logger.debug("Integration access granted", {
...accessMetadata,
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
});
return next({
ctx: {
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => {
const narrowedKind = kinds.find((allowedKind) => allowedKind === kind);
if (!narrowedKind) return throwUnexpectedIntegrationKind(kind, kinds);
return {
...rest,
externalUrl: rest.app?.href ?? null,
kind: narrowedKind,
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
@@ -215,27 +92,55 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
action: IntegrationAction,
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
) => {
return createManyIntegrationMiddlewareForKinds(action, kinds);
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),
})),
}),
),
},
});
});
};
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
action: IntegrationAction,
widgetKind: TWidgetKind,
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>(
action,
getWidgetIntegrationConfig(widgetKind).supportedIntegrations,
);
/**
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
* @param action action to perform
@@ -247,39 +152,25 @@ export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends Widget
const throwIfActionIsNotAllowedAsync = async (
action: IntegrationAction,
db: Database,
integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
session: Session | null,
) => {
if (action === "interact") {
const haveAllInteractAccess = integrationRecords
const haveAllInteractAccess = integrations
.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, integrationRecords, session);
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, 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",
+1 -15
View File
@@ -5,10 +5,9 @@ import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferSelectModel } from "@homarr/db";
import { asc, eq, inArray, like } from "@homarr/db";
import { apps, integrations } from "@homarr/db/schema";
import { apps } from "@homarr/db/schema";
import { selectAppSchema } from "@homarr/db/validationSchemas";
import { getIconForName } from "@homarr/icons";
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
import { appCreateManySchema, appEditSchema, appManageSchema } from "@homarr/validation/app";
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
@@ -257,14 +256,6 @@ export const appRouter = createTRPCRouter({
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
})
.where(eq(apps.id, input.id));
if (app.href !== input.href) {
const linkedIntegrations = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: eq(integrations.appId, input.id),
});
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
}
}),
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
@@ -280,12 +271,7 @@ export const appRouter = createTRPCRouter({
})
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
const linkedIntegrations = await ctx.db.query.integrations.findMany({
columns: { id: true },
where: eq(integrations.appId, input.id),
});
await ctx.db.delete(apps).where(eq(apps.id, input.id));
await Promise.all(linkedIntegrations.map(({ id }) => invalidateIntegrationResponseCacheAsync(id)));
}),
});
+130 -168
View File
@@ -20,7 +20,9 @@ import {
groupMembers,
groupPermissions,
groups,
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
integrations,
apps,
itemLayouts,
@@ -69,7 +71,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
@@ -129,75 +131,6 @@ const findFirstAvailableBoardItemPosition = (
return null;
};
interface WidgetConfiguration {
id: string;
kind: WidgetKind;
integrationIds: readonly string[];
}
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => {
if (left.length !== right.length) return false;
const sortedRight = right.toSorted();
return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]);
};
const validateWidgetConfigurationsAsync = async (
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
submittedItems: readonly WidgetConfiguration[],
storedItems: readonly WidgetConfiguration[] = [],
) => {
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: { id: string; kind: IntegrationKind }[] = [];
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: "NOT_FOUND",
message: "Integration not found",
});
}
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) => integrationRecordsById.get(integrationId)?.kind ?? [],
);
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
if (!integrationIssue) continue;
throw new TRPCError({
code: "BAD_REQUEST",
message: getWidgetIntegrationIssueMessage(item.kind, integrationIssue),
});
}
};
const searchAccessibleBoardsAsync = async (
ctx: { db: Database; session: Session | null },
query: string,
@@ -236,7 +169,11 @@ const searchAccessibleBoardsAsync = async (
like(boards.name, `%${query}%`),
ctx.session?.user.permissions.includes("board-view-all")
? undefined
: or(eq(boards.isPublic, true), eq(boards.creatorId, ctx.session?.user.id ?? ""), inArray(boards.id, boardIds)),
: or(
eq(boards.isPublic, true),
eq(boards.creatorId, ctx.session?.user.id ?? ""),
inArray(boards.id, boardIds),
),
),
limit,
orderBy: asc(boards.name),
@@ -722,7 +659,7 @@ export const boardRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. 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 and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
},
})
.input(boardDuplicateSchema)
@@ -759,14 +696,6 @@ 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();
@@ -847,19 +776,17 @@ 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) => ({
@@ -876,13 +803,11 @@ 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()]));
@@ -897,17 +822,15 @@ 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) => ({
@@ -921,12 +844,34 @@ 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.map((integration) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
itemId: itemMap.get(item.id)!,
integrationId: integration.integrationId,
})),
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,
})),
);
await handleTransactionsAsync(ctx.db, {
@@ -1569,7 +1514,6 @@ 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,
@@ -1579,9 +1523,10 @@ export const boardRouter = createTRPCRouter({
for (const item of input.items) {
if (item.kind !== "timetable") continue;
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
let previousOptions: Record<string, unknown> | undefined;
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
await validateTimetableOptionsChangeAsync(
item.options,
previousItem?.kind === "timetable" ? previousItem.options : undefined,
);
}
await handleTransactionsAsync(ctx.db, {
@@ -1605,17 +1550,15 @@ 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) {
@@ -1637,17 +1580,15 @@ 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,
})),
),
);
}
@@ -1805,17 +1746,15 @@ 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) {
@@ -1842,17 +1781,15 @@ 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();
@@ -2155,10 +2092,6 @@ 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 }],
@@ -2169,6 +2102,35 @@ export const boardRouter = createTRPCRouter({
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),
@@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove";
export const performDockerContainerActionsAsync = async (
targets: DockerContainerTarget[],
action: DockerContainerAction,
) => {
const results = await Promise.all(
) =>
await Promise.all(
targets.map(async (target) => {
try {
const capability = action === "remove" ? "remove" : "lifecycle";
@@ -271,13 +271,6 @@ export const performDockerContainerActionsAsync = async (
}),
);
if (results.some(({ success }) => success)) {
dockerContainersRequestHandler.invalidateCache();
}
return results;
};
const assertDockerEndpointCapability = (
endpointId: string,
capability: "inventory" | "logs" | "lifecycle" | "remove",
@@ -1,59 +1,12 @@
import { TRPCError } from "@trpc/server";
import type { Session } from "@homarr/auth";
import type { IntegrationPermissionsProps } from "@homarr/auth/shared";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import type { Database, SQL } from "@homarr/db";
import { eq, inArray } from "@homarr/db";
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
import { groupMembers, integrationGroupPermissions, integrationUserPermissions } from "@homarr/db/schema";
import type { IntegrationPermission } from "@homarr/definitions";
const hasIntegrationPermission = (
integration: IntegrationPermissionsProps,
session: Session | null,
permission: IntegrationPermission,
) => {
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
if (hasFullAccess) return true;
if (permission === "interact") return hasInteractAccess;
if (permission === "use") return hasUseAccess || hasInteractAccess;
return false;
};
const throwIfActionsForbiddenAsync = async (
ctx: { db: Database; session: Session | null },
integrationWhere: SQL<unknown>,
expectedCount: number,
permission: IntegrationPermission,
) => {
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integrationRecords = await db.query.integrations.findMany({
where: integrationWhere,
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 !== expectedCount ||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
) {
notAllowed();
}
};
/**
* Throws NOT_FOUND if user is not allowed to perform action on integration
* @param ctx trpc router context
@@ -65,23 +18,47 @@ export const throwIfActionForbiddenAsync = async (
integrationWhere: SQL<unknown>,
permission: IntegrationPermission,
) => {
await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
};
const { db, session } = ctx;
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
where: eq(groupMembers.userId, session?.user.id ?? ""),
});
const integration = await db.query.integrations.findFirst({
where: integrationWhere,
columns: {
id: true,
},
with: {
userPermissions: {
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
},
groupPermissions: {
where: inArray(
integrationGroupPermissions.groupId,
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
),
},
},
});
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;
if (!integration) {
notAllowed();
}
await throwIfActionsForbiddenAsync(
ctx,
inArray(integrations.id, uniqueIntegrationIds),
uniqueIntegrationIds.length,
permission,
);
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
}
notAllowed();
};
/**
@@ -27,10 +27,8 @@ import {
integrationKinds,
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -158,21 +156,9 @@ export const integrationRouter = createTRPCRouter({
})
.input(byIdSchema)
.query(async ({ ctx, input }) => {
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;
});
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
const integration = await ctx.db.query.integrations.findFirst({
where: integrationWhere,
where: eq(integrations.id, input.id),
with: {
secrets: {
columns: {
@@ -193,11 +179,6 @@ 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",
@@ -714,17 +695,7 @@ export const integrationRouter = createTRPCRouter({
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
try {
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
} catch (error) {
logger.warn("Failed to invalidate media request caches after a successful request", {
integrationId: ctx.integration.id,
error: String(error),
});
}
return result;
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
});
@@ -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/factory";
import { createIntegrationAsync } from "@homarr/integrations";
const logger = createLogger({ module: "integrationTestConnection" });
@@ -218,7 +218,6 @@ 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,
@@ -232,7 +231,7 @@ export const onboardRouter = createTRPCRouter({
group: service.group,
boardName: service.boardName,
host: service.host,
widgetKind,
widgetKind: service.widgetKind,
description: service.description,
pingUrl: service.pingUrl,
});
@@ -250,7 +249,7 @@ export const onboardRouter = createTRPCRouter({
description: service.description,
pingUrl: service.pingUrl,
host: service.host,
widgetKind,
widgetKind: service.widgetKind,
});
}
}
+7 -16
View File
@@ -41,14 +41,6 @@ const defaultSession = {
expires: new Date().toISOString(),
} satisfies Session;
const integrationUseSession = {
...defaultSession,
user: {
...defaultSession.user,
permissions: ["integration-use-all"],
},
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
@@ -706,8 +698,7 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
},
};
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
const { boardId, integrationId } = await createFullBoardAsync(db, "source");
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
const { boardId } = await createFullBoardAsync(db, "source");
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
@@ -1204,7 +1195,7 @@ describe("saveBoard should save full board", () => {
it("should remove integration reference when not present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const anotherIntegration = {
id: createId(),
kind: "adGuardHome",
@@ -1228,8 +1219,8 @@ describe("saveBoard should save full board", () => {
items: [
{
id: itemId,
kind: "dnsHoleSummary",
options: { usePiHoleColors: true, layout: "grid" },
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [anotherIntegration.id],
layouts: [
{
@@ -1535,7 +1526,7 @@ describe("saveBoard should save full board", () => {
it("should add integration reference when present in input", async () => {
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const integration = {
id: createId(),
kind: "plex",
@@ -1559,8 +1550,8 @@ describe("saveBoard should save full board", () => {
items: [
{
id: itemId,
kind: "mediaServer",
options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
kind: "clock",
options: { is24HourFormat: true },
integrationIds: [integration.id],
layouts: [
{
@@ -1,8 +1,7 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import type * as homarrIntegrations from "@homarr/integrations";
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
import * as homarrIntegrations from "@homarr/integrations";
import { testConnectionAsync } from "../../integration/integration-test-connection";
@@ -19,7 +18,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(homarrIntegrationFactory, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -62,7 +61,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(homarrIntegrationFactory, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -112,7 +111,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(homarrIntegrationFactory, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -162,7 +161,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(homarrIntegrationFactory, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
@@ -216,7 +215,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(homarrIntegrationFactory, "createIntegrationAsync");
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
+1 -1
View File
@@ -280,7 +280,7 @@ describe("onboard.completeSetup", () => {
expect(mediaMissing).toBeDefined();
expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
).toMatchObject({ width: 2, height: 2 });
).toMatchObject({ width: 4, height: 3 });
});
it("requires an exact board id when more than one board exists", async () => {
@@ -3,12 +3,15 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { mockWidgetData } from "@homarr/integrations";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import {
anchorNotesListInputSchema,
anchorNoteUpdateInputSchema,
createIntegrationAsync,
mockWidgetData,
} from "@homarr/integrations";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
@@ -48,7 +51,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
@@ -70,7 +73,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data;
}),
getNote: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
@@ -88,7 +91,7 @@ export const anchorNotesRouter = createTRPCRouter({
return data;
}),
updateNote: protectedProcedure
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const normalizedContent = normalizeAnchorContent(input.content);
@@ -112,8 +115,6 @@ export const anchorNotesRouter = createTRPCRouter({
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
anchorNotesListRequestHandler.invalidateCache();
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
return updatedNote;
} catch (error) {
@@ -1,12 +1,12 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
@@ -2,12 +2,12 @@ import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobooks
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
+2 -2
View File
@@ -1,7 +1,7 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const bazarrRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
.concat(createOneIntegrationMiddleware("query", "bazarr", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
+6 -6
View File
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import type { LiveStatsEvent } from "@homarr/integrations/types";
import {
beszelAlertsRequestHandler,
@@ -11,7 +11,7 @@ import {
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -27,7 +27,7 @@ export const beszelRouter = createTRPCRouter({
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
},
})
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
@@ -71,7 +71,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(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
includeHistory: z.boolean().default(true),
@@ -145,7 +145,7 @@ export const beszelRouter = createTRPCRouter({
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
},
})
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
@@ -196,7 +196,7 @@ export const beszelRouter = createTRPCRouter({
}),
subscribeSystemStats: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.input(
z.object({
systemId: z.string(),
+4 -3
View File
@@ -1,9 +1,10 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -24,7 +25,7 @@ export const calendarRouter = createTRPCRouter({
showUnmonitored: z.boolean(),
}),
)
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -43,7 +44,7 @@ export const calendarRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
},
error: undefined,
error: undefined as string | undefined,
};
},
{
+2 -2
View File
@@ -2,7 +2,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -17,7 +17,7 @@ interface CoolifyInstanceResult {
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
.concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+6 -10
View File
@@ -1,14 +1,12 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -34,7 +32,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(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
ctx.integrations,
@@ -68,11 +66,10 @@ export const dnsHoleRouter = createTRPCRouter({
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
},
})
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
disable: protectedProcedure
@@ -88,10 +85,9 @@ export const dnsHoleRouter = createTRPCRouter({
duration: z.number().optional(),
}),
)
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
}),
});
+23 -27
View File
@@ -1,15 +1,19 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } 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;
@@ -38,14 +42,6 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
}
);
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
const results = await Promise.allSettled(mutations);
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
const failure = results.find((result) => result.status === "rejected");
if (failure) throw failure.reason;
};
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
.meta({
@@ -55,7 +51,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(createManyWidgetIntegrationMiddleware("query", "downloads"))
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
@@ -90,27 +86,27 @@ export const downloadsRouter = createTRPCRouter({
"Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.concat(createDownloadClientIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await runDownloadMutationsAsync(
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await runDownloadMutationsAsync(
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.pauseItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
.meta({
@@ -120,38 +116,38 @@ export const downloadsRouter = createTRPCRouter({
"Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
},
})
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.concat(createDownloadClientIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
await runDownloadMutationsAsync(
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeQueueAsync();
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
.mutation(async ({ ctx, input }) => {
await runDownloadMutationsAsync(
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.resumeItemAsync(input.item);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
.mutation(async ({ ctx, input }) => {
await runDownloadMutationsAsync(
await Promise.all(
ctx.integrations.map(async (integration) => {
const integrationInstance = await createIntegrationAsync(integration);
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
ctx.integrations.map(({ id }) => id),
);
downloadClientRequestHandler.invalidateCache();
}),
});
+24 -23
View File
@@ -1,3 +1,5 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
@@ -6,37 +8,36 @@ import {
firewallVersionRequestHandler,
} from "@homarr/request-handler/firewall";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
const firewallMiddleware = createManyIntegrationMiddleware(
"query",
...getIntegrationKindsByCategory("firewall"),
"mock",
);
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
interface FirewallHandler<TData> {
handler: (
integration: FirewallIntegration,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
interface FirewallResult<TSummary> {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: TSummary;
error?: string;
}
interface FirewallQueryResult<TData> {
integration: {
id: string;
name: string;
kind: FirewallIntegration["kind"] | "mock";
updatedAt: Date;
};
summary: TData;
error: string | undefined;
}
const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: () => TData, getMockData: () => TData) =>
const queryFirewall = <TSummary>(
handler: {
handler: (
integration: any,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
},
createFallback: () => TSummary,
getMockData: () => TSummary,
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<FirewallQueryResult<TData>> => {
async (integration): Promise<FirewallResult<TSummary>> => {
if (integration.kind === "mock") {
return {
integration: {
@@ -57,7 +58,7 @@ const queryFirewall = <TData>(handler: FirewallHandler<TData>, createFallback: (
};
},
{
fallback: (integration, error): FirewallQueryResult<TData> => ({
fallback: (integration, error): FirewallResult<TSummary> => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
@@ -1,12 +1,22 @@
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync } from "@homarr/integrations";
import type { SystemHealthMonitoring } from "@homarr/integrations";
import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
"mock",
] as const;
interface SystemHealthQueryResult {
integrationId: string;
integrationName: string;
@@ -24,7 +34,7 @@ export const healthMonitoringRouter = createTRPCRouter({
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds))
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
ctx.integrations,
@@ -59,6 +69,10 @@ export const healthMonitoringRouter = createTRPCRouter({
.concat(createOneIntegrationMiddleware("query", "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
@@ -69,7 +83,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", ...healthMonitoringClusterIntegrationKinds))
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
+14 -11
View File
@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
immichAlbumRequestHandler,
@@ -7,21 +8,23 @@ import {
immichStatsRequestHandler,
} from "@homarr/request-handler/immich";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-serverStats"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getAlbum: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string().optional(),
@@ -45,7 +48,7 @@ export const immichRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500).optional(),
}),
)
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
@@ -1,15 +1,20 @@
import { TRPCError } from "@trpc/server";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } 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(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -21,7 +26,7 @@ export const indexerManagerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
indexers,
error: undefined,
error: undefined as string | undefined,
};
},
{
@@ -36,9 +41,9 @@ export const indexerManagerRouter = createTRPCRouter({
);
}),
testAllIndexers: protectedProcedure
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
.concat(createIndexerManagerIntegrationMiddleware("interact"))
.mutation(async ({ ctx }) => {
const results = await Promise.allSettled(
await Promise.all(
ctx.integrations.map(async (integration) => {
const client = await createIntegrationAsync(integration);
await client.testAllAsync().catch((err) => {
@@ -50,9 +55,5 @@ export const indexerManagerRouter = createTRPCRouter({
});
}),
);
await indexerManagerRequestHandler.invalidateCacheAsync(ctx.integrations.map(({ id }) => id));
const firstFailure = results.find((result) => result.status === "rejected");
if (firstFailure?.status === "rejected") throw firstFailure.reason;
}),
});
@@ -1,8 +1,9 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -15,7 +16,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(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -27,7 +28,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
...data,
error: undefined,
error: undefined as string | undefined,
};
},
{
@@ -1,12 +1,13 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { getMediaReleaseResponse } from "./media-release-result";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
@@ -1,7 +1,8 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import {
@@ -10,10 +11,7 @@ import {
} from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
@@ -32,7 +30,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(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
@@ -42,7 +40,7 @@ export const mediaRequestsRouter = createTRPCRouter({
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined,
error: undefined as string | undefined,
};
},
{
@@ -87,7 +85,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(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results: IntegrationQueryResult<{
stats: MediaRequestStats["stats"] | null;
@@ -98,8 +96,8 @@ export const mediaRequestsRouter = createTRPCRouter({
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data,
error: undefined,
data: { ...data, stats: data.stats as typeof data.stats | null },
error: undefined as string | undefined,
};
},
{
@@ -136,7 +134,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(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
.input(
z.object({
requestId: z.number(),
@@ -152,8 +150,6 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
mediaRequestListRequestHandler.invalidateCache();
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
return { success: true };
}),
});
@@ -1,11 +1,16 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } 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({
@@ -15,7 +20,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(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
@@ -29,7 +34,7 @@ export const mediaServerRouter = createTRPCRouter({
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
error: undefined,
error: undefined as string | undefined,
};
},
{
@@ -1,12 +1,17 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
import { paginatedSchema } from "@homarr/validation/common";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
export const mediaTranscodingRouter = createTRPCRouter({
getDataAsync: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
.concat(createIndexerManagerIntegrationMiddleware("query"))
.input(paginatedSchema.pick({ page: true, pageSize: true }))
.query(async ({ ctx, input }) => {
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
@@ -1,8 +1,9 @@
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 { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -21,7 +22,7 @@ interface NetworkControllerQueryResult {
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
ctx.integrations,
@@ -1,12 +1,17 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } 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(createManyWidgetIntegrationMiddleware("query", "notifications"))
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -22,7 +27,7 @@ export const notificationsRouter = createTRPCRouter({
updatedAt: timestamp,
},
data,
error: undefined,
error: undefined as string | undefined,
};
},
{
@@ -1,12 +1,12 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
+2 -2
View File
@@ -1,7 +1,7 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
export const patchmonRouter = createTRPCRouter({
@@ -13,7 +13,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(createOneWidgetIntegrationMiddleware("query", "patchmon"))
.concat(createOneIntegrationMiddleware("query", "patchmon", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
+11 -8
View File
@@ -1,15 +1,20 @@
import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import {
smartHomeEntityStateRequestHandler,
toSafeEntityDetails,
} from "@homarr/request-handler/smart-home-entity-state";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
.meta({
@@ -20,7 +25,7 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
@@ -35,7 +40,7 @@ export const smartHomeRouter = createTRPCRouter({
},
})
.input(z.object({ entityId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
@@ -49,7 +54,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(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
@@ -57,7 +62,6 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
executeAutomation: protectedProcedure
@@ -68,7 +72,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(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
@@ -76,7 +80,6 @@ export const smartHomeRouter = createTRPCRouter({
if (!success) {
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
}
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
return true;
}),
});
@@ -1,13 +1,13 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker"))
.concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -29,9 +29,9 @@ export const speedtestTrackerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined,
error: undefined as string | undefined,
};
},
{
+2 -2
View File
@@ -1,13 +1,13 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
.concat(createManyIntegrationMiddleware("query", "tracearr", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
+4 -4
View File
@@ -1,7 +1,7 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -14,7 +14,7 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyWidgetIntegrationMiddleware("query", "traefik"))
.concat(createManyIntegrationMiddleware("query", "traefik", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -36,9 +36,9 @@ export const traefikRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined,
error: undefined as string | undefined,
};
},
{
+20 -22
View File
@@ -2,8 +2,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { mockWidgetData } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
@@ -13,26 +12,25 @@ import {
umamiTopReferrersRequestHandler,
} from "@homarr/request-handler/umami";
import {
createManyWidgetIntegrationMiddleware,
createOneWidgetIntegrationMiddleware,
} from "../../middlewares/integration";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } 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(createOneWidgetIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getWebsites: publicProcedure
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getVisitorStats: publicProcedure
.input(
@@ -42,7 +40,7 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(),
}),
)
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
.concat(createManyIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
if (integration.kind === "mock") {
@@ -76,7 +74,7 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try {
@@ -104,7 +102,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try {
@@ -138,7 +136,7 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try {
@@ -172,7 +170,7 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()),
}),
)
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
@@ -203,7 +201,7 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return 7;
try {
+32 -29
View File
@@ -1,37 +1,40 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
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();
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();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
error: 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,13 +1,13 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { mockWidgetData } from "@homarr/integrations";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma"))
.concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -29,9 +29,9 @@ export const uptimeKumaRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined,
error: undefined as string | undefined,
};
},
{
+5 -4
View File
@@ -1,9 +1,10 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -15,7 +16,7 @@ interface VpnResult {
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
@@ -42,8 +43,8 @@ export const vpnRouter = createTRPCRouter({
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
error: undefined,
summary: data as typeof data | null,
error: undefined as string | undefined,
};
},
{
+2 -2
View File
@@ -1,7 +1,7 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { mockWidgetData } from "@homarr/integrations";
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
@@ -13,7 +13,7 @@ export const wudRouter = createTRPCRouter({
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
.concat(createOneIntegrationMiddleware("query", "wud", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
+6 -6
View File
@@ -15,18 +15,18 @@ interface IntegrationLike {
kind: string;
}
interface Options<TIntegration extends IntegrationLike, TFallback> {
fallback?: (integration: TIntegration, error: unknown) => TFallback;
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult, TFallback = TResult>(
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TFallback>,
): Promise<(TResult | TFallback)[]> {
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const results: (TResult | TFallback)[] = [];
const results: TResult[] = [];
const errors: unknown[] = [];
settled.forEach((result, index) => {
-12
View File
@@ -7,7 +7,6 @@ const algorithm = "aes-256-cbc"; //Using AES encryption
// We fallback to a key of 0s if the key was not provided because env validation was skipped
// This should only be the case in CI
const key = Buffer.from(env.SECRET_ENCRYPTION_KEY || "0".repeat(64), "hex");
const fingerprintKey = crypto.createHmac("sha256", key).update("homarr-keyed-fingerprint-v1").digest();
export function encryptSecret(text: string): `${string}.${string}` {
const initializationVector = crypto.randomBytes(16);
@@ -30,14 +29,3 @@ export function decryptSecretWithKey(value: `${string}.${string}`, key: Buffer)
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString();
}
export function createKeyedFingerprint(value: string) {
return crypto.createHmac("sha256", fingerprintKey).update(value).digest("hex");
}
export function verifyKeyedFingerprint(value: string, fingerprint: string) {
const expected = Buffer.from(createKeyedFingerprint(value), "hex");
const actual = Buffer.from(fingerprint, "hex");
if (expected.length !== actual.length) return false;
return crypto.timingSafeEqual(expected, actual);
}
@@ -9,7 +9,7 @@ const defaultRedisOptions = {
export type { Redis as RedisClient } from "ioredis";
export const createRedisClient = (options: RedisOptions = {}) =>
export const createRedisClient = () =>
redisEnv.IS_EXTERNAL
? new Redis({
...defaultRedisOptions,
@@ -23,6 +23,5 @@ export const createRedisClient = (options: RedisOptions = {}) =>
: undefined,
username: redisEnv.USERNAME,
password: redisEnv.PASSWORD,
...options,
})
: new Redis({ ...defaultRedisOptions, ...options });
: new Redis(defaultRedisOptions);
-1
View File
@@ -14,7 +14,6 @@
"exports": {
".": "./index.ts",
"./cookie": "./src/cookie.ts",
"./integration": "./src/integration.ts",
"./docs/*": "./src/docs/*.ts"
},
"scripts": {
@@ -1,6 +1,6 @@
import { objectKeys } from "@homarr/common";
import { getIntegrationDockerMetadata, integrationDefs } from "./integration";
import { integrationDefs } from "./integration";
import type { IntegrationKind } from "./integration";
export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
@@ -14,6 +14,30 @@ 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;
@@ -27,8 +51,8 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
if (integrationIconSlugs[kind] === normalized) return kind;
}
for (const kind of objectKeys(integrationDefs)) {
if (getIntegrationDockerMetadata(kind).aliases.some((alias) => alias === 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)) {
@@ -39,6 +63,8 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
return null;
};
const notDockerDiscoverable = new Set<IntegrationKind>(["ical", "mock"]);
interface ContainerMatchInput {
image: string;
name: string;
@@ -47,10 +73,10 @@ interface ContainerMatchInput {
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
const imageName = extractContainerImageName(container.image);
const fromImage = matchIntegrationKind(imageName);
if (fromImage && getIntegrationDockerMetadata(fromImage).discoverable) return fromImage;
if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage;
const fromName = matchIntegrationKind(container.name);
if (fromName && getIntegrationDockerMetadata(fromName).discoverable) return fromName;
if (fromName && !notDockerDiscoverable.has(fromName)) return fromName;
return null;
};
+2 -3
View File
@@ -5,8 +5,8 @@ import { XMLParser } from "fast-xml-parser";
import { z } from "zod/v4";
import { createDocumentationLink } from "./index";
import { integrationDocSlugs } from "./integration-doc-slugs";
import { widgetDocSlugs } from "./widget-doc-slugs";
import { getIntegrationDocumentationSlug, integrationKinds } from "../integration";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -60,8 +60,7 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
};
const slugMapPaths = [
...integrationKinds
.map(getIntegrationDocumentationSlug)
...Object.values(integrationDocSlugs)
.filter(Boolean)
.map((slug) => `/docs/integrations/${slug}`),
...Object.values(widgetDocSlugs)
@@ -0,0 +1,59 @@
import type { IntegrationKind } from "../integration";
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,
};
@@ -1,6 +1,6 @@
import type { WidgetKind } from "../widget";
export const widgetDocSlugs = {
export const widgetDocSlugs: Record<WidgetKind, string | null> = {
clock: "clock",
weather: "weather",
airQuality: "air-quality",
@@ -60,4 +60,4 @@ export const widgetDocSlugs = {
customApi: "custom-api",
assistant: "assistant",
wud: "whats-up-docker",
} satisfies Record<WidgetKind, string | null>;
};
+76 -167
View File
@@ -1,12 +1,7 @@
import { objectKeys } from "@homarr/common";
import type { AtLeastOneOf } from "@homarr/common/types";
import { createDocumentationLink } from "./docs";
import type { HomarrDocumentationPath } from "./docs/homarr-docs-sitemap";
type IntegrationDocumentationPath = Extract<HomarrDocumentationPath, `/docs/integrations/${string}`>;
type IntegrationDocumentationSlug = IntegrationDocumentationPath extends `/docs/integrations/${infer TSlug}`
? TSlug
: never;
export const integrationSecretKindObject = {
apiKey: { isPublic: false, multiline: false },
@@ -27,29 +22,14 @@ export const integrationSecretKindObject = {
slug: { isPublic: true, multiline: false },
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
const isIntegrationSecretKind = (value: string): value is IntegrationSecretKind =>
Object.hasOwn(integrationSecretKindObject, value);
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
export const integrationSecretKinds = Object.keys(integrationSecretKindObject).filter(isIntegrationSecretKind);
interface IntegrationFeatureMetadata {
docker?: {
aliases?: readonly string[];
discoverable?: boolean;
};
onboarding?: {
featuredOrder?: number;
hidden?: boolean;
};
}
interface IntegrationDefinition {
interface integrationDefinition {
name: string;
iconUrl: string;
secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
category: readonly [IntegrationCategory, ...IntegrationCategory[]];
documentationSlug: IntegrationDocumentationSlug | null;
features?: IntegrationFeatureMetadata;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
category: AtLeastOneOf<IntegrationCategory>;
documentationUrl: string | null;
defaultUrl?: string;
defaultPort?: number;
apiKeySettingsPath?: string;
@@ -61,29 +41,24 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"],
documentationSlug: "sabnzbd",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "nzbget",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "deluge",
documentationUrl: createDocumentationLink("/docs/integrations/deluge"),
defaultPort: 8112,
},
transmission: {
@@ -91,7 +66,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"],
documentationSlug: "transmission",
documentationUrl: createDocumentationLink("/docs/integrations/transmission"),
defaultPort: 9091,
},
qBittorrent: {
@@ -99,20 +74,16 @@ export const integrationDefs = {
secretKinds: [["apiKey"], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
documentationSlug: "q-bittorent",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "aria2",
documentationUrl: createDocumentationLink("/docs/integrations/aria2"),
defaultPort: 6800,
},
slskd: {
@@ -120,7 +91,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
category: ["downloadClient", "miscellaneous"],
documentationSlug: "slskd",
documentationUrl: createDocumentationLink("/docs/integrations/slskd"),
defaultPort: 5030,
},
sonarr: {
@@ -128,27 +99,25 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar", "mediaOrganizer"],
documentationSlug: "sonarr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "radarr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "lidarr",
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"),
defaultPort: 8686,
apiKeySettingsPath: "/settings/general",
},
@@ -157,7 +126,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
category: ["calendar"],
documentationSlug: "readarr",
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
defaultPort: 8787,
apiKeySettingsPath: "/settings/general",
},
@@ -166,37 +135,34 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"],
documentationSlug: "prowlarr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "bazarr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "jellyfin",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "emby",
documentationUrl: createDocumentationLink("/docs/integrations/emby"),
defaultPort: 8096,
},
plex: {
@@ -204,7 +170,7 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService", "mediaRelease"],
documentationSlug: "plex",
documentationUrl: createDocumentationLink("/docs/integrations/plex"),
defaultPort: 32400,
},
jellyseerr: {
@@ -212,55 +178,50 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationSlug: "jellyseerr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "seerr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "overseerr",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "pi-hole",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "adguard-home",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "technitium-dns",
documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"),
defaultPort: 5380,
},
homeAssistant: {
@@ -268,35 +229,32 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer", "calendar"],
documentationSlug: "home-assistant",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "open-media-vault",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "dash-dot",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "glances",
documentationUrl: createDocumentationLink("/docs/integrations/glances"),
defaultPort: 61208,
},
tdarr: {
@@ -304,7 +262,7 @@ export const integrationDefs = {
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"],
documentationSlug: "tdarr",
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
defaultPort: 8265,
},
proxmox: {
@@ -312,7 +270,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"],
documentationSlug: "proxmox",
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"),
defaultPort: 8006,
},
nextcloud: {
@@ -320,7 +278,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
category: ["calendar", "notifications"],
documentationSlug: "nextcloud",
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"),
defaultPort: 443,
},
unifiController: {
@@ -328,16 +286,15 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
documentationSlug: "unifi-controller",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "opnsense",
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
defaultPort: 443,
apiKeySettingsPath: "/system_usermanager.php",
},
@@ -346,7 +303,7 @@ export const integrationDefs = {
secretKinds: [["topic"], ["topic", "apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
documentationSlug: "ntfy",
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
defaultPort: 80,
apiKeySettingsPath: "/account",
},
@@ -355,7 +312,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
category: ["notifications"],
documentationSlug: "gotify",
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
defaultPort: 80,
},
ical: {
@@ -363,15 +320,14 @@ export const integrationDefs = {
secretKinds: [["url"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg",
category: ["calendar"],
documentationSlug: "ical",
features: { docker: { discoverable: false } },
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
},
anchor: {
name: "Anchor",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"],
documentationSlug: "anchor",
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
defaultPort: 8080,
apiKeySettingsPath: "/settings",
},
@@ -380,25 +336,23 @@ export const integrationDefs = {
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationSlug: "truenas",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "synology",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "unraid",
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
defaultPort: 80,
apiKeySettingsPath: "/Settings/ManagementAccess",
},
@@ -407,17 +361,16 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"],
documentationSlug: "coolify",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "immich",
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
defaultPort: 2283,
apiKeySettingsPath: "/user-settings",
},
@@ -426,26 +379,24 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
category: ["documents"],
documentationSlug: "paperless-ngx",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "patchmon",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "tracearr",
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
defaultPort: 7040,
apiKeySettingsPath: "/settings",
},
@@ -454,39 +405,35 @@ export const integrationDefs = {
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"],
documentationSlug: "speedtest-tracker",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "uptime-kuma",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "audiobookshelf",
documentationUrl: createDocumentationLink("/docs/integrations/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"],
documentationSlug: "navidrome",
documentationUrl: createDocumentationLink("/docs/integrations/navidrome"),
defaultPort: 4533,
features: { docker: { aliases: ["navidrome"] } },
},
umami: {
name: "Umami",
@@ -494,7 +441,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",
documentationSlug: "umami",
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
defaultPort: 3000,
},
peaNut: {
@@ -502,7 +449,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
category: ["ups"],
documentationSlug: "peanut",
documentationUrl: createDocumentationLink("/docs/integrations/peanut"),
defaultPort: 8080,
},
beszel: {
@@ -510,7 +457,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
category: ["beszel"],
documentationSlug: "beszel",
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
gluetun: {
@@ -518,7 +465,7 @@ export const integrationDefs = {
secretKinds: [["username", "password"], ["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"],
documentationSlug: "gluetun",
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
defaultPort: 8000,
},
traefik: {
@@ -526,7 +473,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
category: ["reverseProxy"],
documentationSlug: "traefik",
documentationUrl: createDocumentationLink("/docs/integrations/traefik"),
defaultPort: 8080,
},
archiveTeamWarrior: {
@@ -534,7 +481,7 @@ export const integrationDefs = {
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
documentationSlug: "archiveteam-warrior",
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
defaultPort: 8001,
},
wud: {
@@ -542,7 +489,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"],
documentationSlug: "whats-up-docker",
documentationUrl: createDocumentationLink("/docs/integrations/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)
@@ -567,36 +514,21 @@ export const integrationDefs = {
"uptimeMonitoring",
"mediaOrganizer",
],
documentationSlug: null,
features: {
docker: { discoverable: false },
onboarding: { hidden: true },
},
documentationUrl: null,
},
} as const satisfies Record<string, IntegrationDefinition>;
} as const satisfies Record<string, integrationDefinition>;
const isIntegrationKind = (value: string): value is IntegrationKind => Object.hasOwn(integrationDefs, value);
const integrationKindValues = Object.keys(integrationDefs).filter(isIntegrationKind);
const firstIntegrationKind = integrationKindValues[0];
if (!firstIntegrationKind) throw new Error("At least one integration definition is required");
export const integrationKinds: AtLeastOneOf<IntegrationKind> = [
firstIntegrationKind,
...integrationKindValues.slice(1),
];
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
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[]> => {
const [first, ...rest] = integrationDefs[integration].secretKinds;
return [[...first], ...rest.map((secretKinds) => [...secretKinds])];
};
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
integrationDefs[integration].secretKinds;
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
const definition = integrationDefs[integration];
@@ -608,30 +540,8 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
return "defaultPort" in definition ? definition.defaultPort : undefined;
};
export const getIntegrationDocumentationSlug = (kind: IntegrationKind): IntegrationDocumentationSlug | null =>
integrationDefs[kind].documentationSlug;
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => {
const slug = getIntegrationDocumentationSlug(kind);
if (slug === null) return null;
return createDocumentationLink(`/docs/integrations/${slug}`);
};
export const getIntegrationDockerMetadata = (kind: IntegrationKind) => {
const definition: IntegrationDefinition = integrationDefs[kind];
return {
aliases: definition.features?.docker?.aliases ?? [],
discoverable: definition.features?.docker?.discoverable ?? true,
};
};
export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => {
const definition: IntegrationDefinition = integrationDefs[kind];
return {
featuredOrder: definition.features?.onboarding?.featuredOrder ?? null,
hidden: definition.features?.onboarding?.hidden ?? false,
};
};
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null =>
integrationDefs[kind].documentationUrl;
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
const definition = integrationDefs[kind];
@@ -657,12 +567,9 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio
* @returns Partial list of integration kinds
*/
export const getIntegrationKindsByCategory = <TCategory extends IntegrationCategory>(category: TCategory) => {
const matchesCategory = (integration: IntegrationKind): integration is IntegrationKindByCategory<TCategory> =>
integrationDefs[integration].category.some((definitionCategory) => definitionCategory === category);
const matches = integrationKinds.filter(matchesCategory);
const firstMatch = matches[0];
if (!firstMatch) throw new Error(`No integration definition uses the ${category} category`);
return [firstMatch, ...matches.slice(1)] satisfies AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
return objectKeys(integrationDefs).filter((integration) =>
integrationDefs[integration].category.some((defCategory) => defCategory === category),
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
};
/**
@@ -672,8 +579,10 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
[Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number]
? Key
: never;
}[keyof typeof integrationDefs] &
IntegrationKind;
}[keyof typeof integrationDefs] extends infer U
? //Needed to simplify the type when using it
U
: never;
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
export type IntegrationKind = keyof typeof integrationDefs;
@@ -1,14 +1,12 @@
import { describe, expect, it } from "vitest";
import { objectEntries } from "@homarr/common";
import { getIntegrationApiKeyUrl, getIntegrationDocumentationUrl, integrationDefs } from "../integration";
import { getIntegrationApiKeyUrl, integrationDefs } from "../integration";
describe("getIntegrationApiKeyUrl", () => {
it("links every user-facing integration to its Homarr setup guide", () => {
expect(
objectEntries(integrationDefs)
.filter(([kind]) => kind !== "mock" && !getIntegrationDocumentationUrl(kind))
Object.entries(integrationDefs)
.filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl)
.map(([kind]) => kind),
).toEqual([]);
});
@@ -4,19 +4,19 @@ import { integrationKinds } from "../integration";
import {
getWidgetKindsForIntegration,
integrationWidgetSupport,
widgetIntegrationConfigs,
nativeFeatureCapabilities,
widgetIntegrationLimits,
widgetIntegrationSupport,
widgetKindsWithOptionalIntegrations,
} from "../widget-integration-map";
describe("widget integration config", () => {
describe("native feature capability descriptor", () => {
test("derives forward and reverse support without losing relationships", () => {
for (const [widgetKind, capability] of Object.entries(widgetIntegrationConfigs)) {
for (const [widgetKind, capability] of Object.entries(nativeFeatureCapabilities)) {
expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual(
capability.supportedIntegrations,
capability.integrations,
);
for (const integrationKind of capability.supportedIntegrations) {
for (const integrationKind of capability.integrations) {
expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind);
}
}
@@ -27,8 +27,8 @@ describe("widget integration config", () => {
});
test("supports the mock integration for every integration-backed widget", () => {
for (const config of Object.values(widgetIntegrationConfigs)) {
expect(config.supportedIntegrations).toContain("mock");
for (const capability of Object.values(nativeFeatureCapabilities)) {
expect(capability.integrations).toContain("mock");
}
});
@@ -1,108 +1,82 @@
import { objectEntries } from "@homarr/common";
import { createDocumentationLink } from "./docs";
import type { IntegrationKind } from "./integration";
import { getIntegrationKindsByCategory, integrationKinds } from "./integration";
import type { WidgetKind } from "./widget";
import { widgetDefaultSizes } from "./widget";
/** Integration selection shared by the widget UI and API trust boundary. */
export interface WidgetIntegrationConfig {
supportedIntegrations: IntegrationKind[];
integrationsRequired?: false;
maxIntegrations?: number;
/**
* 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;
}
const healthMonitoringGeneralIntegrationKinds = [
"openmediavault",
"dashDot",
"truenas",
"unraid",
"glances",
"synology",
] as const satisfies readonly IntegrationKind[];
export const healthMonitoringSystemIntegrationKinds = [
...healthMonitoringGeneralIntegrationKinds,
"mock",
] as const satisfies readonly IntegrationKind[];
export const healthMonitoringClusterIntegrationKinds = [
"proxmox",
"mock",
] as const satisfies readonly IntegrationKind[];
export const widgetIntegrationConfigs = {
dnsHoleSummary: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") },
dnsHoleControls: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") },
export const nativeFeatureCapabilities = {
dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") },
dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") },
"smartHome-entityState": {
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
maxIntegrations: 1,
integrations: getIntegrationKindsByCategory("smartHomeServer"),
serverMaxIntegrations: 1,
},
"smartHome-executeAutomation": {
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
maxIntegrations: 1,
integrations: getIntegrationKindsByCategory("smartHomeServer"),
serverMaxIntegrations: 1,
},
mediaServer: { supportedIntegrations: getIntegrationKindsByCategory("mediaService") },
calendar: { supportedIntegrations: getIntegrationKindsByCategory("calendar"), integrationsRequired: false },
downloads: { supportedIntegrations: getIntegrationKindsByCategory("downloadClient") },
"mediaRequests-requestList": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") },
"mediaRequests-requestStats": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") },
mediaTranscoding: {
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
maxIntegrations: 1,
},
mediaMissing: { supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer") },
networkControllerSummary: { supportedIntegrations: getIntegrationKindsByCategory("networkController") },
networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") },
indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") },
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: {
supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds, ...healthMonitoringClusterIntegrationKinds],
integrations: getIntegrationKindsByCategory("healthMonitoring").filter(
(kind) => kind !== "patchmon" && kind !== "wud",
),
},
firewall: { supportedIntegrations: [...getIntegrationKindsByCategory("firewall"), "mock"] },
notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") },
mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] },
systemResources: {
supportedIntegrations: [...healthMonitoringSystemIntegrationKinds],
},
systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] },
beszelAlerts: { supportedIntegrations: ["beszel", "mock"] },
beszelSystemGrid: { supportedIntegrations: ["beszel", "mock"] },
beszelSystemStats: { supportedIntegrations: ["beszel", "mock"] },
beszelSystemTable: { supportedIntegrations: ["beszel", "mock"] },
coolify: { supportedIntegrations: ["coolify", "mock"] },
"immich-serverStats": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 },
"immich-albumCarousel": { supportedIntegrations: ["immich", "mock"], maxIntegrations: 1 },
paperlessNgx: { supportedIntegrations: ["paperlessNgx", "mock"], maxIntegrations: 1 },
patchmon: { supportedIntegrations: ["patchmon", "mock"], maxIntegrations: 1 },
bazarr: { supportedIntegrations: ["bazarr", "mock"], maxIntegrations: 1 },
tracearr: { supportedIntegrations: ["tracearr", "mock"] },
speedtestTracker: { supportedIntegrations: ["speedtestTracker", "mock"] },
uptimeKuma: { supportedIntegrations: ["uptimeKuma", "mock"] },
audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf", "mock"], maxIntegrations: 1 },
umami: { supportedIntegrations: ["umami", "mock"], maxIntegrations: 1 },
vpn: { supportedIntegrations: [...getIntegrationKindsByCategory("vpn"), "mock"] },
ups: { supportedIntegrations: getIntegrationKindsByCategory("ups") },
archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior", "mock"], maxIntegrations: 1 },
anchorNote: { supportedIntegrations: ["anchor", "mock"], maxIntegrations: 1 },
traefik: { supportedIntegrations: ["traefik", "mock"] },
wud: { supportedIntegrations: ["wud", "mock"], maxIntegrations: 1 },
} satisfies Partial<Record<WidgetKind, WidgetIntegrationConfig>>;
firewall: { integrations: [...getIntegrationKindsByCategory("firewall"), "mock"] },
notifications: { integrations: getIntegrationKindsByCategory("notifications") },
mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] },
systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"] },
systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] },
beszelAlerts: { integrations: ["beszel", "mock"] },
beszelSystemGrid: { integrations: ["beszel", "mock"] },
beszelSystemStats: { integrations: ["beszel", "mock"] },
beszelSystemTable: { integrations: ["beszel", "mock"] },
coolify: { integrations: ["coolify", "mock"] },
"immich-serverStats": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
"immich-albumCarousel": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
paperlessNgx: { integrations: ["paperlessNgx", "mock"], serverMaxIntegrations: 1 },
patchmon: { integrations: ["patchmon", "mock"], serverMaxIntegrations: 1 },
bazarr: { integrations: ["bazarr", "mock"], serverMaxIntegrations: 1 },
tracearr: { integrations: ["tracearr", "mock"] },
speedtestTracker: { integrations: ["speedtestTracker", "mock"] },
uptimeKuma: { integrations: ["uptimeKuma", "mock"] },
audioStats: { integrations: ["navidrome", "audiobookshelf", "mock"], serverMaxIntegrations: 1 },
umami: { integrations: ["umami", "mock"], serverMaxIntegrations: 1 },
vpn: { integrations: [...getIntegrationKindsByCategory("vpn"), "mock"] },
ups: { integrations: getIntegrationKindsByCategory("ups") },
archiveTeamWarrior: { integrations: ["archiveTeamWarrior", "mock"], serverMaxIntegrations: 1 },
anchorNote: { integrations: ["anchor", "mock"], serverMaxIntegrations: 1 },
traefik: { integrations: ["traefik", "mock"] },
wud: { integrations: ["wud", "mock"], serverMaxIntegrations: 1 },
} as const satisfies Partial<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
export type WidgetIntegrationKind<TKind extends WidgetKindWithIntegration> =
(typeof widgetIntegrationConfigs)[TKind]["supportedIntegrations"][number];
export const getWidgetIntegrationConfig = <TKind extends WidgetKindWithIntegration>(
kind: TKind,
): (typeof widgetIntegrationConfigs)[TKind] => widgetIntegrationConfigs[kind];
const widgetIntegrationConfigEntries = objectEntries(widgetIntegrationConfigs);
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
WidgetKind,
NativeFeatureCapabilityDescriptor,
][];
const createWidgetIntegrationSupport = () => {
const result: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {};
for (const [kind, config] of widgetIntegrationConfigEntries) result[kind] = config.supportedIntegrations;
for (const [kind, capability] of nativeFeatureCapabilityEntries) result[kind] = capability.integrations;
return result;
};
@@ -110,15 +84,16 @@ export const widgetIntegrationSupport = createWidgetIntegrationSupport();
/** Widgets that remain useful without a configured integration. */
export const widgetKindsWithOptionalIntegrations = new Set<WidgetKind>(
widgetIntegrationConfigEntries
.filter(([, config]) => "integrationsRequired" in config && config.integrationsRequired === false)
nativeFeatureCapabilityEntries
.filter(([, capability]) => capability.connectionOptional === true)
.map(([kind]) => kind),
);
/** Widgets that support fewer integrations than the default unlimited selection. */
const createWidgetIntegrationLimits = () => {
const result: Partial<Record<WidgetKind, number>> = {};
for (const [kind, config] of widgetIntegrationConfigEntries) {
if ("maxIntegrations" in config) result[kind] = config.maxIntegrations;
for (const [kind, capability] of nativeFeatureCapabilityEntries) {
if (capability.serverMaxIntegrations !== undefined) result[kind] = capability.serverMaxIntegrations;
}
return result;
};
@@ -133,26 +108,21 @@ export type WidgetIntegrationIssue =
export const getWidgetIntegrationIssue = (
widgetKind: WidgetKind,
selectedIntegrationKinds: readonly IntegrationKind[],
integrationKinds: readonly IntegrationKind[],
): WidgetIntegrationIssue | null => {
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY;
if (selectedIntegrationKinds.length > integrationLimit) {
return { code: "integration-limit", limit: integrationLimit };
}
if (supportedIntegrations === undefined && selectedIntegrationKinds.length > 0) {
return { code: "integration-not-supported" };
}
if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit };
if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" };
if (
supportedIntegrations !== undefined &&
selectedIntegrationKinds.length === 0 &&
integrationKinds.length === 0 &&
!widgetKindsWithOptionalIntegrations.has(widgetKind)
) {
return { code: "integration-required" };
}
const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
if (incompatibleKinds.length === 0) return null;
return { code: "incompatible-integration", incompatibleKinds };
const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null;
};
export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => {
@@ -164,24 +134,22 @@ export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue:
return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`;
};
/** Reverse capability index generated from nativeFeatureCapabilities. */
const createIntegrationWidgetSupport = () => {
const result = {} as Record<IntegrationKind, readonly WidgetKind[]>;
for (const integrationKind of integrationKinds) {
result[integrationKind] = widgetIntegrationConfigEntries
.filter(([, config]) => supportsIntegration(config, integrationKind))
result[integrationKind] = nativeFeatureCapabilityEntries
.filter(([, capability]) => capability.integrations.includes(integrationKind))
.map(([widgetKind]) => widgetKind);
}
return result;
};
const supportsIntegration = (config: WidgetIntegrationConfig, integrationKind: IntegrationKind) =>
config.supportedIntegrations.includes(integrationKind);
export const integrationWidgetSupport = createIntegrationWidgetSupport();
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
return [...integrationWidgetSupport[integrationKind]];
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => [
...integrationWidgetSupport[integrationKind],
];
export interface DefaultWidgetConfig {
kind: WidgetKind;
-4
View File
@@ -59,19 +59,15 @@ export const widgetKinds = [
"assistant",
"wud",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
clock: { width: 2, height: 1 },
weather: { width: 2, height: 1 },
airQuality: { width: 2, height: 1 },
calendar: { width: 2, height: 2 },
countdown: { width: 2, height: 1 },
downloads: { width: 4, height: 2 },
indexerManager: { width: 2, height: 3 },
timer: { width: 2, height: 1 },
bookmarks: { width: 2, height: 2 },
uptimeKuma: { width: 2, height: 3 },
audioStats: { width: 2, height: 2 },
paperlessNgx: { width: 2, height: 2 },
-3
View File
@@ -13,9 +13,6 @@
},
"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",
"./test-connection": "./src/base/test-connection/index.ts",
"./types": "./src/types.ts"
+134 -150
View File
@@ -1,162 +1,146 @@
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 type { Integration, IntegrationInput } from "./integration";
const logger = createLogger({ module: "integrationFactory" });
// Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk.
const integrationCreators = {
anchor: async (input: IntegrationInput) =>
new (await import("../anchor/anchor-integration")).AnchorIntegration(input),
piHole: async (input: IntegrationInput) =>
(await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input),
adGuardHome: async (input: IntegrationInput) =>
new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input),
technitiumDns: async (input: IntegrationInput) =>
(await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input),
homeAssistant: async (input: IntegrationInput) =>
new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input),
jellyfin: async (input: IntegrationInput) =>
new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input),
plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input),
sonarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input),
radarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input),
sabNzbd: async (input: IntegrationInput) =>
new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input),
nzbGet: async (input: IntegrationInput) =>
new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input),
qBittorrent: async (input: IntegrationInput) =>
new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input),
deluge: async (input: IntegrationInput) =>
new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input),
transmission: async (input: IntegrationInput) =>
new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input),
slskd: async (input: IntegrationInput) =>
new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input),
aria2: async (input: IntegrationInput) =>
new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input),
jellyseerr: async (input: IntegrationInput) =>
new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input),
seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input),
overseerr: async (input: IntegrationInput) =>
new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input),
prowlarr: async (input: IntegrationInput) =>
new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input),
openmediavault: async (input: IntegrationInput) =>
new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input),
lidarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input),
readarr: async (input: IntegrationInput) =>
new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input),
dashDot: async (input: IntegrationInput) =>
new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input),
tdarr: async (input: IntegrationInput) =>
new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input),
proxmox: async (input: IntegrationInput) =>
new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input),
emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input),
nextcloud: async (input: IntegrationInput) =>
new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input),
unifiController: async (input: IntegrationInput) =>
new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input),
opnsense: async (input: IntegrationInput) =>
new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input),
ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input),
ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input),
gotify: async (input: IntegrationInput) =>
new (await import("../gotify/gotify-integration")).GotifyIntegration(input),
mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input),
truenas: async (input: IntegrationInput) =>
new (await import("../truenas/truenas-integration")).TrueNasIntegration(input),
synology: async (input: IntegrationInput) =>
new (await import("../synology/synology-integration")).SynologyIntegration(input),
unraid: async (input: IntegrationInput) =>
new (await import("../unraid/unraid-integration")).UnraidIntegration(input),
coolify: async (input: IntegrationInput) =>
new (await import("../coolify/coolify-integration")).CoolifyIntegration(input),
tracearr: async (input: IntegrationInput) =>
new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input),
glances: async (input: IntegrationInput) =>
new (await import("../glances/glances-integration")).GlancesIntegration(input),
immich: async (input: IntegrationInput) =>
new (await import("../immich/immich-integration")).ImmichIntegration(input),
paperlessNgx: async (input: IntegrationInput) =>
new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input),
patchmon: async (input: IntegrationInput) =>
new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input),
speedtestTracker: async (input: IntegrationInput) =>
new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input),
audiobookshelf: async (input: IntegrationInput) =>
new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input),
navidrome: async (input: IntegrationInput) =>
new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input),
umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input),
gluetun: async (input: IntegrationInput) =>
new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input),
archiveTeamWarrior: async (input: IntegrationInput) =>
new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input),
uptimeKuma: async (input: IntegrationInput) =>
new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input),
peaNut: async (input: IntegrationInput) =>
new (await import("../peanut/peanut-integration")).PeaNutIntegration(input),
beszel: async (input: IntegrationInput) =>
new (await import("../beszel/beszel-integration")).BeszelIntegration(input),
bazarr: async (input: IntegrationInput) =>
new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input),
traefik: async (input: IntegrationInput) =>
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
type IntegrationCreators = typeof integrationCreators;
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreators> = Awaited<
ReturnType<IntegrationCreators[TKind]>
>;
export function createIntegrationAsync<TKind extends keyof IntegrationCreators>(
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
integration: IntegrationInput & { kind: TKind },
): Promise<IntegrationInstanceOfKind<TKind>>;
export async function createIntegrationAsync(
integration: IntegrationInput & { kind: IntegrationKind },
): Promise<Integration> {
if (!Object.hasOwn(integrationCreators, integration.kind)) {
logger.warn("Integration client creation refused", {
integrationId: integration.id,
integrationKind: integration.kind,
reason: "integration-kind-unregistered",
});
) => {
if (!(integration.kind in integrationCreators)) {
throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
);
}
const startedAt = Date.now();
logger.debug("Creating integration client", { integrationId: integration.id, integrationKind: integration.kind });
const creator = integrationCreators[integration.kind];
try {
const instance = await integrationCreators[integration.kind](integration);
logger.debug("Integration client created", {
integrationId: integration.id,
integrationKind: integration.kind,
durationMs: Date.now() - startedAt,
});
return instance;
} catch (error) {
logger.warn(
new ErrorWithMetadata(
"Integration client creation failed",
{
integrationId: integration.id,
integrationKind: integration.kind,
durationMs: Date.now() - startedAt,
},
{ cause: error },
),
);
throw error;
// 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>;
};
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,21 +1,15 @@
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>();
// ponytail: TypeScript requires `any[]` for mixin constructors (TS2545). The
// decorator returns the same constructor type T, so callers retain exact args.
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/no-explicit-any
type AbstractConstructor<T = {}> = abstract new (...args: any[]) => T;
@@ -25,68 +19,6 @@ 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;
}
if (error instanceof Error && error.stack) {
const originalHeader = Error.prototype.toString.call(error);
if (error.stack.startsWith(originalHeader)) {
safeError.stack = `${safeError.name}: ${safeError.message}${error.stack.slice(originalHeader.length)}`;
}
}
return safeError;
};
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 => {
@@ -112,27 +44,8 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
processedProperties.add(propertyKey);
const wrapped = (...methodArgs: 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 => {
const handleError = (error: unknown) => {
if (error instanceof IntegrationError) {
const failureType = error instanceof IntegrationUnknownError ? "unknown" : "normalized";
logFailure(error, failureType, operationMetadata, Date.now() - startedAt);
throw error;
}
@@ -140,29 +53,26 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
const handledError = handler.handleError(error, this.publicIntegration);
if (!handledError) continue;
logFailure(handledError, "normalized", operationMetadata, Date.now() - startedAt);
throw handledError;
}
const unknownError = new IntegrationUnknownError(this.publicIntegration, { cause: error });
logFailure(unknownError, "unknown", operationMetadata, Date.now() - startedAt);
throw unknownError;
// 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 });
};
try {
const result = original.apply(this, methodArgs);
if (result instanceof Promise) {
return result.then(
(value: unknown) => {
logCompletion();
return value;
},
(error: unknown) => handleError(error),
);
return result.catch((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 { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationSecretKind } from "@homarr/definitions";
import { HandleIntegrationErrors } from "./errors/decorator";
import { TestConnectionError } from "./test-connection/test-connection-error";
@@ -15,7 +15,6 @@ import type { IntegrationSecret } from "./types";
export interface IntegrationInput {
id: string;
kind?: IntegrationKind;
name: string;
url: string;
externalUrl: string | null;
@@ -1,66 +1,15 @@
import superjson from "superjson";
import { createKeyedFingerprint, decryptSecret, encryptSecret, verifyKeyedFingerprint } from "@homarr/common/server";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createGetSetChannel, getIntegrationSessionStoreKey } from "@homarr/redis";
import type { IntegrationSecret } from "./types";
import { createGetSetChannel } from "@homarr/redis";
const logger = createLogger({ module: "sessionStore" });
const sessionEnvelopeType = "homarr-integration-session";
const sessionEnvelopeVersion = 1;
interface SessionStoreIntegration {
id: string;
url: string;
decryptedSecrets: readonly IntegrationSecret[];
}
interface SessionEnvelope<TValue> {
type: typeof sessionEnvelopeType;
version: typeof sessionEnvelopeVersion;
configurationFingerprint: string;
value: TValue;
}
const compareStrings = (left: string, right: string) => {
if (left < right) return -1;
if (left > right) return 1;
return 0;
};
const serializeSessionConfiguration = (integration: SessionStoreIntegration) => {
const secrets = integration.decryptedSecrets
.map(({ kind, value }) => [kind, value] as const)
.toSorted(([leftKind, leftValue], [rightKind, rightValue]) => {
const kindComparison = compareStrings(leftKind, rightKind);
if (kindComparison !== 0) return kindComparison;
return compareStrings(leftValue, rightValue);
});
return JSON.stringify({ integrationId: integration.id, url: integration.url, secrets });
};
const isSessionEnvelope = <TValue>(value: unknown): value is SessionEnvelope<TValue> => {
if (typeof value !== "object" || value === null) return false;
if (!("value" in value)) return false;
return (
"type" in value &&
value.type === sessionEnvelopeType &&
"version" in value &&
value.version === sessionEnvelopeVersion &&
"configurationFingerprint" in value &&
typeof value.configurationFingerprint === "string"
);
};
export const createSessionStore = <TValue>(integration: SessionStoreIntegration) => {
const channelName = getIntegrationSessionStoreKey(integration.id);
export const createSessionStore = <TValue>(integration: { id: string }) => {
const channelName = `session-store:${integration.id}`;
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
const serializedConfiguration = serializeSessionConfiguration(integration);
const configurationFingerprint = createKeyedFingerprint(serializedConfiguration);
return {
async getAsync() {
@@ -68,16 +17,7 @@ export const createSessionStore = <TValue>(integration: SessionStoreIntegration)
const value = await channel.getAsync();
if (!value) return null;
try {
const envelope = superjson.parse<unknown>(decryptSecret(value));
if (!isSessionEnvelope<TValue>(envelope)) {
logger.debug("Ignoring legacy or unsupported session", { store: channelName });
return null;
}
if (!verifyKeyedFingerprint(serializedConfiguration, envelope.configurationFingerprint)) {
logger.debug("Ignoring session for a different integration configuration", { store: channelName });
return null;
}
return envelope.value;
return superjson.parse<TValue>(decryptSecret(value));
} catch (error) {
logger.warn("Failed to load session", { store: channelName, error });
return null;
@@ -91,13 +31,7 @@ export const createSessionStore = <TValue>(integration: SessionStoreIntegration)
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
try {
const envelope: SessionEnvelope<TValue> = {
type: sessionEnvelopeType,
version: sessionEnvelopeVersion,
configurationFingerprint,
value,
};
await channel.setAsync(encryptSecret(superjson.stringify(envelope)), options);
await channel.setAsync(encryptSecret(superjson.stringify(value)), options);
} catch (error) {
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
}
@@ -13,7 +13,6 @@ const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
const closeAgent = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock("@homarr/redis", () => ({
getIntegrationSessionStoreKey: (integrationId: string) => `session-store:${integrationId}`,
createGetSetChannel: () => ({
getAsync: vi.fn().mockResolvedValue(null),
setAsync: vi.fn().mockResolvedValue(undefined),
@@ -12,7 +12,6 @@ vi.hoisted(() => {
const store = vi.hoisted(() => new Map<string, { value: string; ttlSeconds?: number }>());
vi.mock("@homarr/redis", () => ({
getIntegrationSessionStoreKey: (integrationId: string) => `session-store:${integrationId}`,
createGetSetChannel: (name: string) => ({
getAsync: () => Promise.resolve(store.get(name)?.value ?? null),
setAsync: (value: string, options?: { ttlSeconds?: number }) => {
-1
View File
@@ -1 +0,0 @@
export { createIntegrationAsync } from "./base/creator";
+2 -1
View File
@@ -97,7 +97,8 @@ export type {
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
// Schemas
export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
+4 -9
View File
@@ -470,11 +470,9 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
...generalWidgets,
...discoveredIntegrations.filter((integration) => selectedSourceIds.has(integration.sourceId)),
...appsToCreate,
].flatMap((selection) => {
if (typeof selection === "string") return [selection];
if (!selection.widgetKind) return [];
return [selection.widgetKind];
}),
].flatMap((selection) =>
typeof selection === "string" ? [selection] : selection.widgetKind ? [selection.widgetKind] : [],
),
),
],
});
@@ -1924,10 +1922,7 @@ const getPreviewWidgetKinds = (props: StudioSectionProps) => [
...props.drafts.filter(isIntegrationDraftComplete).flatMap((draft) => getWidgetKindsForIntegration(draft.kind)),
...props.discoveredApps
.filter((app) => props.selectedAppIds.includes(app.sourceId))
.flatMap((app) => {
if (!app.widgetKind) return [];
return [app.widgetKind];
}),
.flatMap((app) => (app.widgetKind ? [app.widgetKind] : [])),
]),
];
-6
View File
@@ -16,15 +16,9 @@ export {
handshakeAsync,
createSubPubChannel,
createGetSetChannel,
getIntegrationSessionStoreKey,
createLockChannel,
getIntegrationCacheGenerationAsync,
getWidgetCacheGenerationAsync,
invalidateIntegrationCacheAsync,
invalidateIntegrationResponseCacheAsync,
invalidateWidgetCache,
} from "./lib/channel";
export type { IntegrationCacheGeneration, WidgetCacheGeneration } from "./lib/channel";
export const pingChannel = createSubPubChannel<
{ url: string; statusCode: number; durationMs: number } | { url: string; error: string }
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
import type { MaybePromise } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createRedisConnection, requireRedisConnection } from "./connection";
import { createRedisConnection } from "./connection";
const logger = createLogger({ module: "channelSubscriptionTracker" });
@@ -63,9 +63,7 @@ export class ChannelSubscriptionTracker {
// Subscribe when this channel has no active or pending Redis subscription.
if (!ready) {
logger.debug("Subscribing to redis channel", { channel: channelName });
ready = requireRedisConnection(this.redis)
.subscribe(channelName)
.then(() => undefined);
ready = this.redis.subscribe(channelName).then(() => undefined);
this.subscriptionReadiness.set(channelName, ready);
void ready.catch(() => {
if (this.subscriptionReadiness.get(channelName) === ready) {
@@ -93,7 +91,7 @@ export class ChannelSubscriptionTracker {
}
logger.debug("Unsubscribing from redis channel", { channel: channelName });
void requireRedisConnection(this.redis).unsubscribe(channelName);
void this.redis.unsubscribe(channelName);
this.subscriptions.delete(channelName);
this.subscriptionReadiness.delete(channelName);
};
@@ -106,7 +104,7 @@ export class ChannelSubscriptionTracker {
*/
private static activateListener() {
logger.debug("Activating listener");
requireRedisConnection(this.redis).on("message", (channel, message) => {
this.redis.on("message", (channel, message) => {
const channelSubscriptions = this.subscriptions.get(channel);
if (!channelSubscriptions) {
logger.warn("Received message on unknown channel", { channel });
-1
View File
@@ -16,7 +16,6 @@ describe("createLockChannel", () => {
expect(token).toEqual(expect.any(String));
if (!token) throw new Error("Expected the fallback lock to return a token");
await expect(lock.renewAsync(token, 60)).resolves.toBe(true);
await expect(lock.releaseAsync(token)).resolves.toBeUndefined();
});
});
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more