Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
062fda642c | ||
|
|
7d1d9062ed | ||
|
|
8d0b13b309 | ||
|
|
ea250d418d | ||
|
|
e58c229664 | ||
|
|
ee08beef59 | ||
|
|
b53042f055 | ||
|
|
bf0eed7607 | ||
|
|
8e29f7aa18 | ||
|
|
5ac4a4cffc | ||
|
|
ac7e3f2ef4 | ||
|
|
47724f44eb | ||
|
|
c81088d876 | ||
|
|
9bf266935b | ||
|
|
0ca62f1232 | ||
|
|
4ca17d8e8b | ||
|
|
24e6e4a47d | ||
|
|
ad0120fe01 | ||
|
|
17c47ac9d1 | ||
|
|
97e0adbb3a | ||
|
|
addf1c7335 | ||
|
|
8f9efd0074 | ||
|
|
c988c35b29 | ||
|
|
425cb653a1 |
No files matched your search
@@ -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/ # All 39 dashboard widgets (definitions + components)
|
||||
│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry
|
||||
│ ├── 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,11 +118,15 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
|
||||
|
||||
### Widget System
|
||||
|
||||
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)
|
||||
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.
|
||||
|
||||
### Integration System
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
import type { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const beszelIntegration: IntegrationDefinition = {
|
||||
export const beszelIntegration = {
|
||||
name: "Beszel",
|
||||
description:
|
||||
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
path: "../../integrations/beszel",
|
||||
};
|
||||
} satisfies IntegrationDefinition;
|
||||
@@ -113,7 +113,10 @@ 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
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestListWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -39,6 +40,9 @@ import mediaRequestList from "./img/list.png";
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -46,6 +50,7 @@ import mediaRequestList from "./img/list.png";
|
||||
|
||||
- Approve / decline a request by clicking on the thumbs up or down icon.
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers the same media search action instead of reporting an integration error.
|
||||
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -16,6 +16,7 @@ import TabItem from "@theme/TabItem";
|
||||
import { mediaRequestStatsWidget } from ".";
|
||||
import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
|
||||
import { overseerrIntegration } from "@site/docs/integrations/overseerr";
|
||||
import { seerrIntegration } from "@site/docs/integrations/seerr";
|
||||
|
||||
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
|
||||
|
||||
@@ -31,6 +32,9 @@ Provides an overview of media requests, including totals, availability, approval
|
||||
{
|
||||
integration: overseerrIntegration,
|
||||
},
|
||||
{
|
||||
integration: seerrIntegration,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -43,6 +47,7 @@ import mediaRequestStats from "./img/stats.png";
|
||||
### Interactions
|
||||
|
||||
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
|
||||
- When no requests exist yet, the widget offers media search and explains when statistics will appear.
|
||||
- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible.
|
||||
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
@@ -2,13 +2,17 @@ import type { LoadContext, Plugin } from "@docusaurus/types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs";
|
||||
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
|
||||
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
|
||||
|
||||
const slugMaps = {
|
||||
Integration: { slugs: integrationDocSlugs, folder: "integrations" },
|
||||
Widget: { slugs: widgetDocSlugs, folder: "widgets" },
|
||||
} as const;
|
||||
const documentationEntries = [
|
||||
{
|
||||
label: "Integration",
|
||||
folder: "integrations",
|
||||
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
|
||||
},
|
||||
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
|
||||
] as const;
|
||||
|
||||
export default function validateDocsCoveragePlugin(context: LoadContext): Plugin {
|
||||
return {
|
||||
@@ -17,8 +21,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin
|
||||
const docsDir = path.resolve(context.siteDir, "docs");
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const [label, { slugs, folder }] of Object.entries(slugMaps)) {
|
||||
for (const [kind, slug] of Object.entries(slugs)) {
|
||||
for (const { label, folder, entries } of documentationEntries) {
|
||||
for (const [kind, slug] of entries) {
|
||||
if (!slug) continue;
|
||||
if (!fs.existsSync(path.join(docsDir, folder, slug))) {
|
||||
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TablerIcon } from "@tabler/icons-react";
|
||||
import { ReactNode } from "react";
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface IntegrationDefinition {
|
||||
name: string;
|
||||
|
||||
@@ -145,10 +145,14 @@ const ScopedTRPCReactProvider = ({
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const { queryKey, intervalSeconds } of widgetQueryRefetchIntervals) {
|
||||
client.setQueryDefaults(queryKey, {
|
||||
refetchInterval: intervalSeconds === null ? false : intervalSeconds * 1000,
|
||||
});
|
||||
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, ...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/widgets/catalog";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import classes from "./_layout-preview.module.css";
|
||||
|
||||
@@ -114,12 +114,15 @@ export default async function Layout(props: {
|
||||
})
|
||||
: null,
|
||||
);
|
||||
const assistantAvailabilityPromise: Promise<AssistantAvailability> = sessionPromise.then((session) => {
|
||||
const assistantAvailabilityPromise = sessionPromise.then(async (session): Promise<AssistantAvailability> => {
|
||||
if (!session) return "unauthenticated";
|
||||
return api.assistant
|
||||
.getAvailability()
|
||||
.then((availability) => (availability.enabled ? "enabled" : "unconfigured"))
|
||||
.catch(() => "error");
|
||||
|
||||
try {
|
||||
const availability = await api.assistant.getAvailability();
|
||||
return availability.enabled ? "enabled" : "unconfigured";
|
||||
} catch {
|
||||
return "error";
|
||||
}
|
||||
});
|
||||
const [session, user, serverSettings, colorScheme, assistantAvailability] = await Promise.all([
|
||||
sessionPromise,
|
||||
|
||||
+2
-1
@@ -50,7 +50,8 @@ function CustomWidgetManifestEditor({ form }: { form: CustomWidgetWorkbenchForm
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(JSON.parse(next) as unknown);
|
||||
const value: unknown = JSON.parse(next);
|
||||
const parsed = customWidgetDefinitionSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
setError(parsed.error.issues[0]?.message ?? t("invalidWidget"));
|
||||
return;
|
||||
|
||||
@@ -76,7 +76,8 @@ export function parseSources(value: string): Array<CustomWidgetSource & { id: st
|
||||
|
||||
export function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -129,7 +129,9 @@ const CustomWidgetFormView = memo(function CustomWidgetFormView(props: CustomWid
|
||||
className={classes.paneSwitcher}
|
||||
fullWidth
|
||||
value={props.mobilePane}
|
||||
onChange={(value) => props.setMobilePane(value as MobilePane)}
|
||||
onChange={(value) => {
|
||||
if (value === "configure" || value === "preview") props.setMobilePane(value);
|
||||
}}
|
||||
data={[
|
||||
{ value: "configure", label: w("configure") },
|
||||
{ value: "preview", label: w("section.preview") },
|
||||
|
||||
+3
-1
@@ -173,7 +173,9 @@ export function RequestBodyEditor({
|
||||
onChange={(next) => {
|
||||
setDraft(next);
|
||||
try {
|
||||
onChange(next.trim() ? (JSON.parse(next) as unknown) : undefined);
|
||||
let parsed: unknown;
|
||||
if (next.trim()) parsed = JSON.parse(next);
|
||||
onChange(parsed);
|
||||
setError(undefined);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : t("invalidJson"));
|
||||
|
||||
@@ -2,7 +2,6 @@ import { headers } from "next/headers";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack } from "@mantine/core";
|
||||
|
||||
import { mcpRouter } from "@homarr/api/mcp";
|
||||
import { openApiDocument } from "@homarr/api/open-api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
@@ -16,40 +15,24 @@ 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: procedureTypeMap.get(procedureKey) ?? "query",
|
||||
type: tool.type,
|
||||
});
|
||||
}
|
||||
cachedToolGroups = Array.from(groups.entries()).map(([namespace, items]) => ({
|
||||
return Array.from(groups.entries()).map(([namespace, items]) => ({
|
||||
namespace,
|
||||
tools: items,
|
||||
}));
|
||||
return cachedToolGroups;
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
@@ -70,12 +53,14 @@ export default async function ApiPage() {
|
||||
if (!session?.user.permissions.includes("admin")) {
|
||||
notFound();
|
||||
}
|
||||
const requestHeaders = await headers();
|
||||
const [requestHeaders, apiKeys, t, toolGroups] = await Promise.all([
|
||||
headers(),
|
||||
api.apiKeys.getAll(),
|
||||
getI18n("management.page.tool.api.tab"),
|
||||
getMcpToolGroups(),
|
||||
]);
|
||||
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/widgets/catalog";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
|
||||
import { MainNavigation } from "~/components/layout/navigation";
|
||||
import { ClientShell } from "~/components/layout/shell";
|
||||
|
||||
@@ -327,20 +327,6 @@ const sumCompleteStepMetric = (
|
||||
return (values as number[]).reduce((sum, value) => sum + value, 0);
|
||||
};
|
||||
|
||||
const getProcedureTypeMap = () => {
|
||||
const procedures = (
|
||||
mcpRouter as unknown as {
|
||||
["_def"]: { procedures: Record<string, { ["_def"]?: { type?: string } }> };
|
||||
}
|
||||
)["_def"].procedures;
|
||||
return new Map(
|
||||
Object.entries(procedures).flatMap(([path, procedure]) => {
|
||||
const type = procedure["_def"]?.type;
|
||||
return type === "query" || type === "mutation" ? [[path, type] as const] : [];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const waitForDemoChunkAsync = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) => {
|
||||
@@ -385,7 +371,8 @@ export async function POST(request: Request) {
|
||||
const parsed = requestSchema.safeParse(
|
||||
(() => {
|
||||
try {
|
||||
return JSON.parse(requestBody) as unknown;
|
||||
const value: unknown = JSON.parse(requestBody);
|
||||
return value;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -516,14 +503,13 @@ 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 = procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
|
||||
const requiresApproval = mcpTool.type === "mutation";
|
||||
return [
|
||||
mcpTool.name,
|
||||
tool({
|
||||
@@ -571,11 +557,9 @@ export async function POST(request: Request) {
|
||||
}),
|
||||
) satisfies ToolSet;
|
||||
const toolApproval = Object.fromEntries(
|
||||
mcpTools.flatMap((mcpTool) =>
|
||||
procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
|
||||
? [[mcpTool.name, "user-approval" as const]]
|
||||
: [],
|
||||
),
|
||||
mcpTools
|
||||
.filter((mcpTool) => mcpTool.type === "mutation")
|
||||
.map((mcpTool) => [mcpTool.name, "user-approval" as const]),
|
||||
);
|
||||
|
||||
const frontendTools = Object.fromEntries(
|
||||
|
||||
@@ -46,18 +46,21 @@ 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: {} },
|
||||
},
|
||||
|
||||
@@ -80,18 +80,10 @@ function clearAuthFailures(ip: string | null) {
|
||||
authFailures.delete(ip ?? "unknown");
|
||||
}
|
||||
|
||||
let toolsCache: McpTool[] | null = null;
|
||||
|
||||
function getTools() {
|
||||
if (!toolsCache) {
|
||||
toolsCache = extractMcpTools();
|
||||
logger.info(`Extracted ${toolsCache.length} MCP tools`);
|
||||
}
|
||||
return toolsCache;
|
||||
}
|
||||
|
||||
const getVisibleTools = (isAdmin: boolean) =>
|
||||
isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
|
||||
if (isAdmin) return tools;
|
||||
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
};
|
||||
|
||||
function registerCustomWidgetAuthoring(server: McpServer) {
|
||||
server.registerPrompt(
|
||||
@@ -336,7 +328,7 @@ export const handleMcpRequest = async (req: NextRequest) => {
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
const protocolHandler = createMcpProtocolHandler({
|
||||
caller,
|
||||
tools: getVisibleTools(isAdmin),
|
||||
tools: getVisibleTools(extractMcpTools(), isAdmin),
|
||||
version: getPackageVersion(),
|
||||
instructions: SERVER_INSTRUCTIONS,
|
||||
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
|
||||
|
||||
@@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() });
|
||||
const echoTool: McpTool = {
|
||||
name: "echo",
|
||||
description: "Echo a message",
|
||||
type: "query",
|
||||
pathInRouter: ["echo"],
|
||||
inputValidator: echoValidator,
|
||||
inputSchema: z.toJSONSchema(echoValidator),
|
||||
|
||||
@@ -2,25 +2,32 @@ import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { widgetIntegrationSupport } from "@homarr/definitions";
|
||||
import { widgetImports } from "@homarr/widgets";
|
||||
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
|
||||
|
||||
import { getAssistantWidgetConfiguration } from "./assistant-widget-tool";
|
||||
|
||||
const settings = { enableStatusByDefault: true, forceDisableStatus: false };
|
||||
const widgetDefinitions = await loadAllWidgetDefinitions();
|
||||
|
||||
const getWidgetDefinition = (kind: WidgetKind) => {
|
||||
const definition = widgetDefinitions.get(kind);
|
||||
if (!definition) throw new Error(`No definition is registered for ${kind}`);
|
||||
return definition;
|
||||
};
|
||||
|
||||
describe("getAssistantWidgetConfiguration", () => {
|
||||
test("keeps server-side integration support synchronized with widget definitions", () => {
|
||||
for (const [kind, widgetImport] of Object.entries(widgetImports)) {
|
||||
const definition = widgetImport.definition;
|
||||
for (const [kind, definition] of widgetDefinitions) {
|
||||
const expected = "supportedIntegrations" in definition ? definition.supportedIntegrations : undefined;
|
||||
const supportedIntegrations = widgetIntegrationSupport[kind];
|
||||
|
||||
expect(widgetIntegrationSupport[kind as WidgetKind], kind).toEqual(expected);
|
||||
expect(supportedIntegrations, kind).toEqual(expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves generated notebook content and fills its hidden defaults", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
widgetImports.notebook.definition,
|
||||
getWidgetDefinition("notebook"),
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -42,7 +49,7 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("keeps only accessible, compatible integrations and respects the widget maximum", () => {
|
||||
const result = getAssistantWidgetConfiguration(
|
||||
widgetImports.audioStats.definition,
|
||||
getWidgetDefinition("audioStats"),
|
||||
{
|
||||
boardId: "board-1",
|
||||
boardName: "Home",
|
||||
@@ -90,13 +97,13 @@ describe("getAssistantWidgetConfiguration", () => {
|
||||
|
||||
test("treats integration-backed widgets as required unless explicitly optional", () => {
|
||||
const mediaServer = getAssistantWidgetConfiguration(
|
||||
widgetImports.mediaServer.definition,
|
||||
getWidgetDefinition("mediaServer"),
|
||||
{ boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" },
|
||||
settings,
|
||||
[],
|
||||
);
|
||||
const calendar = getAssistantWidgetConfiguration(
|
||||
widgetImports.calendar.definition,
|
||||
getWidgetDefinition("calendar"),
|
||||
{ boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" },
|
||||
settings,
|
||||
[],
|
||||
|
||||
@@ -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/widgets/catalog";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
|
||||
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: "mediaMissing" })(board);
|
||||
const result = createItemCallback({ kind: "downloads" })(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: 1,
|
||||
width: 2,
|
||||
...emptyPosition,
|
||||
sectionId: firstSectionId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
|
||||
});
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
|
||||
}),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
undefined,
|
||||
{ height: 1, width: 1 },
|
||||
{ height: 1, width: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: 4 }),
|
||||
expect.objectContaining({ layoutId: "layout", sectionId: "media", width: 2 }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -262,7 +262,7 @@ describe("item actions create-item", () => {
|
||||
layoutId: layout.id,
|
||||
sectionId: "canvas",
|
||||
width: 1,
|
||||
height: 3,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 1,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
|
||||
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
export interface BoardRecipeRecommendation {
|
||||
widgetKind: WidgetKind;
|
||||
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
return widgetKinds
|
||||
.filter((widgetKind) => !existingKinds.has(widgetKind))
|
||||
.flatMap((widgetKind) => {
|
||||
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
|
||||
if (!capability) return [];
|
||||
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
|
||||
if (!supportedIntegrations) return [];
|
||||
|
||||
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
const matches = supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
if (matches.length === 0) return [];
|
||||
|
||||
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
|
||||
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
widgetKind,
|
||||
integrationKind,
|
||||
isNewlyAvailable: Boolean(preferredMatch),
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - supportedIntegrations.length,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.slice(0, Math.max(0, limit));
|
||||
};
|
||||
@@ -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/widgets/catalog";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
|
||||
import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { NormalizedWidgetQuery, WidgetQueryMatcherScope } from "@homarr/widgets";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets";
|
||||
import { loadAllWidgetDefinitions } from "@homarr/widgets/manifest";
|
||||
|
||||
import { matchesWidgetItemQuery } from "./widget-query-scope";
|
||||
|
||||
type WidgetKind = keyof typeof widgetImports;
|
||||
const widgetDefinitions = await loadAllWidgetDefinitions();
|
||||
const allPhotosAlbumId = "all";
|
||||
|
||||
const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQueryMatcherScope => ({
|
||||
@@ -19,7 +21,9 @@ const createScope = (overrides: Partial<WidgetQueryMatcherScope> = {}): WidgetQu
|
||||
});
|
||||
|
||||
const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
|
||||
const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
|
||||
const definition = widgetDefinitions.get(kind);
|
||||
if (!definition) throw new Error(`No definition is registered for ${kind}`);
|
||||
|
||||
const queryKey: QueryKey = [path, { input, type: "query" }];
|
||||
return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
|
||||
};
|
||||
|
||||
@@ -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: var(--homarr-z-index-widget-focus);
|
||||
z-index: calc(var(--homarr-z-index-board-header) - 1);
|
||||
}
|
||||
|
||||
.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/widgets/catalog";
|
||||
import { widgetCatalogIcons } from "@homarr/ui/widget-icons";
|
||||
|
||||
import type { EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { useRegisteredGridEditors } from "~/components/board/sections/grid/grid-editor-registry";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export interface McpMeta {
|
||||
@@ -12,6 +12,7 @@ export interface McpMeta {
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "query" | "mutation";
|
||||
pathInRouter: string[];
|
||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||
inputValidator: z.ZodObject;
|
||||
@@ -19,13 +20,54 @@ 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 ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = ((schema.required as string[] | undefined) ?? []).filter(
|
||||
(key) => !("default" in (properties[key] ?? {})),
|
||||
);
|
||||
const properties = schema.properties ?? {};
|
||||
const required = (schema.required ?? []).filter((key) => {
|
||||
const property = properties[key];
|
||||
return !property || typeof property !== "object" || !("default" in property);
|
||||
});
|
||||
|
||||
return {
|
||||
...schema,
|
||||
@@ -41,22 +83,15 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
|
||||
const tools: McpTool[] = [];
|
||||
|
||||
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
||||
const definition = procedure["_def"] as unknown as {
|
||||
inputs?: z.ZodType[];
|
||||
meta?: McpMeta;
|
||||
};
|
||||
const metadata = definition.meta?.mcp;
|
||||
if (!isProcedure(procedure)) continue;
|
||||
const definition = procedure["_def"];
|
||||
if (definition.type === "subscription") continue;
|
||||
|
||||
const metadata = getMcpMetadata(definition.meta);
|
||||
if (!metadata?.enabled) continue;
|
||||
|
||||
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
|
||||
const procedureInput =
|
||||
inputs.length === 0
|
||||
? emptyInput
|
||||
: inputs.length === 1
|
||||
? (inputs[0] ?? emptyInput)
|
||||
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
|
||||
? mergeInputs(inputs)
|
||||
: null;
|
||||
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
||||
const procedureInput = getProcedureInput(inputs);
|
||||
|
||||
if (
|
||||
procedureInput === null ||
|
||||
@@ -69,6 +104,7 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
|
||||
tools.push({
|
||||
name: metadata.name ?? path.replaceAll(".", "_"),
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputValidator,
|
||||
inputSchema: normalizeJsonSchema(
|
||||
|
||||
@@ -9,26 +9,51 @@ import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
|
||||
import { getWidgetIntegrationConfig } from "@homarr/definitions";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export type IntegrationAction = "query" | "interact";
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
|
||||
*/
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
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>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
kinds: readonly TKind[],
|
||||
) => {
|
||||
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: {
|
||||
@@ -45,6 +70,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.warn("Integration access target was not found", accessMetadata);
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
|
||||
@@ -52,22 +78,24 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
}
|
||||
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, [integration], ctx.session);
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: [integration.kind],
|
||||
});
|
||||
|
||||
const {
|
||||
secrets,
|
||||
kind,
|
||||
items: _ignore1,
|
||||
groupPermissions: _ignore2,
|
||||
userPermissions: _ignore3,
|
||||
...rest
|
||||
} = integration;
|
||||
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;
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integration: {
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
kind: narrowedKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
@@ -78,6 +106,101 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a middleware that provides the integration in the context that is of the specified kinds
|
||||
* @param action query for showing data or interact for mutating data
|
||||
* @param kinds kinds of integrations that are supported
|
||||
* @returns middleware that can be used with trpc
|
||||
* @example publicProcedure.concat(createOneIntegrationMiddleware("query", "piHole", "homeAssistant")).query(...)
|
||||
* @throws TRPCError NOT_FOUND if the integration was not found
|
||||
* @throws TRPCError FORBIDDEN if the user does not have permission to perform the specified action on the specified integration
|
||||
*/
|
||||
export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return createOneIntegrationMiddlewareForKinds(action, kinds);
|
||||
};
|
||||
|
||||
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
) => {
|
||||
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
|
||||
@@ -92,55 +215,27 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
...kinds: AtLeastOneOf<TKind> // Ensure at least one kind is provided
|
||||
) => {
|
||||
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
|
||||
const dbIntegrations =
|
||||
input.integrationIds.length >= 1
|
||||
? await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
return createManyIntegrationMiddlewareForKinds(action, kinds);
|
||||
};
|
||||
|
||||
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
|
||||
@@ -152,25 +247,39 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
const throwIfActionIsNotAllowedAsync = async (
|
||||
action: IntegrationAction,
|
||||
db: Database,
|
||||
integrations: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
|
||||
integrationRecords: Parameters<typeof hasQueryAccessToIntegrationsAsync>[1],
|
||||
session: Session | null,
|
||||
) => {
|
||||
if (action === "interact") {
|
||||
const haveAllInteractAccess = integrations
|
||||
const haveAllInteractAccess = integrationRecords
|
||||
.map((integration) => constructIntegrationPermissions(integration, session))
|
||||
.every(({ hasInteractAccess }) => hasInteractAccess);
|
||||
if (haveAllInteractAccess) return;
|
||||
|
||||
logger.warn("Integration access denied", {
|
||||
action,
|
||||
userId: session?.user.id,
|
||||
integrationIds: integrationRecords.map(({ id }) => id),
|
||||
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to interact with at least one of the specified integrations",
|
||||
});
|
||||
}
|
||||
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrations, session);
|
||||
const hasQueryAccess = await hasQueryAccessToIntegrationsAsync(db, integrationRecords, session);
|
||||
|
||||
if (hasQueryAccess) return;
|
||||
|
||||
logger.warn("Integration access denied", {
|
||||
action,
|
||||
userId: session?.user.id,
|
||||
integrationIds: integrationRecords.map(({ id }) => id),
|
||||
integrationKinds: integrationRecords.map(getIntegrationKindForLog),
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User does not have permission to query at least one of the specified integration",
|
||||
|
||||
@@ -5,9 +5,10 @@ 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 } from "@homarr/db/schema";
|
||||
import { apps, integrations } 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";
|
||||
|
||||
@@ -256,6 +257,14 @@ 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")
|
||||
@@ -271,7 +280,12 @@ 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)));
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
+168
-130
@@ -20,9 +20,7 @@ import {
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
groups,
|
||||
integrationGroupPermissions,
|
||||
integrationItems,
|
||||
integrationUserPermissions,
|
||||
integrations,
|
||||
apps,
|
||||
itemLayouts,
|
||||
@@ -71,7 +69,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./board/board-access";
|
||||
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
|
||||
import { throwIfIntegrationActionsForbiddenAsync } from "./integration/integration-access";
|
||||
import {
|
||||
throwIfCustomWidgetBoardDuplicationForbidden,
|
||||
throwIfCustomWidgetPlacementChangeForbidden,
|
||||
@@ -131,6 +129,75 @@ 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,
|
||||
@@ -169,11 +236,7 @@ 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),
|
||||
@@ -659,7 +722,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Duplicate an existing board into a new board. Requires board-create permission and view permission on the source board. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
},
|
||||
})
|
||||
.input(boardDuplicateSchema)
|
||||
@@ -696,6 +759,14 @@ 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();
|
||||
|
||||
@@ -776,17 +847,19 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const sectionLayoutsToInsert: InferInsertModel<typeof sectionLayouts>[] = boardSections
|
||||
.flatMap((section) =>
|
||||
section.layouts.map((layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
})),
|
||||
section.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof sectionLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
parentSectionId: layoutSection.parentSectionId
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionMap.get(layoutSection.parentSectionId)!
|
||||
: layoutSection.parentSectionId,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
|
||||
@@ -803,11 +876,13 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
const sectionCollapseStatesToInsert: InferInsertModel<typeof sectionCollapseStates>[] = boardSections.flatMap(
|
||||
(section) =>
|
||||
section.collapseStates.map((collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
|
||||
...collapseState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(collapseState.sectionId)!,
|
||||
})),
|
||||
section.collapseStates.map(
|
||||
(collapseState): InferInsertModel<typeof sectionCollapseStates> => ({
|
||||
...collapseState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(collapseState.sectionId)!,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const itemMap = new Map<string, string>(boardItems.map((item) => [item.id, createId()]));
|
||||
@@ -822,15 +897,17 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
const itemLayoutsToInsert: InferInsertModel<typeof itemLayouts>[] = boardItems
|
||||
.flatMap((item) =>
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
})),
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
...layoutSection,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sectionId: sectionMap.get(layoutSection.sectionId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(layoutSection.itemId)!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
layoutId: layoutsMap.get(layoutSection.layoutId)!,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.concat(
|
||||
(generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
|
||||
@@ -844,34 +921,12 @@ export const boardRouter = createTRPCRouter({
|
||||
})),
|
||||
);
|
||||
|
||||
// Creates a list with all integration ids the user has access to
|
||||
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
|
||||
const integrationIdsWithAccess = hasAccessForAll
|
||||
? []
|
||||
: await ctx.db
|
||||
.selectDistinct({
|
||||
id: integrationGroupPermissions.integrationId,
|
||||
})
|
||||
.from(integrationGroupPermissions)
|
||||
.leftJoin(groupMembers, eq(integrationGroupPermissions.groupId, groupMembers.groupId))
|
||||
.where(eq(groupMembers.userId, ctx.session.user.id))
|
||||
.union(
|
||||
ctx.db
|
||||
.selectDistinct({ id: integrationUserPermissions.integrationId })
|
||||
.from(integrationUserPermissions)
|
||||
.where(eq(integrationUserPermissions.userId, ctx.session.user.id)),
|
||||
)
|
||||
.then((result) => result.map((row) => row.id));
|
||||
|
||||
const itemIntegrationsToInsert = boardItems.flatMap((item) =>
|
||||
item.integrations
|
||||
// Restrict integrations to only those the user has access to
|
||||
.filter(({ integrationId }) => integrationIdsWithAccess.includes(integrationId) || hasAccessForAll)
|
||||
.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
item.integrations.map((integration) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
itemId: itemMap.get(item.id)!,
|
||||
integrationId: integration.integrationId,
|
||||
})),
|
||||
);
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -1514,6 +1569,7 @@ export const boardRouter = createTRPCRouter({
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
|
||||
|
||||
const dbBoard = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
|
||||
await validateWidgetConfigurationsAsync(ctx, input.items, dbBoard.items);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: input.items,
|
||||
@@ -1523,10 +1579,9 @@ 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);
|
||||
await validateTimetableOptionsChangeAsync(
|
||||
item.options,
|
||||
previousItem?.kind === "timetable" ? previousItem.options : undefined,
|
||||
);
|
||||
let previousOptions: Record<string, unknown> | undefined;
|
||||
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
|
||||
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
|
||||
}
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -1550,15 +1605,17 @@ export const boardRouter = createTRPCRouter({
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map((sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
})),
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof schema.sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
@@ -1580,15 +1637,17 @@ export const boardRouter = createTRPCRouter({
|
||||
);
|
||||
await transaction.insert(schema.itemLayouts).values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
})),
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof schema.itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1746,15 +1805,17 @@ export const boardRouter = createTRPCRouter({
|
||||
const sectionLayoutsToInsert = addedSections
|
||||
.filter((section) => section.kind === "container")
|
||||
.flatMap((section) =>
|
||||
section.layouts.map((sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
})),
|
||||
section.layouts.map(
|
||||
(sectionLayout): InferInsertModel<typeof sectionLayouts> => ({
|
||||
layoutId: sectionLayout.layoutId,
|
||||
sectionId: section.id,
|
||||
parentSectionId: sectionLayout.parentSectionId,
|
||||
height: sectionLayout.height,
|
||||
width: sectionLayout.width,
|
||||
xOffset: sectionLayout.xOffset,
|
||||
yOffset: sectionLayout.yOffset,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (sectionLayoutsToInsert.length > 0) {
|
||||
@@ -1781,15 +1842,17 @@ export const boardRouter = createTRPCRouter({
|
||||
.insert(itemLayouts)
|
||||
.values(
|
||||
addedItems.flatMap((item) =>
|
||||
item.layouts.map((layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
})),
|
||||
item.layouts.map(
|
||||
(layoutSection): InferInsertModel<typeof itemLayouts> => ({
|
||||
layoutId: layoutSection.layoutId,
|
||||
sectionId: layoutSection.sectionId,
|
||||
itemId: item.id,
|
||||
height: layoutSection.height,
|
||||
width: layoutSection.width,
|
||||
xOffset: layoutSection.xOffset,
|
||||
yOffset: layoutSection.yOffset,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
@@ -2092,6 +2155,10 @@ 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 }],
|
||||
@@ -2102,35 +2169,6 @@ 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,
|
||||
) =>
|
||||
await Promise.all(
|
||||
) => {
|
||||
const results = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const capability = action === "remove" ? "remove" : "lifecycle";
|
||||
@@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async (
|
||||
}),
|
||||
);
|
||||
|
||||
if (results.some(({ success }) => success)) {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const assertDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
|
||||
@@ -1,12 +1,59 @@
|
||||
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, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import { groupMembers, integrationGroupPermissions, integrations, 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
|
||||
@@ -18,47 +65,23 @@ export const throwIfActionForbiddenAsync = async (
|
||||
integrationWhere: SQL<unknown>,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integration = await db.query.integrations.findFirst({
|
||||
where: integrationWhere,
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
|
||||
};
|
||||
|
||||
if (!integration) {
|
||||
notAllowed();
|
||||
}
|
||||
export const throwIfIntegrationActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationIds: readonly string[],
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const uniqueIntegrationIds = [...new Set(integrationIds)];
|
||||
if (uniqueIntegrationIds.length === 0) return;
|
||||
|
||||
const { 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();
|
||||
await throwIfActionsForbiddenAsync(
|
||||
ctx,
|
||||
inArray(integrations.id, uniqueIntegrationIds),
|
||||
uniqueIntegrationIds.length,
|
||||
permission,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
integrationKinds,
|
||||
integrationSecretKindObject,
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -156,9 +158,21 @@ export const integrationRouter = createTRPCRouter({
|
||||
})
|
||||
.input(byIdSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
|
||||
const integrationWhere = eq(integrations.id, input.id);
|
||||
|
||||
await throwIfActionForbiddenAsync(ctx, integrationWhere, "full").catch((error) => {
|
||||
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
|
||||
logger.warn("Integration lookup refused", {
|
||||
integrationId: input.id,
|
||||
errorCode: error.code,
|
||||
reason: "integration-not-found-or-forbidden",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, input.id),
|
||||
where: integrationWhere,
|
||||
with: {
|
||||
secrets: {
|
||||
columns: {
|
||||
@@ -179,6 +193,11 @@ export const integrationRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.warn("Integration lookup refused", {
|
||||
integrationId: input.id,
|
||||
errorCode: "NOT_FOUND",
|
||||
reason: "integration-not-found",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Integration not found",
|
||||
@@ -695,7 +714,17 @@ export const integrationRouter = createTRPCRouter({
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
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;
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { Integration } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getAllSecretKindOptions } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
const logger = createLogger({ module: "integrationTestConnection" });
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
const discoveredApps: DiscoveredApp[] = [];
|
||||
|
||||
for (const service of discovery.services) {
|
||||
const widgetKind = service.widgetKind;
|
||||
if (service.integrationKind) {
|
||||
discoveredIntegrations.push({
|
||||
sourceId: service.sourceId,
|
||||
@@ -231,7 +232,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
group: service.group,
|
||||
boardName: service.boardName,
|
||||
host: service.host,
|
||||
widgetKind: service.widgetKind,
|
||||
widgetKind,
|
||||
description: service.description,
|
||||
pingUrl: service.pingUrl,
|
||||
});
|
||||
@@ -249,7 +250,7 @@ export const onboardRouter = createTRPCRouter({
|
||||
description: service.description,
|
||||
pingUrl: service.pingUrl,
|
||||
host: service.host,
|
||||
widgetKind: service.widgetKind,
|
||||
widgetKind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,14 @@ const defaultSession = {
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const integrationUseSession = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["integration-use-all"],
|
||||
},
|
||||
} satisfies Session;
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
@@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
const { boardId, integrationId } = await createFullBoardAsync(db, "source");
|
||||
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
@@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should remove integration reference when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
|
||||
const anotherIntegration = {
|
||||
id: createId(),
|
||||
kind: "adGuardHome",
|
||||
@@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => {
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
kind: "dnsHoleSummary",
|
||||
options: { usePiHoleColors: true, layout: "grid" },
|
||||
integrationIds: [anotherIntegration.id],
|
||||
layouts: [
|
||||
{
|
||||
@@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should add integration reference when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
|
||||
const integration = {
|
||||
id: createId(),
|
||||
kind: "plex",
|
||||
@@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => {
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
kind: "mediaServer",
|
||||
options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
|
||||
integrationIds: [integration.id],
|
||||
layouts: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import * as homarrDefinitions from "@homarr/definitions";
|
||||
import * as homarrIntegrations from "@homarr/integrations";
|
||||
import type * as homarrIntegrations from "@homarr/integrations";
|
||||
import * as homarrIntegrationFactory from "@homarr/integrations/factory";
|
||||
|
||||
import { testConnectionAsync } from "../../integration/integration-test-connection";
|
||||
|
||||
@@ -18,7 +19,7 @@ vi.mock("@homarr/common/server", async (importActual) => {
|
||||
describe("testConnectionAsync should run test connection of integration", () => {
|
||||
test("with input of only form secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -61,7 +62,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of only null form secrets and the required db secrets matching api key kind it should use db apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -111,7 +112,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form and db secrets matching api key kind it should use form apiKey", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -161,7 +162,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of form apiKey and db secrets for username and password it should use form apiKey when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
@@ -215,7 +216,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
||||
|
||||
test("with input of null form apiKey and db secrets for username and password it should use db username and password when both is allowed", async () => {
|
||||
// Arrange
|
||||
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
|
||||
const factorySpy = vi.spyOn(homarrIntegrationFactory, "createIntegrationAsync");
|
||||
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
|
||||
factorySpy.mockReturnValue(
|
||||
Promise.resolve({
|
||||
|
||||
@@ -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: 4, height: 3 });
|
||||
).toMatchObject({ width: 2, height: 2 });
|
||||
});
|
||||
|
||||
it("requires an exact board id when more than one board exists", async () => {
|
||||
|
||||
@@ -3,15 +3,12 @@ import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import {
|
||||
anchorNotesListInputSchema,
|
||||
anchorNoteUpdateInputSchema,
|
||||
createIntegrationAsync,
|
||||
mockWidgetData,
|
||||
} from "@homarr/integrations";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "@homarr/integrations/anchor";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const noteIdInput = z.object({
|
||||
@@ -51,7 +48,7 @@ const normalizeAnchorContent = (content: string | undefined) => {
|
||||
|
||||
export const anchorNotesRouter = createTRPCRouter({
|
||||
listNotes: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.input(anchorNotesListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
@@ -73,7 +70,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
getNote: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "anchorNote"))
|
||||
.input(noteIdInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
@@ -91,7 +88,7 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
return data;
|
||||
}),
|
||||
updateNote: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "anchorNote"))
|
||||
.input(anchorNoteUpdateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const normalizedContent = normalizeAnchorContent(input.content);
|
||||
@@ -115,6 +112,8 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||
});
|
||||
anchorNotesListRequestHandler.invalidateCache();
|
||||
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
|
||||
|
||||
return updatedNote;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.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 { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const audioStatsRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "audioStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const { kind } = ctx.integration;
|
||||
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } 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(createOneIntegrationMiddleware("query", "bazarr", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "bazarr"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { LiveStatsEvent } from "@homarr/integrations/types";
|
||||
import {
|
||||
beszelAlertsRequestHandler,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@homarr/request-handler/beszel";
|
||||
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } 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(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
@@ -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(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelAlerts"))
|
||||
.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(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
@@ -196,7 +196,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { radarrReleaseTypes } from "@homarr/integrations/types";
|
||||
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -25,7 +24,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
showUnmonitored: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "calendar"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -44,7 +43,7 @@ export const calendarRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
},
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -17,7 +17,7 @@ interface CoolifyInstanceResult {
|
||||
|
||||
export const coolifyRouter = createTRPCRouter({
|
||||
getInstancesInfo: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "coolify"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -32,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
|
||||
ctx.integrations,
|
||||
@@ -66,10 +68,11 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.enableAsync();
|
||||
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
}),
|
||||
|
||||
disable: protectedProcedure
|
||||
@@ -85,9 +88,10 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
duration: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "dnsHoleControls"))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.disableAsync(input.duration);
|
||||
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
}),
|
||||
});
|
||||
@@ -1,19 +1,15 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations";
|
||||
import { downloadClientItemSchema } from "@homarr/integrations/downloads";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { downloadClientRequestHandler } from "@homarr/request-handler/downloads";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient"));
|
||||
|
||||
interface DownloadClientQueryResultBase {
|
||||
integrationId: string;
|
||||
integrationName: string;
|
||||
@@ -42,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
|
||||
}
|
||||
);
|
||||
|
||||
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
|
||||
const results = await Promise.allSettled(mutations);
|
||||
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
|
||||
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure) throw failure.reason;
|
||||
};
|
||||
|
||||
export const downloadsRouter = createTRPCRouter({
|
||||
getJobsAndStatuses: publicProcedure
|
||||
.meta({
|
||||
@@ -51,7 +55,7 @@ export const downloadsRouter = createTRPCRouter({
|
||||
"Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
|
||||
},
|
||||
})
|
||||
.concat(createDownloadClientIntegrationMiddleware("query"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "downloads"))
|
||||
.input(z.object({ limitPerIntegration: z.number().default(50) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>(
|
||||
@@ -86,27 +90,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(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.pauseQueueAsync();
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
pauseItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.pauseItemAsync(input.item);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resume: protectedProcedure
|
||||
.meta({
|
||||
@@ -116,38 +120,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(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.resumeQueueAsync();
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resumeItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.resumeItemAsync(input.item);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.concat(createDownloadClientIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
firewallCpuRequestHandler,
|
||||
@@ -8,36 +6,37 @@ import {
|
||||
firewallVersionRequestHandler,
|
||||
} from "@homarr/request-handler/firewall";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const firewallMiddleware = createManyIntegrationMiddleware(
|
||||
"query",
|
||||
...getIntegrationKindsByCategory("firewall"),
|
||||
"mock",
|
||||
);
|
||||
const firewallMiddleware = createManyWidgetIntegrationMiddleware("query", "firewall");
|
||||
|
||||
interface FirewallResult<TSummary> {
|
||||
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
|
||||
summary: TSummary;
|
||||
error?: string;
|
||||
type FirewallIntegration = Parameters<typeof firewallCpuRequestHandler.handler>[0];
|
||||
|
||||
interface FirewallHandler<TData> {
|
||||
handler: (
|
||||
integration: FirewallIntegration,
|
||||
input: Record<string, never>,
|
||||
) => { getDataAsync: () => Promise<{ data: TData; timestamp: Date }> };
|
||||
}
|
||||
|
||||
const queryFirewall = <TSummary>(
|
||||
handler: {
|
||||
handler: (
|
||||
integration: any,
|
||||
input: Record<string, never>,
|
||||
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
|
||||
},
|
||||
createFallback: () => TSummary,
|
||||
getMockData: () => TSummary,
|
||||
) =>
|
||||
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) =>
|
||||
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
|
||||
settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration): Promise<FirewallResult<TSummary>> => {
|
||||
async (integration): Promise<FirewallQueryResult<TData>> => {
|
||||
if (integration.kind === "mock") {
|
||||
return {
|
||||
integration: {
|
||||
@@ -58,7 +57,7 @@ const queryFirewall = <TSummary>(
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error): FirewallResult<TSummary> => ({
|
||||
fallback: (integration, error): FirewallQueryResult<TData> => ({
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
|
||||
summary: createFallback(),
|
||||
error: toPublicIntegrationError(error),
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
||||
import { SynologyIntegration } from "@homarr/integrations";
|
||||
import { healthMonitoringClusterIntegrationKinds, healthMonitoringSystemIntegrationKinds } from "@homarr/definitions";
|
||||
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;
|
||||
@@ -34,7 +24,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", ...healthMonitoringIntegrationKinds))
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringSystemIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
|
||||
ctx.integrations,
|
||||
@@ -69,10 +59,6 @@ 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
|
||||
@@ -83,7 +69,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.concat(createOneIntegrationMiddleware("query", ...healthMonitoringClusterIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import {
|
||||
immichAlbumRequestHandler,
|
||||
@@ -8,23 +7,21 @@ import {
|
||||
immichStatsRequestHandler,
|
||||
} from "@homarr/request-handler/immich";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
|
||||
|
||||
export const immichRouter = createTRPCRouter({
|
||||
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;
|
||||
}),
|
||||
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;
|
||||
}),
|
||||
|
||||
getAlbum: publicProcedure
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.input(
|
||||
z.object({
|
||||
albumId: z.string().optional(),
|
||||
@@ -48,7 +45,7 @@ export const immichRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createImmichIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
if (input.limit === undefined) return [...mockWidgetData.immichAlbums];
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-manager";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("indexerManager"));
|
||||
|
||||
export const indexerManagerRouter = createTRPCRouter({
|
||||
getIndexersStatus: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "indexerManager"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -26,7 +21,7 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
indexers,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -41,9 +36,9 @@ export const indexerManagerRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
testAllIndexers: protectedProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("interact"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "indexerManager"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
const results = await Promise.allSettled(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.testAllAsync().catch((err) => {
|
||||
@@ -55,5 +50,9 @@ 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,9 +1,8 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -16,7 +15,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaMissing"))
|
||||
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
@@ -28,7 +27,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
...data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { getMediaReleaseResponse } from "./media-release-result";
|
||||
|
||||
export const mediaReleaseRouter = createTRPCRouter({
|
||||
getMediaReleases: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaReleases"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await getMediaReleaseResponse(ctx.integrations, async (integration) => {
|
||||
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types";
|
||||
import { mediaRequestStatusConfiguration } from "@homarr/integrations/types";
|
||||
import {
|
||||
@@ -11,7 +10,10 @@ import {
|
||||
} from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -30,7 +32,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, failed, completed). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all). OPTIONAL: statuses (array of statuses to include, must be non-empty) and recentDays (number 0-365; 0 disables the time filter).",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestList"))
|
||||
.input(mediaRequestListInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results: IntegrationQueryResult<MediaRequest[]>[] = await settleIntegrationQueries(
|
||||
@@ -40,7 +42,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -85,7 +87,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaRequests-requestStats"))
|
||||
.query(async ({ ctx }) => {
|
||||
const results: IntegrationQueryResult<{
|
||||
stats: MediaRequestStats["stats"] | null;
|
||||
@@ -96,8 +98,8 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
|
||||
return {
|
||||
integration: { id: integration.id, name: integration.name, kind: integration.kind },
|
||||
data: { ...data, stats: data.stats as typeof data.stats | null },
|
||||
error: undefined as string | undefined,
|
||||
data,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
@@ -134,7 +136,7 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
"Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
|
||||
},
|
||||
})
|
||||
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "mediaRequests-requestList"))
|
||||
.input(
|
||||
z.object({
|
||||
requestId: z.number(),
|
||||
@@ -150,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
} as const;
|
||||
|
||||
await answerActions[input.answer](input.requestId);
|
||||
mediaRequestListRequestHandler.invalidateCache();
|
||||
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -1,16 +1,11 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaService"));
|
||||
|
||||
export const mediaServerRouter = createTRPCRouter({
|
||||
getCurrentStreams: publicProcedure
|
||||
.meta({
|
||||
@@ -20,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
|
||||
},
|
||||
})
|
||||
.concat(createMediaServerIntegrationMiddleware("query"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
@@ -34,7 +29,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
integrationName: integration.name,
|
||||
integrationKind: integration.kind,
|
||||
sessions: data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaTranscodingRequestHandler } from "@homarr/request-handler/media-transcoding";
|
||||
import { paginatedSchema } from "@homarr/validation/common";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("mediaTranscoding"));
|
||||
|
||||
export const mediaTranscodingRouter = createTRPCRouter({
|
||||
getDataAsync: publicProcedure
|
||||
.concat(createIndexerManagerIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "mediaTranscoding"))
|
||||
.input(paginatedSchema.pick({ page: true, pageSize: true }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = mediaTranscodingRequestHandler.handler(ctx.integration, {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -22,7 +21,7 @@ interface NetworkControllerQueryResult {
|
||||
|
||||
export const networkControllerRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { notificationsRequestHandler } from "@homarr/request-handler/notifications";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("notifications"));
|
||||
|
||||
export const notificationsRouter = createTRPCRouter({
|
||||
getNotifications: publicProcedure
|
||||
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
|
||||
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "notifications"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -27,7 +22,7 @@ export const notificationsRouter = createTRPCRouter({
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
data,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const paperlessNgxRouter = createTRPCRouter({
|
||||
getStats: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "paperlessNgx"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
|
||||
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } 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(createOneIntegrationMiddleware("query", "patchmon", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "patchmon"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
|
||||
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { z } from "zod/v4";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import {
|
||||
smartHomeEntityStateRequestHandler,
|
||||
toSafeEntityDetails,
|
||||
} from "@homarr/request-handler/smart-home-entity-state";
|
||||
|
||||
import type { IntegrationAction } from "../../middlewares/integration";
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
|
||||
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
|
||||
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("smartHomeServer"));
|
||||
|
||||
export const smartHomeRouter = createTRPCRouter({
|
||||
entityState: publicProcedure
|
||||
.meta({
|
||||
@@ -25,7 +20,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
@@ -40,7 +35,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.concat(createSmartHomeIntegrationMiddleware("query"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "smartHome-entityState"))
|
||||
.query(async ({ ctx: { integration }, input }) => {
|
||||
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
@@ -54,7 +49,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
"Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
|
||||
},
|
||||
})
|
||||
.concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-entityState"))
|
||||
.input(z.object({ entityId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
@@ -62,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
if (!success) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
|
||||
}
|
||||
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return true;
|
||||
}),
|
||||
executeAutomation: protectedProcedure
|
||||
@@ -72,7 +68,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
"Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
|
||||
},
|
||||
})
|
||||
.concat(createSmartHomeIntegrationMiddleware("interact"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("interact", "smartHome-executeAutomation"))
|
||||
.input(z.object({ automationId: z.string() }))
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
@@ -80,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
if (!success) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
|
||||
}
|
||||
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const speedtestTrackerRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "speedtestTracker"))
|
||||
.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 as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const tracearrRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "tracearr", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "tracearr"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } 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(createManyIntegrationMiddleware("query", "traefik", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "traefik"))
|
||||
.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 as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,8 @@ import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import {
|
||||
umamiActiveVisitorsRequestHandler,
|
||||
umamiEventNamesRequestHandler,
|
||||
@@ -12,25 +13,26 @@ import {
|
||||
umamiTopReferrersRequestHandler,
|
||||
} from "@homarr/request-handler/umami";
|
||||
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import {
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
const logger = createLogger({ module: "umami-router" });
|
||||
|
||||
export const umamiRouter = createTRPCRouter({
|
||||
getWebsites: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "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 [];
|
||||
}
|
||||
}),
|
||||
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 [];
|
||||
}
|
||||
}),
|
||||
|
||||
getVisitorStats: publicProcedure
|
||||
.input(
|
||||
@@ -40,7 +42,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
eventName: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", "umami", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
|
||||
if (integration.kind === "mock") {
|
||||
@@ -74,7 +76,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
|
||||
getEventNames: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
|
||||
try {
|
||||
@@ -102,7 +104,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
|
||||
try {
|
||||
@@ -136,7 +138,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
|
||||
try {
|
||||
@@ -170,7 +172,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
eventNames: z.array(z.string()),
|
||||
}),
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
|
||||
@@ -201,7 +203,7 @@ export const umamiRouter = createTRPCRouter({
|
||||
|
||||
getActiveVisitors: publicProcedure
|
||||
.input(z.object({ websiteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "umami"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (ctx.integration.kind === "mock") return 7;
|
||||
try {
|
||||
|
||||
@@ -1,40 +1,37 @@
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const upsRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
getSummaries: publicProcedure.concat(createManyWidgetIntegrationMiddleware("query", "ups")).query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
async (integration) => {
|
||||
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
|
||||
const { data, timestamp } = await innerHandler.getDataAsync();
|
||||
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
fallback: (integration, error) => ({
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: [],
|
||||
updatedAt: new Date(0),
|
||||
error: toPublicIntegrationError(error),
|
||||
}),
|
||||
throwOnAllFailures: true,
|
||||
},
|
||||
);
|
||||
}),
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
integrationUrl: integration.url,
|
||||
summaries: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
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 { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const uptimeKumaRouter = createTRPCRouter({
|
||||
getDashboard: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock"))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "uptimeKuma"))
|
||||
.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 as typeof data | null,
|
||||
dashboard: data,
|
||||
updatedAt: timestamp,
|
||||
error: undefined as string | undefined,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -16,7 +15,7 @@ interface VpnResult {
|
||||
|
||||
export const vpnRouter = createTRPCRouter({
|
||||
getSummaries: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock"))
|
||||
.unstable_concat(createManyWidgetIntegrationMiddleware("query", "vpn"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries(
|
||||
ctx.integrations,
|
||||
@@ -43,8 +42,8 @@ export const vpnRouter = createTRPCRouter({
|
||||
kind: integration.kind,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
summary: data as typeof data | null,
|
||||
error: undefined as string | undefined,
|
||||
summary: data,
|
||||
error: undefined,
|
||||
};
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
|
||||
import { mockWidgetData } from "@homarr/integrations";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } 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(createOneIntegrationMiddleware("query", "wud", "mock"))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "wud"))
|
||||
.query(async ({ ctx }) => {
|
||||
if (ctx.integration.kind === "mock") {
|
||||
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
|
||||
|
||||
@@ -15,18 +15,18 @@ interface IntegrationLike {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface Options<TIntegration extends IntegrationLike, TResult> {
|
||||
fallback?: (integration: TIntegration, error: unknown) => TResult;
|
||||
interface Options<TIntegration extends IntegrationLike, TFallback> {
|
||||
fallback?: (integration: TIntegration, error: unknown) => TFallback;
|
||||
throwOnAllFailures?: boolean;
|
||||
}
|
||||
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
|
||||
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult, TFallback = TResult>(
|
||||
integrations: TIntegration[],
|
||||
fn: (integration: TIntegration) => Promise<TResult>,
|
||||
options?: Options<TIntegration, TResult>,
|
||||
): Promise<TResult[]> {
|
||||
options?: Options<TIntegration, TFallback>,
|
||||
): Promise<(TResult | TFallback)[]> {
|
||||
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
|
||||
const results: TResult[] = [];
|
||||
const results: (TResult | TFallback)[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
settled.forEach((result, index) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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);
|
||||
@@ -29,3 +30,14 @@ 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 = () =>
|
||||
export const createRedisClient = (options: RedisOptions = {}) =>
|
||||
redisEnv.IS_EXTERNAL
|
||||
? new Redis({
|
||||
...defaultRedisOptions,
|
||||
@@ -23,5 +23,6 @@ export const createRedisClient = () =>
|
||||
: undefined,
|
||||
username: redisEnv.USERNAME,
|
||||
password: redisEnv.PASSWORD,
|
||||
...options,
|
||||
})
|
||||
: new Redis(defaultRedisOptions);
|
||||
: new Redis({ ...defaultRedisOptions, ...options });
|
||||
@@ -14,6 +14,7 @@
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./cookie": "./src/cookie.ts",
|
||||
"./integration": "./src/integration.ts",
|
||||
"./docs/*": "./src/docs/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
import { integrationDefs } from "./integration";
|
||||
import { getIntegrationDockerMetadata, integrationDefs } from "./integration";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
|
||||
export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
@@ -14,30 +14,6 @@ export const integrationIconSlugs: Record<IntegrationKind, string> = Object.from
|
||||
objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]),
|
||||
) as Record<IntegrationKind, string>;
|
||||
|
||||
const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> = {
|
||||
piHole: ["pihole", "pi-hole"],
|
||||
adGuardHome: ["adguardhome", "adguard-home", "adguard"],
|
||||
homeAssistant: ["homeassistant", "home-assistant", "hass"],
|
||||
openmediavault: ["omv"],
|
||||
qBittorrent: ["qbittorrent"],
|
||||
sabNzbd: ["sabnzbd"],
|
||||
nzbGet: ["nzbget"],
|
||||
unifiController: ["unifi", "unifi-controller"],
|
||||
jellyseerr: ["jellyseerr"],
|
||||
overseerr: ["overseerr"],
|
||||
dashDot: ["dashdot", "dash-dot", "dash."],
|
||||
speedtestTracker: ["speedtest-tracker"],
|
||||
uptimeKuma: ["uptime-kuma"],
|
||||
audiobookshelf: ["audiobookshelf"],
|
||||
navidrome: ["navidrome"],
|
||||
paperlessNgx: ["paperless-ngx", "paperless"],
|
||||
patchmon: ["patchmon", "patch-mon"],
|
||||
coolify: ["coolify"],
|
||||
truenas: ["truenas"],
|
||||
bazarr: ["bazarr"],
|
||||
synology: ["synology", "diskstation"],
|
||||
};
|
||||
|
||||
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
|
||||
const normalized = search.toLowerCase().trim();
|
||||
if (!normalized) return null;
|
||||
@@ -51,8 +27,8 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
|
||||
if (integrationIconSlugs[kind] === normalized) return kind;
|
||||
}
|
||||
|
||||
for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) {
|
||||
if (aliases.some((alias) => alias === normalized)) return kind;
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
if (getIntegrationDockerMetadata(kind).aliases.some((alias) => alias === normalized)) return kind;
|
||||
}
|
||||
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
@@ -63,8 +39,6 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
|
||||
return null;
|
||||
};
|
||||
|
||||
const notDockerDiscoverable = new Set<IntegrationKind>(["ical", "mock"]);
|
||||
|
||||
interface ContainerMatchInput {
|
||||
image: string;
|
||||
name: string;
|
||||
@@ -73,10 +47,10 @@ interface ContainerMatchInput {
|
||||
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
|
||||
const imageName = extractContainerImageName(container.image);
|
||||
const fromImage = matchIntegrationKind(imageName);
|
||||
if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage;
|
||||
if (fromImage && getIntegrationDockerMetadata(fromImage).discoverable) return fromImage;
|
||||
|
||||
const fromName = matchIntegrationKind(container.name);
|
||||
if (fromName && !notDockerDiscoverable.has(fromName)) return fromName;
|
||||
if (fromName && getIntegrationDockerMetadata(fromName).discoverable) return fromName;
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -5,8 +5,8 @@ import { XMLParser } from "fast-xml-parser";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createDocumentationLink } from "./index";
|
||||
import { integrationDocSlugs } from "./integration-doc-slugs";
|
||||
import { widgetDocSlugs } from "./widget-doc-slugs";
|
||||
import { getIntegrationDocumentationSlug, integrationKinds } from "../integration";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -60,7 +60,8 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
|
||||
};
|
||||
|
||||
const slugMapPaths = [
|
||||
...Object.values(integrationDocSlugs)
|
||||
...integrationKinds
|
||||
.map(getIntegrationDocumentationSlug)
|
||||
.filter(Boolean)
|
||||
.map((slug) => `/docs/integrations/${slug}`),
|
||||
...Object.values(widgetDocSlugs)
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
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: Record<WidgetKind, string | null> = {
|
||||
export const widgetDocSlugs = {
|
||||
clock: "clock",
|
||||
weather: "weather",
|
||||
airQuality: "air-quality",
|
||||
@@ -60,4 +60,4 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
customApi: "custom-api",
|
||||
assistant: "assistant",
|
||||
wud: "whats-up-docker",
|
||||
};
|
||||
} satisfies Record<WidgetKind, string | null>;
|
||||
@@ -1,7 +1,12 @@
|
||||
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 },
|
||||
@@ -22,14 +27,29 @@ export const integrationSecretKindObject = {
|
||||
slug: { isPublic: true, multiline: false },
|
||||
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
|
||||
|
||||
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
||||
const isIntegrationSecretKind = (value: string): value is IntegrationSecretKind =>
|
||||
Object.hasOwn(integrationSecretKindObject, value);
|
||||
|
||||
interface integrationDefinition {
|
||||
export const integrationSecretKinds = Object.keys(integrationSecretKindObject).filter(isIntegrationSecretKind);
|
||||
|
||||
interface IntegrationFeatureMetadata {
|
||||
docker?: {
|
||||
aliases?: readonly string[];
|
||||
discoverable?: boolean;
|
||||
};
|
||||
onboarding?: {
|
||||
featuredOrder?: number;
|
||||
hidden?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface IntegrationDefinition {
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
|
||||
category: AtLeastOneOf<IntegrationCategory>;
|
||||
documentationUrl: string | null;
|
||||
secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
|
||||
category: readonly [IntegrationCategory, ...IntegrationCategory[]];
|
||||
documentationSlug: IntegrationDocumentationSlug | null;
|
||||
features?: IntegrationFeatureMetadata;
|
||||
defaultUrl?: string;
|
||||
defaultPort?: number;
|
||||
apiKeySettingsPath?: string;
|
||||
@@ -41,24 +61,29 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
|
||||
category: ["downloadClient", "usenet"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"),
|
||||
documentationSlug: "sabnzbd",
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/config/general/",
|
||||
features: {
|
||||
docker: { aliases: ["sabnzbd"] },
|
||||
onboarding: { featuredOrder: 3 },
|
||||
},
|
||||
},
|
||||
nzbGet: {
|
||||
name: "NZBGet",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
|
||||
category: ["downloadClient", "usenet"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/nzbget"),
|
||||
documentationSlug: "nzbget",
|
||||
defaultPort: 6789,
|
||||
features: { docker: { aliases: ["nzbget"] } },
|
||||
},
|
||||
deluge: {
|
||||
name: "Deluge",
|
||||
secretKinds: [["password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/deluge"),
|
||||
documentationSlug: "deluge",
|
||||
defaultPort: 8112,
|
||||
},
|
||||
transmission: {
|
||||
@@ -66,7 +91,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/transmission"),
|
||||
documentationSlug: "transmission",
|
||||
defaultPort: 9091,
|
||||
},
|
||||
qBittorrent: {
|
||||
@@ -74,16 +99,20 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
|
||||
category: ["downloadClient", "torrent"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"),
|
||||
documentationSlug: "q-bittorent",
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/#/settings/webui",
|
||||
features: {
|
||||
docker: { aliases: ["qbittorrent"] },
|
||||
onboarding: { featuredOrder: 4 },
|
||||
},
|
||||
},
|
||||
aria2: {
|
||||
name: "Aria2",
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
|
||||
category: ["downloadClient", "torrent", "miscellaneous"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/aria2"),
|
||||
documentationSlug: "aria2",
|
||||
defaultPort: 6800,
|
||||
},
|
||||
slskd: {
|
||||
@@ -91,7 +120,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
|
||||
category: ["downloadClient", "miscellaneous"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/slskd"),
|
||||
documentationSlug: "slskd",
|
||||
defaultPort: 5030,
|
||||
},
|
||||
sonarr: {
|
||||
@@ -99,25 +128,27 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
|
||||
category: ["calendar", "mediaOrganizer"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
|
||||
documentationSlug: "sonarr",
|
||||
defaultPort: 8989,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
features: { onboarding: { featuredOrder: 0 } },
|
||||
},
|
||||
radarr: {
|
||||
name: "Radarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
|
||||
category: ["calendar", "mediaOrganizer"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
|
||||
documentationSlug: "radarr",
|
||||
defaultPort: 7878,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
features: { onboarding: { featuredOrder: 1 } },
|
||||
},
|
||||
lidarr: {
|
||||
name: "Lidarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"),
|
||||
documentationSlug: "lidarr",
|
||||
defaultPort: 8686,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
@@ -126,7 +157,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@main/png/readarr.png",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
|
||||
documentationSlug: "readarr",
|
||||
defaultPort: 8787,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
},
|
||||
@@ -135,34 +166,37 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
|
||||
category: ["indexerManager"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"),
|
||||
documentationSlug: "prowlarr",
|
||||
defaultPort: 9696,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
features: { onboarding: { featuredOrder: 2 } },
|
||||
},
|
||||
bazarr: {
|
||||
name: "Bazarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/bazarr.svg",
|
||||
category: ["subtitleManager"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/bazarr"),
|
||||
documentationSlug: "bazarr",
|
||||
defaultPort: 6767,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
features: { docker: { aliases: ["bazarr"] } },
|
||||
},
|
||||
jellyfin: {
|
||||
name: "Jellyfin",
|
||||
secretKinds: [["username", "password"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
||||
category: ["mediaService", "mediaRelease"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"),
|
||||
documentationSlug: "jellyfin",
|
||||
defaultPort: 8096,
|
||||
apiKeySettingsPath: "/web/index.html#!/dashboard/keys",
|
||||
features: { onboarding: { featuredOrder: 6 } },
|
||||
},
|
||||
emby: {
|
||||
name: "Emby",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
|
||||
category: ["mediaService", "mediaRelease"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/emby"),
|
||||
documentationSlug: "emby",
|
||||
defaultPort: 8096,
|
||||
},
|
||||
plex: {
|
||||
@@ -170,7 +204,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
|
||||
category: ["mediaService", "mediaRelease"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/plex"),
|
||||
documentationSlug: "plex",
|
||||
defaultPort: 32400,
|
||||
},
|
||||
jellyseerr: {
|
||||
@@ -178,50 +212,55 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"),
|
||||
documentationSlug: "jellyseerr",
|
||||
defaultPort: 5055,
|
||||
apiKeySettingsPath: "/settings",
|
||||
features: { docker: { aliases: ["jellyseerr"] } },
|
||||
},
|
||||
seerr: {
|
||||
name: "Seerr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/seerr"),
|
||||
documentationSlug: "seerr",
|
||||
defaultPort: 5055,
|
||||
apiKeySettingsPath: "/settings",
|
||||
features: { onboarding: { featuredOrder: 5 } },
|
||||
},
|
||||
overseerr: {
|
||||
name: "Overseerr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
|
||||
category: ["mediaSearch", "mediaRequest", "search"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/overseerr"),
|
||||
documentationSlug: "overseerr",
|
||||
defaultPort: 5055,
|
||||
apiKeySettingsPath: "/settings",
|
||||
features: { docker: { aliases: ["overseerr"] } },
|
||||
},
|
||||
piHole: {
|
||||
name: "Pi-hole",
|
||||
secretKinds: [["apiKey"], []],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
|
||||
category: ["dnsHole"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"),
|
||||
documentationSlug: "pi-hole",
|
||||
defaultPort: 80,
|
||||
features: { docker: { aliases: ["pihole", "pi-hole"] } },
|
||||
},
|
||||
adGuardHome: {
|
||||
name: "AdGuard Home",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
|
||||
category: ["dnsHole"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"),
|
||||
documentationSlug: "adguard-home",
|
||||
defaultPort: 3000,
|
||||
features: { docker: { aliases: ["adguardhome", "adguard-home", "adguard"] } },
|
||||
},
|
||||
technitiumDns: {
|
||||
name: "Technitium DNS",
|
||||
secretKinds: [["apiKey"], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg",
|
||||
category: ["dnsHole"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/technitium-dns"),
|
||||
documentationSlug: "technitium-dns",
|
||||
defaultPort: 5380,
|
||||
},
|
||||
homeAssistant: {
|
||||
@@ -229,32 +268,35 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
|
||||
category: ["smartHomeServer", "calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
|
||||
documentationSlug: "home-assistant",
|
||||
defaultPort: 8123,
|
||||
apiKeySettingsPath: "/profile/security",
|
||||
features: { docker: { aliases: ["homeassistant", "home-assistant", "hass"] } },
|
||||
},
|
||||
openmediavault: {
|
||||
name: "OpenMediaVault",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"),
|
||||
documentationSlug: "open-media-vault",
|
||||
defaultPort: 80,
|
||||
features: { docker: { aliases: ["omv"] } },
|
||||
},
|
||||
dashDot: {
|
||||
name: "Dash.",
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"),
|
||||
documentationSlug: "dash-dot",
|
||||
defaultPort: 3001,
|
||||
features: { docker: { aliases: ["dashdot", "dash-dot", "dash."] } },
|
||||
},
|
||||
glances: {
|
||||
name: "Glances",
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/glances"),
|
||||
documentationSlug: "glances",
|
||||
defaultPort: 61208,
|
||||
},
|
||||
tdarr: {
|
||||
@@ -262,7 +304,7 @@ export const integrationDefs = {
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
|
||||
category: ["mediaTranscoding"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
|
||||
documentationSlug: "tdarr",
|
||||
defaultPort: 8265,
|
||||
},
|
||||
proxmox: {
|
||||
@@ -270,7 +312,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"),
|
||||
documentationSlug: "proxmox",
|
||||
defaultPort: 8006,
|
||||
},
|
||||
nextcloud: {
|
||||
@@ -278,7 +320,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
|
||||
category: ["calendar", "notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"),
|
||||
documentationSlug: "nextcloud",
|
||||
defaultPort: 443,
|
||||
},
|
||||
unifiController: {
|
||||
@@ -286,15 +328,16 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
|
||||
category: ["networkController"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
|
||||
documentationSlug: "unifi-controller",
|
||||
defaultPort: 8443,
|
||||
features: { docker: { aliases: ["unifi", "unifi-controller"] } },
|
||||
},
|
||||
opnsense: {
|
||||
name: "OPNsense",
|
||||
secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
|
||||
category: ["firewall"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
|
||||
documentationSlug: "opnsense",
|
||||
defaultPort: 443,
|
||||
apiKeySettingsPath: "/system_usermanager.php",
|
||||
},
|
||||
@@ -303,7 +346,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["topic"], ["topic", "apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
|
||||
category: ["notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
|
||||
documentationSlug: "ntfy",
|
||||
defaultPort: 80,
|
||||
apiKeySettingsPath: "/account",
|
||||
},
|
||||
@@ -312,7 +355,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg",
|
||||
category: ["notifications"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/gotify"),
|
||||
documentationSlug: "gotify",
|
||||
defaultPort: 80,
|
||||
},
|
||||
ical: {
|
||||
@@ -320,14 +363,15 @@ export const integrationDefs = {
|
||||
secretKinds: [["url"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ical.svg",
|
||||
category: ["calendar"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/ical"),
|
||||
documentationSlug: "ical",
|
||||
features: { docker: { discoverable: false } },
|
||||
},
|
||||
anchor: {
|
||||
name: "Anchor",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
|
||||
category: ["notes"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
|
||||
documentationSlug: "anchor",
|
||||
defaultPort: 8080,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
@@ -336,23 +380,25 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
|
||||
documentationSlug: "truenas",
|
||||
defaultPort: 80,
|
||||
features: { docker: { aliases: ["truenas"] } },
|
||||
},
|
||||
synology: {
|
||||
name: "Synology DiskStation",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/synology"),
|
||||
documentationSlug: "synology",
|
||||
defaultPort: 5000,
|
||||
features: { docker: { aliases: ["synology", "diskstation"] } },
|
||||
},
|
||||
unraid: {
|
||||
name: "Unraid",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
|
||||
documentationSlug: "unraid",
|
||||
defaultPort: 80,
|
||||
apiKeySettingsPath: "/Settings/ManagementAccess",
|
||||
},
|
||||
@@ -361,16 +407,17 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
|
||||
documentationSlug: "coolify",
|
||||
defaultPort: 8000,
|
||||
apiKeySettingsPath: "/security/api-tokens",
|
||||
features: { docker: { aliases: ["coolify"] } },
|
||||
},
|
||||
immich: {
|
||||
name: "Immich",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg",
|
||||
category: ["photoService"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
|
||||
documentationSlug: "immich",
|
||||
defaultPort: 2283,
|
||||
apiKeySettingsPath: "/user-settings",
|
||||
},
|
||||
@@ -379,24 +426,26 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
|
||||
category: ["documents"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/paperless-ngx"),
|
||||
documentationSlug: "paperless-ngx",
|
||||
defaultPort: 8000,
|
||||
features: { docker: { aliases: ["paperless-ngx", "paperless"] } },
|
||||
},
|
||||
patchmon: {
|
||||
name: "PatchMon",
|
||||
secretKinds: [["patchmonApiKey", "patchmonApiSecret"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/patchmon.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/patchmon"),
|
||||
documentationSlug: "patchmon",
|
||||
defaultPort: 3413,
|
||||
apiKeySettingsPath: "/settings/integrations",
|
||||
features: { docker: { aliases: ["patchmon", "patch-mon"] } },
|
||||
},
|
||||
tracearr: {
|
||||
name: "Tracearr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg",
|
||||
category: ["mediaMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
|
||||
documentationSlug: "tracearr",
|
||||
defaultPort: 7040,
|
||||
apiKeySettingsPath: "/settings",
|
||||
},
|
||||
@@ -405,35 +454,39 @@ export const integrationDefs = {
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
|
||||
category: ["speedtest"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
|
||||
documentationSlug: "speedtest-tracker",
|
||||
defaultPort: 80,
|
||||
apiKeySettingsPath: "/admin/api-tokens",
|
||||
features: { docker: { aliases: ["speedtest-tracker"] } },
|
||||
},
|
||||
uptimeKuma: {
|
||||
name: "Uptime Kuma",
|
||||
secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
|
||||
category: ["uptimeMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/uptime-kuma"),
|
||||
documentationSlug: "uptime-kuma",
|
||||
defaultPort: 3001,
|
||||
apiKeySettingsPath: "/settings/api-keys",
|
||||
features: { docker: { aliases: ["uptime-kuma"] } },
|
||||
},
|
||||
audiobookshelf: {
|
||||
name: "Audiobookshelf",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
|
||||
category: ["mediaLibrary"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/audiobookshelf"),
|
||||
documentationSlug: "audiobookshelf",
|
||||
defaultPort: 13378,
|
||||
apiKeySettingsPath: "/account",
|
||||
features: { docker: { aliases: ["audiobookshelf"] } },
|
||||
},
|
||||
navidrome: {
|
||||
name: "Navidrome",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
|
||||
category: ["mediaLibrary", "mediaService"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/navidrome"),
|
||||
documentationSlug: "navidrome",
|
||||
defaultPort: 4533,
|
||||
features: { docker: { aliases: ["navidrome"] } },
|
||||
},
|
||||
umami: {
|
||||
name: "Umami",
|
||||
@@ -441,7 +494,7 @@ export const integrationDefs = {
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/umami.svg",
|
||||
category: ["analytics"],
|
||||
defaultUrl: "https://api.umami.is/v1",
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
|
||||
documentationSlug: "umami",
|
||||
defaultPort: 3000,
|
||||
},
|
||||
peaNut: {
|
||||
@@ -449,7 +502,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"], []],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
|
||||
category: ["ups"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/peanut"),
|
||||
documentationSlug: "peanut",
|
||||
defaultPort: 8080,
|
||||
},
|
||||
beszel: {
|
||||
@@ -457,7 +510,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
category: ["beszel"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
|
||||
documentationSlug: "beszel",
|
||||
defaultPort: 8090,
|
||||
},
|
||||
gluetun: {
|
||||
@@ -465,7 +518,7 @@ export const integrationDefs = {
|
||||
secretKinds: [["username", "password"], ["apiKey"], []],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
|
||||
category: ["vpn"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
|
||||
documentationSlug: "gluetun",
|
||||
defaultPort: 8000,
|
||||
},
|
||||
traefik: {
|
||||
@@ -473,7 +526,7 @@ export const integrationDefs = {
|
||||
secretKinds: [[], ["username", "password"], ["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/traefik.svg",
|
||||
category: ["reverseProxy"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/traefik"),
|
||||
documentationSlug: "traefik",
|
||||
defaultPort: 8080,
|
||||
},
|
||||
archiveTeamWarrior: {
|
||||
@@ -481,7 +534,7 @@ export const integrationDefs = {
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
|
||||
category: ["archiving"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
|
||||
documentationSlug: "archiveteam-warrior",
|
||||
defaultPort: 8001,
|
||||
},
|
||||
wud: {
|
||||
@@ -489,7 +542,7 @@ export const integrationDefs = {
|
||||
secretKinds: [[], ["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
|
||||
documentationSlug: "whats-up-docker",
|
||||
defaultPort: 3000,
|
||||
},
|
||||
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
|
||||
@@ -514,21 +567,36 @@ export const integrationDefs = {
|
||||
"uptimeMonitoring",
|
||||
"mediaOrganizer",
|
||||
],
|
||||
documentationUrl: null,
|
||||
documentationSlug: null,
|
||||
features: {
|
||||
docker: { discoverable: false },
|
||||
onboarding: { hidden: true },
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
} as const satisfies Record<string, IntegrationDefinition>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
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 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[]> =>
|
||||
integrationDefs[integration].secretKinds;
|
||||
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> => {
|
||||
const [first, ...rest] = integrationDefs[integration].secretKinds;
|
||||
return [[...first], ...rest.map((secretKinds) => [...secretKinds])];
|
||||
};
|
||||
|
||||
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
|
||||
const definition = integrationDefs[integration];
|
||||
@@ -540,8 +608,30 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
|
||||
return "defaultPort" in definition ? definition.defaultPort : undefined;
|
||||
};
|
||||
|
||||
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null =>
|
||||
integrationDefs[kind].documentationUrl;
|
||||
export const getIntegrationDocumentationSlug = (kind: IntegrationKind): IntegrationDocumentationSlug | null =>
|
||||
integrationDefs[kind].documentationSlug;
|
||||
|
||||
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => {
|
||||
const slug = getIntegrationDocumentationSlug(kind);
|
||||
if (slug === null) return null;
|
||||
return createDocumentationLink(`/docs/integrations/${slug}`);
|
||||
};
|
||||
|
||||
export const getIntegrationDockerMetadata = (kind: IntegrationKind) => {
|
||||
const definition: IntegrationDefinition = integrationDefs[kind];
|
||||
return {
|
||||
aliases: definition.features?.docker?.aliases ?? [],
|
||||
discoverable: definition.features?.docker?.discoverable ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => {
|
||||
const definition: IntegrationDefinition = integrationDefs[kind];
|
||||
return {
|
||||
featuredOrder: definition.features?.onboarding?.featuredOrder ?? null,
|
||||
hidden: definition.features?.onboarding?.hidden ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
|
||||
const definition = integrationDefs[kind];
|
||||
@@ -567,9 +657,12 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio
|
||||
* @returns Partial list of integration kinds
|
||||
*/
|
||||
export const getIntegrationKindsByCategory = <TCategory extends IntegrationCategory>(category: TCategory) => {
|
||||
return objectKeys(integrationDefs).filter((integration) =>
|
||||
integrationDefs[integration].category.some((defCategory) => defCategory === category),
|
||||
) as AtLeastOneOf<IntegrationKindByCategory<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>>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -579,10 +672,8 @@ export type IntegrationKindByCategory<TCategory extends IntegrationCategory> = {
|
||||
[Key in keyof typeof integrationDefs]: TCategory extends (typeof integrationDefs)[Key]["category"][number]
|
||||
? Key
|
||||
: never;
|
||||
}[keyof typeof integrationDefs] extends infer U
|
||||
? //Needed to simplify the type when using it
|
||||
U
|
||||
: never;
|
||||
}[keyof typeof integrationDefs] &
|
||||
IntegrationKind;
|
||||
|
||||
export type IntegrationSecretKind = keyof typeof integrationSecretKindObject;
|
||||
export type IntegrationKind = keyof typeof integrationDefs;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getIntegrationApiKeyUrl, integrationDefs } from "../integration";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import { getIntegrationApiKeyUrl, getIntegrationDocumentationUrl, integrationDefs } from "../integration";
|
||||
|
||||
describe("getIntegrationApiKeyUrl", () => {
|
||||
it("links every user-facing integration to its Homarr setup guide", () => {
|
||||
expect(
|
||||
Object.entries(integrationDefs)
|
||||
.filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl)
|
||||
objectEntries(integrationDefs)
|
||||
.filter(([kind]) => kind !== "mock" && !getIntegrationDocumentationUrl(kind))
|
||||
.map(([kind]) => kind),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -4,19 +4,19 @@ import { integrationKinds } from "../integration";
|
||||
import {
|
||||
getWidgetKindsForIntegration,
|
||||
integrationWidgetSupport,
|
||||
nativeFeatureCapabilities,
|
||||
widgetIntegrationConfigs,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
} from "../widget-integration-map";
|
||||
|
||||
describe("native feature capability descriptor", () => {
|
||||
describe("widget integration config", () => {
|
||||
test("derives forward and reverse support without losing relationships", () => {
|
||||
for (const [widgetKind, capability] of Object.entries(nativeFeatureCapabilities)) {
|
||||
for (const [widgetKind, capability] of Object.entries(widgetIntegrationConfigs)) {
|
||||
expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual(
|
||||
capability.integrations,
|
||||
capability.supportedIntegrations,
|
||||
);
|
||||
for (const integrationKind of capability.integrations) {
|
||||
for (const integrationKind of capability.supportedIntegrations) {
|
||||
expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind);
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ describe("native feature capability descriptor", () => {
|
||||
});
|
||||
|
||||
test("supports the mock integration for every integration-backed widget", () => {
|
||||
for (const capability of Object.values(nativeFeatureCapabilities)) {
|
||||
expect(capability.integrations).toContain("mock");
|
||||
for (const config of Object.values(widgetIntegrationConfigs)) {
|
||||
expect(config.supportedIntegrations).toContain("mock");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,82 +1,108 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** Integration selection shared by the widget UI and API trust boundary. */
|
||||
export interface WidgetIntegrationConfig {
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
integrationsRequired?: false;
|
||||
maxIntegrations?: number;
|
||||
}
|
||||
|
||||
export const nativeFeatureCapabilities = {
|
||||
dnsHoleSummary: { integrations: getIntegrationKindsByCategory("dnsHole") },
|
||||
dnsHoleControls: { integrations: getIntegrationKindsByCategory("dnsHole") },
|
||||
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") },
|
||||
"smartHome-entityState": {
|
||||
integrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
serverMaxIntegrations: 1,
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
maxIntegrations: 1,
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
integrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
serverMaxIntegrations: 1,
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
maxIntegrations: 1,
|
||||
},
|
||||
mediaServer: { integrations: getIntegrationKindsByCategory("mediaService") },
|
||||
calendar: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true },
|
||||
downloads: { integrations: getIntegrationKindsByCategory("downloadClient") },
|
||||
"mediaRequests-requestList": { integrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
"mediaRequests-requestStats": { integrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
mediaTranscoding: { integrations: getIntegrationKindsByCategory("mediaTranscoding"), serverMaxIntegrations: 1 },
|
||||
mediaMissing: { integrations: getIntegrationKindsByCategory("mediaOrganizer") },
|
||||
networkControllerSummary: { integrations: getIntegrationKindsByCategory("networkController") },
|
||||
networkControllerStatus: { integrations: getIntegrationKindsByCategory("networkController") },
|
||||
indexerManager: { integrations: getIntegrationKindsByCategory("indexerManager") },
|
||||
mediaServer: { supportedIntegrations: getIntegrationKindsByCategory("mediaService") },
|
||||
calendar: { supportedIntegrations: getIntegrationKindsByCategory("calendar"), integrationsRequired: false },
|
||||
downloads: { supportedIntegrations: getIntegrationKindsByCategory("downloadClient") },
|
||||
"mediaRequests-requestList": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
"mediaRequests-requestStats": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
mediaTranscoding: {
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
maxIntegrations: 1,
|
||||
},
|
||||
mediaMissing: { supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer") },
|
||||
networkControllerSummary: { supportedIntegrations: getIntegrationKindsByCategory("networkController") },
|
||||
networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") },
|
||||
indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") },
|
||||
healthMonitoring: {
|
||||
integrations: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
supportedIntegrations: [...healthMonitoringGeneralIntegrationKinds, ...healthMonitoringClusterIntegrationKinds],
|
||||
},
|
||||
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>>;
|
||||
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>>;
|
||||
|
||||
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
|
||||
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 createWidgetIntegrationSupport = () => {
|
||||
const result: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {};
|
||||
for (const [kind, capability] of nativeFeatureCapabilityEntries) result[kind] = capability.integrations;
|
||||
for (const [kind, config] of widgetIntegrationConfigEntries) result[kind] = config.supportedIntegrations;
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -84,16 +110,15 @@ export const widgetIntegrationSupport = createWidgetIntegrationSupport();
|
||||
|
||||
/** Widgets that remain useful without a configured integration. */
|
||||
export const widgetKindsWithOptionalIntegrations = new Set<WidgetKind>(
|
||||
nativeFeatureCapabilityEntries
|
||||
.filter(([, capability]) => capability.connectionOptional === true)
|
||||
widgetIntegrationConfigEntries
|
||||
.filter(([, config]) => "integrationsRequired" in config && config.integrationsRequired === false)
|
||||
.map(([kind]) => kind),
|
||||
);
|
||||
|
||||
/** Widgets that support fewer integrations than the default unlimited selection. */
|
||||
const createWidgetIntegrationLimits = () => {
|
||||
const result: Partial<Record<WidgetKind, number>> = {};
|
||||
for (const [kind, capability] of nativeFeatureCapabilityEntries) {
|
||||
if (capability.serverMaxIntegrations !== undefined) result[kind] = capability.serverMaxIntegrations;
|
||||
for (const [kind, config] of widgetIntegrationConfigEntries) {
|
||||
if ("maxIntegrations" in config) result[kind] = config.maxIntegrations;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -108,21 +133,26 @@ export type WidgetIntegrationIssue =
|
||||
|
||||
export const getWidgetIntegrationIssue = (
|
||||
widgetKind: WidgetKind,
|
||||
integrationKinds: readonly IntegrationKind[],
|
||||
selectedIntegrationKinds: readonly IntegrationKind[],
|
||||
): WidgetIntegrationIssue | null => {
|
||||
const supportedIntegrations = widgetIntegrationSupport[widgetKind];
|
||||
const integrationLimit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY;
|
||||
if (integrationKinds.length > integrationLimit) return { code: "integration-limit", limit: integrationLimit };
|
||||
if (supportedIntegrations === undefined && integrationKinds.length > 0) return { code: "integration-not-supported" };
|
||||
if (selectedIntegrationKinds.length > integrationLimit) {
|
||||
return { code: "integration-limit", limit: integrationLimit };
|
||||
}
|
||||
if (supportedIntegrations === undefined && selectedIntegrationKinds.length > 0) {
|
||||
return { code: "integration-not-supported" };
|
||||
}
|
||||
if (
|
||||
supportedIntegrations !== undefined &&
|
||||
integrationKinds.length === 0 &&
|
||||
selectedIntegrationKinds.length === 0 &&
|
||||
!widgetKindsWithOptionalIntegrations.has(widgetKind)
|
||||
) {
|
||||
return { code: "integration-required" };
|
||||
}
|
||||
const incompatibleKinds = integrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
|
||||
return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null;
|
||||
const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
|
||||
if (incompatibleKinds.length === 0) return null;
|
||||
return { code: "incompatible-integration", incompatibleKinds };
|
||||
};
|
||||
|
||||
export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => {
|
||||
@@ -134,22 +164,24 @@ export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue:
|
||||
return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`;
|
||||
};
|
||||
|
||||
/** Reverse capability index generated from nativeFeatureCapabilities. */
|
||||
const createIntegrationWidgetSupport = () => {
|
||||
const result = {} as Record<IntegrationKind, readonly WidgetKind[]>;
|
||||
for (const integrationKind of integrationKinds) {
|
||||
result[integrationKind] = nativeFeatureCapabilityEntries
|
||||
.filter(([, capability]) => capability.integrations.includes(integrationKind))
|
||||
result[integrationKind] = widgetIntegrationConfigEntries
|
||||
.filter(([, config]) => supportsIntegration(config, integrationKind))
|
||||
.map(([widgetKind]) => widgetKind);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const supportsIntegration = (config: WidgetIntegrationConfig, integrationKind: IntegrationKind) =>
|
||||
config.supportedIntegrations.includes(integrationKind);
|
||||
|
||||
export const integrationWidgetSupport = createIntegrationWidgetSupport();
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => [
|
||||
...integrationWidgetSupport[integrationKind],
|
||||
];
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
return [...integrationWidgetSupport[integrationKind]];
|
||||
};
|
||||
|
||||
export interface DefaultWidgetConfig {
|
||||
kind: WidgetKind;
|
||||
|
||||
@@ -59,17 +59,27 @@ 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 },
|
||||
patchmon: { width: 2, height: 2 },
|
||||
mediaMissing: { width: 4, height: 3 },
|
||||
mediaServer: { width: 3, height: 2 },
|
||||
"mediaRequests-requestList": { width: 3, height: 2 },
|
||||
"mediaRequests-requestStats": { width: 2, height: 2 },
|
||||
mediaMissing: { width: 2, height: 2 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
assistant: { width: 10, height: 4 },
|
||||
};
|
||||
@@ -13,6 +13,9 @@
|
||||
},
|
||||
"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"
|
||||
|
||||
@@ -1,146 +1,162 @@
|
||||
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";
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
|
||||
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>(
|
||||
integration: IntegrationInput & { kind: TKind },
|
||||
) => {
|
||||
if (!(integration.kind in integrationCreators)) {
|
||||
): 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",
|
||||
});
|
||||
throw new Error(
|
||||
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
|
||||
);
|
||||
}
|
||||
|
||||
const creator = integrationCreators[integration.kind];
|
||||
const startedAt = Date.now();
|
||||
logger.debug("Creating integration client", { integrationId: integration.id, integrationKind: integration.kind });
|
||||
|
||||
// factories are an array, to differentiate in js between class constructors and functions
|
||||
if (Array.isArray(creator)) {
|
||||
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
|
||||
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;
|
||||
}
|
||||
|
||||
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,15 +1,21 @@
|
||||
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;
|
||||
|
||||
@@ -19,6 +25,68 @@ 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 => {
|
||||
@@ -44,8 +112,27 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
|
||||
processedProperties.add(propertyKey);
|
||||
|
||||
const wrapped = (...methodArgs: unknown[]) => {
|
||||
const handleError = (error: unknown) => {
|
||||
const startedAt = Date.now();
|
||||
const operationMetadata: IntegrationOperationMetadata = {
|
||||
operation: propertyKey,
|
||||
integrationId: this.publicIntegration.id,
|
||||
integrationKind: this.integration.kind ?? "unknown",
|
||||
integrationName: this.publicIntegration.name,
|
||||
};
|
||||
|
||||
logger.debug("Integration operation started", operationMetadata);
|
||||
|
||||
const logCompletion = () => {
|
||||
logger.debug("Integration operation completed", {
|
||||
...operationMetadata,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
};
|
||||
|
||||
const handleError = (error: unknown): never => {
|
||||
if (error instanceof IntegrationError) {
|
||||
const failureType = error instanceof IntegrationUnknownError ? "unknown" : "normalized";
|
||||
logFailure(error, failureType, operationMetadata, Date.now() - startedAt);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -53,26 +140,29 @@ export const HandleIntegrationErrors = (errorHandlers: IIntegrationErrorHandler[
|
||||
const handledError = handler.handleError(error, this.publicIntegration);
|
||||
if (!handledError) continue;
|
||||
|
||||
logFailure(handledError, "normalized", operationMetadata, Date.now() - startedAt);
|
||||
throw handledError;
|
||||
}
|
||||
|
||||
// If the error was handled and should be thrown again, throw it
|
||||
logger.debug("Unhandled error in integration", {
|
||||
error: error instanceof Error ? `${error.name}: ${error.message}` : undefined,
|
||||
integrationName: this.publicIntegration.name,
|
||||
});
|
||||
throw new IntegrationUnknownError(this.publicIntegration, { cause: error });
|
||||
const unknownError = new IntegrationUnknownError(this.publicIntegration, { cause: error });
|
||||
logFailure(unknownError, "unknown", operationMetadata, Date.now() - startedAt);
|
||||
throw unknownError;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = original.apply(this, methodArgs);
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch((error: unknown) => {
|
||||
handleError(error);
|
||||
});
|
||||
return result.then(
|
||||
(value: unknown) => {
|
||||
logCompletion();
|
||||
return value;
|
||||
},
|
||||
(error: unknown) => handleError(error),
|
||||
);
|
||||
}
|
||||
|
||||
logCompletion();
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
handleError(error);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fetch as undiciFetch } from "undici";
|
||||
|
||||
import { removeTrailingSlash } from "@homarr/common";
|
||||
import { createAxiosCertificateInstanceAsync, createCertificateAgentAsync } from "@homarr/core/infrastructure/http";
|
||||
import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
||||
|
||||
import { HandleIntegrationErrors } from "./errors/decorator";
|
||||
import { TestConnectionError } from "./test-connection/test-connection-error";
|
||||
@@ -15,6 +15,7 @@ import type { IntegrationSecret } from "./types";
|
||||
|
||||
export interface IntegrationInput {
|
||||
id: string;
|
||||
kind?: IntegrationKind;
|
||||
name: string;
|
||||
url: string;
|
||||
externalUrl: string | null;
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createKeyedFingerprint, decryptSecret, encryptSecret, verifyKeyedFingerprint } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createGetSetChannel } from "@homarr/redis";
|
||||
import { createGetSetChannel, getIntegrationSessionStoreKey } from "@homarr/redis";
|
||||
|
||||
import type { IntegrationSecret } from "./types";
|
||||
|
||||
const logger = createLogger({ module: "sessionStore" });
|
||||
const sessionEnvelopeType = "homarr-integration-session";
|
||||
const sessionEnvelopeVersion = 1;
|
||||
|
||||
export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
const channelName = `session-store:${integration.id}`;
|
||||
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);
|
||||
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
|
||||
const serializedConfiguration = serializeSessionConfiguration(integration);
|
||||
const configurationFingerprint = createKeyedFingerprint(serializedConfiguration);
|
||||
|
||||
return {
|
||||
async getAsync() {
|
||||
@@ -17,7 +68,16 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
const value = await channel.getAsync();
|
||||
if (!value) return null;
|
||||
try {
|
||||
return superjson.parse<TValue>(decryptSecret(value));
|
||||
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;
|
||||
} catch (error) {
|
||||
logger.warn("Failed to load session", { store: channelName, error });
|
||||
return null;
|
||||
@@ -31,7 +91,13 @@ export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
async setAsync(value: TValue, options?: { ttlSeconds?: number }) {
|
||||
logger.debug("Updating session in store", { store: channelName, ttlSeconds: options?.ttlSeconds });
|
||||
try {
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(value)), options);
|
||||
const envelope: SessionEnvelope<TValue> = {
|
||||
type: sessionEnvelopeType,
|
||||
version: sessionEnvelopeVersion,
|
||||
configurationFingerprint,
|
||||
value,
|
||||
};
|
||||
await channel.setAsync(encryptSecret(superjson.stringify(envelope)), options);
|
||||
} catch (error) {
|
||||
logger.error(new ErrorWithMetadata("Failed to save session", { store: channelName }, { cause: error }));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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,6 +12,7 @@ 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 }) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
@@ -97,8 +97,7 @@ export type {
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
|
||||
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
// Helpers
|
||||
|
||||
@@ -470,9 +470,11 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
|
||||
...generalWidgets,
|
||||
...discoveredIntegrations.filter((integration) => selectedSourceIds.has(integration.sourceId)),
|
||||
...appsToCreate,
|
||||
].flatMap((selection) =>
|
||||
typeof selection === "string" ? [selection] : selection.widgetKind ? [selection.widgetKind] : [],
|
||||
),
|
||||
].flatMap((selection) => {
|
||||
if (typeof selection === "string") return [selection];
|
||||
if (!selection.widgetKind) return [];
|
||||
return [selection.widgetKind];
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
@@ -1922,7 +1924,10 @@ const getPreviewWidgetKinds = (props: StudioSectionProps) => [
|
||||
...props.drafts.filter(isIntegrationDraftComplete).flatMap((draft) => getWidgetKindsForIntegration(draft.kind)),
|
||||
...props.discoveredApps
|
||||
.filter((app) => props.selectedAppIds.includes(app.sourceId))
|
||||
.flatMap((app) => (app.widgetKind ? [app.widgetKind] : [])),
|
||||
.flatMap((app) => {
|
||||
if (!app.widgetKind) return [];
|
||||
return [app.widgetKind];
|
||||
}),
|
||||
]),
|
||||
];
|
||||
|
||||
|
||||
@@ -16,9 +16,15 @@ 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 } from "./connection";
|
||||
import { createRedisConnection, requireRedisConnection } from "./connection";
|
||||
|
||||
const logger = createLogger({ module: "channelSubscriptionTracker" });
|
||||
|
||||
@@ -63,7 +63,9 @@ 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 = this.redis.subscribe(channelName).then(() => undefined);
|
||||
ready = requireRedisConnection(this.redis)
|
||||
.subscribe(channelName)
|
||||
.then(() => undefined);
|
||||
this.subscriptionReadiness.set(channelName, ready);
|
||||
void ready.catch(() => {
|
||||
if (this.subscriptionReadiness.get(channelName) === ready) {
|
||||
@@ -91,7 +93,7 @@ export class ChannelSubscriptionTracker {
|
||||
}
|
||||
|
||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||
void this.redis.unsubscribe(channelName);
|
||||
void requireRedisConnection(this.redis).unsubscribe(channelName);
|
||||
this.subscriptions.delete(channelName);
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
};
|
||||
@@ -104,7 +106,7 @@ export class ChannelSubscriptionTracker {
|
||||
*/
|
||||
private static activateListener() {
|
||||
logger.debug("Activating listener");
|
||||
this.redis.on("message", (channel, message) => {
|
||||
requireRedisConnection(this.redis).on("message", (channel, message) => {
|
||||
const channelSubscriptions = this.subscriptions.get(channel);
|
||||
if (!channelSubscriptions) {
|
||||
logger.warn("Received message on unknown channel", { channel });
|
||||
|
||||
@@ -16,6 +16,7 @@ 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();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,30 @@
|
||||
import superjson from "superjson";
|
||||
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { ChannelSubscriptionTracker } from "./channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./connection";
|
||||
import { createRedisConnection, requireRedisConnection } from "./connection";
|
||||
|
||||
const publisher = createRedisConnection();
|
||||
const lastDataClient = createRedisConnection();
|
||||
const logger = createLogger({ module: "redisChannel" });
|
||||
const boundedCacheClient = createRedisConnection({
|
||||
autoResendUnfulfilledCommands: false,
|
||||
commandTimeout: 500,
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
});
|
||||
|
||||
interface RedisChannelOptions {
|
||||
useBoundedCacheClient?: boolean;
|
||||
}
|
||||
|
||||
const selectRedisClient = (options: RedisChannelOptions) => {
|
||||
if (options.useBoundedCacheClient) return boundedCacheClient;
|
||||
return getSetClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new pub/sub channel.
|
||||
@@ -23,11 +41,13 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
|
||||
*/
|
||||
subscribe: (callback: (data: TData) => void) => {
|
||||
if (persist) {
|
||||
void lastDataClient.get(lastChannelName).then((data) => {
|
||||
if (data) {
|
||||
callback(superjson.parse(data));
|
||||
}
|
||||
});
|
||||
void requireRedisConnection(lastDataClient)
|
||||
.get(lastChannelName)
|
||||
.then((data) => {
|
||||
if (data) {
|
||||
callback(superjson.parse(data));
|
||||
}
|
||||
});
|
||||
}
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||
callback(superjson.parse(message));
|
||||
@@ -39,12 +59,12 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
|
||||
*/
|
||||
publishAsync: async (data: TData) => {
|
||||
if (persist) {
|
||||
await lastDataClient.set(lastChannelName, superjson.stringify(data));
|
||||
await requireRedisConnection(lastDataClient).set(lastChannelName, superjson.stringify(data));
|
||||
}
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data));
|
||||
},
|
||||
getLastDataAsync: async () => {
|
||||
const data = await lastDataClient.get(lastChannelName);
|
||||
const data = await requireRedisConnection(lastDataClient).get(lastChannelName);
|
||||
return data ? superjson.parse<TData>(data) : null;
|
||||
},
|
||||
};
|
||||
@@ -65,7 +85,7 @@ export const createListChannel = <TItem>(name: string) => {
|
||||
* @returns an array of all items
|
||||
*/
|
||||
getAllAsync: async () => {
|
||||
const items = await getSetClient.lrange(listChannelName, 0, -1);
|
||||
const items = await requireRedisConnection(getSetClient).lrange(listChannelName, 0, -1);
|
||||
return items.map((item) => superjson.parse<TItem>(item));
|
||||
},
|
||||
/**
|
||||
@@ -73,20 +93,20 @@ export const createListChannel = <TItem>(name: string) => {
|
||||
* @param item item to remove
|
||||
*/
|
||||
removeAsync: async (item: TItem) => {
|
||||
await getSetClient.lrem(listChannelName, 0, superjson.stringify(item));
|
||||
await requireRedisConnection(getSetClient).lrem(listChannelName, 0, superjson.stringify(item));
|
||||
},
|
||||
/**
|
||||
* Clear all items from the channels list
|
||||
*/
|
||||
clearAsync: async () => {
|
||||
await getSetClient.del(listChannelName);
|
||||
await requireRedisConnection(getSetClient).del(listChannelName);
|
||||
},
|
||||
/**
|
||||
* Add an item to the channels list
|
||||
* @param item item to add
|
||||
*/
|
||||
addAsync: async (item: TItem) => {
|
||||
await getSetClient.lpush(listChannelName, superjson.stringify(item));
|
||||
await requireRedisConnection(getSetClient).lpush(listChannelName, superjson.stringify(item));
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -95,33 +115,46 @@ export const createListChannel = <TItem>(name: string) => {
|
||||
* Creates a new redis channel for getting and setting data
|
||||
* @param name name of channel
|
||||
*/
|
||||
export const createGetSetChannel = <TData>(name: string) => {
|
||||
export const createGetSetChannel = <TData>(name: string, options: RedisChannelOptions = {}) => {
|
||||
const client = selectRedisClient(options);
|
||||
return {
|
||||
/**
|
||||
* Get data from the channel
|
||||
* @returns data or null if not found
|
||||
*/
|
||||
getAsync: async () => {
|
||||
const data = await getSetClient.get(name);
|
||||
const data = await requireRedisConnection(client).get(name);
|
||||
return data ? superjson.parse<TData>(data) : null;
|
||||
},
|
||||
/**
|
||||
* Set data in the channel
|
||||
* @param data data to be stored in the channel
|
||||
* @param options optional TTL in seconds
|
||||
* @param options optional TTL in seconds or milliseconds
|
||||
*/
|
||||
setAsync: async (data: TData, options?: { ttlSeconds?: number }) => {
|
||||
if (options?.ttlSeconds) {
|
||||
await getSetClient.set(name, superjson.stringify(data), "EX", options.ttlSeconds);
|
||||
setAsync: async (data: TData, options?: { ttlSeconds?: number; ttlMs?: number }) => {
|
||||
if (options?.ttlMs !== undefined) {
|
||||
if (options.ttlMs <= 0) {
|
||||
await requireRedisConnection(client).del(name);
|
||||
return;
|
||||
}
|
||||
await requireRedisConnection(client).set(name, superjson.stringify(data), "PX", options.ttlMs);
|
||||
return;
|
||||
}
|
||||
await getSetClient.set(name, superjson.stringify(data));
|
||||
if (options?.ttlSeconds !== undefined) {
|
||||
if (options.ttlSeconds <= 0) {
|
||||
await requireRedisConnection(client).del(name);
|
||||
return;
|
||||
}
|
||||
await requireRedisConnection(client).set(name, superjson.stringify(data), "EX", options.ttlSeconds);
|
||||
return;
|
||||
}
|
||||
await requireRedisConnection(client).set(name, superjson.stringify(data));
|
||||
},
|
||||
/**
|
||||
* Remove data from the channel
|
||||
*/
|
||||
removeAsync: async () => {
|
||||
await getSetClient.del(name);
|
||||
await requireRedisConnection(client).del(name);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -132,34 +165,571 @@ export const createGetSetChannel = <TData>(name: string) => {
|
||||
* The token prevents a process from releasing a lock that expired and was
|
||||
* acquired by another process in the meantime.
|
||||
*/
|
||||
export const createLockChannel = (name: string) => {
|
||||
export const createLockChannel = (name: string, options: RedisChannelOptions = {}) => {
|
||||
const selectedClient = selectRedisClient(options);
|
||||
const releaseIfOwnedAsync = async (token: string) => {
|
||||
if (!selectedClient) return;
|
||||
|
||||
await selectedClient.eval(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
|
||||
1,
|
||||
name,
|
||||
token,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
acquireAsync: async (ttlSeconds: number) => {
|
||||
const token = createId();
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
const client = selectedClient;
|
||||
if (!client) return token;
|
||||
|
||||
const result = await client.set(name, token, "EX", ttlSeconds, "NX");
|
||||
return result === "OK" ? token : null;
|
||||
try {
|
||||
const result = await client.set(name, token, "EX", ttlSeconds, "NX");
|
||||
return result === "OK" ? token : null;
|
||||
} catch (error) {
|
||||
// The SET may have reached Redis even when its reply timed out. Queue a
|
||||
// token-fenced cleanup so an ambiguous acquisition cannot orphan a lock.
|
||||
void releaseIfOwnedAsync(token).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
releaseAsync: async (token: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) return;
|
||||
renewAsync: async (token: string, ttlSeconds: number) => {
|
||||
const client = selectedClient;
|
||||
if (!client) return true;
|
||||
|
||||
await client.eval(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
|
||||
const result = await client.eval(
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end",
|
||||
1,
|
||||
name,
|
||||
token,
|
||||
ttlSeconds,
|
||||
);
|
||||
return result === 1;
|
||||
},
|
||||
setIfOwnedAsync: async <TData>(token: string, targetName: string, data: TData, ttlMs: number) => {
|
||||
const client = selectedClient;
|
||||
if (!client) return true;
|
||||
|
||||
const result = await client.eval(
|
||||
"if redis.call('get', KEYS[1]) ~= ARGV[1] then return 0 end redis.call('set', KEYS[2], ARGV[2], 'PX', ARGV[3]) return 1",
|
||||
2,
|
||||
name,
|
||||
targetName,
|
||||
token,
|
||||
superjson.stringify(data),
|
||||
ttlMs,
|
||||
);
|
||||
return result === 1;
|
||||
},
|
||||
releaseAsync: async (token: string) => {
|
||||
await releaseIfOwnedAsync(token);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const integrationCacheGenerationKey = (integrationId: string) => `integration-cache:generation:${integrationId}`;
|
||||
const integrationCacheGlobalGenerationKey = "integration-cache:global-generation:v1";
|
||||
export const getIntegrationSessionStoreKey = (integrationId: string) => `session-store:${integrationId}`;
|
||||
interface PendingIntegrationCacheInvalidation {
|
||||
generation: string;
|
||||
clearSession: boolean;
|
||||
}
|
||||
|
||||
const pendingIntegrationCacheInvalidations = new Map<string, PendingIntegrationCacheInvalidation>();
|
||||
let integrationCacheOverflowGeneration: string | undefined;
|
||||
const MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS = 1000;
|
||||
const CACHE_GENERATION_REDIS_TIMEOUT_MS = 750;
|
||||
const CACHE_GENERATION_SUCCESS_TTL_MS = 1000;
|
||||
const CACHE_GENERATION_UNAVAILABLE_TTL_MS = 5000;
|
||||
const CACHE_GENERATION_REDIS_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
const MAX_CACHE_GENERATION_READS = 1000;
|
||||
|
||||
export interface IntegrationCacheGeneration {
|
||||
value: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
interface CacheGenerationReadEntry {
|
||||
generation: IntegrationCacheGeneration;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface CacheGenerationReadInFlight {
|
||||
promise: Promise<IntegrationCacheGeneration>;
|
||||
token: object;
|
||||
}
|
||||
|
||||
const cacheGenerationReads = new Map<string, CacheGenerationReadEntry>();
|
||||
const cacheGenerationReadsInFlight = new Map<string, CacheGenerationReadInFlight>();
|
||||
|
||||
const logCacheGenerationFailure = (message: string, metadata: Record<string, unknown>, error: unknown) => {
|
||||
logger.warn(new ErrorWithMetadata(message, metadata, { cause: error }));
|
||||
};
|
||||
|
||||
const withCacheGenerationRedisTimeoutAsync = async <T>(promise: Promise<T>): Promise<T> => {
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Redis cache generation operation timed out")),
|
||||
CACHE_GENERATION_REDIS_TIMEOUT_MS,
|
||||
);
|
||||
timer.unref?.();
|
||||
void promise.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
};
|
||||
|
||||
const combineCacheGenerations = (
|
||||
globalGeneration: string | null | undefined,
|
||||
scopedGeneration: string | null | undefined,
|
||||
) => {
|
||||
const scopedValue = scopedGeneration ?? "0";
|
||||
if (!globalGeneration || globalGeneration === "0") return scopedValue;
|
||||
return `${globalGeneration}:${scopedValue}`;
|
||||
};
|
||||
|
||||
const advanceGlobalCacheGenerationAsync = async (cacheKey: string) => {
|
||||
const client = boundedCacheClient;
|
||||
if (!client) return null;
|
||||
|
||||
const results = await withCacheGenerationRedisTimeoutAsync(
|
||||
client.multi().incr(cacheKey).expire(cacheKey, CACHE_GENERATION_REDIS_TTL_SECONDS).exec(),
|
||||
);
|
||||
const generationResult = results?.[0];
|
||||
const expiryResult = results?.[1];
|
||||
if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result");
|
||||
if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result");
|
||||
return String(generationResult[1]);
|
||||
};
|
||||
|
||||
const setCacheGenerationRead = (
|
||||
cacheKey: string,
|
||||
generation: IntegrationCacheGeneration,
|
||||
ttlMs = generation.isShared ? CACHE_GENERATION_SUCCESS_TTL_MS : CACHE_GENERATION_UNAVAILABLE_TTL_MS,
|
||||
) => {
|
||||
if (cacheGenerationReads.has(cacheKey)) cacheGenerationReads.delete(cacheKey);
|
||||
while (cacheGenerationReads.size >= MAX_CACHE_GENERATION_READS) {
|
||||
const oldestCacheKey = cacheGenerationReads.keys().next().value;
|
||||
if (!oldestCacheKey) break;
|
||||
cacheGenerationReads.delete(oldestCacheKey);
|
||||
}
|
||||
cacheGenerationReads.set(cacheKey, {
|
||||
generation,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
};
|
||||
|
||||
const beginCacheGenerationInvalidation = (cacheKey: string, pendingGeneration: string) => {
|
||||
cacheGenerationReadsInFlight.delete(cacheKey);
|
||||
setCacheGenerationRead(cacheKey, { value: pendingGeneration, isShared: false });
|
||||
};
|
||||
|
||||
interface GetCachedGenerationOptions {
|
||||
cacheKey: string;
|
||||
readAsync: () => Promise<string | null>;
|
||||
fallback: IntegrationCacheGeneration;
|
||||
errorMessage: string;
|
||||
errorMetadata: Record<string, unknown>;
|
||||
force?: boolean;
|
||||
throwOnError?: boolean;
|
||||
}
|
||||
|
||||
const getCachedGenerationAsync = async ({
|
||||
cacheKey,
|
||||
readAsync,
|
||||
fallback,
|
||||
errorMessage,
|
||||
errorMetadata,
|
||||
force = false,
|
||||
throwOnError = false,
|
||||
}: GetCachedGenerationOptions): Promise<IntegrationCacheGeneration> => {
|
||||
if (!force) {
|
||||
const cached = cacheGenerationReads.get(cacheKey);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
cacheGenerationReads.delete(cacheKey);
|
||||
cacheGenerationReads.set(cacheKey, cached);
|
||||
return cached.generation;
|
||||
}
|
||||
if (cached) cacheGenerationReads.delete(cacheKey);
|
||||
}
|
||||
|
||||
const existing = cacheGenerationReadsInFlight.get(cacheKey);
|
||||
if (existing) return await existing.promise;
|
||||
|
||||
const token = {};
|
||||
const promise = (async () => {
|
||||
let generation = fallback;
|
||||
try {
|
||||
const value = await readAsync();
|
||||
if (value !== null) generation = { value, isShared: true };
|
||||
} catch (error) {
|
||||
logCacheGenerationFailure(errorMessage, errorMetadata, error);
|
||||
if (throwOnError) throw error;
|
||||
}
|
||||
|
||||
if (cacheGenerationReadsInFlight.get(cacheKey)?.token === token) {
|
||||
setCacheGenerationRead(cacheKey, generation);
|
||||
}
|
||||
return generation;
|
||||
})().finally(() => {
|
||||
if (cacheGenerationReadsInFlight.get(cacheKey)?.token === token) {
|
||||
cacheGenerationReadsInFlight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
cacheGenerationReadsInFlight.set(cacheKey, { promise, token });
|
||||
return await promise;
|
||||
};
|
||||
|
||||
const advanceIntegrationCacheGenerationAsync = async (integrationId: string) => {
|
||||
const client = boundedCacheClient;
|
||||
if (!client) return null;
|
||||
|
||||
const results = await withCacheGenerationRedisTimeoutAsync(
|
||||
client
|
||||
.multi()
|
||||
.incr(integrationCacheGenerationKey(integrationId))
|
||||
.expire(integrationCacheGenerationKey(integrationId), CACHE_GENERATION_REDIS_TTL_SECONDS)
|
||||
.del(getIntegrationSessionStoreKey(integrationId))
|
||||
.get(integrationCacheGlobalGenerationKey)
|
||||
.exec(),
|
||||
);
|
||||
const generationResult = results?.[0];
|
||||
const expiryResult = results?.[1];
|
||||
const sessionResult = results?.[2];
|
||||
const globalGenerationResult = results?.[3];
|
||||
if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result");
|
||||
if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result");
|
||||
if (!sessionResult || sessionResult[0]) throw sessionResult?.[0] ?? new Error("Missing session result");
|
||||
if (!globalGenerationResult || globalGenerationResult[0]) {
|
||||
throw globalGenerationResult?.[0] ?? new Error("Missing global generation result");
|
||||
}
|
||||
return combineCacheGenerations(globalGenerationResult[1] as string | null, String(generationResult[1]));
|
||||
};
|
||||
|
||||
const advanceIntegrationResponseCacheGenerationAsync = async (integrationId: string) => {
|
||||
const client = boundedCacheClient;
|
||||
if (!client) return null;
|
||||
|
||||
const cacheKey = integrationCacheGenerationKey(integrationId);
|
||||
const results = await withCacheGenerationRedisTimeoutAsync(
|
||||
client
|
||||
.multi()
|
||||
.incr(cacheKey)
|
||||
.expire(cacheKey, CACHE_GENERATION_REDIS_TTL_SECONDS)
|
||||
.get(integrationCacheGlobalGenerationKey)
|
||||
.exec(),
|
||||
);
|
||||
const generationResult = results?.[0];
|
||||
const expiryResult = results?.[1];
|
||||
const globalGenerationResult = results?.[2];
|
||||
if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result");
|
||||
if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result");
|
||||
if (!globalGenerationResult || globalGenerationResult[0]) {
|
||||
throw globalGenerationResult?.[0] ?? new Error("Missing global generation result");
|
||||
}
|
||||
return combineCacheGenerations(globalGenerationResult[1] as string | null, String(generationResult[1]));
|
||||
};
|
||||
|
||||
const getIntegrationCacheInvalidationOperation = (clearSession: boolean) => {
|
||||
if (clearSession) {
|
||||
return {
|
||||
advanceAsync: advanceIntegrationCacheGenerationAsync,
|
||||
operation: "generation-advance-and-session-delete",
|
||||
failureMessage: "Failed to invalidate integration and session caches in Redis",
|
||||
};
|
||||
}
|
||||
return {
|
||||
advanceAsync: advanceIntegrationResponseCacheGenerationAsync,
|
||||
operation: "generation-advance",
|
||||
failureMessage: "Failed to invalidate integration response caches in Redis",
|
||||
};
|
||||
};
|
||||
|
||||
let integrationCacheOverflowRecoveryInFlight: Promise<boolean> | undefined;
|
||||
|
||||
const recoverIntegrationCacheOverflowAsync = async () => {
|
||||
if (!integrationCacheOverflowGeneration) return true;
|
||||
if (integrationCacheOverflowRecoveryInFlight) return await integrationCacheOverflowRecoveryInFlight;
|
||||
|
||||
const overflowGeneration = integrationCacheOverflowGeneration;
|
||||
let promise: Promise<boolean>;
|
||||
promise = (async () => {
|
||||
try {
|
||||
const generation = await advanceGlobalCacheGenerationAsync(integrationCacheGlobalGenerationKey);
|
||||
if (generation === null) return false;
|
||||
|
||||
cacheGenerationReads.clear();
|
||||
cacheGenerationReadsInFlight.clear();
|
||||
if (integrationCacheOverflowGeneration !== overflowGeneration) return false;
|
||||
integrationCacheOverflowGeneration = undefined;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logCacheGenerationFailure(
|
||||
"Failed to recover integration cache invalidation overflow",
|
||||
{ operation: "global-generation-advance" },
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})().finally(() => {
|
||||
if (integrationCacheOverflowRecoveryInFlight === promise) {
|
||||
integrationCacheOverflowRecoveryInFlight = undefined;
|
||||
}
|
||||
});
|
||||
integrationCacheOverflowRecoveryInFlight = promise;
|
||||
return await promise;
|
||||
};
|
||||
|
||||
export const getIntegrationCacheGenerationAsync = async (
|
||||
integrationId: string,
|
||||
): Promise<IntegrationCacheGeneration> => {
|
||||
const cacheKey = integrationCacheGenerationKey(integrationId);
|
||||
const pendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
if (pendingInvalidation) {
|
||||
const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession);
|
||||
const generation = await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => await invalidationOperation.advanceAsync(integrationId),
|
||||
fallback: { value: pendingInvalidation.generation, isShared: false },
|
||||
errorMessage: "Failed to retry integration cache invalidation",
|
||||
errorMetadata: {
|
||||
integrationId,
|
||||
operation: invalidationOperation.operation,
|
||||
},
|
||||
});
|
||||
if (
|
||||
generation.isShared &&
|
||||
pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation
|
||||
) {
|
||||
pendingIntegrationCacheInvalidations.delete(integrationId);
|
||||
return generation;
|
||||
}
|
||||
const latestPendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
if (latestPendingInvalidation) return { value: latestPendingInvalidation.generation, isShared: false };
|
||||
return generation;
|
||||
}
|
||||
|
||||
if (integrationCacheOverflowGeneration) {
|
||||
await recoverIntegrationCacheOverflowAsync();
|
||||
if (integrationCacheOverflowGeneration) {
|
||||
return { value: integrationCacheOverflowGeneration, isShared: false };
|
||||
}
|
||||
}
|
||||
|
||||
const client = boundedCacheClient;
|
||||
return await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => {
|
||||
if (!client) return null;
|
||||
const [globalGeneration, scopedGeneration] = await withCacheGenerationRedisTimeoutAsync(
|
||||
client.mget(integrationCacheGlobalGenerationKey, cacheKey),
|
||||
);
|
||||
return combineCacheGenerations(globalGeneration, scopedGeneration);
|
||||
},
|
||||
fallback: { value: "redis-unavailable", isShared: false },
|
||||
errorMessage: "Failed to read integration cache generation",
|
||||
errorMetadata: { integrationId, operation: "generation-read" },
|
||||
});
|
||||
};
|
||||
|
||||
const invalidateIntegrationCacheGenerationAsync = async (
|
||||
integrationId: string,
|
||||
clearSession: boolean,
|
||||
): Promise<void> => {
|
||||
const previousInvalidation = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
const pendingInvalidation: PendingIntegrationCacheInvalidation = {
|
||||
generation: `local-${createId()}`,
|
||||
clearSession: clearSession || previousInvalidation?.clearSession === true,
|
||||
};
|
||||
const canTrackInvalidation =
|
||||
!integrationCacheOverflowGeneration &&
|
||||
(previousInvalidation !== undefined ||
|
||||
pendingIntegrationCacheInvalidations.size < MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS);
|
||||
if (canTrackInvalidation) {
|
||||
pendingIntegrationCacheInvalidations.set(integrationId, pendingInvalidation);
|
||||
} else if (integrationCacheOverflowGeneration) {
|
||||
integrationCacheOverflowGeneration = pendingInvalidation.generation;
|
||||
}
|
||||
const cacheKey = integrationCacheGenerationKey(integrationId);
|
||||
beginCacheGenerationInvalidation(cacheKey, pendingInvalidation.generation);
|
||||
const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession);
|
||||
|
||||
let generation: IntegrationCacheGeneration;
|
||||
try {
|
||||
generation = await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => await invalidationOperation.advanceAsync(integrationId),
|
||||
fallback: { value: pendingInvalidation.generation, isShared: false },
|
||||
errorMessage: invalidationOperation.failureMessage,
|
||||
errorMetadata: {
|
||||
integrationId,
|
||||
operation: invalidationOperation.operation,
|
||||
},
|
||||
force: true,
|
||||
throwOnError: clearSession,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!canTrackInvalidation) integrationCacheOverflowGeneration = pendingInvalidation.generation;
|
||||
throw error;
|
||||
}
|
||||
if (!generation.isShared && !canTrackInvalidation) {
|
||||
integrationCacheOverflowGeneration = pendingInvalidation.generation;
|
||||
}
|
||||
if (
|
||||
generation.isShared &&
|
||||
pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation
|
||||
) {
|
||||
pendingIntegrationCacheInvalidations.delete(integrationId);
|
||||
}
|
||||
};
|
||||
|
||||
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) return;
|
||||
await client.del(`session-store:${integrationId}`);
|
||||
await invalidateIntegrationCacheGenerationAsync(integrationId, true);
|
||||
};
|
||||
|
||||
/** Advances response-cache generation without evicting cached integration credentials. */
|
||||
export const invalidateIntegrationResponseCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
await invalidateIntegrationCacheGenerationAsync(integrationId, false);
|
||||
};
|
||||
|
||||
const widgetCacheGenerationKey = (namespace: string) => `widget-cache:generation:${namespace}`;
|
||||
const widgetCacheGlobalGenerationKey = "widget-cache:global-generation:v1";
|
||||
const pendingWidgetCacheInvalidations = new Map<string, string>();
|
||||
let widgetCacheOverflowGeneration: string | undefined;
|
||||
const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000;
|
||||
|
||||
const advanceWidgetCacheGenerationAsync = async (namespace: string) => {
|
||||
const client = boundedCacheClient;
|
||||
if (!client) return null;
|
||||
|
||||
const cacheKey = widgetCacheGenerationKey(namespace);
|
||||
const results = await withCacheGenerationRedisTimeoutAsync(
|
||||
client
|
||||
.multi()
|
||||
.incr(cacheKey)
|
||||
.expire(cacheKey, CACHE_GENERATION_REDIS_TTL_SECONDS)
|
||||
.get(widgetCacheGlobalGenerationKey)
|
||||
.exec(),
|
||||
);
|
||||
const generationResult = results?.[0];
|
||||
const expiryResult = results?.[1];
|
||||
const globalGenerationResult = results?.[2];
|
||||
if (!generationResult || generationResult[0]) throw generationResult?.[0] ?? new Error("Missing generation result");
|
||||
if (!expiryResult || expiryResult[0]) throw expiryResult?.[0] ?? new Error("Missing generation expiry result");
|
||||
if (!globalGenerationResult || globalGenerationResult[0]) {
|
||||
throw globalGenerationResult?.[0] ?? new Error("Missing global generation result");
|
||||
}
|
||||
return combineCacheGenerations(globalGenerationResult[1] as string | null, String(generationResult[1]));
|
||||
};
|
||||
|
||||
export type WidgetCacheGeneration = IntegrationCacheGeneration;
|
||||
|
||||
let widgetCacheOverflowRecoveryInFlight: Promise<boolean> | undefined;
|
||||
|
||||
const recoverWidgetCacheOverflowAsync = async () => {
|
||||
if (!widgetCacheOverflowGeneration) return true;
|
||||
if (widgetCacheOverflowRecoveryInFlight) return await widgetCacheOverflowRecoveryInFlight;
|
||||
|
||||
const overflowGeneration = widgetCacheOverflowGeneration;
|
||||
let promise: Promise<boolean>;
|
||||
promise = (async () => {
|
||||
try {
|
||||
const generation = await advanceGlobalCacheGenerationAsync(widgetCacheGlobalGenerationKey);
|
||||
if (generation === null) return false;
|
||||
|
||||
cacheGenerationReads.clear();
|
||||
cacheGenerationReadsInFlight.clear();
|
||||
if (widgetCacheOverflowGeneration !== overflowGeneration) return false;
|
||||
widgetCacheOverflowGeneration = undefined;
|
||||
return true;
|
||||
} catch (error) {
|
||||
logCacheGenerationFailure(
|
||||
"Failed to recover widget cache invalidation overflow",
|
||||
{ operation: "global-generation-advance" },
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
})().finally(() => {
|
||||
if (widgetCacheOverflowRecoveryInFlight === promise) {
|
||||
widgetCacheOverflowRecoveryInFlight = undefined;
|
||||
}
|
||||
});
|
||||
widgetCacheOverflowRecoveryInFlight = promise;
|
||||
return await promise;
|
||||
};
|
||||
|
||||
export const getWidgetCacheGenerationAsync = async (namespace: string): Promise<WidgetCacheGeneration> => {
|
||||
const cacheKey = widgetCacheGenerationKey(namespace);
|
||||
const pendingGeneration = pendingWidgetCacheInvalidations.get(namespace);
|
||||
if (pendingGeneration) {
|
||||
const generation = await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => await advanceWidgetCacheGenerationAsync(namespace),
|
||||
fallback: { value: pendingGeneration, isShared: false },
|
||||
errorMessage: "Failed to retry widget cache invalidation",
|
||||
errorMetadata: { cacheNamespace: namespace, operation: "generation-advance" },
|
||||
});
|
||||
if (generation.isShared && pendingWidgetCacheInvalidations.get(namespace) === pendingGeneration) {
|
||||
pendingWidgetCacheInvalidations.delete(namespace);
|
||||
return generation;
|
||||
}
|
||||
const latestPendingGeneration = pendingWidgetCacheInvalidations.get(namespace);
|
||||
if (latestPendingGeneration) return { value: latestPendingGeneration, isShared: false };
|
||||
return generation;
|
||||
}
|
||||
|
||||
if (widgetCacheOverflowGeneration) {
|
||||
await recoverWidgetCacheOverflowAsync();
|
||||
if (widgetCacheOverflowGeneration) {
|
||||
return { value: widgetCacheOverflowGeneration, isShared: false };
|
||||
}
|
||||
}
|
||||
|
||||
const client = boundedCacheClient;
|
||||
return await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => {
|
||||
if (!client) return null;
|
||||
const [globalGeneration, scopedGeneration] = await withCacheGenerationRedisTimeoutAsync(
|
||||
client.mget(widgetCacheGlobalGenerationKey, cacheKey),
|
||||
);
|
||||
return combineCacheGenerations(globalGeneration, scopedGeneration);
|
||||
},
|
||||
fallback: { value: "redis-unavailable", isShared: false },
|
||||
errorMessage: "Failed to read widget cache generation",
|
||||
errorMetadata: { cacheNamespace: namespace, operation: "generation-read" },
|
||||
});
|
||||
};
|
||||
|
||||
export const invalidateWidgetCache = (namespace: string): void => {
|
||||
const pendingGeneration = `local-${createId()}`;
|
||||
const previousGeneration = pendingWidgetCacheInvalidations.get(namespace);
|
||||
const canTrackInvalidation =
|
||||
!widgetCacheOverflowGeneration &&
|
||||
(previousGeneration !== undefined || pendingWidgetCacheInvalidations.size < MAX_PENDING_WIDGET_CACHE_INVALIDATIONS);
|
||||
if (canTrackInvalidation) {
|
||||
pendingWidgetCacheInvalidations.set(namespace, pendingGeneration);
|
||||
} else if (widgetCacheOverflowGeneration) {
|
||||
widgetCacheOverflowGeneration = pendingGeneration;
|
||||
}
|
||||
const cacheKey = widgetCacheGenerationKey(namespace);
|
||||
beginCacheGenerationInvalidation(cacheKey, pendingGeneration);
|
||||
|
||||
void getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => await advanceWidgetCacheGenerationAsync(namespace),
|
||||
fallback: { value: pendingGeneration, isShared: false },
|
||||
errorMessage: "Failed to invalidate widget caches in Redis",
|
||||
errorMetadata: { cacheNamespace: namespace, operation: "generation-advance" },
|
||||
force: true,
|
||||
}).then((generation) => {
|
||||
if (!generation.isShared && !canTrackInvalidation) {
|
||||
widgetCacheOverflowGeneration = pendingGeneration;
|
||||
}
|
||||
if (generation.isShared && pendingWidgetCacheInvalidations.get(namespace) === pendingGeneration) {
|
||||
pendingWidgetCacheInvalidations.delete(namespace);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,11 +737,11 @@ export const invalidateIntegrationCacheAsync = async (integrationId: string): Pr
|
||||
*/
|
||||
export const createChannelEventHistoryOld = <TData>(channelName: string, maxElements = 15) => {
|
||||
const popElementsOverMaxAsync = async () => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const length = await requireRedisConnection(getSetClient).llen(channelName);
|
||||
if (length <= maxElements) {
|
||||
return;
|
||||
}
|
||||
await getSetClient.ltrim(channelName, 0, maxElements - 1);
|
||||
await requireRedisConnection(getSetClient).ltrim(channelName, 0, maxElements - 1);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -181,33 +751,41 @@ export const createChannelEventHistoryOld = <TData>(channelName: string, maxElem
|
||||
});
|
||||
},
|
||||
publishAndPushAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
await requireRedisConnection(publisher).publish(channelName, superjson.stringify(data));
|
||||
await requireRedisConnection(getSetClient).lpush(
|
||||
channelName,
|
||||
superjson.stringify({ data, timestamp: new Date() }),
|
||||
);
|
||||
await popElementsOverMaxAsync();
|
||||
},
|
||||
pushAsync: async (data: TData) => {
|
||||
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
await requireRedisConnection(getSetClient).lpush(
|
||||
channelName,
|
||||
superjson.stringify({ data, timestamp: new Date() }),
|
||||
);
|
||||
await popElementsOverMaxAsync();
|
||||
},
|
||||
clearAsync: async () => {
|
||||
await getSetClient.del(channelName);
|
||||
await requireRedisConnection(getSetClient).del(channelName);
|
||||
},
|
||||
getLastAsync: async () => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const data = await getSetClient.lrange(channelName, length - 1, length);
|
||||
const client = requireRedisConnection(getSetClient);
|
||||
const length = await client.llen(channelName);
|
||||
const data = await client.lrange(channelName, length - 1, length);
|
||||
if (data.length !== 1) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
|
||||
},
|
||||
getSliceAsync: async (startIndex: number, endIndex: number) => {
|
||||
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
|
||||
const range = await requireRedisConnection(getSetClient).lrange(channelName, startIndex, endIndex);
|
||||
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
|
||||
},
|
||||
getSliceUntilTimeAsync: async (time: Date) => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const client = requireRedisConnection(getSetClient);
|
||||
const length = await client.llen(channelName);
|
||||
const items: TData[] = [];
|
||||
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
|
||||
const itemsInCollection = await client.lrange(channelName, 0, length - 1);
|
||||
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -220,7 +798,7 @@ export const createChannelEventHistoryOld = <TData>(channelName: string, maxElem
|
||||
return items;
|
||||
},
|
||||
getLengthAsync: async () => {
|
||||
return await getSetClient.llen(channelName);
|
||||
return await requireRedisConnection(getSetClient).llen(channelName);
|
||||
},
|
||||
name: channelName,
|
||||
};
|
||||
@@ -238,11 +816,11 @@ type WithId<TItem> = TItem & { _id: string };
|
||||
export const createQueueChannel = <TItem>(name: string) => {
|
||||
const queueChannelName = `queue:${name}`;
|
||||
const getDataAsync = async () => {
|
||||
const data = await queueClient.get(queueChannelName);
|
||||
const data = await requireRedisConnection(queueClient).get(queueChannelName);
|
||||
return data ? superjson.parse<WithId<TItem>[]>(data) : [];
|
||||
};
|
||||
const setDataAsync = async (data: WithId<TItem>[]) => {
|
||||
await queueClient.set(queueChannelName, superjson.stringify(data));
|
||||
await requireRedisConnection(queueClient).set(queueChannelName, superjson.stringify(data));
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -289,5 +867,5 @@ export const createQueueChannel = <TItem>(name: string) => {
|
||||
};
|
||||
|
||||
export const handshakeAsync = async () => {
|
||||
await getSetClient.hello();
|
||||
await requireRedisConnection(getSetClient).hello();
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RedisOptions } from "ioredis";
|
||||
|
||||
import type { RedisClient } from "@homarr/core/infrastructure/redis";
|
||||
import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
|
||||
@@ -5,11 +7,15 @@ import { createRedisClient } from "@homarr/core/infrastructure/redis";
|
||||
* Creates a new Redis connection
|
||||
* @returns redis client
|
||||
*/
|
||||
export const createRedisConnection = () => {
|
||||
export const createRedisConnection = (options: RedisOptions = {}) => {
|
||||
if (Boolean(process.env.CI) || Boolean(process.env.DISABLE_REDIS_LOGS)) {
|
||||
// Return null if we are in CI as we don't want to connect to Redis
|
||||
return null as unknown as RedisClient;
|
||||
return null;
|
||||
}
|
||||
|
||||
return createRedisClient();
|
||||
return createRedisClient(options);
|
||||
};
|
||||
|
||||
export const requireRedisConnection = (client: RedisClient | null): RedisClient => {
|
||||
if (!client) throw new Error("Redis is unavailable in this process");
|
||||
return client;
|
||||
};
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user