refactor: simplify widget and integration architecture
This commit is contained in:
1 parent
c988c35b29
commit
8f9efd0074
179 files changed
+1509
-4187
No files matched your search
@@ -8,206 +8,58 @@ tags:
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
Homarr is moving native widgets and integrations to vertical feature modules: the stable identity, lightweight metadata,
|
||||
client entry, server registration, cache policy, and documentation source live beside the implementation. Generated
|
||||
files preserve static imports and package boundaries for Next.js, tRPC, and Docusaurus.
|
||||
Homarr keeps feature registration explicit and typed. There is no generator or generated registry to run after a
|
||||
change. A new feature is available when its typed metadata and lazy loader are registered.
|
||||
|
||||
Registration means availability. There is no separate feature-enable list or runtime kill switch.
|
||||
## Add a widget
|
||||
|
||||
## Current status
|
||||
Keep the UI, options, helpers, and tests together in `packages/widgets/src/<widget>/`. The usual folder only needs an
|
||||
`index.ts` definition and a lazily imported `component.tsx`; complex widgets can add local files as needed.
|
||||
|
||||
Weather and Downloads are the reference widget modules. Their folders now own registration and canonical docs:
|
||||
1. Add the stable kind to `packages/definitions/src/widget.ts` and its documentation slug to the direct typed map in
|
||||
`packages/definitions/src/docs/widget-doc-slugs.ts`.
|
||||
2. Export `definition` and `componentLoader` from `packages/widgets/src/<widget>/index.ts` with
|
||||
`createWidgetDefinition`. The local definition owns its icon, query keys, refresh interval, options, matchers, and
|
||||
errors. Keep the component behind `withDynamicImport` so adding a widget does not load it on every dashboard.
|
||||
3. For an integration-backed widget, add its integration kinds and selection rules once to
|
||||
`packages/definitions/src/widget-integration-map.ts`, then spread `getWidgetIntegrationConfig(kind)` into the local
|
||||
definition. This server-safe config is also the API authorization boundary, avoiding a package cycle without
|
||||
duplicating widget metadata.
|
||||
4. Register the module in the explicit lazy-loader and type-only maps in `packages/widgets/src/registry.ts`, then add
|
||||
its icon to `packages/ui/src/widget-icons.ts`. The two registry entries preserve exact option/component inference;
|
||||
keep them in the same order so drift is obvious in review.
|
||||
5. If the widget needs server data, add its tRPC router under `packages/api/src/router/widgets/` and add its explicit
|
||||
lazy import to `packages/api/src/router/widgets/index.ts`. Keep upstream requests and Redis response caching in
|
||||
`@homarr/request-handler`. Use the widget integration middleware for integration-backed procedures.
|
||||
6. Add canonical documentation in `apps/docs/docs/widgets/<slug>/`.
|
||||
|
||||
```text
|
||||
packages/widgets/src/weather/
|
||||
├── module.ts # authored manifest and client/server data policy
|
||||
├── index.ts # options and presentation-specific definition
|
||||
├── component.tsx # client UI
|
||||
├── module.generated.ts # generated icon projection; do not edit
|
||||
└── docs/ # canonical MDX, metadata, and assets
|
||||
```
|
||||
Weather is the small integration-free example. Downloads shows integration selection, polling, server caching,
|
||||
mutations, partial upstream failures, and a larger options surface.
|
||||
|
||||
The remaining widgets still use the compatibility registries and can be migrated one folder at a time without changing
|
||||
their persisted `WidgetKind` or tRPC paths. The generated and legacy entries are merged behind the same runtime loaders.
|
||||
## Add an integration
|
||||
|
||||
Beszel is the reference integration module. Its folder owns the manifest, implementation, capability helpers, tests,
|
||||
and canonical docs. The generator projects its metadata into definitions and its constructor into the lazy integration
|
||||
factory without eagerly importing the implementation.
|
||||
Keep the HTTP client and its capability-specific types in `packages/integrations/src/<integration>/`.
|
||||
|
||||
The server router implementation is not physically inside these first two widget folders yet. `@homarr/widgets`
|
||||
currently imports `@homarr/api/client`; making `@homarr/api` import widget server files would create a package cycle and
|
||||
risk shipping server code to the browser. The module already owns the lazy router registration, and the neutral server
|
||||
operation types are the cycle-breaking seam for the next migration stage.
|
||||
1. Add the typed definition to `packages/definitions/src/integration.ts`, including credential alternatives,
|
||||
categories, documentation slug, ports, Docker aliases, and onboarding metadata when relevant.
|
||||
2. Add an explicit lazy creator to `packages/integrations/src/base/creator.ts`. The literal import keeps the
|
||||
implementation out of the initial server bundle and makes the registration easy to find.
|
||||
3. Implement only the interfaces required by the widgets that use the service.
|
||||
4. Add canonical documentation in `apps/docs/docs/integrations/<slug>/`. Its `index.ts` must satisfy
|
||||
`IntegrationDefinition` so invalid metadata fails type checking.
|
||||
|
||||
## The module manifest
|
||||
Beszel is a compact example with several widgets sharing one integration client.
|
||||
|
||||
`module.ts` is pure and server-safe. It must not import React, tRPC, an integration implementation, secrets, or a request
|
||||
handler. It describes import locations as strings so the generator can emit literal imports into the correct package.
|
||||
## Data and errors
|
||||
|
||||
```ts
|
||||
import { defineWidgetModule } from "@homarr/definitions";
|
||||
- TanStack Query owns browser caching and refresh behavior. Redis owns shared server response caching.
|
||||
- Cache raw service responses, never authorization decisions or decrypted credentials. Integration-backed cache keys
|
||||
include the integration identity and are invalidated after mutations.
|
||||
- Keep validation and authorization on the server. Client-side checks improve UX but do not replace tRPC guards.
|
||||
- Use the shared logger with safe identifiers, operation names, duration, and the original error cause. Never log
|
||||
secrets or full upstream payloads.
|
||||
- Multi-integration reads should return successful providers when one service fails and surface a clear error when all
|
||||
providers fail.
|
||||
|
||||
export default defineWidgetModule({
|
||||
kind: "example",
|
||||
icon: "IconSparkles",
|
||||
clientEntry: ".",
|
||||
documentation: {
|
||||
slug: "example",
|
||||
sourceDirectory: "docs",
|
||||
},
|
||||
integration: {
|
||||
categories: ["downloadClient"],
|
||||
serverMaxIntegrations: 1,
|
||||
},
|
||||
operations: [
|
||||
{
|
||||
name: "getData",
|
||||
path: ["widget", "example", "getData"],
|
||||
client: {
|
||||
refetchIntervalSeconds: 30,
|
||||
persist: true,
|
||||
},
|
||||
serverCache: {
|
||||
namespace: "example:data",
|
||||
ttlMs: 30_000,
|
||||
scope: "integration",
|
||||
},
|
||||
},
|
||||
],
|
||||
routers: [
|
||||
{
|
||||
namespace: "example",
|
||||
module: "./example",
|
||||
exportName: "exampleRouter",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Run the synchronizer after adding or changing a module:
|
||||
|
||||
```sh
|
||||
pnpm widgets:new example --icon IconSparkles
|
||||
pnpm widgets:sync
|
||||
pnpm widgets:sync:check
|
||||
```
|
||||
|
||||
`widgets:new` creates a minimal client-only folder and immediately runs the synchronizer. Replace the empty component,
|
||||
add options, and extend the manifest with operations or router registrations as needed; it never edits a hand-written
|
||||
central feature list.
|
||||
|
||||
The check command is read-only and reports drift. The synchronizer rejects duplicate widget kinds, docs slugs, router
|
||||
namespaces with conflicting targets, operation names, and cache namespaces. It also verifies client entries, router
|
||||
modules, and local docs before producing anything.
|
||||
|
||||
It generates these compatibility projections:
|
||||
|
||||
| Consumer | Generated projection |
|
||||
| ---------------------- | -------------------------------------------------------------------- |
|
||||
| Definitions and policy | `packages/definitions/src/generated/widget-modules.ts` |
|
||||
| Client lazy loading | `packages/widgets/src/generated/widget-modules.ts` |
|
||||
| Lightweight icons | `packages/ui/src/generated/widget-icons.ts` |
|
||||
| Lazy tRPC routers | `packages/api/src/router/widgets/generated-registry.ts` |
|
||||
| Docusaurus | `apps/docs/docs/widgets/<slug>/` from the module's canonical `docs/` |
|
||||
|
||||
Generated client and server registries contain explicit import literals. Do not replace them with a variable dynamic
|
||||
import: explicit paths let Next.js and Turbopack discover chunks without eagerly loading every widget.
|
||||
|
||||
Use `pnpm features:sync` or `pnpm features:sync:check` when a change spans widgets and integrations.
|
||||
|
||||
## Client and server data caching
|
||||
|
||||
The operation is the shared identity for both cache layers:
|
||||
|
||||
- The tRPC path drives the TanStack Query key.
|
||||
- `refetchIntervalSeconds` configures the root Query Client without importing widget UI code.
|
||||
- `persist` controls session-scoped browser persistence. Persisted payloads are size-bounded, sanitized, and separated
|
||||
by the authenticated user and permission scope.
|
||||
- `serverCache` is imported by the request handler through `getWidgetServerCachePolicy`, so its namespace and TTL are
|
||||
not copied into another file.
|
||||
|
||||
Server reads use bounded in-process L1 caching and a shared Redis L2. Inputs are canonically serialized and SHA-256
|
||||
hashed; raw URLs, options, and secrets never appear in Redis keys or cache logs. Redis operations and lock waits are
|
||||
bounded, refreshes use token-safe distributed locks, and stale data can be retained for the declared error window.
|
||||
Redis failure degrades to local caching and produces structured logs with safe metadata and the original cause.
|
||||
|
||||
Integration-backed cache keys include the integration ID and its Redis generation. Mutations advance that generation,
|
||||
which invalidates all replicas without scanning keys. Integration-free widget handlers use a namespace generation;
|
||||
manual refreshes such as Docker inventory invalidation therefore clear both local and shared values. A zero TTL keeps
|
||||
only overlapping in-flight request deduplication and never stores a Redis payload, which is appropriate for realtime
|
||||
or subscription data.
|
||||
|
||||
Generation reads are process-cached for one second and concurrent misses are deduplicated, so a hot L1 hit does not add
|
||||
a Redis round trip. Redis failure backs off for five seconds instead of producing a request storm; local invalidations
|
||||
take effect immediately and other replicas converge within the one-second generation window.
|
||||
|
||||
TanStack Query remains the browser cache and Redis remains the server cache. A browser persistence hit never bypasses
|
||||
server authorization, and Redis never caches permission decisions or decrypted credentials.
|
||||
|
||||
## Server operations and tRPC
|
||||
|
||||
The neutral contract includes helpers for queries, mutations, subscriptions, and routes:
|
||||
|
||||
- `defineWidgetServerOperation`
|
||||
- `defineWidgetServerSubscription`
|
||||
- `defineWidgetServerRoute`
|
||||
- `defineWidgetServerModule`
|
||||
|
||||
They describe validated input, authorization intent, cache policy, an `AbortSignal`, and a framework-neutral resolver.
|
||||
The future API adapter will inject tRPC procedure builders, integration middleware, logging, and error normalization.
|
||||
This dependency direction lets a local `server.ts` be consumed by API code without the widget client importing API
|
||||
runtime code.
|
||||
|
||||
Until that adapter replaces the current client dependency, keep actual upstream calls in `@homarr/request-handler` and
|
||||
the tRPC compatibility router in `packages/api/src/router/widgets/`. Put the router registration in the local manifest;
|
||||
do not add it to the hand-written registry.
|
||||
|
||||
An integration-backed procedure must use the widget-aware middleware. It derives compatible kinds and maximum
|
||||
selection limits from the module/catalog, resolves only the requested records, decrypts secrets on the server, and
|
||||
checks direct user and group permissions before the procedure runs. Use `query` permission for reads and `interact`
|
||||
permission for mutations.
|
||||
|
||||
## Errors and logs
|
||||
|
||||
- Root tRPC instrumentation records the procedure path, operation type, user, duration, and outcome.
|
||||
- Integration middleware records safe requested/resolved IDs and kinds plus denial reasons; it never logs credentials.
|
||||
- Integration calls are timed and normalized into public errors while preserving a structured server-side cause.
|
||||
- Multi-integration reads use `settleIntegrationQueries` so one failed service does not erase successful providers.
|
||||
- Widget components read through the shared usable-data helper: stale data remains visible after a refresh error, while
|
||||
first-load failures reach the common widget error boundary and logs link.
|
||||
- Lazy client, router, and integration imports cache successful and in-flight loads, evict failed promises, and log
|
||||
load duration so a transient chunk failure can retry.
|
||||
|
||||
## Complex modules
|
||||
|
||||
The manifest is the common path, not a limit on implementation size.
|
||||
|
||||
- Beszel can group multiple stable widget kinds behind one router and keep live statistics on an SSE subscription with
|
||||
Redis payload caching disabled.
|
||||
- Assistant can use a route/stream adapter for chat while keeping client runtime state and tools in subfolders.
|
||||
- A proxy-only widget can expose a server route without inventing a polling query.
|
||||
- A widget can add custom query matchers, context actions, prefetching, or nested client components while retaining the
|
||||
same registration, permissions, logging, error, and cache contracts.
|
||||
|
||||
## Integration modules
|
||||
|
||||
Create an integration module with an explicit icon URL:
|
||||
|
||||
```sh
|
||||
pnpm integrations:new example --icon-url https://example.com/example.svg
|
||||
pnpm integrations:sync:check
|
||||
```
|
||||
|
||||
The scaffold creates a compileable connection test, pure `module.ts`, and canonical docs in
|
||||
`packages/integrations/src/example/`. Add capability interfaces and service methods beside that implementation, then
|
||||
connect them to widget request handlers. Declare real credential alternatives, categories, Docker discovery aliases,
|
||||
ports, and onboarding metadata in the local module instead of editing the central definitions and creator registries.
|
||||
|
||||
The integration synchronizer validates kinds, credential kinds, categories, ports, URLs, documentation, creator
|
||||
exports, and collisions. It generates the definitions projection, a typed lazy creator registry with literal imports,
|
||||
and Docusaurus copies whose edit links point back to the canonical module docs.
|
||||
|
||||
Beszel is migrated to this path. Other integrations still use the compatibility registry and can move independently
|
||||
without changing persisted `IntegrationKind` values or widget capability checks. Keep integrations server-only,
|
||||
implement only their capability interfaces, and use narrow schema/interface exports instead of the broad runtime
|
||||
barrel while migrating them.
|
||||
Registration files intentionally contain boring, explicit imports. This small amount of repetition preserves exact
|
||||
TypeScript inference, gives Next.js discoverable lazy chunks, and keeps Ctrl+click navigation reliable.
|
||||
@@ -1 +0,0 @@
|
||||
packages/integrations/src/beszel/docs
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const beszelIntegration = {
|
||||
name: "Beszel",
|
||||
description:
|
||||
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
path: "../../integrations/beszel",
|
||||
} as const;
|
||||
} satisfies IntegrationDefinition;
|
||||
@@ -1 +0,0 @@
|
||||
packages/widgets/src/downloads/docs
|
||||
@@ -1 +0,0 @@
|
||||
packages/widgets/src/weather/docs
|
||||
@@ -2,37 +2,8 @@ import type * as Preset from "@docusaurus/preset-classic";
|
||||
import type { Config } from "@docusaurus/types";
|
||||
import { themes as prismThemes } from "prism-react-renderer";
|
||||
import { resolveHomarrUrlConfig } from "@homarr/workshop/schema";
|
||||
import { generatedIntegrationDocumentationSources, generatedWidgetDocumentationSources } from "@homarr/definitions";
|
||||
const a11yEmoji = require("@fec/remark-a11y-emoji");
|
||||
|
||||
const githubDocsEditRoot = "https://github.com/homarr-labs/homarr/edit/dev";
|
||||
|
||||
const getDocumentationEditUrl = (docPath: string) => {
|
||||
const integrationPath = /^integrations\/([^/]+)\/(.+)$/u.exec(docPath);
|
||||
const integrationSlug = integrationPath?.[1];
|
||||
const integrationRelativePath = integrationPath?.[2];
|
||||
if (
|
||||
integrationSlug &&
|
||||
integrationRelativePath &&
|
||||
Object.hasOwn(generatedIntegrationDocumentationSources, integrationSlug)
|
||||
) {
|
||||
const source =
|
||||
generatedIntegrationDocumentationSources[
|
||||
integrationSlug as keyof typeof generatedIntegrationDocumentationSources
|
||||
];
|
||||
return `${githubDocsEditRoot}/${source}/${integrationRelativePath}`;
|
||||
}
|
||||
|
||||
const widgetPath = /^widgets\/([^/]+)\/(.+)$/u.exec(docPath);
|
||||
const slug = widgetPath?.[1];
|
||||
const relativePath = widgetPath?.[2];
|
||||
if (slug && relativePath && Object.hasOwn(generatedWidgetDocumentationSources, slug)) {
|
||||
const source = generatedWidgetDocumentationSources[slug as keyof typeof generatedWidgetDocumentationSources];
|
||||
return `${githubDocsEditRoot}/${source}/${relativePath}`;
|
||||
}
|
||||
return `${githubDocsEditRoot}/apps/docs/docs/${docPath}`;
|
||||
};
|
||||
|
||||
const deprecatedWorkshopUrl = process.env.WORKSHOP_URL;
|
||||
if (deprecatedWorkshopUrl && !process.env.WORKSHOP_API_URL) {
|
||||
console.warn("WORKSHOP_URL is deprecated; use WORKSHOP_API_URL instead.");
|
||||
@@ -94,7 +65,7 @@ const config: Config = {
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
editUrl: ({ docPath }) => getDocumentationEditUrl(docPath),
|
||||
editUrl: ({ docPath }) => `https://github.com/homarr-labs/homarr/edit/dev/apps/docs/docs/${docPath}`,
|
||||
remarkPlugins: [a11yEmoji],
|
||||
exclude: [],
|
||||
showLastUpdateAuthor: false,
|
||||
|
||||
@@ -2,13 +2,17 @@ import type { LoadContext, Plugin } from "@docusaurus/types";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { integrationDocSlugs } from "@homarr/definitions/docs/integration-doc-slugs";
|
||||
import { widgetDocSlugs } from "@homarr/definitions/docs/widget-doc-slugs";
|
||||
import { getIntegrationDocumentationSlug, integrationKinds } from "@homarr/definitions/integration";
|
||||
|
||||
const slugMaps = {
|
||||
Integration: { slugs: integrationDocSlugs, folder: "integrations" },
|
||||
Widget: { slugs: widgetDocSlugs, folder: "widgets" },
|
||||
} as const;
|
||||
const documentationEntries = [
|
||||
{
|
||||
label: "Integration",
|
||||
folder: "integrations",
|
||||
entries: integrationKinds.map((kind) => [kind, getIntegrationDocumentationSlug(kind)] as const),
|
||||
},
|
||||
{ label: "Widget", folder: "widgets", entries: Object.entries(widgetDocSlugs) },
|
||||
] as const;
|
||||
|
||||
export default function validateDocsCoveragePlugin(context: LoadContext): Plugin {
|
||||
return {
|
||||
@@ -17,8 +21,8 @@ export default function validateDocsCoveragePlugin(context: LoadContext): Plugin
|
||||
const docsDir = path.resolve(context.siteDir, "docs");
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const [label, { slugs, folder }] of Object.entries(slugMaps)) {
|
||||
for (const [kind, slug] of Object.entries(slugs)) {
|
||||
for (const { label, folder, entries } of documentationEntries) {
|
||||
for (const [kind, slug] of entries) {
|
||||
if (!slug) continue;
|
||||
if (!fs.existsSync(path.join(docsDir, folder, slug))) {
|
||||
missing.push(`${label} "${kind}" -> docs/${folder}/${slug}/`);
|
||||
|
||||
@@ -141,12 +141,14 @@ const ScopedTRPCReactProvider = ({
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
for (const { queryKey, intervalSeconds, staleTimeSeconds } of widgetQueryRefetchIntervals) {
|
||||
for (const queryDefaults of widgetQueryRefetchIntervals) {
|
||||
const policy: { refetchInterval?: number | false; staleTime?: number } = {};
|
||||
if (intervalSeconds === null) policy.refetchInterval = false;
|
||||
if (typeof intervalSeconds === "number") policy.refetchInterval = intervalSeconds * 1000;
|
||||
if (staleTimeSeconds !== undefined) policy.staleTime = staleTimeSeconds * 1000;
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
if (queryDefaults.intervalSeconds === null) policy.refetchInterval = false;
|
||||
if (typeof queryDefaults.intervalSeconds === "number") {
|
||||
policy.refetchInterval = queryDefaults.intervalSeconds * 1000;
|
||||
}
|
||||
if ("staleTimeSeconds" in queryDefaults) policy.staleTime = queryDefaults.staleTimeSeconds * 1000;
|
||||
client.setQueryDefaults(queryDefaults.queryKey, policy);
|
||||
}
|
||||
for (const { queryKey, ...policy } of dashboardSupportingQueryPolicies) {
|
||||
client.setQueryDefaults(queryKey, policy);
|
||||
|
||||
@@ -10,26 +10,25 @@ import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
import { createMetaTitle } from "~/metadata";
|
||||
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
|
||||
import { getMcpRuntimeAsync } from "~/app/api/mcp/_extract-tools";
|
||||
import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
|
||||
import { ApiKeysManagement } from "./components/api-keys";
|
||||
import { ApiPageTabs } from "./components/api-page-tabs";
|
||||
import { ScalarApiReference } from "./components/scalar-api-reference";
|
||||
|
||||
import type { McpToolGroup } from "./components/api-page-tabs";
|
||||
|
||||
async function getMcpToolGroupsAsync(): Promise<McpToolGroup[]> {
|
||||
const { tools, procedureTypes } = await getMcpRuntimeAsync();
|
||||
function getMcpToolGroups(): McpToolGroup[] {
|
||||
const tools = extractMcpTools();
|
||||
const groups = new Map<string, McpToolGroup["tools"]>();
|
||||
for (const tool of tools) {
|
||||
const namespace = tool.pathInRouter[0] ?? "other";
|
||||
if (!groups.has(namespace)) {
|
||||
groups.set(namespace, []);
|
||||
}
|
||||
const procedureKey = tool.pathInRouter.join(".");
|
||||
groups.get(namespace)?.push({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
type: procedureTypes.get(procedureKey) ?? "query",
|
||||
type: tool.type,
|
||||
});
|
||||
}
|
||||
return Array.from(groups.entries()).map(([namespace, items]) => ({
|
||||
@@ -60,7 +59,7 @@ export default async function ApiPage() {
|
||||
headers(),
|
||||
api.apiKeys.getAll(),
|
||||
getI18n("management.page.tool.api.tab"),
|
||||
getMcpToolGroupsAsync(),
|
||||
getMcpToolGroups(),
|
||||
]);
|
||||
const baseUrl = extractBaseUrlFromHeaders(requestHeaders);
|
||||
const document = openApiDocument(baseUrl);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { ToolSet, UIMessage } from "ai";
|
||||
import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createTRPCContext } from "@homarr/api/mcp";
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import {
|
||||
createAssistantGenerationAccessToken,
|
||||
getAssistantContextEntitiesAsync,
|
||||
@@ -16,7 +16,6 @@ import { auth } from "@homarr/auth/next";
|
||||
import { env } from "@homarr/common/env";
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { and, eq } from "@homarr/db";
|
||||
import { db } from "@homarr/db";
|
||||
import { assistantConfigurations, assistantThreads } from "@homarr/db/schema";
|
||||
@@ -36,7 +35,7 @@ import type {
|
||||
AssistantWebSearchSource,
|
||||
} from "~/components/assistant/assistant-message-metadata";
|
||||
|
||||
import { getMcpRuntimeAsync } from "../../mcp/_extract-tools";
|
||||
import { extractMcpTools } from "../../mcp/_extract-tools";
|
||||
import { buildAssistantRequestContext, sanitizeAttachmentFilename } from "./assistant-chat-input";
|
||||
import { getAssistantModelLookupStatus } from "./assistant-model-lookup";
|
||||
import { convertAssistantMessagesToModelMessages } from "./assistant-message-conversion";
|
||||
@@ -493,24 +492,14 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const canAuthorCustomWidgets = session.user.permissions.includes("admin");
|
||||
const customWidgetAuthoringContext = getCustomWidgetAuthoringContext(incomingMessages, canAuthorCustomWidgets);
|
||||
let mcpRuntime;
|
||||
try {
|
||||
mcpRuntime = await getMcpRuntimeAsync();
|
||||
} catch (error) {
|
||||
logger.error(new ErrorWithMetadata("Assistant tools are unavailable", { requestId }, { cause: error }));
|
||||
return Response.json(
|
||||
{ error: "Homarr tools could not be loaded. Check the Homarr logs and try again." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const caller = mcpRuntime.router.createCaller(context);
|
||||
const caller = mcpRouter.createCaller(context);
|
||||
const omittedCustomWidgetTools = new Set<string>(customWidgetAuthoringContext.omittedToolNames);
|
||||
const mcpTools = mcpRuntime.tools.filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
|
||||
const mcpTools = extractMcpTools().filter((mcpTool) => !omittedCustomWidgetTools.has(mcpTool.name));
|
||||
const customWidgetComponentDocumentBudget = createCustomWidgetComponentDocumentBudget();
|
||||
|
||||
const homarrTools = Object.fromEntries(
|
||||
mcpTools.map((mcpTool) => {
|
||||
const requiresApproval = mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation";
|
||||
const requiresApproval = mcpTool.type === "mutation";
|
||||
return [
|
||||
mcpTool.name,
|
||||
tool({
|
||||
@@ -558,11 +547,9 @@ export async function POST(request: Request) {
|
||||
}),
|
||||
) satisfies ToolSet;
|
||||
const toolApproval = Object.fromEntries(
|
||||
mcpTools.flatMap((mcpTool) =>
|
||||
mcpRuntime.procedureTypes.get(mcpTool.pathInRouter.join(".")) === "mutation"
|
||||
? [[mcpTool.name, "user-approval" as const]]
|
||||
: [],
|
||||
),
|
||||
mcpTools
|
||||
.filter((mcpTool) => mcpTool.type === "mutation")
|
||||
.map((mcpTool) => [mcpTool.name, "user-approval" as const]),
|
||||
);
|
||||
|
||||
const frontendTools = Object.fromEntries(
|
||||
|
||||
@@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({
|
||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||
vi.mock("@homarr/api/mcp", () => ({
|
||||
createTRPCContext: vi.fn(() => ({})),
|
||||
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||
}));
|
||||
vi.mock("@homarr/auth/api-key", () => ({
|
||||
API_KEY_HEADER_NAME: "ApiKey",
|
||||
@@ -41,30 +42,29 @@ vi.mock("@homarr/custom-widgets/core", () => ({
|
||||
vi.mock("@homarr/db", () => ({ db: {} }));
|
||||
vi.mock("~/versions/package-reader", () => ({ getPackageVersion: () => "test-version" }));
|
||||
vi.mock("../_extract-tools", () => ({
|
||||
getMcpRuntimeAsync: async () => ({
|
||||
router: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||
procedureTypes: new Map([["board.getAllBoards", "query"]]),
|
||||
tools: [
|
||||
{
|
||||
name: "board_getAllBoards",
|
||||
description: "List boards",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_list",
|
||||
description: "List Custom Widgets",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_workshopSearch",
|
||||
description: "Search Workshop",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
}),
|
||||
extractMcpTools: () => [
|
||||
{
|
||||
name: "board_getAllBoards",
|
||||
description: "List boards",
|
||||
type: "query",
|
||||
pathInRouter: ["board", "getAllBoards"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_list",
|
||||
description: "List Custom Widgets",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "list"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
{
|
||||
name: "customWidget_workshopSearch",
|
||||
description: "Search Workshop",
|
||||
type: "query",
|
||||
pathInRouter: ["customWidget", "workshopSearch"],
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
@@ -1,55 +1,11 @@
|
||||
import type { McpRouter, McpTool } from "@homarr/api/mcp";
|
||||
import { extractMcpToolsFromProcedures, getMcpRouterAsync } from "@homarr/api/mcp";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
|
||||
|
||||
const logger = createLogger({ module: "mcpTools" });
|
||||
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
|
||||
|
||||
export type McpProcedureType = "query" | "mutation";
|
||||
export function extractMcpTools() {
|
||||
if (cache) return cache;
|
||||
|
||||
export interface McpRuntime {
|
||||
router: McpRouter;
|
||||
tools: McpTool[];
|
||||
procedureTypes: ReadonlyMap<string, McpProcedureType>;
|
||||
}
|
||||
|
||||
const createMcpRuntimeAsync = async (): Promise<McpRuntime> => {
|
||||
const startedAt = Date.now();
|
||||
const router = await getMcpRouterAsync();
|
||||
const tools = extractMcpToolsFromProcedures(router);
|
||||
const procedures = router["_def"].procedures as unknown as Record<string, { ["_def"]?: { type?: McpProcedureType } }>;
|
||||
const procedureTypes = new Map<string, McpProcedureType>();
|
||||
|
||||
for (const [path, procedure] of Object.entries(procedures)) {
|
||||
const type = procedure["_def"]?.type;
|
||||
if (type === "query" || type === "mutation") procedureTypes.set(path, type);
|
||||
}
|
||||
|
||||
logger.info("Extracted MCP tools", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
toolCount: tools.length,
|
||||
});
|
||||
return { router, tools, procedureTypes };
|
||||
};
|
||||
|
||||
let mcpRuntimePromise: Promise<McpRuntime> | null = null;
|
||||
|
||||
export async function getMcpRuntimeAsync(): Promise<McpRuntime> {
|
||||
if (mcpRuntimePromise) return await mcpRuntimePromise;
|
||||
|
||||
const loadPromise = createMcpRuntimeAsync();
|
||||
mcpRuntimePromise = loadPromise;
|
||||
|
||||
try {
|
||||
return await loadPromise;
|
||||
} catch (error) {
|
||||
if (mcpRuntimePromise === loadPromise) mcpRuntimePromise = null;
|
||||
logger.error(new ErrorWithMetadata("Failed to extract MCP tools", {}, { cause: error }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractMcpTools(): Promise<McpTool[]> {
|
||||
const { tools } = await getMcpRuntimeAsync();
|
||||
return tools;
|
||||
cache = extractMcpToolsFromProcedures(mcpRouter);
|
||||
|
||||
return cache;
|
||||
}
|
||||
@@ -5,12 +5,11 @@ import { ResourceTemplate } from "@modelcontextprotocol/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { McpTool } from "@homarr/api/mcp";
|
||||
import { createTRPCContext } from "@homarr/api/mcp";
|
||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||
import { extractBaseUrlFromHeaders } from "@homarr/common";
|
||||
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
@@ -23,7 +22,7 @@ import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
|
||||
import { getMcpRuntimeAsync } from "./_extract-tools";
|
||||
import { extractMcpTools } from "./_extract-tools";
|
||||
import { createMcpProtocolHandler } from "./_protocol";
|
||||
|
||||
const logger = createLogger({ module: "mcpRoute" });
|
||||
@@ -81,8 +80,10 @@ function clearAuthFailures(ip: string | null) {
|
||||
authFailures.delete(ip ?? "unknown");
|
||||
}
|
||||
|
||||
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) =>
|
||||
isAdmin ? tools : tools.filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
const getVisibleTools = (tools: McpTool[], isAdmin: boolean) => {
|
||||
if (isAdmin) return tools;
|
||||
return tools.filter((tool) => !tool.name.startsWith("customWidget_"));
|
||||
};
|
||||
|
||||
function registerCustomWidgetAuthoring(server: McpServer) {
|
||||
server.registerPrompt(
|
||||
@@ -322,23 +323,12 @@ export const handleMcpRequest = async (req: NextRequest) => {
|
||||
clearAuthFailures(ipAddress);
|
||||
logger.info("MCP request authenticated", { userId: session.user.id });
|
||||
|
||||
let mcpRuntime;
|
||||
try {
|
||||
mcpRuntime = await getMcpRuntimeAsync();
|
||||
} catch (error) {
|
||||
logger.error(new ErrorWithMetadata("MCP runtime is unavailable", {}, { cause: error }));
|
||||
return jsonErrorResponse(503, {
|
||||
error: "service_unavailable",
|
||||
hint: "The MCP tool registry could not be loaded. Check the Homarr logs and try again.",
|
||||
});
|
||||
}
|
||||
|
||||
const ctx = createTRPCContext({ session, headers: req.headers });
|
||||
const caller = mcpRuntime.router.createCaller(ctx);
|
||||
const caller = mcpRouter.createCaller(ctx);
|
||||
const isAdmin = session.user.permissions.includes("admin");
|
||||
const protocolHandler = createMcpProtocolHandler({
|
||||
caller,
|
||||
tools: getVisibleTools(mcpRuntime.tools, isAdmin),
|
||||
tools: getVisibleTools(extractMcpTools(), isAdmin),
|
||||
version: getPackageVersion(),
|
||||
instructions: SERVER_INSTRUCTIONS,
|
||||
configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined,
|
||||
|
||||
@@ -50,6 +50,7 @@ const echoValidator = z.object({ message: z.string() });
|
||||
const echoTool: McpTool = {
|
||||
name: "echo",
|
||||
description: "Echo a message",
|
||||
type: "query",
|
||||
pathInRouter: ["echo"],
|
||||
inputValidator: echoValidator,
|
||||
inputSchema: z.toJSONSchema(echoValidator),
|
||||
|
||||
@@ -74,6 +74,7 @@ const unavailableMessageKeys = {
|
||||
|
||||
export const AssistantGate = ({ availability, children }: AssistantGateProps) => {
|
||||
const t = useI18n("assistant");
|
||||
|
||||
if (availability === "enabled") {
|
||||
return <EnabledAssistantRoot>{children}</EnabledAssistantRoot>;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
/**
|
||||
* Keeps the assistant discoverable in the spotlight when it is unavailable, without loading the
|
||||
* assistant runtime. Lives apart from `assistant-provider` so the unavailable path stays cheap.
|
||||
* assistant runtime. Lives apart from `assistant-provider` so the disabled path stays cheap.
|
||||
*/
|
||||
export const useRegisterAssistantSpotlightPlaceholder = (description: string) => {
|
||||
const t = useI18n("assistant");
|
||||
|
||||
@@ -63,14 +63,14 @@ describe("item actions create-item", () => {
|
||||
{
|
||||
layoutId,
|
||||
height: 1,
|
||||
width: 1,
|
||||
width: 2,
|
||||
...emptyPosition,
|
||||
sectionId: firstSectionId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 1 });
|
||||
expect(emptyPositionSpy).toHaveBeenCalledWith([], layout.columnCount, undefined, { height: 1, width: 2 });
|
||||
});
|
||||
test("should correctly pass containers and items to getFirstEmptyPosition", () => {
|
||||
// Arrange
|
||||
@@ -118,14 +118,14 @@ describe("item actions create-item", () => {
|
||||
expect(createdItem).toEqual(
|
||||
expect.objectContaining({
|
||||
kind: itemKind,
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 1, sectionId: firstSectionId, layoutId }],
|
||||
layouts: [{ ...emptyPosition, height: 1, width: 2, sectionId: firstSectionId, layoutId }],
|
||||
}),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
[expect.objectContaining(itemAndSectionPosition), expect.objectContaining(itemAndSectionPosition)],
|
||||
layout.columnCount,
|
||||
undefined,
|
||||
{ height: 1, width: 1 },
|
||||
{ height: 1, width: 2 },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import { nativeFeatureCapabilities, widgetKinds } from "@homarr/definitions";
|
||||
import { widgetIntegrationConfigs, widgetKinds } from "@homarr/definitions";
|
||||
|
||||
export interface BoardRecipeRecommendation {
|
||||
widgetKind: WidgetKind;
|
||||
@@ -36,10 +36,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
return widgetKinds
|
||||
.filter((widgetKind) => !existingKinds.has(widgetKind))
|
||||
.flatMap((widgetKind) => {
|
||||
const capability = nativeFeatureCapabilities[widgetKind as keyof typeof nativeFeatureCapabilities];
|
||||
const capability = widgetIntegrationConfigs[widgetKind as keyof typeof widgetIntegrationConfigs];
|
||||
if (!capability) return [];
|
||||
|
||||
const matches = capability.integrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
const matches = capability.supportedIntegrations.filter((integrationKind) => availableKinds.has(integrationKind));
|
||||
if (matches.length === 0) return [];
|
||||
|
||||
const preferredMatch = preferredIntegrationKind && matches.includes(preferredIntegrationKind);
|
||||
@@ -51,10 +51,10 @@ export const getBoardRecipeRecommendations = ({
|
||||
widgetKind,
|
||||
integrationKind,
|
||||
isNewlyAvailable: Boolean(preferredMatch),
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.integrations.length,
|
||||
score: (preferredMatch ? 1_000 : 0) + matches.length * 10 - capability.supportedIntegrations.length,
|
||||
},
|
||||
];
|
||||
})
|
||||
.toSorted((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.sort((left, right) => right.score - left.score || left.widgetKind.localeCompare(right.widgetKind))
|
||||
.slice(0, Math.max(0, limit));
|
||||
};
|
||||
@@ -91,7 +91,6 @@ const ItemSelectModalContent = ({
|
||||
initialSearch = "",
|
||||
}: ItemSelectModalContentProps) => {
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
const canBrowseCustomWidgets = isAdmin;
|
||||
const t = useI18n();
|
||||
const board = useRequiredBoard();
|
||||
const currentLayoutId = useCurrentLayout();
|
||||
@@ -480,7 +479,7 @@ const ItemSelectModalContent = ({
|
||||
/>
|
||||
))}
|
||||
|
||||
{canBrowseCustomWidgets && (
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Divider
|
||||
label={
|
||||
@@ -535,7 +534,7 @@ const ItemSelectModalContent = ({
|
||||
)}
|
||||
</SimpleGrid>
|
||||
|
||||
{filteredItems.length === 0 && (!canBrowseCustomWidgets || filteredCustomWidgets.length === 0) && (
|
||||
{filteredItems.length === 0 && (!isAdmin || filteredCustomWidgets.length === 0) && (
|
||||
<Center p="xl">
|
||||
<Text c="dimmed">{t("common.noResults")}</Text>
|
||||
</Center>
|
||||
@@ -654,13 +653,12 @@ const ItemSelectModalFrame = ({
|
||||
const utils = clientApi.useUtils();
|
||||
const { data: session } = useSession();
|
||||
const isAdmin = session?.user.permissions.includes("admin") ?? false;
|
||||
const canBrowseCustomWidgets = isAdmin;
|
||||
const canCreateIntegration = session?.user.permissions.includes("integration-create") ?? false;
|
||||
const t = useI18n();
|
||||
const { data: integrationData } = clientApi.integration.all.useQuery();
|
||||
const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery(
|
||||
{ boardId: innerProps.boardId },
|
||||
{ enabled: canBrowseCustomWidgets },
|
||||
{ enabled: isAdmin },
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -40,11 +40,6 @@
|
||||
"docker:dev:up": "docker compose -f ./development/development.docker-compose.yml up -d redis",
|
||||
"format": "turbo format --continue -- --check",
|
||||
"format:fix": "turbo format --continue",
|
||||
"features:sync": "pnpm widgets:sync && pnpm integrations:sync",
|
||||
"features:sync:check": "pnpm widgets:sync:check && pnpm integrations:sync:check",
|
||||
"integrations:new": "tsx ./scripts/integrations/create-integration-module.mts",
|
||||
"integrations:sync": "tsx ./scripts/integrations/sync-integration-modules.mts",
|
||||
"integrations:sync:check": "tsx ./scripts/integrations/sync-integration-modules.mts --check",
|
||||
"lint": "turbo lint --continue",
|
||||
"lint:fix": "turbo lint --continue -- --fix",
|
||||
"lint:ws": "pnpm dlx sherif@latest",
|
||||
@@ -59,9 +54,6 @@
|
||||
"test:workshop-image": "sh apps/workshop/test-image.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
"widgets:sync": "tsx ./scripts/widgets/sync-widget-modules.mts",
|
||||
"widgets:sync:check": "tsx ./scripts/widgets/sync-widget-modules.mts --check",
|
||||
"widgets:new": "tsx ./scripts/widgets/create-widget-module.mts",
|
||||
"with-env": "dotenv -e .env --"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export interface McpMeta {
|
||||
@@ -12,6 +12,7 @@ export interface McpMeta {
|
||||
export interface McpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "query" | "mutation";
|
||||
pathInRouter: string[];
|
||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||
inputValidator: z.ZodObject;
|
||||
@@ -19,8 +20,32 @@ export interface McpTool {
|
||||
|
||||
const emptyInput = z.object({});
|
||||
|
||||
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
|
||||
|
||||
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
|
||||
|
||||
const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
|
||||
if (!meta || typeof meta !== "object" || !("mcp" in meta)) return undefined;
|
||||
|
||||
const metadata = meta.mcp;
|
||||
if (!metadata || typeof metadata !== "object" || !("enabled" in metadata)) return undefined;
|
||||
if (typeof metadata.enabled !== "boolean") return undefined;
|
||||
|
||||
const result: NonNullable<McpMeta["mcp"]> = { enabled: metadata.enabled };
|
||||
if ("name" in metadata && typeof metadata.name === "string") result.name = metadata.name;
|
||||
if ("description" in metadata && typeof metadata.description === "string") {
|
||||
result.description = metadata.description;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const getProcedureInput = (inputs: z.ZodType[]) => {
|
||||
if (inputs.length === 0) return emptyInput;
|
||||
if (inputs.length === 1) return inputs[0] ?? emptyInput;
|
||||
if (inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)) return mergeInputs(inputs);
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
|
||||
const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>;
|
||||
const required = ((schema.required as string[] | undefined) ?? []).filter(
|
||||
@@ -41,22 +66,15 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
|
||||
const tools: McpTool[] = [];
|
||||
|
||||
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
||||
const definition = procedure["_def"] as unknown as {
|
||||
inputs?: z.ZodType[];
|
||||
meta?: McpMeta;
|
||||
};
|
||||
const metadata = definition.meta?.mcp;
|
||||
if (!isProcedure(procedure)) continue;
|
||||
const definition = procedure["_def"];
|
||||
if (definition.type === "subscription") continue;
|
||||
|
||||
const metadata = getMcpMetadata(definition.meta);
|
||||
if (!metadata?.enabled) continue;
|
||||
|
||||
const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined);
|
||||
const procedureInput =
|
||||
inputs.length === 0
|
||||
? emptyInput
|
||||
: inputs.length === 1
|
||||
? (inputs[0] ?? emptyInput)
|
||||
: inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject)
|
||||
? mergeInputs(inputs)
|
||||
: null;
|
||||
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
||||
const procedureInput = getProcedureInput(inputs);
|
||||
|
||||
if (
|
||||
procedureInput === null ||
|
||||
@@ -69,6 +87,7 @@ export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecor
|
||||
tools.push({
|
||||
name: metadata.name ?? path.replaceAll(".", "_"),
|
||||
description: metadata.description ?? "",
|
||||
type: definition.type,
|
||||
pathInRouter: path.split("."),
|
||||
inputValidator,
|
||||
inputSchema: normalizeJsonSchema(
|
||||
|
||||
+36
-49
@@ -2,9 +2,6 @@ export { createTRPCContext } from "./trpc";
|
||||
export { extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||
export type { McpTool } from "./mcp-tools";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { createTRPCRouter } from "./trpc";
|
||||
import { appRouter as appRouterForApps } from "./router/app";
|
||||
import { apiKeysRouter } from "./router/apiKeys";
|
||||
@@ -15,51 +12,41 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { airQualityRouter } from "./router/widgets/air-quality";
|
||||
import { bazarrRouter } from "./router/widgets/bazarr";
|
||||
import { serverSettingsRouter } from "./router/serverSettings";
|
||||
import { loadMcpWidgetRoutersAsync } from "./router/widgets/registry";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
import { healthMonitoringRouter } from "./router/widgets/health-monitoring";
|
||||
import { mediaRequestsRouter } from "./router/widgets/media-requests";
|
||||
import { mediaServerRouter } from "./router/widgets/media-server";
|
||||
import { patchmonRouter } from "./router/widgets/patchmon";
|
||||
import { smartHomeRouter } from "./router/widgets/smart-home";
|
||||
import { widgetSecretsRouter } from "./router/widgets/widget-secrets";
|
||||
|
||||
const logger = createLogger({ module: "mcpRouter" });
|
||||
|
||||
const createMcpRouterAsync = async () => {
|
||||
const startedAt = Date.now();
|
||||
const widgetRouters = await loadMcpWidgetRoutersAsync();
|
||||
|
||||
const router = createTRPCRouter({
|
||||
app: appRouterForApps,
|
||||
apiKeys: apiKeysRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconsRouter,
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
...widgetRouters,
|
||||
});
|
||||
|
||||
logger.info("Materialized MCP router", {
|
||||
durationMs: Date.now() - startedAt,
|
||||
widgetRouters: Object.keys(widgetRouters),
|
||||
});
|
||||
return router;
|
||||
};
|
||||
|
||||
export type McpRouter = Awaited<ReturnType<typeof createMcpRouterAsync>>;
|
||||
|
||||
let mcpRouterPromise: Promise<McpRouter> | null = null;
|
||||
|
||||
export async function getMcpRouterAsync(): Promise<McpRouter> {
|
||||
if (mcpRouterPromise) return await mcpRouterPromise;
|
||||
|
||||
const loadPromise = createMcpRouterAsync();
|
||||
mcpRouterPromise = loadPromise;
|
||||
|
||||
try {
|
||||
return await loadPromise;
|
||||
} catch (error) {
|
||||
if (mcpRouterPromise === loadPromise) mcpRouterPromise = null;
|
||||
logger.error(new ErrorWithMetadata("Failed to materialize MCP router", {}, { cause: error }));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const mcpRouter = createTRPCRouter({
|
||||
app: appRouterForApps,
|
||||
apiKeys: apiKeysRouter,
|
||||
board: boardRouter,
|
||||
customWidget: customWidgetRouter,
|
||||
docker: dockerRouter,
|
||||
icon: iconsRouter,
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
airQuality: airQualityRouter,
|
||||
bazarr: bazarrRouter,
|
||||
serverSettings: serverSettingsRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
healthMonitoring: healthMonitoringRouter,
|
||||
mediaRequests: mediaRequestsRouter,
|
||||
mediaServer: mediaServerRouter,
|
||||
patchmon: patchmonRouter,
|
||||
smartHome: smartHomeRouter,
|
||||
widgetSecrets: widgetSecretsRouter,
|
||||
});
|
||||
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { isDemoMode, middlewareProcedure } from "../trpc";
|
||||
import { isDemoMode, publicProcedure } from "../trpc";
|
||||
|
||||
export const dockerMiddleware = () => {
|
||||
return middlewareProcedure.use(async ({ next }) => {
|
||||
return publicProcedure.use(async ({ next }) => {
|
||||
if (env.ENABLE_DOCKER || isDemoMode) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import type { Database } from "@homarr/db";
|
||||
import { and, eq, inArray } from "@homarr/db";
|
||||
import { integrations } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, WidgetIntegrationKind, WidgetKindWithIntegration } from "@homarr/definitions";
|
||||
import { getWidgetFeatureIntegrationKinds } from "@homarr/definitions";
|
||||
import { getWidgetIntegrationConfig } from "@homarr/definitions";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { middlewareProcedure } from "../trpc";
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export type IntegrationAction = "query" | "interact";
|
||||
|
||||
@@ -32,51 +32,22 @@ interface IntegrationAccessMetadata {
|
||||
allowedIntegrationKinds: readonly IntegrationKind[];
|
||||
}
|
||||
|
||||
interface WidgetIntegrationAvailability {
|
||||
widgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]];
|
||||
}
|
||||
|
||||
const getCompatibleIntegrationKindsOrThrow = <TKind extends IntegrationKind>(
|
||||
kinds: readonly TKind[],
|
||||
widgetAvailability?: WidgetIntegrationAvailability,
|
||||
) => {
|
||||
if (!widgetAvailability) return kinds;
|
||||
|
||||
const widgetIntegrationKinds = new Set(
|
||||
widgetAvailability.widgetKinds.flatMap((widgetKind) => getWidgetFeatureIntegrationKinds(widgetKind)),
|
||||
);
|
||||
const compatibleKinds = kinds.filter((kind) => widgetIntegrationKinds.has(kind));
|
||||
if (compatibleKinds.length >= 1) return compatibleKinds;
|
||||
|
||||
logger.warn("No compatible integration kinds for widget route", {
|
||||
widgetKinds: widgetAvailability.widgetKinds,
|
||||
configuredIntegrationKinds: kinds,
|
||||
reason: "no-compatible-widget-integration-kinds",
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "No integration kind is compatible with the consuming widget",
|
||||
});
|
||||
};
|
||||
|
||||
const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
widgetAvailability?: WidgetIntegrationAvailability,
|
||||
) => {
|
||||
return middlewareProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability);
|
||||
return publicProcedure.input(z.object({ integrationId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "one",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: [input.integrationId],
|
||||
allowedIntegrationKinds: compatibleKinds,
|
||||
allowedIntegrationKinds: kinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
|
||||
const integration = await ctx.db.query.integrations.findFirst({
|
||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, compatibleKinds)),
|
||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
@@ -94,7 +65,7 @@ const createOneIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
logger.warn("Integration access target was not found", accessMetadata);
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Integration with id ${input.integrationId} not found or not of kinds ${compatibleKinds.join(",")}`,
|
||||
message: `Integration with id ${input.integrationId} not found or not of kinds ${kinds.join(",")}`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -148,80 +119,76 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
const createManyIntegrationMiddlewareForKinds = <TKind extends IntegrationKind>(
|
||||
action: IntegrationAction,
|
||||
kinds: readonly TKind[],
|
||||
widgetAvailability?: WidgetIntegrationAvailability,
|
||||
) => {
|
||||
return middlewareProcedure
|
||||
.input(z.object({ integrationIds: z.array(z.string()) }))
|
||||
.use(async ({ ctx, input, next }) => {
|
||||
const compatibleKinds = getCompatibleIntegrationKindsOrThrow(kinds, widgetAvailability);
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "many",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: input.integrationIds,
|
||||
allowedIntegrationKinds: compatibleKinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
return publicProcedure.input(z.object({ integrationIds: z.array(z.string()) })).use(async ({ ctx, input, next }) => {
|
||||
const accessMetadata: IntegrationAccessMetadata = {
|
||||
action,
|
||||
cardinality: "many",
|
||||
userId: ctx.session?.user.id,
|
||||
requestedIntegrationIds: input.integrationIds,
|
||||
allowedIntegrationKinds: kinds,
|
||||
};
|
||||
logger.debug("Resolving integration access", accessMetadata);
|
||||
|
||||
const findIntegrations = async () => {
|
||||
if (input.integrationIds.length === 0) return [];
|
||||
const findIntegrations = async () => {
|
||||
if (input.integrationIds.length === 0) return [];
|
||||
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, compatibleKinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
return await ctx.db.query.integrations.findMany({
|
||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||
with: {
|
||||
app: true,
|
||||
secrets: true,
|
||||
items: {
|
||||
with: {
|
||||
item: true,
|
||||
},
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
const dbIntegrations = await findIntegrations();
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
logger.warn("Integration access targets were not found", {
|
||||
...accessMetadata,
|
||||
missingIntegrationCount: offset,
|
||||
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${compatibleKinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
userPermissions: true,
|
||||
groupPermissions: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
const dbIntegrations = await findIntegrations();
|
||||
|
||||
const offset = input.integrationIds.length - dbIntegrations.length;
|
||||
if (offset !== 0) {
|
||||
logger.warn("Integration access targets were not found", {
|
||||
...accessMetadata,
|
||||
missingIntegrationCount: offset,
|
||||
resolvedIntegrationIds: dbIntegrations.map(({ id }) => id),
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: `${offset} of the specified integrations not found or not of kinds ${kinds.join(",")}: ([${input.integrationIds.join(",")}] compared to [${dbIntegrations.map(({ id, kind }) => `${kind}:${id}`).join(",")}])`,
|
||||
});
|
||||
}
|
||||
|
||||
if (dbIntegrations.length >= 1) {
|
||||
await throwIfActionIsNotAllowedAsync(action, ctx.db, dbIntegrations, ctx.session);
|
||||
}
|
||||
|
||||
logger.debug("Integration access granted", {
|
||||
...accessMetadata,
|
||||
resolvedIntegrationKinds: dbIntegrations.map(({ kind }) => kind),
|
||||
});
|
||||
|
||||
return next({
|
||||
ctx: {
|
||||
integrations: dbIntegrations.map(
|
||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||
...rest,
|
||||
externalUrl: rest.app?.href ?? null,
|
||||
kind: kind as TKind,
|
||||
decryptedSecrets: secrets.map((secret) => ({
|
||||
...secret,
|
||||
value: decryptSecret(secret.value),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -241,111 +208,17 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
||||
return createManyIntegrationMiddlewareForKinds(action, kinds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Widget procedures should use these adapters so their server-side permission
|
||||
* guards stay aligned with the feature catalog automatically.
|
||||
*/
|
||||
export function createOneWidgetIntegrationMiddleware<TWidgetKind extends WidgetKindWithIntegration>(
|
||||
export const createOneWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
|
||||
export function createOneWidgetIntegrationMiddleware<
|
||||
TWidgetKind extends WidgetKindWithIntegration,
|
||||
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createOneWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
widgetKind: WidgetKindWithIntegration,
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
|
||||
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
|
||||
}
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
|
||||
createOneIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
|
||||
|
||||
export function createOneSharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
|
||||
export function createOneSharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createOneIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createOneSharedWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: WidgetKindWithIntegration,
|
||||
additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind);
|
||||
return createOneIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, {
|
||||
widgetKinds: [contractWidgetKind, ...additionalWidgetKinds],
|
||||
});
|
||||
}
|
||||
|
||||
export function createManyWidgetIntegrationMiddleware<TWidgetKind extends WidgetKindWithIntegration>(
|
||||
export const createManyWidgetIntegrationMiddleware = <TWidgetKind extends WidgetKindWithIntegration>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>>;
|
||||
export function createManyWidgetIntegrationMiddleware<
|
||||
TWidgetKind extends WidgetKindWithIntegration,
|
||||
TIntegrationKind extends WidgetIntegrationKind<TWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
widgetKind: TWidgetKind,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createManyWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
widgetKind: WidgetKindWithIntegration,
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(widgetKind);
|
||||
return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, { widgetKinds: [widgetKind] });
|
||||
}
|
||||
|
||||
export function createManySharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TContractWidgetKind>>>;
|
||||
export function createManySharedWidgetIntegrationMiddleware<
|
||||
TContractWidgetKind extends WidgetKindWithIntegration,
|
||||
const TAdditionalWidgetKinds extends readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
TIntegrationKind extends WidgetIntegrationKind<TContractWidgetKind>,
|
||||
>(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: TContractWidgetKind,
|
||||
additionalWidgetKinds: TAdditionalWidgetKinds,
|
||||
integrationKinds: readonly [TIntegrationKind, ...TIntegrationKind[]],
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<TIntegrationKind>>;
|
||||
export function createManySharedWidgetIntegrationMiddleware(
|
||||
action: IntegrationAction,
|
||||
contractWidgetKind: WidgetKindWithIntegration,
|
||||
additionalWidgetKinds: readonly [WidgetKindWithIntegration, ...WidgetKindWithIntegration[]],
|
||||
integrationKinds?: readonly IntegrationKind[],
|
||||
) {
|
||||
const configuredIntegrationKinds = integrationKinds ?? getWidgetFeatureIntegrationKinds(contractWidgetKind);
|
||||
return createManyIntegrationMiddlewareForKinds(action, configuredIntegrationKinds, {
|
||||
widgetKinds: [contractWidgetKind, ...additionalWidgetKinds],
|
||||
});
|
||||
}
|
||||
): ReturnType<typeof createManyIntegrationMiddlewareForKinds<WidgetIntegrationKind<TWidgetKind>>> =>
|
||||
createManyIntegrationMiddlewareForKinds(action, getWidgetIntegrationConfig(widgetKind).supportedIntegrations);
|
||||
|
||||
/**
|
||||
* Throws a TRPCError FORBIDDEN if the user does not have permission to perform the specified action on at least one of the specified integrations
|
||||
|
||||
@@ -5,10 +5,10 @@ import { and, eq } from "@homarr/db";
|
||||
import { items } from "@homarr/db/schema";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { middlewareProcedure } from "../trpc";
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export const createOneItemMiddleware = (kind: WidgetKind) => {
|
||||
return middlewareProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
return publicProcedure.input(z.object({ itemId: z.string() })).use(async ({ input, ctx, next }) => {
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: and(eq(items.id, input.itemId), eq(items.kind, kind)),
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@ import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { env } from "@homarr/docker/env";
|
||||
|
||||
import { middlewareProcedure } from "../trpc";
|
||||
import { publicProcedure } from "../trpc";
|
||||
|
||||
export const kubernetesMiddleware = () => {
|
||||
return middlewareProcedure.use(async ({ next }) => {
|
||||
return publicProcedure.use(async ({ next }) => {
|
||||
if (env.ENABLE_KUBERNETES) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
|
||||
import { generatedWidgetOperationPolicies } from "@homarr/definitions";
|
||||
|
||||
// RSC-prefetched data must survive the hydration pass without an immediate
|
||||
// duplicate fetch. Widget-specific intervals still control live refreshes.
|
||||
export const queryCacheDefaultStaleTimeMs = 30_000;
|
||||
@@ -13,15 +11,8 @@ const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContai
|
||||
// Beszel system stats are large time-series payloads and were deliberately kept out of
|
||||
// the persisted cache in #6289; live stats come from an SSE subscription instead.
|
||||
const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]);
|
||||
const generatedWidgetPersistencePolicies = new Map(
|
||||
generatedWidgetOperationPolicies
|
||||
.filter((policy) => policy.kind === "query")
|
||||
.map((policy) => [policy.path.join("."), policy.persist] as const),
|
||||
);
|
||||
|
||||
export const isWidgetDataTrpcPath = (path: string) => {
|
||||
const generatedPolicy = generatedWidgetPersistencePolicies.get(path);
|
||||
if (generatedPolicy !== undefined) return generatedPolicy;
|
||||
if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path);
|
||||
return widgetDataQueryPaths.has(path);
|
||||
};
|
||||
|
||||
@@ -25,9 +25,7 @@ import {
|
||||
assistantProviderPresets,
|
||||
assistantProviderRequiresApiKey,
|
||||
getIconUrl,
|
||||
integrationKinds,
|
||||
resolveAssistantModelId,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
|
||||
import type { createTRPCContext } from "../trpc";
|
||||
@@ -338,7 +336,6 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
boardRouter.createCaller(ctx).getAllBoards(),
|
||||
ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, name: true, kind: true },
|
||||
where: inArray(integrations.kind, integrationKinds),
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session.user.id),
|
||||
@@ -367,12 +364,9 @@ export const getAssistantContextEntitiesAsync = async (ctx: AssistantContext): P
|
||||
? []
|
||||
: await ctx.db.query.items.findMany({
|
||||
columns: { id: true, boardId: true, kind: true },
|
||||
where: and(
|
||||
inArray(
|
||||
items.boardId,
|
||||
availableBoards.map((board) => board.id),
|
||||
),
|
||||
inArray(items.kind, widgetKinds),
|
||||
where: inArray(
|
||||
items.boardId,
|
||||
availableBoards.map((board) => board.id),
|
||||
),
|
||||
orderBy: asc(items.kind),
|
||||
limit: 500,
|
||||
|
||||
@@ -120,27 +120,22 @@ const findFirstAvailableBoardItemPosition = (
|
||||
return null;
|
||||
};
|
||||
|
||||
interface WidgetPlacementRecord {
|
||||
interface WidgetConfiguration {
|
||||
id: string;
|
||||
kind: WidgetKind;
|
||||
}
|
||||
|
||||
interface WidgetConfigurationRecord extends WidgetPlacementRecord {
|
||||
integrationIds: readonly string[];
|
||||
}
|
||||
|
||||
interface IntegrationKindRecord {
|
||||
id: string;
|
||||
kind: IntegrationKind;
|
||||
}
|
||||
|
||||
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) =>
|
||||
left.length === right.length && left.every((integrationId) => right.includes(integrationId));
|
||||
const haveSameIntegrationIds = (left: readonly string[], right: readonly string[]) => {
|
||||
if (left.length !== right.length) return false;
|
||||
const sortedRight = right.toSorted();
|
||||
return left.toSorted().every((integrationId, index) => integrationId === sortedRight[index]);
|
||||
};
|
||||
|
||||
const validateWidgetConfigurationsAsync = async (
|
||||
ctx: Parameters<typeof throwIfIntegrationActionsForbiddenAsync>[0],
|
||||
submittedItems: readonly WidgetConfigurationRecord[],
|
||||
storedItems: readonly WidgetConfigurationRecord[],
|
||||
submittedItems: readonly WidgetConfiguration[],
|
||||
storedItems: readonly WidgetConfiguration[] = [],
|
||||
) => {
|
||||
const storedItemsById = new Map(storedItems.map((item) => [item.id, item]));
|
||||
const changedItems = submittedItems.filter((item) => {
|
||||
@@ -151,7 +146,7 @@ const validateWidgetConfigurationsAsync = async (
|
||||
if (changedItems.length === 0) return;
|
||||
|
||||
const selectedIntegrationIds = [...new Set(changedItems.flatMap(({ integrationIds }) => integrationIds))];
|
||||
let integrationRecords: IntegrationKindRecord[] = [];
|
||||
let integrationRecords: { id: string; kind: IntegrationKind }[] = [];
|
||||
if (selectedIntegrationIds.length > 0) {
|
||||
integrationRecords = await ctx.db.query.integrations.findMany({
|
||||
columns: { id: true, kind: true },
|
||||
@@ -181,10 +176,9 @@ const validateWidgetConfigurationsAsync = async (
|
||||
await throwIfIntegrationActionsForbiddenAsync(ctx, addedOrReconfiguredIntegrationIds, "use");
|
||||
|
||||
for (const item of changedItems) {
|
||||
const selectedIntegrationKinds = item.integrationIds.flatMap((integrationId) => {
|
||||
const integration = integrationRecordsById.get(integrationId);
|
||||
return integration ? [integration.kind] : [];
|
||||
});
|
||||
const selectedIntegrationKinds = item.integrationIds.flatMap(
|
||||
(integrationId) => integrationRecordsById.get(integrationId)?.kind ?? [],
|
||||
);
|
||||
const integrationIssue = getWidgetIntegrationIssue(item.kind, selectedIntegrationKinds);
|
||||
if (!integrationIssue) continue;
|
||||
|
||||
@@ -705,7 +699,7 @@ export const boardRouter = createTRPCRouter({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Disabled or invalid widget configurations must be removed before duplication. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
"Duplicate an existing board into a new board. Requires board-create permission, view permission on the source board, and use permission for every linked integration. Every widget-integration configuration must be valid. REQUIRED: id (source board ID), name (unique name for the new board). Returns { boardId }",
|
||||
},
|
||||
})
|
||||
.input(boardDuplicateSchema)
|
||||
@@ -749,7 +743,6 @@ export const boardRouter = createTRPCRouter({
|
||||
kind: item.kind,
|
||||
integrationIds: item.integrations.map(({ integrationId }) => integrationId),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
|
||||
const newBoardId = createId();
|
||||
@@ -1563,11 +1556,10 @@ export const boardRouter = createTRPCRouter({
|
||||
for (const item of input.items) {
|
||||
if (item.kind !== "timetable") continue;
|
||||
const previousItem = dbBoard.items.find((dbItem) => dbItem.id === item.id);
|
||||
let previousOptions: Record<string, unknown> | undefined;
|
||||
if (previousItem?.kind === "timetable") previousOptions = previousItem.options;
|
||||
const { validateTimetableOptionsChangeAsync } = await import("./timetable-options-validation");
|
||||
await validateTimetableOptionsChangeAsync(
|
||||
item.options,
|
||||
previousItem?.kind === "timetable" ? previousItem.options : undefined,
|
||||
);
|
||||
await validateTimetableOptionsChangeAsync(item.options, previousOptions);
|
||||
}
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
@@ -2142,11 +2134,9 @@ export const boardRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
await validateWidgetConfigurationsAsync(
|
||||
ctx,
|
||||
[{ id: "", kind: input.kind, integrationIds: input.integrationIds }],
|
||||
[],
|
||||
);
|
||||
await validateWidgetConfigurationsAsync(ctx, [
|
||||
{ id: "", kind: input.kind, integrationIds: input.integrationIds },
|
||||
]);
|
||||
throwIfCustomWidgetPlacementChangeForbidden({
|
||||
isAdmin: ctx.session.user.permissions.includes("admin"),
|
||||
submittedItems: [{ id: "", kind: input.kind, options: input.options }],
|
||||
|
||||
@@ -245,8 +245,8 @@ type DockerContainerAction = "start" | "stop" | "restart" | "remove";
|
||||
export const performDockerContainerActionsAsync = async (
|
||||
targets: DockerContainerTarget[],
|
||||
action: DockerContainerAction,
|
||||
) =>
|
||||
await Promise.all(
|
||||
) => {
|
||||
const results = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const capability = action === "remove" ? "remove" : "lifecycle";
|
||||
@@ -271,6 +271,13 @@ export const performDockerContainerActionsAsync = async (
|
||||
}),
|
||||
);
|
||||
|
||||
if (results.some(({ success }) => success)) {
|
||||
dockerContainersRequestHandler.invalidateCache();
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const assertDockerEndpointCapability = (
|
||||
endpointId: string,
|
||||
capability: "inventory" | "logs" | "lifecycle" | "remove",
|
||||
|
||||
@@ -19,12 +19,12 @@ import {
|
||||
import { getAppManagementAccess, getIntegrationManagementAccess } from "@homarr/definitions";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
const getFullAccessIntegrationIdsAsync = async (database: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await database.query.groupMembers.findMany({
|
||||
const getFullAccessIntegrationIdsAsync = async (db: Database, userId: string) => {
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, userId),
|
||||
});
|
||||
|
||||
const accessibleIntegrations = await database.query.integrations.findMany({
|
||||
const accessibleIntegrations = await db.query.integrations.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
|
||||
@@ -1,89 +1,37 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { IntegrationPermissionsProps } from "@homarr/auth/shared";
|
||||
import { constructIntegrationPermissions } from "@homarr/auth/shared";
|
||||
import type { Database, SQL } from "@homarr/db";
|
||||
import { eq, inArray } from "@homarr/db";
|
||||
import { groupMembers, integrationGroupPermissions, integrations, integrationUserPermissions } from "@homarr/db/schema";
|
||||
import type { IntegrationPermission } from "@homarr/definitions";
|
||||
|
||||
interface IntegrationPermissionRecord {
|
||||
userPermissions: { permission: IntegrationPermission }[];
|
||||
groupPermissions: { permission: IntegrationPermission }[];
|
||||
}
|
||||
|
||||
const hasIntegrationPermission = (
|
||||
integration: IntegrationPermissionRecord,
|
||||
integration: IntegrationPermissionsProps,
|
||||
session: Session | null,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const { hasUseAccess, hasInteractAccess, hasFullAccess } = constructIntegrationPermissions(integration, session);
|
||||
if (hasFullAccess) return true;
|
||||
if (permission === "interact") return hasInteractAccess;
|
||||
if (permission === "use") return hasUseAccess;
|
||||
if (permission === "use") return hasUseAccess || hasInteractAccess;
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws NOT_FOUND if user is not allowed to perform action on integration
|
||||
* @param ctx trpc router context
|
||||
* @param integrationWhere where clause for the integration
|
||||
* @param permission permission required to perform action on integration
|
||||
*/
|
||||
export const throwIfActionForbiddenAsync = async (
|
||||
const throwIfActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationWhere: SQL<unknown>,
|
||||
expectedCount: number,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integration = await db.query.integrations.findFirst({
|
||||
where: integrationWhere,
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId).concat(""),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
notAllowed();
|
||||
}
|
||||
|
||||
if (hasIntegrationPermission(integration, session, permission)) return;
|
||||
|
||||
notAllowed();
|
||||
};
|
||||
|
||||
/**
|
||||
* Batched variant for feature configuration flows. It applies the same direct
|
||||
* user, group, and global permission rules without issuing one query per ID.
|
||||
*/
|
||||
export const throwIfIntegrationActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationIds: readonly string[],
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const uniqueIntegrationIds = [...new Set(integrationIds)];
|
||||
if (uniqueIntegrationIds.length === 0) return;
|
||||
|
||||
const { db, session } = ctx;
|
||||
const groupsOfCurrentUser = await db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, session?.user.id ?? ""),
|
||||
});
|
||||
const integrationRecords = await db.query.integrations.findMany({
|
||||
where: inArray(integrations.id, uniqueIntegrationIds),
|
||||
where: integrationWhere,
|
||||
columns: { id: true },
|
||||
with: {
|
||||
userPermissions: {
|
||||
@@ -99,13 +47,43 @@ export const throwIfIntegrationActionsForbiddenAsync = async (
|
||||
});
|
||||
|
||||
if (
|
||||
integrationRecords.length !== uniqueIntegrationIds.length ||
|
||||
integrationRecords.length !== expectedCount ||
|
||||
!integrationRecords.every((integration) => hasIntegrationPermission(integration, session, permission))
|
||||
) {
|
||||
notAllowed();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Throws NOT_FOUND if user is not allowed to perform action on integration
|
||||
* @param ctx trpc router context
|
||||
* @param integrationWhere where clause for the integration
|
||||
* @param permission permission required to perform action on integration
|
||||
*/
|
||||
export const throwIfActionForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationWhere: SQL<unknown>,
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
await throwIfActionsForbiddenAsync(ctx, integrationWhere, 1, permission);
|
||||
};
|
||||
|
||||
export const throwIfIntegrationActionsForbiddenAsync = async (
|
||||
ctx: { db: Database; session: Session | null },
|
||||
integrationIds: readonly string[],
|
||||
permission: IntegrationPermission,
|
||||
) => {
|
||||
const uniqueIntegrationIds = [...new Set(integrationIds)];
|
||||
if (uniqueIntegrationIds.length === 0) return;
|
||||
|
||||
await throwIfActionsForbiddenAsync(
|
||||
ctx,
|
||||
inArray(integrations.id, uniqueIntegrationIds),
|
||||
uniqueIntegrationIds.length,
|
||||
permission,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This method returns NOT_FOUND to prevent snooping on board existence
|
||||
* A function is used to use the method without return statement
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
} from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
|
||||
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
|
||||
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
integrationCreateSchema,
|
||||
@@ -712,7 +714,10 @@ export const integrationRouter = createTRPCRouter({
|
||||
.input(mediaRequestRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const integration = await createIntegrationAsync(ctx.integration);
|
||||
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
|
||||
mediaRequestListRequestHandler.invalidateCache();
|
||||
await mediaRequestStatsRequestHandler.invalidateCacheAsync([ctx.integration.id]);
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ const defaultSession = {
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const integrationUseSession = {
|
||||
...defaultSession,
|
||||
user: {
|
||||
...defaultSession.user,
|
||||
permissions: ["integration-use-all"],
|
||||
},
|
||||
} satisfies Session;
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
@@ -698,7 +706,8 @@ describe("duplicateBoard should preserve protected layout invariants", () => {
|
||||
},
|
||||
};
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { boardId } = await createFullBoardAsync(db, "source");
|
||||
const { boardId, integrationId } = await createFullBoardAsync(db, "source");
|
||||
await db.delete(integrationItems).where(eq(integrationItems.integrationId, integrationId));
|
||||
|
||||
const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
|
||||
|
||||
@@ -1195,7 +1204,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should remove integration reference when not present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
|
||||
const anotherIntegration = {
|
||||
id: createId(),
|
||||
kind: "adGuardHome",
|
||||
@@ -1219,8 +1228,8 @@ describe("saveBoard should save full board", () => {
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
kind: "dnsHoleSummary",
|
||||
options: { usePiHoleColors: true, layout: "grid" },
|
||||
integrationIds: [anotherIntegration.id],
|
||||
layouts: [
|
||||
{
|
||||
@@ -1526,7 +1535,7 @@ describe("saveBoard should save full board", () => {
|
||||
it("should add integration reference when present in input", async () => {
|
||||
const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync");
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: integrationUseSession });
|
||||
const integration = {
|
||||
id: createId(),
|
||||
kind: "plex",
|
||||
@@ -1550,8 +1559,8 @@ describe("saveBoard should save full board", () => {
|
||||
items: [
|
||||
{
|
||||
id: itemId,
|
||||
kind: "clock",
|
||||
options: { is24HourFormat: true },
|
||||
kind: "mediaServer",
|
||||
options: { showOnlyPlaying: true, showBitrate: true, showLocation: true },
|
||||
integrationIds: [integration.id],
|
||||
layouts: [
|
||||
{
|
||||
|
||||
@@ -9,8 +9,7 @@ import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/r
|
||||
|
||||
const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
|
||||
|
||||
const blockedTimetableIpv4Addresses = new BlockList();
|
||||
const blockedTimetableIpv6Addresses = new BlockList();
|
||||
const blockedTimetableAddresses = new BlockList();
|
||||
const blockedTimetableSubnets = [
|
||||
["0.0.0.0", 8, "ipv4"],
|
||||
["10.0.0.0", 8, "ipv4"],
|
||||
@@ -45,8 +44,7 @@ const blockedTimetableSubnets = [
|
||||
] as const;
|
||||
|
||||
for (const [network, prefix, family] of blockedTimetableSubnets) {
|
||||
const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
|
||||
blockList.addSubnet(network, prefix, family);
|
||||
blockedTimetableAddresses.addSubnet(network, prefix, family);
|
||||
}
|
||||
|
||||
export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => {
|
||||
@@ -61,39 +59,28 @@ export const normalizeTimetableBaseUrlOrThrowBadRequest = (baseUrl: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Timetable widget configuration is invalid",
|
||||
cause,
|
||||
});
|
||||
};
|
||||
|
||||
export const parseSavedTimetableOptions = (serializedOptions: string) => {
|
||||
export const getSavedTimetableBaseUrl = (serializedOptions: string) => {
|
||||
try {
|
||||
return timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
|
||||
const options = timetableOptionsSchema.parse(parse<unknown>(serializedOptions));
|
||||
return normalizeTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
|
||||
} catch (cause) {
|
||||
return throwInvalidSavedTimetableConfiguration(cause);
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
|
||||
try {
|
||||
return normalizeTimetableBaseUrl(baseUrl);
|
||||
} catch (cause) {
|
||||
return throwInvalidSavedTimetableConfiguration(cause);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Timetable widget configuration is invalid",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHostname = (hostname: string) => {
|
||||
const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
let withoutBrackets = hostname;
|
||||
if (hostname.startsWith("[") && hostname.endsWith("]")) withoutBrackets = hostname.slice(1, -1);
|
||||
return withoutBrackets.toLowerCase().replace(/\.$/, "");
|
||||
};
|
||||
|
||||
const isBlockedTimetableAddress = (address: string, family: number) => {
|
||||
return family === 6
|
||||
? blockedTimetableIpv6Addresses.check(address, "ipv6")
|
||||
: blockedTimetableIpv4Addresses.check(address, "ipv4");
|
||||
if (family === 6) return blockedTimetableAddresses.check(address, "ipv6");
|
||||
return blockedTimetableAddresses.check(address, "ipv4");
|
||||
};
|
||||
|
||||
export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise<TimetableResolvedAddress[]> => {
|
||||
@@ -115,10 +102,8 @@ export const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Pro
|
||||
|
||||
let addresses: TimetableResolvedAddress[];
|
||||
try {
|
||||
const resolvedAddresses =
|
||||
hostnameFamily === 0
|
||||
? await lookup(hostname, { all: true, verbatim: true })
|
||||
: [{ address: hostname, family: hostnameFamily }];
|
||||
let resolvedAddresses = [{ address: hostname, family: hostnameFamily }];
|
||||
if (hostnameFamily === 0) resolvedAddresses = await lookup(hostname, { all: true, verbatim: true });
|
||||
addresses = resolvedAddresses.filter(
|
||||
(entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
|
||||
);
|
||||
|
||||
@@ -75,6 +75,8 @@ export const anchorNotesRouter = createTRPCRouter({
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
|
||||
});
|
||||
anchorNotesListRequestHandler.invalidateCache();
|
||||
await anchorNoteRequestHandler.invalidateCacheAsync([ctx.integration.id]);
|
||||
|
||||
return updatedNote;
|
||||
} catch (error) {
|
||||
|
||||
@@ -11,10 +11,7 @@ import {
|
||||
} from "@homarr/request-handler/beszel";
|
||||
|
||||
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
|
||||
import {
|
||||
createManySharedWidgetIntegrationMiddleware,
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { BoundedAsyncQueue } from "./bounded-async-queue";
|
||||
|
||||
@@ -30,12 +27,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemGrid", [
|
||||
"beszelSystemTable",
|
||||
"beszelSystemStats",
|
||||
]),
|
||||
)
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemGrid"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationIds = ctx.integrations.map((i) => i.id);
|
||||
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
|
||||
@@ -153,12 +145,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
|
||||
},
|
||||
})
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
]),
|
||||
)
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
@@ -209,12 +196,7 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "beszelSystemStats", [
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
]),
|
||||
)
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "beszelSystemStats"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { DnsHoleSummary } from "@homarr/integrations/types";
|
||||
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
|
||||
|
||||
import {
|
||||
createManySharedWidgetIntegrationMiddleware,
|
||||
createManyWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
@@ -34,7 +34,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
"Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createManySharedWidgetIntegrationMiddleware("query", "dnsHoleSummary", ["dnsHoleControls"]))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "dnsHoleSummary"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], DnsHoleSummaryResult>(
|
||||
ctx.integrations,
|
||||
@@ -72,6 +72,7 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx: { integration } }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.enableAsync();
|
||||
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
}),
|
||||
|
||||
disable: protectedProcedure
|
||||
@@ -91,5 +92,6 @@ export const dnsHoleRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx: { integration }, input }) => {
|
||||
const client = await createIntegrationAsync(integration);
|
||||
await client.disableAsync(input.duration);
|
||||
await dnsHoleRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
}),
|
||||
});
|
||||
@@ -38,6 +38,14 @@ type DownloadClientQueryResult = DownloadClientQueryResultBase &
|
||||
}
|
||||
);
|
||||
|
||||
const runDownloadMutationsAsync = async (mutations: Promise<void>[], integrationIds: string[]) => {
|
||||
const results = await Promise.allSettled(mutations);
|
||||
await downloadClientRequestHandler.invalidateCacheAsync(integrationIds);
|
||||
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure) throw failure.reason;
|
||||
};
|
||||
|
||||
export const downloadsRouter = createTRPCRouter({
|
||||
getJobsAndStatuses: publicProcedure
|
||||
.meta({
|
||||
@@ -84,25 +92,25 @@ export const downloadsRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.pauseQueueAsync();
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
pauseItem: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.pauseItemAsync(input.item);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resume: protectedProcedure
|
||||
.meta({
|
||||
@@ -114,36 +122,36 @@ export const downloadsRouter = createTRPCRouter({
|
||||
})
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.mutation(async ({ ctx }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.resumeQueueAsync();
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
resumeItem: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.resumeItemAsync(input.item);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.concat(createManyWidgetIntegrationMiddleware("interact", "downloads"))
|
||||
.input(z.object({ item: downloadClientItemSchema, fromDisk: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await Promise.all(
|
||||
await runDownloadMutationsAsync(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
|
||||
}),
|
||||
ctx.integrations.map(({ id }) => id),
|
||||
);
|
||||
downloadClientRequestHandler.invalidateCache();
|
||||
}),
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
// This file is generated by pnpm widgets:sync. Do not edit it directly.
|
||||
|
||||
import type { AnyTRPCRouter } from "@trpc/server";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
const registerGeneratedWidgetRouter = <TRouter extends AnyTRPCRouter>(
|
||||
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
|
||||
loadRouter: () => Promise<TRouter>,
|
||||
mcp: false | true | string,
|
||||
) => ({ widgetKinds, loadRouter, mcp });
|
||||
|
||||
export const generatedWidgetRouterRegistry = {
|
||||
downloads: registerGeneratedWidgetRouter(
|
||||
["downloads"],
|
||||
() => import("./downloads").then((module) => module.downloadsRouter),
|
||||
true,
|
||||
),
|
||||
weather: registerGeneratedWidgetRouter(
|
||||
["clock", "weather"],
|
||||
() => import("./weather").then((module) => module.weatherRouter),
|
||||
false,
|
||||
),
|
||||
};
|
||||
@@ -2,11 +2,7 @@ import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { SystemHealthMonitoring } from "@homarr/integrations";
|
||||
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
import {
|
||||
createManySharedWidgetIntegrationMiddleware,
|
||||
createOneSharedWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -39,14 +35,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware(
|
||||
"query",
|
||||
"healthMonitoring",
|
||||
["systemResources", "systemDisks"],
|
||||
healthMonitoringIntegrationKinds,
|
||||
),
|
||||
)
|
||||
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>(
|
||||
ctx.integrations,
|
||||
@@ -78,7 +67,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
|
||||
},
|
||||
})
|
||||
.concat(createOneSharedWidgetIntegrationMiddleware("query", "healthMonitoring", ["systemDisks"], ["synology"]))
|
||||
.concat(createOneIntegrationMiddleware("query", "synology"))
|
||||
.query(async ({ ctx }) => {
|
||||
const integrationInstance = await createIntegrationAsync(ctx.integration);
|
||||
return await integrationInstance.listStorageVolumesAsync();
|
||||
@@ -91,7 +80,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
"Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
|
||||
},
|
||||
})
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "healthMonitoring", ["proxmox", "mock"]))
|
||||
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
|
||||
.query(async ({ ctx }) => {
|
||||
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -6,10 +6,7 @@ import {
|
||||
immichStatsRequestHandler,
|
||||
} from "@homarr/request-handler/immich";
|
||||
|
||||
import {
|
||||
createOneSharedWidgetIntegrationMiddleware,
|
||||
createOneWidgetIntegrationMiddleware,
|
||||
} from "../../middlewares/integration";
|
||||
import { createOneWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const immichRouter = createTRPCRouter({
|
||||
@@ -40,7 +37,7 @@ export const immichRouter = createTRPCRouter({
|
||||
limit: z.number().int().min(1).max(500).optional(),
|
||||
}),
|
||||
)
|
||||
.concat(createOneSharedWidgetIntegrationMiddleware("query", "immich-albumCarousel", ["immich-serverStats"]))
|
||||
.concat(createOneWidgetIntegrationMiddleware("query", "immich-albumCarousel"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
|
||||
const data = await innerHandler.getDataAsync();
|
||||
|
||||
@@ -1,30 +1,49 @@
|
||||
import type { AnyTRPCRouter } from "@trpc/server";
|
||||
import { lazy } from "@trpc/server";
|
||||
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
import { widgetRouterRegistry } from "./registry";
|
||||
import type { WidgetRouterName } from "./registry";
|
||||
|
||||
const createLazyWidgetRouter = <TRouter extends AnyTRPCRouter>(loadRouter: () => Promise<TRouter>) => lazy(loadRouter);
|
||||
|
||||
const lazyWidgetRouters = Object.fromEntries(
|
||||
Object.entries(widgetRouterRegistry).map(([name, registration]) => [
|
||||
name,
|
||||
createLazyWidgetRouter(registration.loadRouter as () => Promise<AnyTRPCRouter>),
|
||||
]),
|
||||
) as unknown as {
|
||||
[TName in WidgetRouterName]: ReturnType<
|
||||
typeof createLazyWidgetRouter<
|
||||
(typeof widgetRouterRegistry)[TName] extends { loadRouter: () => Promise<infer TRouter> }
|
||||
? TRouter extends AnyTRPCRouter
|
||||
? TRouter
|
||||
: never
|
||||
: never
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
...lazyWidgetRouters,
|
||||
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
|
||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)),
|
||||
paperlessNgx: lazy(() => import("./paperless-ngx").then((mod) => mod.paperlessNgxRouter)),
|
||||
patchmon: lazy(() => import("./patchmon").then((mod) => mod.patchmonRouter)),
|
||||
bazarr: lazy(() => import("./bazarr").then((mod) => mod.bazarrRouter)),
|
||||
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
|
||||
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
|
||||
airQuality: lazy(() => import("./air-quality").then((mod) => mod.airQualityRouter)),
|
||||
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
|
||||
dnsHole: lazy(() => import("./dns-hole").then((mod) => mod.dnsHoleRouter)),
|
||||
smartHome: lazy(() => import("./smart-home").then((mod) => mod.smartHomeRouter)),
|
||||
stockPrice: lazy(() => import("./stocks").then((mod) => mod.stockPriceRouter)),
|
||||
mediaServer: lazy(() => import("./media-server").then((mod) => mod.mediaServerRouter)),
|
||||
mediaRelease: lazy(() => import("./media-release").then((mod) => mod.mediaReleaseRouter)),
|
||||
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
|
||||
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
|
||||
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
|
||||
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
|
||||
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
|
||||
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
|
||||
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
|
||||
mediaTranscoding: lazy(() => import("./media-transcoding").then((mod) => mod.mediaTranscodingRouter)),
|
||||
minecraft: lazy(() => import("./minecraft").then((mod) => mod.minecraftRouter)),
|
||||
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
|
||||
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
|
||||
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
|
||||
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
|
||||
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
|
||||
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
|
||||
tracearr: lazy(() => import("./tracearr").then((mod) => mod.tracearrRouter)),
|
||||
speedtestTracker: lazy(() => import("./speedtest-tracker").then((mod) => mod.speedtestTrackerRouter)),
|
||||
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
|
||||
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
|
||||
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
|
||||
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
|
||||
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
|
||||
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
|
||||
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
|
||||
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
|
||||
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
|
||||
});
|
||||
@@ -152,6 +152,8 @@ export const mediaRequestsRouter = createTRPCRouter({
|
||||
} as const;
|
||||
|
||||
await answerActions[input.answer](input.requestId);
|
||||
mediaRequestListRequestHandler.invalidateCache();
|
||||
await mediaRequestStatsRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { mediaServerRequestHandler } from "@homarr/request-handler/media-server";
|
||||
|
||||
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -15,7 +15,7 @@ export const mediaServerRouter = createTRPCRouter({
|
||||
"Get currently active streams from Plex/Jellyfin/Emby/Navidrome media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
|
||||
},
|
||||
})
|
||||
.concat(createManySharedWidgetIntegrationMiddleware("query", "mediaServer", ["audioStats"]))
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "mediaServer"))
|
||||
.input(z.object({ showOnlyPlaying: z.boolean() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await settleIntegrationQueries(
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { NetworkControllerSummary } from "@homarr/integrations/types";
|
||||
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
|
||||
|
||||
import { createManySharedWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createManyWidgetIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
@@ -21,9 +21,7 @@ interface NetworkControllerQueryResult {
|
||||
|
||||
export const networkControllerRouter = createTRPCRouter({
|
||||
summary: publicProcedure
|
||||
.concat(
|
||||
createManySharedWidgetIntegrationMiddleware("query", "networkControllerSummary", ["networkControllerStatus"]),
|
||||
)
|
||||
.concat(createManyWidgetIntegrationMiddleware("query", "networkControllerSummary"))
|
||||
.query(async ({ ctx }) => {
|
||||
return await settleIntegrationQueries<(typeof ctx.integrations)[number], NetworkControllerQueryResult>(
|
||||
ctx.integrations,
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
import type { AnyTRPCRouter } from "@trpc/server";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
import { generatedWidgetRouterRegistry } from "./generated-registry";
|
||||
|
||||
const {
|
||||
weather: generatedWeatherRouter,
|
||||
downloads: generatedDownloadsRouter,
|
||||
...additionalGeneratedWidgetRouters
|
||||
} = generatedWidgetRouterRegistry;
|
||||
|
||||
const logger = createLogger({ module: "widgetRouterRegistry" });
|
||||
|
||||
type McpRouterNamespace = false | true | string;
|
||||
|
||||
type WidgetRouterRegistration<TRouter extends AnyTRPCRouter, TMcpNamespace extends McpRouterNamespace> = {
|
||||
widgetKinds: readonly [WidgetKind, ...WidgetKind[]];
|
||||
loadRouter: () => Promise<TRouter>;
|
||||
mcp: TMcpNamespace;
|
||||
};
|
||||
|
||||
const registerWidgetRouter = <TRouter extends AnyTRPCRouter, const TMcpNamespace extends McpRouterNamespace = false>(
|
||||
widgetKinds: readonly [WidgetKind, ...WidgetKind[]],
|
||||
loadRouter: () => Promise<TRouter>,
|
||||
options?: { mcp?: TMcpNamespace },
|
||||
): WidgetRouterRegistration<TRouter, TMcpNamespace> => ({
|
||||
widgetKinds,
|
||||
loadRouter,
|
||||
mcp: (options?.mcp ?? false) as TMcpNamespace,
|
||||
});
|
||||
|
||||
/**
|
||||
* Shared namespace-to-feature contract for widget tRPC consumers.
|
||||
*
|
||||
* Keep import paths explicit so Next.js can split every implementation while
|
||||
* consumers share one widget-to-router association.
|
||||
*/
|
||||
export const widgetRouterRegistry = {
|
||||
archiveTeamWarrior: registerWidgetRouter(["archiveTeamWarrior"], () =>
|
||||
import("./archive-team-warrior").then((module) => module.archiveTeamWarriorRouter),
|
||||
),
|
||||
beszel: registerWidgetRouter(
|
||||
["beszelSystemTable", "beszelSystemGrid", "beszelAlerts", "beszelSystemStats"],
|
||||
() => import("./beszel").then((module) => module.beszelRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
anchorNotes: registerWidgetRouter(["anchorNote"], () =>
|
||||
import("./anchor-notes").then((module) => module.anchorNotesRouter),
|
||||
),
|
||||
coolify: registerWidgetRouter(["coolify"], () => import("./coolify").then((module) => module.coolifyRouter)),
|
||||
immich: registerWidgetRouter(["immich-serverStats", "immich-albumCarousel"], () =>
|
||||
import("./immich").then((module) => module.immichRouter),
|
||||
),
|
||||
paperlessNgx: registerWidgetRouter(["paperlessNgx"], () =>
|
||||
import("./paperless-ngx").then((module) => module.paperlessNgxRouter),
|
||||
),
|
||||
patchmon: registerWidgetRouter(["patchmon"], () => import("./patchmon").then((module) => module.patchmonRouter), {
|
||||
mcp: true,
|
||||
}),
|
||||
bazarr: registerWidgetRouter(["bazarr"], () => import("./bazarr").then((module) => module.bazarrRouter), {
|
||||
mcp: true,
|
||||
}),
|
||||
notebook: registerWidgetRouter(["notebook"], () => import("./notebook").then((module) => module.notebookRouter)),
|
||||
weather: generatedWeatherRouter,
|
||||
airQuality: registerWidgetRouter(
|
||||
["airQuality"],
|
||||
() => import("./air-quality").then((module) => module.airQualityRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
app: registerWidgetRouter(["app"], () => import("./app").then((module) => module.appRouter)),
|
||||
dnsHole: registerWidgetRouter(
|
||||
["dnsHoleSummary", "dnsHoleControls"],
|
||||
() => import("./dns-hole").then((module) => module.dnsHoleRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
smartHome: registerWidgetRouter(
|
||||
["smartHome-entityState", "smartHome-executeAutomation"],
|
||||
() => import("./smart-home").then((module) => module.smartHomeRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
stockPrice: registerWidgetRouter(["stockPrice"], () => import("./stocks").then((module) => module.stockPriceRouter)),
|
||||
mediaServer: registerWidgetRouter(
|
||||
["mediaServer", "audioStats"],
|
||||
() => import("./media-server").then((module) => module.mediaServerRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
mediaRelease: registerWidgetRouter(["mediaReleases"], () =>
|
||||
import("./media-release").then((module) => module.mediaReleaseRouter),
|
||||
),
|
||||
calendar: registerWidgetRouter(["calendar"], () => import("./calendar").then((module) => module.calendarRouter), {
|
||||
mcp: true,
|
||||
}),
|
||||
downloads: generatedDownloadsRouter,
|
||||
mediaRequests: registerWidgetRouter(
|
||||
["mediaRequests-requestList", "mediaRequests-requestStats"],
|
||||
() => import("./media-requests").then((module) => module.mediaRequestsRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
mediaOrganizer: registerWidgetRouter(["mediaMissing"], () =>
|
||||
import("./media-organizer").then((module) => module.mediaOrganizerRouter),
|
||||
),
|
||||
rssFeed: registerWidgetRouter(["rssFeed"], () => import("./rssFeed").then((module) => module.rssFeedRouter)),
|
||||
indexerManager: registerWidgetRouter(["indexerManager"], () =>
|
||||
import("./indexer-manager").then((module) => module.indexerManagerRouter),
|
||||
),
|
||||
healthMonitoring: registerWidgetRouter(
|
||||
["healthMonitoring", "systemResources", "systemDisks"],
|
||||
() => import("./health-monitoring").then((module) => module.healthMonitoringRouter),
|
||||
{ mcp: true },
|
||||
),
|
||||
mediaTranscoding: registerWidgetRouter(["mediaTranscoding"], () =>
|
||||
import("./media-transcoding").then((module) => module.mediaTranscodingRouter),
|
||||
),
|
||||
minecraft: registerWidgetRouter(["minecraftServerStatus"], () =>
|
||||
import("./minecraft").then((module) => module.minecraftRouter),
|
||||
),
|
||||
releases: registerWidgetRouter(["releases"], () => import("./releases").then((module) => module.releasesRouter)),
|
||||
networkController: registerWidgetRouter(["networkControllerSummary", "networkControllerStatus"], () =>
|
||||
import("./network-controller").then((module) => module.networkControllerRouter),
|
||||
),
|
||||
firewall: registerWidgetRouter(["firewall"], () => import("./firewall").then((module) => module.firewallRouter)),
|
||||
notifications: registerWidgetRouter(["notifications"], () =>
|
||||
import("./notifications").then((module) => module.notificationsRouter),
|
||||
),
|
||||
timetable: registerWidgetRouter(["timetable"], () => import("./timetable").then((module) => module.timetableRouter)),
|
||||
tracearr: registerWidgetRouter(["tracearr"], () => import("./tracearr").then((module) => module.tracearrRouter)),
|
||||
speedtestTracker: registerWidgetRouter(["speedtestTracker"], () =>
|
||||
import("./speedtest-tracker").then((module) => module.speedtestTrackerRouter),
|
||||
),
|
||||
uptimeKuma: registerWidgetRouter(["uptimeKuma"], () =>
|
||||
import("./uptime-kuma").then((module) => module.uptimeKumaRouter),
|
||||
),
|
||||
audioStats: registerWidgetRouter(["audioStats"], () =>
|
||||
import("./audio-stats").then((module) => module.audioStatsRouter),
|
||||
),
|
||||
umami: registerWidgetRouter(["umami"], () => import("./umami").then((module) => module.umamiRouter)),
|
||||
vpn: registerWidgetRouter(["vpn"], () => import("./vpn").then((module) => module.vpnRouter)),
|
||||
ups: registerWidgetRouter(["ups"], () => import("./ups").then((module) => module.upsRouter)),
|
||||
traefik: registerWidgetRouter(["traefik"], () => import("./traefik").then((module) => module.traefikRouter)),
|
||||
customApi: registerWidgetRouter(["customApi"], () => import("./custom-api").then((module) => module.customApiRouter)),
|
||||
secrets: registerWidgetRouter(
|
||||
["releases"],
|
||||
() => import("./widget-secrets").then((module) => module.widgetSecretsRouter),
|
||||
{ mcp: "widgetSecrets" },
|
||||
),
|
||||
wud: registerWidgetRouter(["wud"], () => import("./wud").then((module) => module.wudRouter)),
|
||||
...additionalGeneratedWidgetRouters,
|
||||
};
|
||||
|
||||
export type WidgetRouterName = keyof typeof widgetRouterRegistry;
|
||||
type LoadedWidgetRouterRecord = {
|
||||
[TName in WidgetRouterName]: Awaited<ReturnType<(typeof widgetRouterRegistry)[TName]["loadRouter"]>>;
|
||||
};
|
||||
|
||||
interface McpWidgetRouterSelection {
|
||||
name: WidgetRouterName;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export const mcpWidgetRouterSelections: readonly McpWidgetRouterSelection[] = Object.entries(
|
||||
widgetRouterRegistry,
|
||||
).flatMap(([name, registration]) => {
|
||||
if (!registration.mcp) return [];
|
||||
|
||||
let namespace = name;
|
||||
if (typeof registration.mcp === "string") namespace = registration.mcp;
|
||||
return [{ name: name as WidgetRouterName, namespace }];
|
||||
});
|
||||
|
||||
export const mcpWidgetRouterNames: readonly WidgetRouterName[] = mcpWidgetRouterSelections.map(
|
||||
(selection) => selection.name,
|
||||
);
|
||||
|
||||
export async function loadWidgetRoutersAsync<const TNames extends readonly WidgetRouterName[]>(
|
||||
names: TNames,
|
||||
consumer: string,
|
||||
): Promise<Pick<LoadedWidgetRouterRecord, TNames[number]>> {
|
||||
const loadedRouters: Partial<Record<WidgetRouterName, AnyTRPCRouter>> = {};
|
||||
|
||||
await Promise.all(
|
||||
names.map(async (name) => {
|
||||
const registration = widgetRouterRegistry[name];
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
loadedRouters[name] = await registration.loadRouter();
|
||||
logger.debug("Loaded widget router", {
|
||||
consumer,
|
||||
routerName: name,
|
||||
widgetKinds: registration.widgetKinds,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
new ErrorWithMetadata(
|
||||
"Failed to load widget router",
|
||||
{
|
||||
consumer,
|
||||
routerName: name,
|
||||
widgetKinds: registration.widgetKinds,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return loadedRouters as Pick<LoadedWidgetRouterRecord, TNames[number]>;
|
||||
}
|
||||
|
||||
export async function loadMcpWidgetRoutersAsync(): Promise<Record<string, AnyTRPCRouter>> {
|
||||
const loadedRouters = await loadWidgetRoutersAsync(mcpWidgetRouterNames, "mcp");
|
||||
const mcpRouters: Record<string, AnyTRPCRouter> = {};
|
||||
|
||||
for (const selection of mcpWidgetRouterSelections) {
|
||||
mcpRouters[selection.namespace] = loadedRouters[selection.name];
|
||||
}
|
||||
|
||||
return mcpRouters;
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
if (!success) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
|
||||
}
|
||||
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return true;
|
||||
}),
|
||||
executeAutomation: protectedProcedure
|
||||
@@ -75,6 +76,7 @@ export const smartHomeRouter = createTRPCRouter({
|
||||
if (!success) {
|
||||
throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
|
||||
}
|
||||
await smartHomeEntityStateRequestHandler.invalidateCacheAsync([integration.id]);
|
||||
return true;
|
||||
}),
|
||||
});
|
||||
@@ -15,9 +15,8 @@ import { DEFAULT_TIMETABLE_BASE_URL } from "@homarr/request-handler/timetable-ur
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import {
|
||||
normalizeSavedTimetableBaseUrl,
|
||||
getSavedTimetableBaseUrl,
|
||||
normalizeTimetableBaseUrlOrThrowBadRequest,
|
||||
parseSavedTimetableOptions,
|
||||
resolvePublicTimetableAddressesAsync,
|
||||
} from "../timetable-options-validation";
|
||||
|
||||
@@ -27,11 +26,6 @@ const timetableSourceSchema = z.object({
|
||||
itemId: z.string().max(100).optional(),
|
||||
boardId: z.string().max(100).optional(),
|
||||
});
|
||||
const getSavedTimetableBaseUrl = (serializedOptions: string) => {
|
||||
const options = parseSavedTimetableOptions(serializedOptions);
|
||||
return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
|
||||
};
|
||||
|
||||
interface ResolvedTimetableSource {
|
||||
baseUrl: string;
|
||||
pinnedAddresses?: TimetableResolvedAddress[];
|
||||
|
||||
@@ -2,13 +2,11 @@ import type { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getMcpRouterAsync } from "../mcp";
|
||||
import { mcpRouter } from "../mcp";
|
||||
import { extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||
|
||||
vi.mock("@homarr/auth", () => ({}));
|
||||
|
||||
const mcpRouter = await getMcpRouterAsync();
|
||||
|
||||
const MCP_TOOL_ALLOWLIST = {
|
||||
query: [
|
||||
"airQuality_atLocation",
|
||||
|
||||
@@ -16,7 +16,6 @@ import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
|
||||
import { userAgent } from "@homarr/common/server";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { db } from "@homarr/db";
|
||||
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
|
||||
|
||||
@@ -106,13 +105,6 @@ export const createCallerFactory = t.createCallerFactory;
|
||||
*/
|
||||
export const createTRPCRouter = t.router;
|
||||
|
||||
/**
|
||||
* Unadorned procedure builder for middleware fragments that are concatenated
|
||||
* onto a public, protected, or internal procedure. Global middleware belongs
|
||||
* on the outer procedure so observability and policy checks run exactly once.
|
||||
*/
|
||||
export const middlewareProcedure = t.procedure;
|
||||
|
||||
export const isDemoMode = env.DEMO_MODE;
|
||||
|
||||
// The public "real demo" (demo + mock integrations) stays read-only, while preview
|
||||
@@ -129,47 +121,7 @@ const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
|
||||
return next({ ctx });
|
||||
});
|
||||
|
||||
const observeProcedure = t.middleware(async ({ ctx, next, path, type }) => {
|
||||
const startedAt = Date.now();
|
||||
const metadata = {
|
||||
path,
|
||||
type,
|
||||
userId: ctx.session?.user.id,
|
||||
};
|
||||
|
||||
logger.debug("tRPC procedure started", metadata);
|
||||
|
||||
const result = await next();
|
||||
const durationMs = Date.now() - startedAt;
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug("tRPC procedure completed", { ...metadata, durationMs });
|
||||
return result;
|
||||
}
|
||||
|
||||
const safeError = new Error(result.error.message);
|
||||
safeError.name = result.error.name;
|
||||
safeError.stack = result.error.stack;
|
||||
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"tRPC procedure failed",
|
||||
{
|
||||
...metadata,
|
||||
durationMs,
|
||||
errorCode: result.error.code,
|
||||
causeType: result.error.cause instanceof Error ? result.error.cause.name : undefined,
|
||||
},
|
||||
{ cause: safeError },
|
||||
),
|
||||
);
|
||||
return result;
|
||||
});
|
||||
|
||||
let baseProcedure = t.procedure.use(observeProcedure);
|
||||
if (isDemoReadOnly) {
|
||||
baseProcedure = baseProcedure.use(enforceDemoModeReadOnly);
|
||||
}
|
||||
const baseProcedure = isDemoReadOnly ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
|
||||
|
||||
/**
|
||||
* Public (unauthed) procedure
|
||||
@@ -193,7 +145,7 @@ const enforceOnboardingAccess = t.middleware(async ({ ctx, next }) => {
|
||||
|
||||
export const onboardingClaimedProcedure = baseProcedure.use(enforceOnboardingAccess);
|
||||
|
||||
export const internalProcedure = t.procedure.use(observeProcedure);
|
||||
export const internalProcedure = t.procedure;
|
||||
|
||||
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
|
||||
if (!ctx.session?.user) {
|
||||
|
||||
@@ -124,7 +124,6 @@ export const placeAllWidgetsAsync = async (
|
||||
}
|
||||
}
|
||||
|
||||
let placedAppCount = 0;
|
||||
for (const app of allApps) {
|
||||
await placeWidgetAsync(
|
||||
db,
|
||||
@@ -134,7 +133,6 @@ export const placeAllWidgetsAsync = async (
|
||||
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
|
||||
{ width: 1, height: 1 },
|
||||
);
|
||||
placedAppCount += 1;
|
||||
}
|
||||
|
||||
const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
|
||||
@@ -155,5 +153,5 @@ export const placeAllWidgetsAsync = async (
|
||||
});
|
||||
}
|
||||
|
||||
return placedWidgets.size + placedAppCount;
|
||||
return placedWidgets.size + allApps.length;
|
||||
};
|
||||
@@ -14,6 +14,7 @@
|
||||
"exports": {
|
||||
".": "./index.ts",
|
||||
"./cookie": "./src/cookie.ts",
|
||||
"./integration": "./src/integration.ts",
|
||||
"./docs/*": "./src/docs/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { objectKeys } from "@homarr/common";
|
||||
|
||||
import { integrationDefs } from "./integration";
|
||||
import { getIntegrationDockerMetadata, integrationDefs } from "./integration";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
|
||||
export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
|
||||
@@ -28,7 +28,7 @@ export const matchIntegrationKind = (search: string): IntegrationKind | null =>
|
||||
}
|
||||
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
if (integrationDefs[kind].features.docker.aliases.some((alias) => alias === normalized)) return kind;
|
||||
if (getIntegrationDockerMetadata(kind).aliases.some((alias) => alias === normalized)) return kind;
|
||||
}
|
||||
|
||||
for (const kind of objectKeys(integrationDefs)) {
|
||||
@@ -47,10 +47,10 @@ interface ContainerMatchInput {
|
||||
export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
|
||||
const imageName = extractContainerImageName(container.image);
|
||||
const fromImage = matchIntegrationKind(imageName);
|
||||
if (fromImage && integrationDefs[fromImage].features.docker.discoverable) return fromImage;
|
||||
if (fromImage && getIntegrationDockerMetadata(fromImage).discoverable) return fromImage;
|
||||
|
||||
const fromName = matchIntegrationKind(container.name);
|
||||
if (fromName && integrationDefs[fromName].features.docker.discoverable) return fromName;
|
||||
if (fromName && getIntegrationDockerMetadata(fromName).discoverable) return fromName;
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -5,8 +5,8 @@ import { XMLParser } from "fast-xml-parser";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createDocumentationLink } from "./index";
|
||||
import { integrationDocSlugs } from "./integration-doc-slugs";
|
||||
import { widgetDocSlugs } from "./widget-doc-slugs";
|
||||
import { getIntegrationDocumentationSlug, integrationKinds } from "../integration";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -60,7 +60,8 @@ const updateSitemapTypeFileAsync = async (sitemapPathType: string) => {
|
||||
};
|
||||
|
||||
const slugMapPaths = [
|
||||
...Object.values(integrationDocSlugs)
|
||||
...integrationKinds
|
||||
.map(getIntegrationDocumentationSlug)
|
||||
.filter(Boolean)
|
||||
.map((slug) => `/docs/integrations/${slug}`),
|
||||
...Object.values(widgetDocSlugs)
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import type { IntegrationKind } from "../integration";
|
||||
import { integrationDefs } from "../integration";
|
||||
|
||||
export const integrationDocSlugs = Object.fromEntries(
|
||||
objectEntries(integrationDefs).map(([kind, definition]) => [kind, definition.documentationSlug]),
|
||||
) as Record<IntegrationKind, string | null>;
|
||||
@@ -1,6 +1,63 @@
|
||||
import type { WidgetKind } from "../widget";
|
||||
import { widgetFeatureCatalog, widgetKinds } from "../widget";
|
||||
|
||||
export const widgetDocSlugs = Object.fromEntries(
|
||||
widgetKinds.map((kind) => [kind, widgetFeatureCatalog[kind].documentationSlug]),
|
||||
) as Record<WidgetKind, string | null>;
|
||||
export const widgetDocSlugs = {
|
||||
clock: "clock",
|
||||
weather: "weather",
|
||||
airQuality: "air-quality",
|
||||
countdown: "countdown",
|
||||
timer: "timer",
|
||||
app: "app",
|
||||
iframe: "iframe",
|
||||
video: "video",
|
||||
notebook: "notebook",
|
||||
anchorNote: "anchor-note",
|
||||
dnsHoleSummary: "dns-hole-summary",
|
||||
dnsHoleControls: "dns-hole-controls",
|
||||
"smartHome-entityState": "smart-home-entity-state",
|
||||
"smartHome-executeAutomation": "smart-home-execute-automation",
|
||||
stockPrice: "stock-price",
|
||||
mediaServer: "media-server",
|
||||
calendar: "calendar",
|
||||
downloads: "downloads",
|
||||
"mediaRequests-requestList": "media-request-list",
|
||||
"mediaRequests-requestStats": "media-request-stats",
|
||||
mediaTranscoding: "media-transcoding",
|
||||
mediaMissing: "media-missing",
|
||||
minecraftServerStatus: "minecraft-server-status",
|
||||
networkControllerSummary: "network-controller-summary",
|
||||
networkControllerStatus: "network-controller-status",
|
||||
rssFeed: "rss-feed",
|
||||
bookmarks: "bookmarks",
|
||||
indexerManager: "indexer-manager",
|
||||
healthMonitoring: "health-monitoring",
|
||||
releases: "releases",
|
||||
mediaReleases: "media-releases",
|
||||
dockerContainers: "docker-containers",
|
||||
firewall: "firewall",
|
||||
notifications: "notifications",
|
||||
systemResources: "system-resources",
|
||||
coolify: "coolify",
|
||||
systemDisks: "system-disks",
|
||||
timetable: "timetable",
|
||||
"immich-serverStats": "immich-server-stats",
|
||||
"immich-albumCarousel": "immich-album-carousel",
|
||||
paperlessNgx: "paperless-ngx",
|
||||
patchmon: "patchmon",
|
||||
bazarr: "bazarr",
|
||||
tracearr: "tracearr",
|
||||
speedtestTracker: "speedtest-tracker",
|
||||
uptimeKuma: "uptime-kuma",
|
||||
audioStats: "audio-stats",
|
||||
umami: "umami",
|
||||
vpn: "vpn",
|
||||
ups: "ups",
|
||||
archiveTeamWarrior: null,
|
||||
beszelSystemTable: "beszel-system-table",
|
||||
beszelSystemGrid: "beszel-system-grid",
|
||||
beszelAlerts: "beszel-alerts",
|
||||
beszelSystemStats: "beszel-system-stats",
|
||||
traefik: "traefik",
|
||||
customApi: "custom-api",
|
||||
assistant: "assistant",
|
||||
wud: "whats-up-docker",
|
||||
} satisfies Record<WidgetKind, string | null>;
|
||||
@@ -1,16 +0,0 @@
|
||||
// This file is generated by pnpm integrations:sync. Do not edit it directly.
|
||||
|
||||
export const generatedIntegrationDefinitions = {
|
||||
beszel: {
|
||||
name: "Beszel",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
category: ["beszel"],
|
||||
documentationSlug: "beszel",
|
||||
defaultPort: 8090,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const generatedIntegrationDocumentationSources = {
|
||||
beszel: "packages/integrations/src/beszel/docs",
|
||||
} as const;
|
||||
@@ -1,59 +0,0 @@
|
||||
// This file is generated by pnpm widgets:sync. Do not edit it directly.
|
||||
|
||||
export const generatedWidgetFeatureCatalog = {
|
||||
downloads: {
|
||||
documentationSlug: "downloads",
|
||||
capability: {
|
||||
integrations: ["sabNzbd", "nzbGet", "deluge", "transmission", "qBittorrent", "aria2", "slskd", "mock"] as const,
|
||||
},
|
||||
query: { paths: [["widget", "downloads", "getJobsAndStatuses"]] as const, refetchIntervalSeconds: 10 },
|
||||
},
|
||||
weather: {
|
||||
documentationSlug: "weather",
|
||||
defaultSize: { width: 2, height: 1 },
|
||||
query: { paths: [["widget", "weather", "atLocation"]] as const, refetchIntervalSeconds: 600 },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const widgetServerCachePolicies = {
|
||||
downloads: {
|
||||
getJobsAndStatuses: { namespace: "downloads:jobs-and-status", ttlMs: 10000, scope: "integration" },
|
||||
},
|
||||
weather: {
|
||||
atLocation: { namespace: "weather:at-location", ttlMs: 300000, scope: "shared" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const generatedWidgetOperationPolicies = [
|
||||
{
|
||||
widgetKind: "downloads",
|
||||
operation: "getJobsAndStatuses",
|
||||
kind: "query",
|
||||
path: ["widget", "downloads", "getJobsAndStatuses"],
|
||||
refetchIntervalSeconds: 10,
|
||||
staleTimeSeconds: 10,
|
||||
persist: true,
|
||||
},
|
||||
{
|
||||
widgetKind: "weather",
|
||||
operation: "atLocation",
|
||||
kind: "query",
|
||||
path: ["widget", "weather", "atLocation"],
|
||||
refetchIntervalSeconds: 600,
|
||||
staleTimeSeconds: 300,
|
||||
persist: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const generatedWidgetDocumentationSources = {
|
||||
downloads: "packages/widgets/src/downloads/docs",
|
||||
weather: "packages/widgets/src/weather/docs",
|
||||
} as const;
|
||||
|
||||
export const getWidgetServerCachePolicy = <
|
||||
TKind extends keyof typeof widgetServerCachePolicies,
|
||||
TOperation extends keyof (typeof widgetServerCachePolicies)[TKind],
|
||||
>(
|
||||
kind: TKind,
|
||||
operation: TOperation,
|
||||
): (typeof widgetServerCachePolicies)[TKind][TOperation] => widgetServerCachePolicies[kind][operation];
|
||||
@@ -1,7 +1,5 @@
|
||||
export * from "./board";
|
||||
export * from "./integration";
|
||||
export * from "./integration-module";
|
||||
export * from "./generated/integration-modules";
|
||||
export * from "./section";
|
||||
export * from "./widget";
|
||||
export * from "./widget-name";
|
||||
@@ -16,8 +14,6 @@ export * from "./cookie";
|
||||
export * from "./search-engine";
|
||||
export * from "./onboarding";
|
||||
export * from "./widget-integration-map";
|
||||
export * from "./widget-module";
|
||||
export * from "./generated/widget-modules";
|
||||
export * from "./emptysuperjson";
|
||||
export * from "./hotkeys";
|
||||
export * from "./docker-integration-match";
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { IntegrationCategory, IntegrationSecretKind } from "./integration";
|
||||
|
||||
export interface IntegrationModuleDocumentation {
|
||||
slug: string;
|
||||
sourceDirectory: string;
|
||||
}
|
||||
|
||||
export interface IntegrationModuleDockerMetadata {
|
||||
aliases?: readonly string[];
|
||||
discoverable?: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrationModuleOnboardingMetadata {
|
||||
featuredOrder?: number;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export type IntegrationModuleCreatorEntry =
|
||||
| {
|
||||
type: "constructor";
|
||||
module: string;
|
||||
exportName: string;
|
||||
}
|
||||
| {
|
||||
type: "factory";
|
||||
module: string;
|
||||
exportName: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure, server-safe authoring contract for a vertical integration module.
|
||||
*
|
||||
* Runtime entry points are strings so reading metadata never imports an
|
||||
* integration implementation or its transitive server dependencies.
|
||||
*/
|
||||
export interface IntegrationModuleDefinition {
|
||||
kind: string;
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
secretKinds: readonly [readonly IntegrationSecretKind[], ...(readonly IntegrationSecretKind[])[]];
|
||||
categories: readonly [IntegrationCategory, ...IntegrationCategory[]];
|
||||
documentation: IntegrationModuleDocumentation;
|
||||
creator: IntegrationModuleCreatorEntry;
|
||||
defaultUrl?: string;
|
||||
defaultPort?: number;
|
||||
apiKeySettingsPath?: string;
|
||||
docker?: IntegrationModuleDockerMetadata;
|
||||
onboarding?: IntegrationModuleOnboardingMetadata;
|
||||
}
|
||||
|
||||
export const defineIntegrationModule = <const TDefinition extends IntegrationModuleDefinition>(
|
||||
definition: TDefinition,
|
||||
) => definition;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { objectEntries, objectKeys } from "@homarr/common";
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
|
||||
import { createDocumentationLink } from "./docs";
|
||||
import type { HomarrDocumentationPath } from "./docs/homarr-docs-sitemap";
|
||||
import { generatedIntegrationDefinitions } from "./generated/integration-modules";
|
||||
|
||||
type IntegrationDocumentationPath = Extract<HomarrDocumentationPath, `/docs/integrations/${string}`>;
|
||||
type IntegrationDocumentationSlug = string;
|
||||
type IntegrationDocumentationSlug = IntegrationDocumentationPath extends `/docs/integrations/${infer TSlug}`
|
||||
? TSlug
|
||||
: never;
|
||||
|
||||
export const integrationSecretKindObject = {
|
||||
apiKey: { isPublic: false, multiline: false },
|
||||
@@ -27,7 +27,7 @@ export const integrationSecretKindObject = {
|
||||
slug: { isPublic: true, multiline: false },
|
||||
} satisfies Record<string, { isPublic: boolean; multiline: boolean }>;
|
||||
|
||||
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
|
||||
export const integrationSecretKinds = Object.keys(integrationSecretKindObject) as IntegrationSecretKind[];
|
||||
|
||||
interface IntegrationFeatureMetadata {
|
||||
docker?: {
|
||||
@@ -40,17 +40,6 @@ interface IntegrationFeatureMetadata {
|
||||
};
|
||||
}
|
||||
|
||||
interface ResolvedIntegrationFeatureMetadata {
|
||||
docker: {
|
||||
aliases: readonly string[];
|
||||
discoverable: boolean;
|
||||
};
|
||||
onboarding: {
|
||||
featuredOrder: number | null;
|
||||
hidden: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface integrationDefinition {
|
||||
name: string;
|
||||
iconUrl: string;
|
||||
@@ -63,45 +52,7 @@ interface integrationDefinition {
|
||||
apiKeySettingsPath?: string;
|
||||
}
|
||||
|
||||
type ResolvedIntegrationDefinitions<TDefinitions extends Record<string, integrationDefinition>> = {
|
||||
[TKind in keyof TDefinitions]: TDefinitions[TKind] & {
|
||||
documentationUrl: string | null;
|
||||
features: ResolvedIntegrationFeatureMetadata;
|
||||
};
|
||||
};
|
||||
|
||||
const createIntegrationDefinitions = <const TDefinitions extends Record<string, integrationDefinition>>(
|
||||
definitions: TDefinitions,
|
||||
): ResolvedIntegrationDefinitions<TDefinitions> => {
|
||||
return Object.fromEntries(
|
||||
objectEntries(definitions).map(([kind, definition]) => {
|
||||
let documentationUrl: string | null = null;
|
||||
if (definition.documentationSlug !== null) {
|
||||
const documentationPath = `/docs/integrations/${definition.documentationSlug}` as IntegrationDocumentationPath;
|
||||
documentationUrl = createDocumentationLink(documentationPath);
|
||||
}
|
||||
return [
|
||||
kind,
|
||||
{
|
||||
...definition,
|
||||
documentationUrl,
|
||||
features: {
|
||||
docker: {
|
||||
aliases: definition.features?.docker?.aliases ?? [],
|
||||
discoverable: definition.features?.docker?.discoverable ?? true,
|
||||
},
|
||||
onboarding: {
|
||||
featuredOrder: definition.features?.onboarding?.featuredOrder ?? null,
|
||||
hidden: definition.features?.onboarding?.hidden ?? false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as unknown as ResolvedIntegrationDefinitions<TDefinitions>;
|
||||
};
|
||||
|
||||
export const integrationDefs = createIntegrationDefinitions({
|
||||
export const integrationDefs = {
|
||||
sabNzbd: {
|
||||
name: "SABnzbd",
|
||||
secretKinds: [["apiKey"]],
|
||||
@@ -551,7 +502,14 @@ export const integrationDefs = createIntegrationDefinitions({
|
||||
documentationSlug: "peanut",
|
||||
defaultPort: 8080,
|
||||
},
|
||||
...generatedIntegrationDefinitions,
|
||||
beszel: {
|
||||
name: "Beszel",
|
||||
secretKinds: [["username", "password"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
category: ["beszel"],
|
||||
documentationSlug: "beszel",
|
||||
defaultPort: 8090,
|
||||
},
|
||||
gluetun: {
|
||||
name: "Gluetun",
|
||||
secretKinds: [["username", "password"], ["apiKey"], []],
|
||||
@@ -612,9 +570,9 @@ export const integrationDefs = createIntegrationDefinitions({
|
||||
onboarding: { hidden: true },
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>);
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
export const integrationKinds = Object.keys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
|
||||
export const getIconUrl = (integration: IntegrationKind) => integrationDefs[integration].iconUrl;
|
||||
|
||||
@@ -624,10 +582,10 @@ export const getDefaultSecretKinds = (integration: IntegrationKind): Integration
|
||||
...integrationDefs[integration].secretKinds[0],
|
||||
];
|
||||
|
||||
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> =>
|
||||
integrationDefs[integration].secretKinds.map((secretKinds) => [...secretKinds]) as AtLeastOneOf<
|
||||
IntegrationSecretKind[]
|
||||
>;
|
||||
export const getAllSecretKindOptions = (integration: IntegrationKind): AtLeastOneOf<IntegrationSecretKind[]> => {
|
||||
const [first, ...rest] = integrationDefs[integration].secretKinds;
|
||||
return [[...first], ...rest.map((secretKinds) => [...secretKinds])];
|
||||
};
|
||||
|
||||
export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
|
||||
const definition = integrationDefs[integration];
|
||||
@@ -639,8 +597,30 @@ export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undef
|
||||
return "defaultPort" in definition ? definition.defaultPort : undefined;
|
||||
};
|
||||
|
||||
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null =>
|
||||
integrationDefs[kind].documentationUrl;
|
||||
export const getIntegrationDocumentationSlug = (kind: IntegrationKind): IntegrationDocumentationSlug | null =>
|
||||
integrationDefs[kind].documentationSlug;
|
||||
|
||||
export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | null => {
|
||||
const slug = getIntegrationDocumentationSlug(kind);
|
||||
if (slug === null) return null;
|
||||
return createDocumentationLink(`/docs/integrations/${slug}`);
|
||||
};
|
||||
|
||||
export const getIntegrationDockerMetadata = (kind: IntegrationKind) => {
|
||||
const definition: integrationDefinition = integrationDefs[kind];
|
||||
return {
|
||||
aliases: definition.features?.docker?.aliases ?? [],
|
||||
discoverable: definition.features?.docker?.discoverable ?? true,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIntegrationOnboardingMetadata = (kind: IntegrationKind) => {
|
||||
const definition: integrationDefinition = integrationDefs[kind];
|
||||
return {
|
||||
featuredOrder: definition.features?.onboarding?.featuredOrder ?? null,
|
||||
hidden: definition.features?.onboarding?.hidden ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
|
||||
const definition = integrationDefs[kind];
|
||||
@@ -666,7 +646,7 @@ export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: Integratio
|
||||
* @returns Partial list of integration kinds
|
||||
*/
|
||||
export const getIntegrationKindsByCategory = <TCategory extends IntegrationCategory>(category: TCategory) => {
|
||||
return objectKeys(integrationDefs).filter((integration) =>
|
||||
return (Object.keys(integrationDefs) as IntegrationKind[]).filter((integration) =>
|
||||
integrationDefs[integration].category.some((defCategory) => defCategory === category),
|
||||
) as AtLeastOneOf<IntegrationKindByCategory<TCategory>>;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getIntegrationApiKeyUrl, integrationDefs } from "../integration";
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import { getIntegrationApiKeyUrl, getIntegrationDocumentationUrl, integrationDefs } from "../integration";
|
||||
|
||||
describe("getIntegrationApiKeyUrl", () => {
|
||||
it("links every user-facing integration to its Homarr setup guide", () => {
|
||||
expect(
|
||||
Object.entries(integrationDefs)
|
||||
.filter(([kind, definition]) => kind !== "mock" && !definition.documentationUrl)
|
||||
objectEntries(integrationDefs)
|
||||
.filter(([kind]) => kind !== "mock" && !getIntegrationDocumentationUrl(kind))
|
||||
.map(([kind]) => kind),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -4,19 +4,19 @@ import { integrationKinds } from "../integration";
|
||||
import {
|
||||
getWidgetKindsForIntegration,
|
||||
integrationWidgetSupport,
|
||||
nativeFeatureCapabilities,
|
||||
widgetIntegrationConfigs,
|
||||
widgetIntegrationLimits,
|
||||
widgetIntegrationSupport,
|
||||
widgetKindsWithOptionalIntegrations,
|
||||
} from "../widget-integration-map";
|
||||
|
||||
describe("native feature capability descriptor", () => {
|
||||
describe("widget integration config", () => {
|
||||
test("derives forward and reverse support without losing relationships", () => {
|
||||
for (const [widgetKind, capability] of Object.entries(nativeFeatureCapabilities)) {
|
||||
for (const [widgetKind, capability] of Object.entries(widgetIntegrationConfigs)) {
|
||||
expect(widgetIntegrationSupport[widgetKind as keyof typeof widgetIntegrationSupport]).toEqual(
|
||||
capability.integrations,
|
||||
capability.supportedIntegrations,
|
||||
);
|
||||
for (const integrationKind of capability.integrations) {
|
||||
for (const integrationKind of capability.supportedIntegrations) {
|
||||
expect(integrationWidgetSupport[integrationKind]).toContain(widgetKind);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,419 +0,0 @@
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import { getIntegrationKindsByCategory } from "./integration";
|
||||
import type { IntegrationKindByCategory } from "./integration";
|
||||
import { generatedWidgetFeatureCatalog } from "./generated/widget-modules";
|
||||
|
||||
const {
|
||||
weather: generatedWeatherFeature,
|
||||
downloads: generatedDownloadsFeature,
|
||||
...additionalGeneratedWidgetFeatures
|
||||
} = generatedWidgetFeatureCatalog;
|
||||
|
||||
export interface NativeFeatureCapabilityDescriptor {
|
||||
integrations: Readonly<AtLeastOneOf<IntegrationKind>>;
|
||||
connectionOptional?: true;
|
||||
serverMaxIntegrations?: number;
|
||||
}
|
||||
|
||||
interface WidgetFeatureDescriptorBase {
|
||||
documentationSlug: string | null;
|
||||
defaultSize?: Readonly<{ width: number; height: number }>;
|
||||
query?: {
|
||||
paths: readonly (readonly string[])[];
|
||||
refetchIntervalSeconds?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface WidgetFeatureDescriptorWithDefaultName extends WidgetFeatureDescriptorBase {
|
||||
displayNameFromIntegration?: false;
|
||||
capability?: NativeFeatureCapabilityDescriptor;
|
||||
}
|
||||
|
||||
interface WidgetFeatureDescriptorWithIntegrationName extends WidgetFeatureDescriptorBase {
|
||||
displayNameFromIntegration: true;
|
||||
capability: Omit<NativeFeatureCapabilityDescriptor, "integrations"> & {
|
||||
integrations: readonly [IntegrationKind];
|
||||
};
|
||||
}
|
||||
|
||||
export type WidgetFeatureDescriptor =
|
||||
| WidgetFeatureDescriptorWithDefaultName
|
||||
| WidgetFeatureDescriptorWithIntegrationName;
|
||||
|
||||
const defineWidgetFeatureCatalog = <const TCatalog extends Record<string, WidgetFeatureDescriptor>>(
|
||||
catalog: TCatalog,
|
||||
) => catalog;
|
||||
|
||||
type GeneralHealthMonitoringIntegrationKind = Exclude<
|
||||
IntegrationKindByCategory<"healthMonitoring">,
|
||||
"patchmon" | "wud"
|
||||
>;
|
||||
|
||||
const generalHealthMonitoringIntegrationKinds = getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind): kind is GeneralHealthMonitoringIntegrationKind => kind !== "patchmon" && kind !== "wud",
|
||||
) as AtLeastOneOf<GeneralHealthMonitoringIntegrationKind>;
|
||||
|
||||
/**
|
||||
* Server-safe source of truth for native widgets.
|
||||
*
|
||||
* Keep each widget's stable kind, documentation, default size, and integration
|
||||
* contract together so every lightweight consumer can derive the same view.
|
||||
*/
|
||||
export const widgetFeatureCatalog = defineWidgetFeatureCatalog({
|
||||
clock: {
|
||||
documentationSlug: "clock",
|
||||
defaultSize: { width: 2, height: 1 },
|
||||
query: { paths: [["widget", "weather", "atLocation"]] },
|
||||
},
|
||||
weather: generatedWeatherFeature,
|
||||
airQuality: {
|
||||
documentationSlug: "air-quality",
|
||||
defaultSize: { width: 2, height: 1 },
|
||||
query: { paths: [["widget", "airQuality", "atLocation"]], refetchIntervalSeconds: 900 },
|
||||
},
|
||||
countdown: { documentationSlug: "countdown", defaultSize: { width: 2, height: 1 } },
|
||||
timer: { documentationSlug: "timer", defaultSize: { width: 2, height: 1 } },
|
||||
app: {
|
||||
documentationSlug: "app",
|
||||
query: {
|
||||
paths: [
|
||||
["app", "byId"],
|
||||
["widget", "app", "ping"],
|
||||
],
|
||||
},
|
||||
},
|
||||
iframe: { documentationSlug: "iframe" },
|
||||
video: { documentationSlug: "video" },
|
||||
notebook: { documentationSlug: "notebook" },
|
||||
anchorNote: {
|
||||
documentationSlug: "anchor-note",
|
||||
capability: { integrations: ["anchor"], serverMaxIntegrations: 1 },
|
||||
query: { paths: [["widget", "anchorNotes"]] },
|
||||
},
|
||||
dnsHoleSummary: {
|
||||
documentationSlug: "dns-hole-summary",
|
||||
capability: { integrations: getIntegrationKindsByCategory("dnsHole") },
|
||||
query: { paths: [["widget", "dnsHole"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
dnsHoleControls: {
|
||||
documentationSlug: "dns-hole-controls",
|
||||
capability: { integrations: getIntegrationKindsByCategory("dnsHole") },
|
||||
query: { paths: [["widget", "dnsHole"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
"smartHome-entityState": {
|
||||
documentationSlug: "smart-home-entity-state",
|
||||
capability: {
|
||||
integrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
serverMaxIntegrations: 1,
|
||||
},
|
||||
query: { paths: [["widget", "smartHome"]] },
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
documentationSlug: "smart-home-execute-automation",
|
||||
capability: {
|
||||
integrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
serverMaxIntegrations: 1,
|
||||
},
|
||||
},
|
||||
stockPrice: {
|
||||
documentationSlug: "stock-price",
|
||||
query: { paths: [["widget", "stockPrice", "getPriceHistory"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
mediaServer: {
|
||||
documentationSlug: "media-server",
|
||||
capability: { integrations: getIntegrationKindsByCategory("mediaService") },
|
||||
query: { paths: [["widget", "mediaServer", "getCurrentStreams"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
calendar: {
|
||||
documentationSlug: "calendar",
|
||||
capability: { integrations: getIntegrationKindsByCategory("calendar"), connectionOptional: true },
|
||||
query: { paths: [["widget", "calendar", "findAllEvents"]] },
|
||||
},
|
||||
downloads: generatedDownloadsFeature,
|
||||
"mediaRequests-requestList": {
|
||||
documentationSlug: "media-request-list",
|
||||
capability: { integrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
query: { paths: [["widget", "mediaRequests", "getLatestRequests"]] },
|
||||
},
|
||||
"mediaRequests-requestStats": {
|
||||
documentationSlug: "media-request-stats",
|
||||
capability: { integrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
query: { paths: [["widget", "mediaRequests", "getStats"]] },
|
||||
},
|
||||
mediaTranscoding: {
|
||||
documentationSlug: "media-transcoding",
|
||||
capability: {
|
||||
integrations: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
serverMaxIntegrations: 1,
|
||||
},
|
||||
query: { paths: [["widget", "mediaTranscoding", "getDataAsync"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
mediaMissing: {
|
||||
documentationSlug: "media-missing",
|
||||
defaultSize: { width: 4, height: 3 },
|
||||
capability: { integrations: getIntegrationKindsByCategory("mediaOrganizer") },
|
||||
query: { paths: [["widget", "mediaOrganizer", "getData"]] },
|
||||
},
|
||||
minecraftServerStatus: {
|
||||
documentationSlug: "minecraft-server-status",
|
||||
query: { paths: [["widget", "minecraft", "getServerStatus"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
networkControllerSummary: {
|
||||
documentationSlug: "network-controller-summary",
|
||||
capability: { integrations: getIntegrationKindsByCategory("networkController") },
|
||||
query: { paths: [["widget", "networkController"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
networkControllerStatus: {
|
||||
documentationSlug: "network-controller-status",
|
||||
capability: { integrations: getIntegrationKindsByCategory("networkController") },
|
||||
query: { paths: [["widget", "networkController"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
rssFeed: {
|
||||
documentationSlug: "rss-feed",
|
||||
query: { paths: [["widget", "rssFeed", "getFeeds"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
bookmarks: {
|
||||
documentationSlug: "bookmarks",
|
||||
defaultSize: { width: 2, height: 2 },
|
||||
query: { paths: [["app", "byIds"]] },
|
||||
},
|
||||
indexerManager: {
|
||||
documentationSlug: "indexer-manager",
|
||||
capability: { integrations: getIntegrationKindsByCategory("indexerManager") },
|
||||
query: { paths: [["widget", "indexerManager"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
healthMonitoring: {
|
||||
documentationSlug: "health-monitoring",
|
||||
capability: { integrations: generalHealthMonitoringIntegrationKinds },
|
||||
query: {
|
||||
paths: [
|
||||
["integration", "byIds"],
|
||||
["widget", "healthMonitoring"],
|
||||
],
|
||||
refetchIntervalSeconds: 10,
|
||||
},
|
||||
},
|
||||
releases: {
|
||||
documentationSlug: "releases",
|
||||
query: { paths: [["widget", "releases", "getLatest"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
mediaReleases: {
|
||||
documentationSlug: "media-releases",
|
||||
capability: { integrations: ["mock", "emby", "jellyfin", "plex"] },
|
||||
query: { paths: [["widget", "mediaRelease"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
dockerContainers: {
|
||||
documentationSlug: "docker-containers",
|
||||
query: { paths: [["docker", "getContainers"]], refetchIntervalSeconds: 30 },
|
||||
},
|
||||
firewall: {
|
||||
documentationSlug: "firewall",
|
||||
capability: { integrations: getIntegrationKindsByCategory("firewall") },
|
||||
query: { paths: [["widget", "firewall"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
notifications: {
|
||||
documentationSlug: "notifications",
|
||||
capability: { integrations: getIntegrationKindsByCategory("notifications") },
|
||||
query: { paths: [["widget", "notifications", "getNotifications"]] },
|
||||
},
|
||||
systemResources: {
|
||||
documentationSlug: "system-resources",
|
||||
capability: {
|
||||
integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||
},
|
||||
query: { paths: [["widget", "healthMonitoring"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
coolify: {
|
||||
documentationSlug: "coolify",
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["coolify"] },
|
||||
query: { paths: [["widget", "coolify"]] },
|
||||
},
|
||||
systemDisks: {
|
||||
documentationSlug: "system-disks",
|
||||
capability: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
|
||||
query: { paths: [["widget", "healthMonitoring"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
timetable: {
|
||||
documentationSlug: "timetable",
|
||||
query: { paths: [["widget", "timetable"]] },
|
||||
},
|
||||
"immich-serverStats": {
|
||||
documentationSlug: "immich-server-stats",
|
||||
capability: { integrations: ["immich"], serverMaxIntegrations: 1 },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "immich", "getServerStats"],
|
||||
["widget", "immich", "getAlbums"],
|
||||
],
|
||||
refetchIntervalSeconds: null,
|
||||
},
|
||||
},
|
||||
"immich-albumCarousel": {
|
||||
documentationSlug: "immich-album-carousel",
|
||||
capability: { integrations: ["immich"], serverMaxIntegrations: 1 },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "immich", "getAlbum"],
|
||||
["widget", "immich", "getAlbums"],
|
||||
],
|
||||
refetchIntervalSeconds: null,
|
||||
},
|
||||
},
|
||||
paperlessNgx: {
|
||||
documentationSlug: "paperless-ngx",
|
||||
defaultSize: { width: 2, height: 2 },
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 },
|
||||
query: { paths: [["widget", "paperlessNgx"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
patchmon: {
|
||||
documentationSlug: "patchmon",
|
||||
defaultSize: { width: 2, height: 2 },
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["patchmon"], serverMaxIntegrations: 1 },
|
||||
query: { paths: [["widget", "patchmon"]] },
|
||||
},
|
||||
bazarr: {
|
||||
documentationSlug: "bazarr",
|
||||
defaultSize: { width: 2, height: 2 },
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["bazarr"], serverMaxIntegrations: 1 },
|
||||
query: { paths: [["widget", "bazarr"]] },
|
||||
},
|
||||
tracearr: {
|
||||
documentationSlug: "tracearr",
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["tracearr"] },
|
||||
query: { paths: [["widget", "tracearr"]], refetchIntervalSeconds: 10 },
|
||||
},
|
||||
speedtestTracker: {
|
||||
documentationSlug: "speedtest-tracker",
|
||||
capability: { integrations: ["speedtestTracker"] },
|
||||
query: { paths: [["widget", "speedtestTracker"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
uptimeKuma: {
|
||||
documentationSlug: "uptime-kuma",
|
||||
defaultSize: { width: 2, height: 3 },
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["uptimeKuma"] },
|
||||
query: { paths: [["widget", "uptimeKuma"]] },
|
||||
},
|
||||
audioStats: {
|
||||
documentationSlug: "audio-stats",
|
||||
defaultSize: { width: 2, height: 2 },
|
||||
capability: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "audioStats", "getStats"],
|
||||
["widget", "mediaServer", "getCurrentStreams"],
|
||||
],
|
||||
},
|
||||
},
|
||||
umami: {
|
||||
documentationSlug: "umami",
|
||||
capability: { integrations: ["umami"], serverMaxIntegrations: 1 },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "umami", "getVisitorStats"],
|
||||
["widget", "umami", "getActiveVisitors"],
|
||||
["widget", "umami", "getMultiEventTimeSeries"],
|
||||
["widget", "umami", "getTopPages"],
|
||||
["widget", "umami", "getTopReferrers"],
|
||||
],
|
||||
refetchIntervalSeconds: null,
|
||||
},
|
||||
},
|
||||
vpn: {
|
||||
documentationSlug: "vpn",
|
||||
capability: { integrations: getIntegrationKindsByCategory("vpn") },
|
||||
query: { paths: [["widget", "vpn"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
archiveTeamWarrior: {
|
||||
documentationSlug: null,
|
||||
capability: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 },
|
||||
query: { paths: [["widget", "archiveTeamWarrior"]] },
|
||||
},
|
||||
ups: {
|
||||
documentationSlug: "ups",
|
||||
capability: { integrations: getIntegrationKindsByCategory("ups") },
|
||||
query: { paths: [["widget", "ups"]] },
|
||||
},
|
||||
beszelSystemTable: {
|
||||
documentationSlug: "beszel-system-table",
|
||||
capability: { integrations: ["beszel", "mock"] },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "beszel", "getSystems"],
|
||||
["widget", "beszel", "getSystemStats"],
|
||||
],
|
||||
},
|
||||
},
|
||||
beszelSystemGrid: {
|
||||
documentationSlug: "beszel-system-grid",
|
||||
capability: { integrations: ["beszel", "mock"] },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "beszel", "getSystems"],
|
||||
["widget", "beszel", "getSystemStats"],
|
||||
],
|
||||
},
|
||||
},
|
||||
beszelAlerts: {
|
||||
documentationSlug: "beszel-alerts",
|
||||
capability: { integrations: ["beszel", "mock"] },
|
||||
query: { paths: [["widget", "beszel", "getAlerts"]] },
|
||||
},
|
||||
beszelSystemStats: {
|
||||
documentationSlug: "beszel-system-stats",
|
||||
capability: { integrations: ["beszel", "mock"] },
|
||||
query: {
|
||||
paths: [
|
||||
["widget", "beszel", "getSystems"],
|
||||
["widget", "beszel", "getSystemStats"],
|
||||
],
|
||||
},
|
||||
},
|
||||
traefik: {
|
||||
documentationSlug: "traefik",
|
||||
displayNameFromIntegration: true,
|
||||
capability: { integrations: ["traefik"] },
|
||||
query: { paths: [["widget", "traefik"]] },
|
||||
},
|
||||
customApi: {
|
||||
documentationSlug: "custom-api",
|
||||
query: { paths: [["widget", "customApi"]] },
|
||||
},
|
||||
assistant: { documentationSlug: "assistant", defaultSize: { width: 10, height: 4 } },
|
||||
wud: {
|
||||
documentationSlug: "whats-up-docker",
|
||||
capability: { integrations: ["wud"], serverMaxIntegrations: 1 },
|
||||
query: { paths: [["widget", "wud"]], refetchIntervalSeconds: null },
|
||||
},
|
||||
...additionalGeneratedWidgetFeatures,
|
||||
});
|
||||
|
||||
export type WidgetFeatureKind = keyof typeof widgetFeatureCatalog;
|
||||
|
||||
export type WidgetIntegrationKind<TKind extends WidgetFeatureKind> = (typeof widgetFeatureCatalog)[TKind] extends {
|
||||
capability: { integrations: readonly (infer TIntegrationKind extends IntegrationKind)[] };
|
||||
}
|
||||
? TIntegrationKind
|
||||
: never;
|
||||
|
||||
export type WidgetKindWithIntegration = {
|
||||
[TKind in WidgetFeatureKind]: WidgetIntegrationKind<TKind> extends never ? never : TKind;
|
||||
}[WidgetFeatureKind];
|
||||
|
||||
export const getWidgetFeatureIntegrationKinds = <TKind extends WidgetKindWithIntegration>(
|
||||
kind: TKind,
|
||||
): AtLeastOneOf<WidgetIntegrationKind<TKind>> => {
|
||||
const descriptor = widgetFeatureCatalog[kind];
|
||||
if (!("capability" in descriptor) || descriptor.capability.integrations.length === 0) {
|
||||
throw new Error(`Widget kind ${kind} does not declare integration support`);
|
||||
}
|
||||
|
||||
return [...descriptor.capability.integrations] as AtLeastOneOf<WidgetIntegrationKind<TKind>>;
|
||||
};
|
||||
@@ -1,31 +1,91 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
|
||||
import { createDocumentationLink } from "./docs";
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import { integrationKinds } from "./integration";
|
||||
import { getIntegrationKindsByCategory, integrationKinds } from "./integration";
|
||||
import type { WidgetKind } from "./widget";
|
||||
import { widgetDefaultSizes, widgetFeatureCatalog, widgetKinds } from "./widget";
|
||||
import type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog";
|
||||
import { widgetDefaultSizes } from "./widget";
|
||||
|
||||
export type { NativeFeatureCapabilityDescriptor } from "./widget-feature-catalog";
|
||||
/** Integration selection shared by the widget UI and API trust boundary. */
|
||||
export interface WidgetIntegrationConfig {
|
||||
supportedIntegrations: IntegrationKind[];
|
||||
integrationsRequired?: false;
|
||||
maxIntegrations?: number;
|
||||
}
|
||||
|
||||
const createNativeFeatureCapabilities = () => {
|
||||
const result: Partial<Record<WidgetKind, NativeFeatureCapabilityDescriptor>> = {};
|
||||
for (const kind of widgetKinds) {
|
||||
const descriptor = widgetFeatureCatalog[kind];
|
||||
if ("capability" in descriptor) result[kind] = descriptor.capability;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
export const widgetIntegrationConfigs = {
|
||||
dnsHoleSummary: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") },
|
||||
dnsHoleControls: { supportedIntegrations: getIntegrationKindsByCategory("dnsHole") },
|
||||
"smartHome-entityState": {
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
maxIntegrations: 1,
|
||||
},
|
||||
"smartHome-executeAutomation": {
|
||||
supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"),
|
||||
maxIntegrations: 1,
|
||||
},
|
||||
mediaServer: { supportedIntegrations: getIntegrationKindsByCategory("mediaService") },
|
||||
calendar: { supportedIntegrations: getIntegrationKindsByCategory("calendar"), integrationsRequired: false },
|
||||
downloads: { supportedIntegrations: getIntegrationKindsByCategory("downloadClient") },
|
||||
"mediaRequests-requestList": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
"mediaRequests-requestStats": { supportedIntegrations: getIntegrationKindsByCategory("mediaRequest") },
|
||||
mediaTranscoding: {
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
maxIntegrations: 1,
|
||||
},
|
||||
mediaMissing: { supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer") },
|
||||
networkControllerSummary: { supportedIntegrations: getIntegrationKindsByCategory("networkController") },
|
||||
networkControllerStatus: { supportedIntegrations: getIntegrationKindsByCategory("networkController") },
|
||||
indexerManager: { supportedIntegrations: getIntegrationKindsByCategory("indexerManager") },
|
||||
healthMonitoring: {
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring").filter(
|
||||
(kind) => kind !== "patchmon" && kind !== "wud",
|
||||
),
|
||||
},
|
||||
firewall: { supportedIntegrations: getIntegrationKindsByCategory("firewall") },
|
||||
notifications: { supportedIntegrations: getIntegrationKindsByCategory("notifications") },
|
||||
mediaReleases: { supportedIntegrations: ["mock", "emby", "jellyfin", "plex"] },
|
||||
systemResources: {
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
|
||||
},
|
||||
systemDisks: { supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
|
||||
beszelAlerts: { supportedIntegrations: ["beszel", "mock"] },
|
||||
beszelSystemGrid: { supportedIntegrations: ["beszel", "mock"] },
|
||||
beszelSystemStats: { supportedIntegrations: ["beszel", "mock"] },
|
||||
beszelSystemTable: { supportedIntegrations: ["beszel", "mock"] },
|
||||
coolify: { supportedIntegrations: ["coolify"] },
|
||||
"immich-serverStats": { supportedIntegrations: ["immich"], maxIntegrations: 1 },
|
||||
"immich-albumCarousel": { supportedIntegrations: ["immich"], maxIntegrations: 1 },
|
||||
paperlessNgx: { supportedIntegrations: ["paperlessNgx"], maxIntegrations: 1 },
|
||||
patchmon: { supportedIntegrations: ["patchmon"], maxIntegrations: 1 },
|
||||
bazarr: { supportedIntegrations: ["bazarr"], maxIntegrations: 1 },
|
||||
tracearr: { supportedIntegrations: ["tracearr"] },
|
||||
speedtestTracker: { supportedIntegrations: ["speedtestTracker"] },
|
||||
uptimeKuma: { supportedIntegrations: ["uptimeKuma"] },
|
||||
audioStats: { supportedIntegrations: ["navidrome", "audiobookshelf"], maxIntegrations: 1 },
|
||||
umami: { supportedIntegrations: ["umami"], maxIntegrations: 1 },
|
||||
vpn: { supportedIntegrations: getIntegrationKindsByCategory("vpn") },
|
||||
ups: { supportedIntegrations: getIntegrationKindsByCategory("ups") },
|
||||
archiveTeamWarrior: { supportedIntegrations: ["archiveTeamWarrior"], maxIntegrations: 1 },
|
||||
anchorNote: { supportedIntegrations: ["anchor"], maxIntegrations: 1 },
|
||||
traefik: { supportedIntegrations: ["traefik"] },
|
||||
wud: { supportedIntegrations: ["wud"], maxIntegrations: 1 },
|
||||
} satisfies Partial<Record<WidgetKind, WidgetIntegrationConfig>>;
|
||||
|
||||
export const nativeFeatureCapabilities = createNativeFeatureCapabilities();
|
||||
export type WidgetKindWithIntegration = keyof typeof widgetIntegrationConfigs;
|
||||
|
||||
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
|
||||
WidgetKind,
|
||||
NativeFeatureCapabilityDescriptor,
|
||||
][];
|
||||
export type WidgetIntegrationKind<TKind extends WidgetKindWithIntegration> =
|
||||
(typeof widgetIntegrationConfigs)[TKind]["supportedIntegrations"][number];
|
||||
|
||||
export const getWidgetIntegrationConfig = <TKind extends WidgetKindWithIntegration>(
|
||||
kind: TKind,
|
||||
): (typeof widgetIntegrationConfigs)[TKind] => widgetIntegrationConfigs[kind];
|
||||
|
||||
const widgetIntegrationConfigEntries = objectEntries(widgetIntegrationConfigs);
|
||||
|
||||
const createWidgetIntegrationSupport = () => {
|
||||
const result: Partial<Record<WidgetKind, readonly IntegrationKind[]>> = {};
|
||||
for (const [kind, capability] of nativeFeatureCapabilityEntries) result[kind] = capability.integrations;
|
||||
for (const [kind, config] of widgetIntegrationConfigEntries) result[kind] = config.supportedIntegrations;
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -33,16 +93,15 @@ export const widgetIntegrationSupport = createWidgetIntegrationSupport();
|
||||
|
||||
/** Widgets that remain useful without a configured integration. */
|
||||
export const widgetKindsWithOptionalIntegrations = new Set<WidgetKind>(
|
||||
nativeFeatureCapabilityEntries
|
||||
.filter(([, capability]) => capability.connectionOptional === true)
|
||||
widgetIntegrationConfigEntries
|
||||
.filter(([, config]) => "integrationsRequired" in config && config.integrationsRequired === false)
|
||||
.map(([kind]) => kind),
|
||||
);
|
||||
|
||||
/** Widgets that support fewer integrations than the default unlimited selection. */
|
||||
const createWidgetIntegrationLimits = () => {
|
||||
const result: Partial<Record<WidgetKind, number>> = {};
|
||||
for (const [kind, capability] of nativeFeatureCapabilityEntries) {
|
||||
if (capability.serverMaxIntegrations !== undefined) result[kind] = capability.serverMaxIntegrations;
|
||||
for (const [kind, config] of widgetIntegrationConfigEntries) {
|
||||
if ("maxIntegrations" in config) result[kind] = config.maxIntegrations;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -75,7 +134,8 @@ export const getWidgetIntegrationIssue = (
|
||||
return { code: "integration-required" };
|
||||
}
|
||||
const incompatibleKinds = selectedIntegrationKinds.filter((kind) => !supportedIntegrations?.includes(kind));
|
||||
return incompatibleKinds.length > 0 ? { code: "incompatible-integration", incompatibleKinds } : null;
|
||||
if (incompatibleKinds.length === 0) return null;
|
||||
return { code: "incompatible-integration", incompatibleKinds };
|
||||
};
|
||||
|
||||
export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue: WidgetIntegrationIssue) => {
|
||||
@@ -87,17 +147,19 @@ export const getWidgetIntegrationIssueMessage = (widgetKind: WidgetKind, issue:
|
||||
return `${widgetKind} does not support integration kind${issue.incompatibleKinds.length === 1 ? "" : "s"}: ${issue.incompatibleKinds.join(", ")}`;
|
||||
};
|
||||
|
||||
/** Reverse capability index generated from nativeFeatureCapabilities. */
|
||||
const createIntegrationWidgetSupport = () => {
|
||||
const result = {} as Record<IntegrationKind, readonly WidgetKind[]>;
|
||||
for (const integrationKind of integrationKinds) {
|
||||
result[integrationKind] = nativeFeatureCapabilityEntries
|
||||
.filter(([, capability]) => capability.integrations.includes(integrationKind))
|
||||
result[integrationKind] = widgetIntegrationConfigEntries
|
||||
.filter(([, config]) => supportsIntegration(config, integrationKind))
|
||||
.map(([widgetKind]) => widgetKind);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const supportsIntegration = (config: WidgetIntegrationConfig, integrationKind: IntegrationKind) =>
|
||||
config.supportedIntegrations.includes(integrationKind);
|
||||
|
||||
export const integrationWidgetSupport = createIntegrationWidgetSupport();
|
||||
|
||||
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
|
||||
@@ -112,28 +174,12 @@ export interface DefaultWidgetConfig {
|
||||
skip?: boolean;
|
||||
}
|
||||
|
||||
type WidgetKindWithDefaultSize = {
|
||||
[TKind in WidgetKind]: (typeof widgetFeatureCatalog)[TKind] extends { defaultSize: Readonly<{ width: number }> }
|
||||
? TKind
|
||||
: never;
|
||||
}[WidgetKind];
|
||||
|
||||
type DefaultWidgetConfigRecipe = Omit<DefaultWidgetConfig, "kind" | "width" | "height"> & {
|
||||
kind: WidgetKindWithDefaultSize;
|
||||
};
|
||||
|
||||
const defaultWidgetConfigDefinitions: DefaultWidgetConfigRecipe[] = [
|
||||
{ kind: "clock" },
|
||||
{ kind: "weather", options: { showHumidity: false, showCity: false, hasForecast: false } },
|
||||
{ kind: "bookmarks", options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
|
||||
{ kind: "clock", width: 2, height: 1 },
|
||||
{ kind: "weather", width: 2, height: 1, options: { showHumidity: false, showCity: false, hasForecast: false } },
|
||||
{ kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
|
||||
];
|
||||
|
||||
export const defaultWidgetConfigs: DefaultWidgetConfig[] = defaultWidgetConfigDefinitions.map((recipe) => {
|
||||
const defaultSize = widgetDefaultSizes[recipe.kind];
|
||||
if (!defaultSize) throw new Error(`Default widget ${recipe.kind} must declare a default size`);
|
||||
return { ...recipe, ...defaultSize };
|
||||
});
|
||||
|
||||
const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
|
||||
|
||||
export const getDefaultWidgetConfig = (kind: WidgetKind): DefaultWidgetConfig =>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import type { AtLeastOneOf } from "@homarr/common/types";
|
||||
|
||||
import type { IntegrationCategory, IntegrationKind } from "./integration";
|
||||
|
||||
export type WidgetModuleIconName = `Icon${string}`;
|
||||
export type WidgetModuleOperationKind = "query" | "mutation" | "subscription" | "route";
|
||||
|
||||
export interface WidgetModuleClientPolicy {
|
||||
refetchIntervalSeconds?: number | null;
|
||||
staleTimeSeconds?: number;
|
||||
persist?: boolean;
|
||||
}
|
||||
|
||||
export interface WidgetModuleServerCachePolicy {
|
||||
namespace: string;
|
||||
ttlMs: number;
|
||||
staleIfErrorTtlMs?: number;
|
||||
scope: "integration" | "shared" | "private";
|
||||
}
|
||||
|
||||
export interface WidgetModuleOperation {
|
||||
name: string;
|
||||
kind?: WidgetModuleOperationKind;
|
||||
path: readonly [string, ...string[]];
|
||||
client?: WidgetModuleClientPolicy;
|
||||
serverCache?: WidgetModuleServerCachePolicy;
|
||||
}
|
||||
|
||||
type WidgetModuleIntegrationSelection =
|
||||
| {
|
||||
categories: Readonly<AtLeastOneOf<IntegrationCategory>>;
|
||||
kinds?: readonly IntegrationKind[];
|
||||
}
|
||||
| {
|
||||
categories?: readonly IntegrationCategory[];
|
||||
kinds: Readonly<AtLeastOneOf<IntegrationKind>>;
|
||||
};
|
||||
|
||||
export type WidgetModuleIntegrationCapability = WidgetModuleIntegrationSelection & {
|
||||
excludeKinds?: readonly IntegrationKind[];
|
||||
connectionOptional?: true;
|
||||
serverMaxIntegrations?: number;
|
||||
};
|
||||
|
||||
export interface WidgetModuleRouterRegistration {
|
||||
namespace: string;
|
||||
module: string;
|
||||
exportName: string;
|
||||
additionalWidgetKinds?: readonly string[];
|
||||
mcp?: true | string;
|
||||
}
|
||||
|
||||
export interface WidgetModuleDocumentation {
|
||||
slug: string;
|
||||
sourceDirectory: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure authoring contract for a vertical widget module.
|
||||
*
|
||||
* This descriptor intentionally contains import paths instead of importing
|
||||
* client or server implementations. The sync tool turns them into explicit
|
||||
* static imports for Next.js and Turbopack without crossing runtime boundaries.
|
||||
*/
|
||||
export interface WidgetModuleDefinition {
|
||||
kind: string;
|
||||
icon: WidgetModuleIconName;
|
||||
clientEntry: string;
|
||||
documentation: WidgetModuleDocumentation;
|
||||
defaultSize?: Readonly<{ width: number; height: number }>;
|
||||
displayNameFromIntegration?: true;
|
||||
integration?: WidgetModuleIntegrationCapability;
|
||||
operations?: readonly WidgetModuleOperation[];
|
||||
routers?: readonly WidgetModuleRouterRegistration[];
|
||||
}
|
||||
|
||||
export const defineWidgetModule = <const TDefinition extends WidgetModuleDefinition>(definition: TDefinition) =>
|
||||
definition;
|
||||
|
||||
export type WidgetServerPermission =
|
||||
| "public"
|
||||
| "authenticated"
|
||||
| "integration-query"
|
||||
| "integration-interact"
|
||||
| "custom";
|
||||
|
||||
export interface WidgetServerInputSchema<TInput> {
|
||||
parseAsync: (input: unknown) => Promise<TInput>;
|
||||
}
|
||||
|
||||
export interface WidgetServerContext<TServices = unknown, TAuthorization = unknown> {
|
||||
services: TServices;
|
||||
authorization: TAuthorization;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
export interface WidgetServerOperationDefinition<TInput, TOutput, TContext = WidgetServerContext> {
|
||||
kind: "query" | "mutation";
|
||||
permission: WidgetServerPermission;
|
||||
input: WidgetServerInputSchema<TInput>;
|
||||
cache?: WidgetModuleServerCachePolicy;
|
||||
resolve: (context: TContext, input: TInput) => Promise<TOutput>;
|
||||
}
|
||||
|
||||
export interface WidgetServerSubscriptionDefinition<TInput, TOutput, TContext = WidgetServerContext> {
|
||||
kind: "subscription";
|
||||
permission: WidgetServerPermission;
|
||||
input: WidgetServerInputSchema<TInput>;
|
||||
transport: "sse" | "websocket";
|
||||
subscribe: (context: TContext, input: TInput) => AsyncIterable<TOutput>;
|
||||
}
|
||||
|
||||
export interface WidgetServerRouteDefinition<TRequest = unknown, TResponse = unknown, TContext = WidgetServerContext> {
|
||||
kind: "route";
|
||||
permission: WidgetServerPermission;
|
||||
handle: (context: TContext, request: TRequest) => Promise<TResponse>;
|
||||
}
|
||||
|
||||
export const defineWidgetServerOperation = <TInput, TOutput, TContext = WidgetServerContext>(
|
||||
operation: WidgetServerOperationDefinition<TInput, TOutput, TContext>,
|
||||
) => operation;
|
||||
|
||||
export const defineWidgetServerSubscription = <TInput, TOutput, TContext = WidgetServerContext>(
|
||||
subscription: WidgetServerSubscriptionDefinition<TInput, TOutput, TContext>,
|
||||
) => subscription;
|
||||
|
||||
export const defineWidgetServerRoute = <TRequest, TResponse, TContext = WidgetServerContext>(
|
||||
route: WidgetServerRouteDefinition<TRequest, TResponse, TContext>,
|
||||
) => route;
|
||||
|
||||
export const defineWidgetServerModule = <
|
||||
const TModule extends {
|
||||
operations?: Readonly<Record<string, object>>;
|
||||
subscriptions?: Readonly<Record<string, object>>;
|
||||
routes?: Readonly<Record<string, object>>;
|
||||
},
|
||||
>(
|
||||
module: TModule,
|
||||
) => module;
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { IntegrationKind } from "./integration";
|
||||
import { getIntegrationName } from "./integration";
|
||||
import type { WidgetKind } from "./widget";
|
||||
import { widgetFeatureCatalog } from "./widget-feature-catalog";
|
||||
|
||||
const widgetIntegrationKinds: Partial<Record<WidgetKind, IntegrationKind>> = {
|
||||
coolify: "coolify",
|
||||
tracearr: "tracearr",
|
||||
paperlessNgx: "paperlessNgx",
|
||||
patchmon: "patchmon",
|
||||
bazarr: "bazarr",
|
||||
uptimeKuma: "uptimeKuma",
|
||||
traefik: "traefik",
|
||||
};
|
||||
|
||||
export const getWidgetIntegrationName = (kind: WidgetKind): string | undefined => {
|
||||
const descriptor = widgetFeatureCatalog[kind];
|
||||
if (!("displayNameFromIntegration" in descriptor) || !descriptor.displayNameFromIntegration) return undefined;
|
||||
return getIntegrationName(descriptor.capability.integrations[0]);
|
||||
const integrationKind = widgetIntegrationKinds[kind];
|
||||
if (!integrationKind) return undefined;
|
||||
return getIntegrationName(integrationKind);
|
||||
};
|
||||
|
||||
type WidgetNameTranslator = (key: never) => string;
|
||||
|
||||
@@ -1,19 +1,79 @@
|
||||
import { widgetFeatureCatalog } from "./widget-feature-catalog";
|
||||
export const widgetKinds = [
|
||||
"clock",
|
||||
"weather",
|
||||
"airQuality",
|
||||
"countdown",
|
||||
"timer",
|
||||
"app",
|
||||
"iframe",
|
||||
"video",
|
||||
"notebook",
|
||||
"anchorNote",
|
||||
"dnsHoleSummary",
|
||||
"dnsHoleControls",
|
||||
"smartHome-entityState",
|
||||
"smartHome-executeAutomation",
|
||||
"stockPrice",
|
||||
"mediaServer",
|
||||
"calendar",
|
||||
"downloads",
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"mediaTranscoding",
|
||||
"mediaMissing",
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"networkControllerStatus",
|
||||
"rssFeed",
|
||||
"bookmarks",
|
||||
"indexerManager",
|
||||
"healthMonitoring",
|
||||
"releases",
|
||||
"mediaReleases",
|
||||
"dockerContainers",
|
||||
"firewall",
|
||||
"notifications",
|
||||
"systemResources",
|
||||
"coolify",
|
||||
"systemDisks",
|
||||
"timetable",
|
||||
"immich-serverStats",
|
||||
"immich-albumCarousel",
|
||||
"paperlessNgx",
|
||||
"patchmon",
|
||||
"bazarr",
|
||||
"tracearr",
|
||||
"speedtestTracker",
|
||||
"uptimeKuma",
|
||||
"audioStats",
|
||||
"umami",
|
||||
"vpn",
|
||||
"archiveTeamWarrior",
|
||||
"ups",
|
||||
"beszelSystemTable",
|
||||
"beszelSystemGrid",
|
||||
"beszelAlerts",
|
||||
"beszelSystemStats",
|
||||
"traefik",
|
||||
"customApi",
|
||||
"assistant",
|
||||
"wud",
|
||||
] as const;
|
||||
|
||||
export * from "./widget-feature-catalog";
|
||||
export type { WidgetFeatureDescriptor } from "./widget-feature-catalog";
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
|
||||
export type WidgetKind = keyof typeof widgetFeatureCatalog;
|
||||
|
||||
export const widgetKinds = Object.keys(widgetFeatureCatalog) as unknown as readonly [WidgetKind, ...WidgetKind[]];
|
||||
|
||||
const createWidgetDefaultSizes = () => {
|
||||
const result: Partial<Record<WidgetKind, { width: number; height: number }>> = {};
|
||||
for (const kind of widgetKinds) {
|
||||
const descriptor = widgetFeatureCatalog[kind];
|
||||
if ("defaultSize" in descriptor) result[kind] = descriptor.defaultSize;
|
||||
}
|
||||
return result;
|
||||
export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; height: number }>> = {
|
||||
clock: { width: 2, height: 1 },
|
||||
weather: { width: 2, height: 1 },
|
||||
airQuality: { width: 2, height: 1 },
|
||||
countdown: { width: 2, height: 1 },
|
||||
timer: { width: 2, height: 1 },
|
||||
bookmarks: { width: 2, height: 2 },
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
patchmon: { width: 2, height: 2 },
|
||||
mediaMissing: { width: 4, height: 3 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
assistant: { width: 10, height: 4 },
|
||||
};
|
||||
|
||||
export const widgetDefaultSizes = createWidgetDefaultSizes();
|
||||
@@ -1,6 +1 @@
|
||||
/**
|
||||
* The package root is intentionally type-only. Runtime integrations must use
|
||||
* a narrow export such as `@homarr/integrations/factory` so importing one
|
||||
* capability cannot eagerly pull every concrete integration into the graph.
|
||||
*/
|
||||
export type * from "./src";
|
||||
export * from "./src";
|
||||
@@ -17,7 +17,6 @@
|
||||
"./downloads": "./src/interfaces/downloads/download-client-items.ts",
|
||||
"./factory": "./src/factory.ts",
|
||||
"./response-contract": "./src/base/response-contract.ts",
|
||||
"./synology": "./src/synology/synology-integration.ts",
|
||||
"./test-connection": "./src/base/test-connection/index.ts",
|
||||
"./types": "./src/types.ts"
|
||||
},
|
||||
|
||||
@@ -2,187 +2,128 @@ import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
|
||||
import { generatedIntegrationCreatorLoaders } from "../generated/integration-creators";
|
||||
import type { Integration, IntegrationInput } from "./integration";
|
||||
|
||||
const logger = createLogger({ module: "integrationFactory" });
|
||||
|
||||
type IntegrationCreator<TIntegration extends Integration = Integration> = (
|
||||
input: IntegrationInput,
|
||||
) => Promise<TIntegration>;
|
||||
|
||||
type IntegrationConstructor<TIntegration extends Integration = Integration> = new (
|
||||
input: IntegrationInput,
|
||||
) => TIntegration;
|
||||
|
||||
const createConstructorCreator =
|
||||
<TIntegration extends Integration>(
|
||||
IntegrationClass: IntegrationConstructor<TIntegration>,
|
||||
): IntegrationCreator<TIntegration> =>
|
||||
async (input) =>
|
||||
new IntegrationClass(input);
|
||||
|
||||
// Keep each import path explicit so Node and Turbopack can discover every lazy integration chunk.
|
||||
const integrationCreatorLoaders = {
|
||||
anchor: async () => createConstructorCreator((await import("../anchor/anchor-integration")).AnchorIntegration),
|
||||
piHole: async () => (await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync,
|
||||
adGuardHome: async () =>
|
||||
createConstructorCreator((await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration),
|
||||
technitiumDns: async () =>
|
||||
(await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync,
|
||||
homeAssistant: async () =>
|
||||
createConstructorCreator((await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration),
|
||||
jellyfin: async () =>
|
||||
createConstructorCreator((await import("../jellyfin/jellyfin-integration")).JellyfinIntegration),
|
||||
plex: async () => createConstructorCreator((await import("../plex/plex-integration")).PlexIntegration),
|
||||
sonarr: async () =>
|
||||
createConstructorCreator((await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration),
|
||||
radarr: async () =>
|
||||
createConstructorCreator((await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration),
|
||||
sabNzbd: async () =>
|
||||
createConstructorCreator((await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration),
|
||||
nzbGet: async () =>
|
||||
createConstructorCreator((await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration),
|
||||
qBittorrent: async () =>
|
||||
createConstructorCreator(
|
||||
(await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration,
|
||||
),
|
||||
deluge: async () =>
|
||||
createConstructorCreator((await import("../download-client/deluge/deluge-integration")).DelugeIntegration),
|
||||
transmission: async () =>
|
||||
createConstructorCreator(
|
||||
(await import("../download-client/transmission/transmission-integration")).TransmissionIntegration,
|
||||
),
|
||||
slskd: async () =>
|
||||
createConstructorCreator((await import("../download-client/slskd/slskd-integration")).SlskdIntegration),
|
||||
aria2: async () =>
|
||||
createConstructorCreator((await import("../download-client/aria2/aria2-integration")).Aria2Integration),
|
||||
jellyseerr: async () =>
|
||||
createConstructorCreator((await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration),
|
||||
seerr: async () => createConstructorCreator((await import("../seerr/seerr-integration")).SeerrIntegration),
|
||||
overseerr: async () =>
|
||||
createConstructorCreator((await import("../overseerr/overseerr-integration")).OverseerrIntegration),
|
||||
prowlarr: async () =>
|
||||
createConstructorCreator((await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration),
|
||||
openmediavault: async () =>
|
||||
createConstructorCreator((await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration),
|
||||
lidarr: async () =>
|
||||
createConstructorCreator((await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration),
|
||||
readarr: async () =>
|
||||
createConstructorCreator((await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration),
|
||||
dashDot: async () => createConstructorCreator((await import("../dashdot/dashdot-integration")).DashDotIntegration),
|
||||
tdarr: async () =>
|
||||
createConstructorCreator((await import("../media-transcoding/tdarr-integration")).TdarrIntegration),
|
||||
proxmox: async () => createConstructorCreator((await import("../proxmox/proxmox-integration")).ProxmoxIntegration),
|
||||
emby: async () => createConstructorCreator((await import("../emby/emby-integration")).EmbyIntegration),
|
||||
nextcloud: async () =>
|
||||
createConstructorCreator((await import("../nextcloud/nextcloud.integration")).NextcloudIntegration),
|
||||
unifiController: async () =>
|
||||
createConstructorCreator(
|
||||
(await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration,
|
||||
),
|
||||
opnsense: async () =>
|
||||
createConstructorCreator((await import("../opnsense/opnsense-integration")).OPNsenseIntegration),
|
||||
ical: async () => createConstructorCreator((await import("../ical/ical-integration")).ICalIntegration),
|
||||
ntfy: async () => createConstructorCreator((await import("../ntfy/ntfy-integration")).NTFYIntegration),
|
||||
gotify: async () => createConstructorCreator((await import("../gotify/gotify-integration")).GotifyIntegration),
|
||||
mock: async () => createConstructorCreator((await import("../mock/mock-integration")).MockIntegration),
|
||||
truenas: async () => createConstructorCreator((await import("../truenas/truenas-integration")).TrueNasIntegration),
|
||||
synology: async () =>
|
||||
createConstructorCreator((await import("../synology/synology-integration")).SynologyIntegration),
|
||||
unraid: async () => createConstructorCreator((await import("../unraid/unraid-integration")).UnraidIntegration),
|
||||
coolify: async () => createConstructorCreator((await import("../coolify/coolify-integration")).CoolifyIntegration),
|
||||
tracearr: async () =>
|
||||
createConstructorCreator((await import("../tracearr/tracearr-integration")).TracearrIntegration),
|
||||
glances: async () => createConstructorCreator((await import("../glances/glances-integration")).GlancesIntegration),
|
||||
immich: async () => createConstructorCreator((await import("../immich/immich-integration")).ImmichIntegration),
|
||||
paperlessNgx: async () =>
|
||||
createConstructorCreator((await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration),
|
||||
patchmon: async () =>
|
||||
createConstructorCreator((await import("../patchmon/patchmon-integration")).PatchMonIntegration),
|
||||
speedtestTracker: async () =>
|
||||
createConstructorCreator(
|
||||
(await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration,
|
||||
),
|
||||
audiobookshelf: async () =>
|
||||
createConstructorCreator((await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration),
|
||||
navidrome: async () =>
|
||||
createConstructorCreator((await import("../navidrome/navidrome-integration")).NavidromeIntegration),
|
||||
umami: async () => createConstructorCreator((await import("../umami/umami-integration")).UmamiIntegration),
|
||||
gluetun: async () => createConstructorCreator((await import("../gluetun/gluetun-integration")).GluetunIntegration),
|
||||
archiveTeamWarrior: async () =>
|
||||
createConstructorCreator(
|
||||
(await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration,
|
||||
),
|
||||
uptimeKuma: async () =>
|
||||
createConstructorCreator((await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration),
|
||||
peaNut: async () => createConstructorCreator((await import("../peanut/peanut-integration")).PeaNutIntegration),
|
||||
...generatedIntegrationCreatorLoaders,
|
||||
bazarr: async () => createConstructorCreator((await import("../bazarr/bazarr-integration")).BazarrIntegration),
|
||||
traefik: async () => createConstructorCreator((await import("../traefik/traefik-integration")).TraefikIntegration),
|
||||
wud: async () => createConstructorCreator((await import("../wud/wud-integration")).WudIntegration),
|
||||
};
|
||||
const integrationCreators = {
|
||||
anchor: async (input: IntegrationInput) =>
|
||||
new (await import("../anchor/anchor-integration")).AnchorIntegration(input),
|
||||
piHole: async (input: IntegrationInput) =>
|
||||
(await import("../pi-hole/pi-hole-integration-factory")).createPiHoleIntegrationAsync(input),
|
||||
adGuardHome: async (input: IntegrationInput) =>
|
||||
new (await import("../adguard-home/adguard-home-integration")).AdGuardHomeIntegration(input),
|
||||
technitiumDns: async (input: IntegrationInput) =>
|
||||
(await import("../technitium/technitium-integration-factory")).createTechnitiumDnsIntegrationAsync(input),
|
||||
homeAssistant: async (input: IntegrationInput) =>
|
||||
new (await import("../homeassistant/homeassistant-integration")).HomeAssistantIntegration(input),
|
||||
jellyfin: async (input: IntegrationInput) =>
|
||||
new (await import("../jellyfin/jellyfin-integration")).JellyfinIntegration(input),
|
||||
plex: async (input: IntegrationInput) => new (await import("../plex/plex-integration")).PlexIntegration(input),
|
||||
sonarr: async (input: IntegrationInput) =>
|
||||
new (await import("../media-organizer/sonarr/sonarr-integration")).SonarrIntegration(input),
|
||||
radarr: async (input: IntegrationInput) =>
|
||||
new (await import("../media-organizer/radarr/radarr-integration")).RadarrIntegration(input),
|
||||
sabNzbd: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/sabnzbd/sabnzbd-integration")).SabnzbdIntegration(input),
|
||||
nzbGet: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/nzbget/nzbget-integration")).NzbGetIntegration(input),
|
||||
qBittorrent: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/qbittorrent/qbittorrent-integration")).QBitTorrentIntegration(input),
|
||||
deluge: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/deluge/deluge-integration")).DelugeIntegration(input),
|
||||
transmission: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/transmission/transmission-integration")).TransmissionIntegration(input),
|
||||
slskd: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/slskd/slskd-integration")).SlskdIntegration(input),
|
||||
aria2: async (input: IntegrationInput) =>
|
||||
new (await import("../download-client/aria2/aria2-integration")).Aria2Integration(input),
|
||||
jellyseerr: async (input: IntegrationInput) =>
|
||||
new (await import("../jellyseerr/jellyseerr-integration")).JellyseerrIntegration(input),
|
||||
seerr: async (input: IntegrationInput) => new (await import("../seerr/seerr-integration")).SeerrIntegration(input),
|
||||
overseerr: async (input: IntegrationInput) =>
|
||||
new (await import("../overseerr/overseerr-integration")).OverseerrIntegration(input),
|
||||
prowlarr: async (input: IntegrationInput) =>
|
||||
new (await import("../prowlarr/prowlarr-integration")).ProwlarrIntegration(input),
|
||||
openmediavault: async (input: IntegrationInput) =>
|
||||
new (await import("../openmediavault/openmediavault-integration")).OpenMediaVaultIntegration(input),
|
||||
lidarr: async (input: IntegrationInput) =>
|
||||
new (await import("../media-organizer/lidarr/lidarr-integration")).LidarrIntegration(input),
|
||||
readarr: async (input: IntegrationInput) =>
|
||||
new (await import("../media-organizer/readarr/readarr-integration")).ReadarrIntegration(input),
|
||||
dashDot: async (input: IntegrationInput) =>
|
||||
new (await import("../dashdot/dashdot-integration")).DashDotIntegration(input),
|
||||
tdarr: async (input: IntegrationInput) =>
|
||||
new (await import("../media-transcoding/tdarr-integration")).TdarrIntegration(input),
|
||||
proxmox: async (input: IntegrationInput) =>
|
||||
new (await import("../proxmox/proxmox-integration")).ProxmoxIntegration(input),
|
||||
emby: async (input: IntegrationInput) => new (await import("../emby/emby-integration")).EmbyIntegration(input),
|
||||
nextcloud: async (input: IntegrationInput) =>
|
||||
new (await import("../nextcloud/nextcloud.integration")).NextcloudIntegration(input),
|
||||
unifiController: async (input: IntegrationInput) =>
|
||||
new (await import("../unifi-controller/unifi-controller-integration")).UnifiControllerIntegration(input),
|
||||
opnsense: async (input: IntegrationInput) =>
|
||||
new (await import("../opnsense/opnsense-integration")).OPNsenseIntegration(input),
|
||||
ical: async (input: IntegrationInput) => new (await import("../ical/ical-integration")).ICalIntegration(input),
|
||||
ntfy: async (input: IntegrationInput) => new (await import("../ntfy/ntfy-integration")).NTFYIntegration(input),
|
||||
gotify: async (input: IntegrationInput) =>
|
||||
new (await import("../gotify/gotify-integration")).GotifyIntegration(input),
|
||||
mock: async (input: IntegrationInput) => new (await import("../mock/mock-integration")).MockIntegration(input),
|
||||
truenas: async (input: IntegrationInput) =>
|
||||
new (await import("../truenas/truenas-integration")).TrueNasIntegration(input),
|
||||
synology: async (input: IntegrationInput) =>
|
||||
new (await import("../synology/synology-integration")).SynologyIntegration(input),
|
||||
unraid: async (input: IntegrationInput) =>
|
||||
new (await import("../unraid/unraid-integration")).UnraidIntegration(input),
|
||||
coolify: async (input: IntegrationInput) =>
|
||||
new (await import("../coolify/coolify-integration")).CoolifyIntegration(input),
|
||||
tracearr: async (input: IntegrationInput) =>
|
||||
new (await import("../tracearr/tracearr-integration")).TracearrIntegration(input),
|
||||
glances: async (input: IntegrationInput) =>
|
||||
new (await import("../glances/glances-integration")).GlancesIntegration(input),
|
||||
immich: async (input: IntegrationInput) =>
|
||||
new (await import("../immich/immich-integration")).ImmichIntegration(input),
|
||||
paperlessNgx: async (input: IntegrationInput) =>
|
||||
new (await import("../paperless-ngx/paperless-ngx-integration")).PaperlessNgxIntegration(input),
|
||||
patchmon: async (input: IntegrationInput) =>
|
||||
new (await import("../patchmon/patchmon-integration")).PatchMonIntegration(input),
|
||||
speedtestTracker: async (input: IntegrationInput) =>
|
||||
new (await import("../speedtest-tracker/speedtest-tracker-integration")).SpeedtestTrackerIntegration(input),
|
||||
audiobookshelf: async (input: IntegrationInput) =>
|
||||
new (await import("../audiobookshelf/audiobookshelf-integration")).AudiobookshelfIntegration(input),
|
||||
navidrome: async (input: IntegrationInput) =>
|
||||
new (await import("../navidrome/navidrome-integration")).NavidromeIntegration(input),
|
||||
umami: async (input: IntegrationInput) => new (await import("../umami/umami-integration")).UmamiIntegration(input),
|
||||
gluetun: async (input: IntegrationInput) =>
|
||||
new (await import("../gluetun/gluetun-integration")).GluetunIntegration(input),
|
||||
archiveTeamWarrior: async (input: IntegrationInput) =>
|
||||
new (await import("../archive-team-warrior/archive-team-warrior-integration")).ArchiveTeamWarriorIntegration(input),
|
||||
uptimeKuma: async (input: IntegrationInput) =>
|
||||
new (await import("../uptime-kuma/uptime-kuma-integration")).UptimeKumaIntegration(input),
|
||||
peaNut: async (input: IntegrationInput) =>
|
||||
new (await import("../peanut/peanut-integration")).PeaNutIntegration(input),
|
||||
beszel: async (input: IntegrationInput) =>
|
||||
new (await import("../beszel/beszel-integration")).BeszelIntegration(input),
|
||||
bazarr: async (input: IntegrationInput) =>
|
||||
new (await import("../bazarr/bazarr-integration")).BazarrIntegration(input),
|
||||
traefik: async (input: IntegrationInput) =>
|
||||
new (await import("../traefik/traefik-integration")).TraefikIntegration(input),
|
||||
wud: async (input: IntegrationInput) => new (await import("../wud/wud-integration")).WudIntegration(input),
|
||||
} satisfies Record<IntegrationKind, (input: IntegrationInput) => Promise<Integration>>;
|
||||
|
||||
type AssertIntegrationCreatorLoaders<TLoaders extends Record<IntegrationKind, () => Promise<IntegrationCreator>>> =
|
||||
TLoaders;
|
||||
type AssertNoUnexpectedIntegrationKinds<TKind extends never> = TKind;
|
||||
type IntegrationCreatorLoadersCheck = AssertIntegrationCreatorLoaders<typeof integrationCreatorLoaders>;
|
||||
type UnexpectedIntegrationKindsCheck = AssertNoUnexpectedIntegrationKinds<
|
||||
Exclude<keyof typeof integrationCreatorLoaders, IntegrationKind>
|
||||
type IntegrationCreators = typeof integrationCreators;
|
||||
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreators> = Awaited<
|
||||
ReturnType<IntegrationCreators[TKind]>
|
||||
>;
|
||||
|
||||
type IntegrationCreatorLoaders = IntegrationCreatorLoadersCheck & Record<UnexpectedIntegrationKindsCheck, never>;
|
||||
type IntegrationCreatorOfKind<TKind extends keyof IntegrationCreatorLoaders> = Awaited<
|
||||
ReturnType<IntegrationCreatorLoaders[TKind]>
|
||||
>;
|
||||
type IntegrationInstanceOfKind<TKind extends keyof IntegrationCreatorLoaders> = Awaited<
|
||||
ReturnType<IntegrationCreatorOfKind<TKind>>
|
||||
>;
|
||||
|
||||
const integrationCreatorCache = new Map<IntegrationKind, Promise<IntegrationCreator>>();
|
||||
|
||||
const loadIntegrationCreatorAsync = async <TKind extends keyof IntegrationCreatorLoaders>(
|
||||
kind: TKind,
|
||||
): Promise<IntegrationCreatorOfKind<TKind>> => {
|
||||
const cachedCreator = integrationCreatorCache.get(kind);
|
||||
if (cachedCreator) {
|
||||
return (await cachedCreator) as IntegrationCreatorOfKind<TKind>;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
logger.debug("Loading integration implementation", { integrationKind: kind });
|
||||
const creatorPromise = integrationCreatorLoaders[kind]();
|
||||
integrationCreatorCache.set(kind, creatorPromise);
|
||||
|
||||
try {
|
||||
const creator = (await creatorPromise) as IntegrationCreatorOfKind<TKind>;
|
||||
logger.debug("Integration implementation loaded", {
|
||||
integrationKind: kind,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return creator;
|
||||
} catch (error) {
|
||||
if (integrationCreatorCache.get(kind) === creatorPromise) {
|
||||
integrationCreatorCache.delete(kind);
|
||||
}
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Integration implementation failed to load",
|
||||
{
|
||||
integrationKind: kind,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createIntegrationAsync = async <TKind extends keyof IntegrationCreatorLoaders>(
|
||||
export function createIntegrationAsync<TKind extends keyof IntegrationCreators>(
|
||||
integration: IntegrationInput & { kind: TKind },
|
||||
): Promise<IntegrationInstanceOfKind<TKind>> => {
|
||||
if (!Object.hasOwn(integrationCreatorLoaders, integration.kind)) {
|
||||
): Promise<IntegrationInstanceOfKind<TKind>>;
|
||||
export async function createIntegrationAsync(
|
||||
integration: IntegrationInput & { kind: IntegrationKind },
|
||||
): Promise<Integration> {
|
||||
if (!Object.hasOwn(integrationCreators, integration.kind)) {
|
||||
logger.warn("Integration client creation refused", {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
@@ -193,6 +134,29 @@ export const createIntegrationAsync = async <TKind extends keyof IntegrationCrea
|
||||
);
|
||||
}
|
||||
|
||||
const creator = await loadIntegrationCreatorAsync(integration.kind);
|
||||
return (await creator(integration)) as IntegrationInstanceOfKind<TKind>;
|
||||
};
|
||||
const startedAt = Date.now();
|
||||
logger.debug("Creating integration client", { integrationId: integration.id, integrationKind: integration.kind });
|
||||
|
||||
try {
|
||||
const instance = await integrationCreators[integration.kind](integration);
|
||||
logger.debug("Integration client created", {
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return instance;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Integration client creation failed",
|
||||
{
|
||||
integrationId: integration.id,
|
||||
integrationKind: integration.kind,
|
||||
durationMs: Date.now() - startedAt,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,12 @@ const createSafeErrorCause = (error: unknown, depth = 0): Error => {
|
||||
if (error instanceof Error && safeErrorNameRegex.test(error.name)) {
|
||||
safeError.name = error.name;
|
||||
}
|
||||
if (error instanceof Error && error.stack) {
|
||||
const originalHeader = Error.prototype.toString.call(error);
|
||||
if (error.stack.startsWith(originalHeader)) {
|
||||
safeError.stack = `${safeError.name}: ${safeError.message}${error.stack.slice(originalHeader.length)}`;
|
||||
}
|
||||
}
|
||||
return safeError;
|
||||
};
|
||||
|
||||
|
||||
@@ -3,12 +3,12 @@ import superjson from "superjson";
|
||||
import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||
import { createGetSetChannel } from "@homarr/redis";
|
||||
import { createGetSetChannel, getIntegrationSessionStoreKey } from "@homarr/redis";
|
||||
|
||||
const logger = createLogger({ module: "sessionStore" });
|
||||
|
||||
export const createSessionStore = <TValue>(integration: { id: string }) => {
|
||||
const channelName = `session-store:${integration.id}`;
|
||||
const channelName = getIntegrationSessionStoreKey(integration.id);
|
||||
const channel = createGetSetChannel<`${string}.${string}`>(channelName);
|
||||
|
||||
return {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 948 KiB |
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: "Beszel"
|
||||
description: "Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { IntegrationHeader } from "@site/src/components/integrations/header";
|
||||
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
|
||||
import { AddingIntegration } from "@site/src/components/integrations/adding";
|
||||
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
|
||||
import { beszelIntegration } from ".";
|
||||
import { beszelSystemStatsWidget } from "@site/docs/widgets/beszel-system-stats";
|
||||
import { beszelSystemGridWidget } from "@site/docs/widgets/beszel-system-grid";
|
||||
import { beszelSystemTableWidget } from "@site/docs/widgets/beszel-system-table";
|
||||
import { beszelAlertsWidget } from "@site/docs/widgets/beszel-alerts";
|
||||
|
||||
import beszelDashboard from "./img/beszel-dashboard.png";
|
||||
|
||||
<IntegrationHeader integration={beszelIntegration} categories={["System Monitoring"]} />
|
||||
|
||||
Beszel monitors your servers in real time via lightweight agents, collecting CPU, memory, disk, network, GPU, temperature, and Docker container metrics. It stores historical data and supports configurable alerts.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<img src={beszelDashboard} alt="Beszel dashboard overview showing all four widgets" />
|
||||
|
||||
### Widgets & Capabilities
|
||||
|
||||
<IntegrationCapabilites
|
||||
items={[
|
||||
{ widget: beszelSystemStatsWidget },
|
||||
{ widget: beszelSystemGridWidget },
|
||||
{ widget: beszelSystemTableWidget },
|
||||
{ widget: beszelAlertsWidget },
|
||||
]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
|
||||
<IntegrationSecrets
|
||||
secrets={[
|
||||
{
|
||||
credentials: ["username", "password"],
|
||||
steps: [
|
||||
"Open your Beszel hub instance (default port 8090)",
|
||||
"Use the same credentials you use to log in to the Beszel web UI",
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
:::tip
|
||||
Beszel uses PocketBase under the hood. The default admin credentials are set during the initial Beszel setup.
|
||||
:::
|
||||
@@ -1,7 +0,0 @@
|
||||
export const beszelIntegration = {
|
||||
name: "Beszel",
|
||||
description:
|
||||
"Beszel is a lightweight server monitoring platform with Docker stats, historical data, and alert functions.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
path: "../../integrations/beszel",
|
||||
} as const;
|
||||
@@ -1,19 +0,0 @@
|
||||
import { defineIntegrationModule } from "@homarr/definitions";
|
||||
|
||||
export default defineIntegrationModule({
|
||||
kind: "beszel",
|
||||
name: "Beszel",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
|
||||
secretKinds: [["username", "password"]],
|
||||
categories: ["beszel"],
|
||||
defaultPort: 8090,
|
||||
documentation: {
|
||||
slug: "beszel",
|
||||
sourceDirectory: "docs",
|
||||
},
|
||||
creator: {
|
||||
type: "constructor",
|
||||
module: "./beszel-integration",
|
||||
exportName: "BeszelIntegration",
|
||||
},
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
// This file is generated by pnpm integrations:sync. Do not edit it directly.
|
||||
|
||||
import type { Integration, IntegrationInput } from "../base/integration";
|
||||
|
||||
type GeneratedIntegrationCreator = (input: IntegrationInput) => Promise<Integration>;
|
||||
|
||||
export const generatedIntegrationCreatorLoaders = {
|
||||
beszel: async () => {
|
||||
const IntegrationClass = (await import("../beszel/beszel-integration")).BeszelIntegration;
|
||||
return async (input: IntegrationInput) => new IntegrationClass(input);
|
||||
},
|
||||
} satisfies Record<string, () => Promise<GeneratedIntegrationCreator>>;
|
||||
@@ -1,6 +1,53 @@
|
||||
// Type-only compatibility surface. Runtime capabilities have explicit subpaths.
|
||||
// General integrations
|
||||
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
|
||||
export { WudIntegration } from "./wud/wud-integration";
|
||||
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
|
||||
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
|
||||
export { AnchorIntegration } from "./anchor/anchor-integration";
|
||||
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
|
||||
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
|
||||
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
|
||||
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
|
||||
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
|
||||
export { SlskdIntegration } from "./download-client/slskd/slskd-integration";
|
||||
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
|
||||
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
|
||||
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
|
||||
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
|
||||
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
|
||||
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
|
||||
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
|
||||
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
|
||||
export { GotifyIntegration } from "./gotify/gotify-integration";
|
||||
export { NTFYIntegration } from "./ntfy/ntfy-integration";
|
||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||
export { GlancesIntegration } from "./glances/glances-integration";
|
||||
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||
export { SeerrIntegration } from "./seerr/seerr-integration";
|
||||
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
|
||||
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
||||
export { PlexIntegration } from "./plex/plex-integration";
|
||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||
export { SynologyIntegration } from "./synology/synology-integration";
|
||||
export { UnraidIntegration } from "./unraid/unraid-integration";
|
||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||
export { ICalIntegration } from "./ical/ical-integration";
|
||||
export { CoolifyIntegration } from "./coolify/coolify-integration";
|
||||
export { ImmichIntegration } from "./immich/immich-integration";
|
||||
export { TracearrIntegration } from "./tracearr/tracearr-integration";
|
||||
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
|
||||
export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration";
|
||||
export { NavidromeIntegration } from "./navidrome/navidrome-integration";
|
||||
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
|
||||
export { UmamiIntegration } from "./umami/umami-integration";
|
||||
export { PeaNutIntegration } from "./peanut/peanut-integration";
|
||||
export { BazarrIntegration } from "./bazarr/bazarr-integration";
|
||||
export { TraefikIntegration } from "./traefik/traefik-integration";
|
||||
|
||||
// Types
|
||||
export type { IntegrationInput } from "./base/integration";
|
||||
export type { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
||||
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
|
||||
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
|
||||
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
|
||||
@@ -12,11 +59,8 @@ export type {
|
||||
FirewallMemorySummary,
|
||||
} from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/health-monitoring-types";
|
||||
export type {
|
||||
MediaRequestList,
|
||||
MediaRequestStats,
|
||||
UpstreamMediaRequestStatus,
|
||||
} from "./interfaces/media-requests/media-request-types";
|
||||
export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types";
|
||||
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
|
||||
export type { StreamSession } from "./interfaces/media-server/media-server-types";
|
||||
export type { EntityState } from "./interfaces/smart-home/smart-home-types";
|
||||
export type {
|
||||
@@ -51,4 +95,11 @@ export type {
|
||||
} from "./archive-team-warrior/archive-team-warrior-types";
|
||||
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
|
||||
|
||||
// Schemas
|
||||
export { anchorNotesListInputSchema, anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
|
||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||
|
||||
// Helpers
|
||||
export { createIntegrationAsync } from "./base/creator";
|
||||
export { simulateResponseContractAsync } from "./base/response-contract";
|
||||
export type { ResponseContractFixture, ResponseContractParser } from "./base/response-contract";
|
||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { Aria2Integration } from "../src/download-client/aria2/aria2-integration";
|
||||
import { Aria2Integration } from "../src";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { HomeAssistantIntegration } from "../src/homeassistant/homeassistant-integration";
|
||||
import { HomeAssistantIntegration } from "../src";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { NextcloudIntegration } from "../src/nextcloud/nextcloud.integration";
|
||||
import { NextcloudIntegration } from "../src";
|
||||
|
||||
// Mock the database
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { NzbGetIntegration } from "../src/download-client/nzbget/nzbget-integration";
|
||||
import { NzbGetIntegration } from "../src";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
|
||||
vi.mock("@homarr/db", async (importActual) => {
|
||||
|
||||
@@ -4,8 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { PiHoleIntegrationV5 } from "../src/pi-hole/v5/pi-hole-integration-v5";
|
||||
import { PiHoleIntegrationV6 } from "../src/pi-hole/v6/pi-hole-integration-v6";
|
||||
import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src";
|
||||
import type { SessionStore } from "../src/base/session-store";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { SabnzbdIntegration } from "../src/download-client/sabnzbd/sabnzbd-integration";
|
||||
import { SabnzbdIntegration } from "../src";
|
||||
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
|
||||
import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items";
|
||||
|
||||
|
||||
@@ -1940,12 +1940,9 @@ const getPreviewWidgetKinds = (props: StudioSectionProps) => [
|
||||
]),
|
||||
];
|
||||
|
||||
const getPreviewAppCount = (props: StudioSectionProps) => {
|
||||
return (
|
||||
props.drafts.filter(isIntegrationDraftComplete).length +
|
||||
props.discoveredApps.filter((app) => props.selectedAppIds.includes(app.sourceId)).length
|
||||
);
|
||||
};
|
||||
const getPreviewAppCount = (props: StudioSectionProps) =>
|
||||
props.drafts.filter(isIntegrationDraftComplete).length +
|
||||
props.discoveredApps.filter((app) => props.selectedAppIds.includes(app.sourceId)).length;
|
||||
|
||||
const createDraft = ({
|
||||
id,
|
||||
|
||||
@@ -16,10 +16,12 @@ export {
|
||||
handshakeAsync,
|
||||
createSubPubChannel,
|
||||
createGetSetChannel,
|
||||
getIntegrationSessionStoreKey,
|
||||
createLockChannel,
|
||||
getIntegrationCacheGenerationAsync,
|
||||
getWidgetCacheGenerationAsync,
|
||||
invalidateIntegrationCacheAsync,
|
||||
invalidateIntegrationResponseCacheAsync,
|
||||
invalidateWidgetCache,
|
||||
} from "./lib/channel";
|
||||
export type { IntegrationCacheGeneration, WidgetCacheGeneration } from "./lib/channel";
|
||||
|
||||
@@ -168,7 +168,13 @@ export const createLockChannel = (name: string) => {
|
||||
};
|
||||
|
||||
const integrationCacheGenerationKey = (integrationId: string) => `integration-cache:generation:${integrationId}`;
|
||||
const pendingIntegrationCacheInvalidations = new Map<string, string>();
|
||||
export const getIntegrationSessionStoreKey = (integrationId: string) => `session-store:${integrationId}`;
|
||||
interface PendingIntegrationCacheInvalidation {
|
||||
generation: string;
|
||||
clearSession: boolean;
|
||||
}
|
||||
|
||||
const pendingIntegrationCacheInvalidations = new Map<string, PendingIntegrationCacheInvalidation>();
|
||||
const MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS = 1000;
|
||||
const CACHE_GENERATION_REDIS_TIMEOUT_MS = 500;
|
||||
const CACHE_GENERATION_SUCCESS_TTL_MS = 1000;
|
||||
@@ -284,7 +290,11 @@ const advanceIntegrationCacheGenerationAsync = async (integrationId: string) =>
|
||||
if (!client) return null;
|
||||
|
||||
const results = await withCacheGenerationRedisTimeoutAsync(
|
||||
client.multi().incr(integrationCacheGenerationKey(integrationId)).del(`session-store:${integrationId}`).exec(),
|
||||
client
|
||||
.multi()
|
||||
.incr(integrationCacheGenerationKey(integrationId))
|
||||
.del(getIntegrationSessionStoreKey(integrationId))
|
||||
.exec(),
|
||||
);
|
||||
const generationResult = results?.[0];
|
||||
const sessionResult = results?.[1];
|
||||
@@ -293,25 +303,54 @@ const advanceIntegrationCacheGenerationAsync = async (integrationId: string) =>
|
||||
return String(generationResult[1]);
|
||||
};
|
||||
|
||||
const advanceIntegrationResponseCacheGenerationAsync = async (integrationId: string) => {
|
||||
const client = getSetClient as typeof getSetClient | null;
|
||||
if (!client) return null;
|
||||
|
||||
return String(await withCacheGenerationRedisTimeoutAsync(client.incr(integrationCacheGenerationKey(integrationId))));
|
||||
};
|
||||
|
||||
const getIntegrationCacheInvalidationOperation = (clearSession: boolean) => {
|
||||
if (clearSession) {
|
||||
return {
|
||||
advanceAsync: advanceIntegrationCacheGenerationAsync,
|
||||
operation: "generation-advance-and-session-delete",
|
||||
failureMessage: "Failed to invalidate integration and session caches in Redis",
|
||||
};
|
||||
}
|
||||
return {
|
||||
advanceAsync: advanceIntegrationResponseCacheGenerationAsync,
|
||||
operation: "generation-advance",
|
||||
failureMessage: "Failed to invalidate integration response caches in Redis",
|
||||
};
|
||||
};
|
||||
|
||||
export const getIntegrationCacheGenerationAsync = async (
|
||||
integrationId: string,
|
||||
): Promise<IntegrationCacheGeneration> => {
|
||||
const cacheKey = integrationCacheGenerationKey(integrationId);
|
||||
const pendingGeneration = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
if (pendingGeneration) {
|
||||
const pendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
if (pendingInvalidation) {
|
||||
const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession);
|
||||
const generation = await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => await advanceIntegrationCacheGenerationAsync(integrationId),
|
||||
fallback: { value: pendingGeneration, isShared: false },
|
||||
readAsync: async () => await invalidationOperation.advanceAsync(integrationId),
|
||||
fallback: { value: pendingInvalidation.generation, isShared: false },
|
||||
errorMessage: "Failed to retry integration cache invalidation",
|
||||
errorMetadata: { integrationId, operation: "generation-advance" },
|
||||
errorMetadata: {
|
||||
integrationId,
|
||||
operation: invalidationOperation.operation,
|
||||
},
|
||||
});
|
||||
if (generation.isShared && pendingIntegrationCacheInvalidations.get(integrationId) === pendingGeneration) {
|
||||
if (
|
||||
generation.isShared &&
|
||||
pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation
|
||||
) {
|
||||
pendingIntegrationCacheInvalidations.delete(integrationId);
|
||||
return generation;
|
||||
}
|
||||
const latestPendingGeneration = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
if (latestPendingGeneration) return { value: latestPendingGeneration, isShared: false };
|
||||
const latestPendingInvalidation = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
if (latestPendingInvalidation) return { value: latestPendingInvalidation.generation, isShared: false };
|
||||
return generation;
|
||||
}
|
||||
|
||||
@@ -328,29 +367,52 @@ export const getIntegrationCacheGenerationAsync = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
const pendingGeneration = `local-${createId()}`;
|
||||
const invalidateIntegrationCacheGenerationAsync = async (
|
||||
integrationId: string,
|
||||
clearSession: boolean,
|
||||
): Promise<void> => {
|
||||
const previousInvalidation = pendingIntegrationCacheInvalidations.get(integrationId);
|
||||
const pendingInvalidation: PendingIntegrationCacheInvalidation = {
|
||||
generation: `local-${createId()}`,
|
||||
clearSession: clearSession || previousInvalidation?.clearSession === true,
|
||||
};
|
||||
if (pendingIntegrationCacheInvalidations.size >= MAX_PENDING_INTEGRATION_CACHE_INVALIDATIONS) {
|
||||
const oldestIntegrationId = pendingIntegrationCacheInvalidations.keys().next().value;
|
||||
if (oldestIntegrationId) pendingIntegrationCacheInvalidations.delete(oldestIntegrationId);
|
||||
}
|
||||
pendingIntegrationCacheInvalidations.set(integrationId, pendingGeneration);
|
||||
pendingIntegrationCacheInvalidations.set(integrationId, pendingInvalidation);
|
||||
const cacheKey = integrationCacheGenerationKey(integrationId);
|
||||
beginCacheGenerationInvalidation(cacheKey, pendingGeneration);
|
||||
beginCacheGenerationInvalidation(cacheKey, pendingInvalidation.generation);
|
||||
const invalidationOperation = getIntegrationCacheInvalidationOperation(pendingInvalidation.clearSession);
|
||||
|
||||
const generation = await getCachedGenerationAsync({
|
||||
cacheKey,
|
||||
readAsync: async () => await advanceIntegrationCacheGenerationAsync(integrationId),
|
||||
fallback: { value: pendingGeneration, isShared: false },
|
||||
errorMessage: "Failed to invalidate integration caches in Redis",
|
||||
errorMetadata: { integrationId, operation: "generation-advance-and-session-delete" },
|
||||
readAsync: async () => await invalidationOperation.advanceAsync(integrationId),
|
||||
fallback: { value: pendingInvalidation.generation, isShared: false },
|
||||
errorMessage: invalidationOperation.failureMessage,
|
||||
errorMetadata: {
|
||||
integrationId,
|
||||
operation: invalidationOperation.operation,
|
||||
},
|
||||
force: true,
|
||||
});
|
||||
if (generation.isShared && pendingIntegrationCacheInvalidations.get(integrationId) === pendingGeneration) {
|
||||
if (
|
||||
generation.isShared &&
|
||||
pendingIntegrationCacheInvalidations.get(integrationId)?.generation === pendingInvalidation.generation
|
||||
) {
|
||||
pendingIntegrationCacheInvalidations.delete(integrationId);
|
||||
}
|
||||
};
|
||||
|
||||
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
await invalidateIntegrationCacheGenerationAsync(integrationId, true);
|
||||
};
|
||||
|
||||
/** Advances response-cache generation without evicting cached integration credentials. */
|
||||
export const invalidateIntegrationResponseCacheAsync = async (integrationId: string): Promise<void> => {
|
||||
await invalidateIntegrationCacheGenerationAsync(integrationId, false);
|
||||
};
|
||||
|
||||
const widgetCacheGenerationKey = (namespace: string) => `widget-cache:generation:${namespace}`;
|
||||
const pendingWidgetCacheInvalidations = new Map<string, string>();
|
||||
const MAX_PENDING_WIDGET_CACHE_INVALIDATIONS = 1000;
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { getWidgetServerCachePolicy } from "@homarr/definitions";
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
const downloadsCachePolicy = getWidgetServerCachePolicy("downloads", "getJobsAndStatuses");
|
||||
|
||||
export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
||||
DownloadClientJobsAndStatus,
|
||||
IntegrationKindByCategory<"downloadClient">,
|
||||
{ limit: number }
|
||||
>({
|
||||
cachePolicy: downloadsCachePolicy,
|
||||
cacheNamespace: "downloads:jobs-and-status",
|
||||
cacheTtlMs: 10 * 1000,
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
import type { IntegrationKind, WidgetModuleServerCachePolicy } from "@homarr/definitions";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./integration-shared-cache";
|
||||
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
@@ -18,7 +19,6 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "integration" };
|
||||
}
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
@@ -28,7 +28,7 @@ export const createIntegrationRequestHandler = <
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync:
|
||||
| ((input: { integration: IntegrationOfKind<TKind>; options: TInput }) => Promise<SharedCacheAdapter<TData>>)
|
||||
| undefined;
|
||||
@@ -45,14 +45,24 @@ export const createIntegrationRequestHandler = <
|
||||
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
|
||||
getCacheKey: ({ integration, options: itemOptions }) =>
|
||||
`${integration.id}:${hashIntegrationCacheOptions(itemOptions)}`,
|
||||
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
return {
|
||||
invalidateCache: inner.invalidateCache,
|
||||
/** Clears this handler's L1 and every shared response-cache namespace for these integrations. */
|
||||
invalidateCacheAsync: async (integrationIds: readonly string[]) => {
|
||||
inner.invalidateCache();
|
||||
if (!cacheNamespace) return;
|
||||
await Promise.all(
|
||||
[...new Set(integrationIds)].map(async (integrationId) => {
|
||||
await invalidateIntegrationResponseCacheAsync(integrationId);
|
||||
}),
|
||||
);
|
||||
},
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
inner.handler({ integration, options: itemOptions }),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./integration-request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import { createWidgetRequestHandler } from "./widget-request-handler";
|
||||
|
||||
@@ -52,6 +53,62 @@ describe("createRequestHandler", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a fresh shared cache entry before calling the upstream service", async () => {
|
||||
const timestamp = new Date();
|
||||
const requestAsync = vi.fn(async () => "upstream");
|
||||
const sharedCache = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: vi.fn(async () => ({
|
||||
data: "shared",
|
||||
timestamp,
|
||||
expiresAt: Date.now() + 1_000,
|
||||
staleUntil: Date.now() + 1_000,
|
||||
})),
|
||||
setAsync: vi.fn(async () => undefined),
|
||||
acquireRefreshLockAsync: vi.fn(async () => "lock"),
|
||||
releaseRefreshLockAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates while the shared cache is being resolved", async () => {
|
||||
const sharedCacheRequest = deferred<SharedCacheAdapter<string>>();
|
||||
const upstreamRequest = deferred<string>();
|
||||
const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise);
|
||||
const requestAsync = vi.fn(async () => await upstreamRequest.promise);
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
const first = handler.handler({ id: "same" }).getDataAsync();
|
||||
const second = handler.handler({ id: "same" }).getDataAsync();
|
||||
expect(getSharedCacheAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
sharedCacheRequest.resolve({
|
||||
generation: "shared-1",
|
||||
isShared: false,
|
||||
getAsync: async () => undefined,
|
||||
setAsync: async () => undefined,
|
||||
acquireRefreshLockAsync: async () => undefined,
|
||||
releaseRefreshLockAsync: async () => undefined,
|
||||
});
|
||||
await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1));
|
||||
upstreamRequest.resolve("value");
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
});
|
||||
|
||||
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<{ call: number }>();
|
||||
|
||||
@@ -95,26 +95,27 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
const sharedCache = ttl > 0 ? await options.getSharedCacheAsync?.(input) : undefined;
|
||||
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
|
||||
|
||||
let cached = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
}
|
||||
if (cached && now >= cached.staleUntil) {
|
||||
cache.delete(key);
|
||||
scheduleExpiry();
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
const existing = inflight.get(key);
|
||||
const existing = inflight.get(baseKey);
|
||||
if (existing) return existing;
|
||||
|
||||
const requestGeneration = generation;
|
||||
let promise: Promise<CacheEntry<TData>>;
|
||||
promise = (async () => {
|
||||
let sharedCache: SharedCacheAdapter<TData> | undefined;
|
||||
if (ttl > 0 && options.getSharedCacheAsync) {
|
||||
sharedCache = await options.getSharedCacheAsync(input);
|
||||
}
|
||||
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
|
||||
|
||||
let cached = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) return cached;
|
||||
if (cached && now >= cached.staleUntil) {
|
||||
cache.delete(key);
|
||||
scheduleExpiry();
|
||||
cached = undefined;
|
||||
}
|
||||
|
||||
let stale = cached;
|
||||
let lockToken: string | null | undefined;
|
||||
|
||||
@@ -179,10 +180,10 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
|
||||
}
|
||||
})().finally(() => {
|
||||
if (inflight.get(key) === promise) inflight.delete(key);
|
||||
if (inflight.get(baseKey) === promise) inflight.delete(baseKey);
|
||||
});
|
||||
|
||||
inflight.set(key, promise);
|
||||
inflight.set(baseKey, promise);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
|
||||
+4
-4
@@ -75,7 +75,7 @@ interface RedisSharedCacheOptions {
|
||||
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Redis widget cache operation failed",
|
||||
"Redis response cache operation failed",
|
||||
{
|
||||
...metadata,
|
||||
operation,
|
||||
@@ -121,7 +121,7 @@ const createRedisSharedCache = <TData>({
|
||||
if (value === undefined || value === null) return value;
|
||||
if (isCacheEntry(value)) return value as CacheEntry<TData>;
|
||||
|
||||
logger.warn("Ignored invalid Redis widget cache entry", {
|
||||
logger.warn("Ignored invalid Redis response cache entry", {
|
||||
...logMetadata,
|
||||
operation: "payload-validate",
|
||||
});
|
||||
@@ -164,7 +164,7 @@ export const createWidgetSharedCacheAsync = async <TData>({
|
||||
const metadata = { cacheNamespace: namespace };
|
||||
let generation: WidgetCacheGeneration;
|
||||
try {
|
||||
generation = await withTimeoutAsync(getWidgetCacheGenerationAsync(namespace), REDIS_OPERATION_TIMEOUT_MS);
|
||||
generation = await getWidgetCacheGenerationAsync(namespace);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
@@ -190,7 +190,7 @@ export const createIntegrationSharedCacheAsync = async <TData>({
|
||||
const metadata = { cacheNamespace: namespace, integrationId };
|
||||
let generation: IntegrationCacheGeneration;
|
||||
try {
|
||||
generation = await withTimeoutAsync(getIntegrationCacheGenerationAsync(integrationId), REDIS_OPERATION_TIMEOUT_MS);
|
||||
generation = await getIntegrationCacheGenerationAsync(integrationId);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
@@ -1,7 +1,6 @@
|
||||
import { invalidateWidgetCache } from "@homarr/redis";
|
||||
import type { WidgetModuleServerCachePolicy } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./integration-shared-cache";
|
||||
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
@@ -11,13 +10,12 @@ interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" };
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync: ((input: TInput) => Promise<SharedCacheAdapter<TData>>) | undefined;
|
||||
if (cacheNamespace) {
|
||||
getSharedCacheAsync = async (input) =>
|
||||
@@ -29,9 +27,9 @@ export const createWidgetRequestHandler = <TData, TInput extends Record<string,
|
||||
|
||||
const inner = createRequestHandler<TData, TInput>({
|
||||
...options,
|
||||
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
getCacheKey: hashSharedCacheInput,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -18,7 +18,7 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
|
||||
>({
|
||||
cacheNamespace: "media-organizer:summary",
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
const [missingResult, queueResult] = await Promise.all([
|
||||
integrationInstance.getMissingAsync(input.pageSize),
|
||||
integrationInstance.getMediaQueueAsync(input.pageSize),
|
||||
|
||||
@@ -6,15 +6,12 @@ import { z } from "zod";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
import { getWidgetServerCachePolicy } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
const weatherCachePolicy = getWidgetServerCachePolicy("weather", "atLocation");
|
||||
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
@@ -143,7 +140,8 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
cachePolicy: weatherCachePolicy,
|
||||
cacheNamespace: "weather:at-location",
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export const newIntegrationChildrenOptions = createChildrenOptions<Record<string
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return objectEntries(integrationDefs)
|
||||
.filter(([, integrationDef]) => integrationDef.name.toLowerCase().includes(normalizedQuery))
|
||||
.toSorted(
|
||||
.sort(
|
||||
([, definitionA], [, definitionB]) =>
|
||||
definitionA.name.toLowerCase().indexOf(normalizedQuery) -
|
||||
definitionB.name.toLowerCase().indexOf(normalizedQuery),
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// This file is generated by pnpm widgets:sync. Do not edit it directly.
|
||||
|
||||
import { IconCloud, IconDownload } from "@tabler/icons-react";
|
||||
import type { TablerIcon } from "@tabler/icons-react";
|
||||
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
|
||||
export const generatedWidgetCatalogIcons = {
|
||||
downloads: IconDownload,
|
||||
weather: IconCloud,
|
||||
} satisfies Partial<Record<WidgetKind, TablerIcon>>;
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
getIntegrationName,
|
||||
getIntegrationOnboardingMetadata,
|
||||
getWidgetKindsForIntegration,
|
||||
integrationDefs,
|
||||
integrationKinds,
|
||||
@@ -54,7 +55,7 @@ export const buildSortedIntegrations = (
|
||||
integrationKinds
|
||||
.filter((kind) => {
|
||||
if (options.allowedKinds && !options.allowedKinds.includes(kind)) return false;
|
||||
if (options.onboarding && integrationDefs[kind].features.onboarding.hidden) return false;
|
||||
if (options.onboarding && getIntegrationOnboardingMetadata(kind).hidden) return false;
|
||||
if (!options.enableMockIntegration && kind === "mock") return false;
|
||||
return true;
|
||||
})
|
||||
@@ -65,8 +66,8 @@ export const buildSortedIntegrations = (
|
||||
widgets: getWidgetKindsForIntegration(kind),
|
||||
}))
|
||||
.toSorted((left, right) => {
|
||||
const leftFeaturedOrder = integrationDefs[left.kind].features.onboarding.featuredOrder;
|
||||
const rightFeaturedOrder = integrationDefs[right.kind].features.onboarding.featuredOrder;
|
||||
const leftFeaturedOrder = getIntegrationOnboardingMetadata(left.kind).featuredOrder;
|
||||
const rightFeaturedOrder = getIntegrationOnboardingMetadata(right.kind).featuredOrder;
|
||||
if (leftFeaturedOrder !== null && rightFeaturedOrder !== null) return leftFeaturedOrder - rightFeaturedOrder;
|
||||
if (leftFeaturedOrder !== null) return -1;
|
||||
if (rightFeaturedOrder !== null) return 1;
|
||||
|
||||
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user