;
+}
+
+type TourController = OnboardingTourController;
+
+const stepIndexFromController = (controller: TourController) => controller.currentStepIndex ?? 0;
+
+const stepAtOffset = (controller: TourController, offset: number) => {
+ const index = stepIndexFromController(controller) + offset;
+ return controller.tour[index];
+};
+
+const pollForElement = (targetId: string, callback: () => void) => {
+ const interval = setInterval(() => {
+ const targetElement = document.querySelector(`[data-tour-target="${targetId}"]`);
+ if (targetElement) {
+ clearInterval(interval);
+ callback();
+ }
+ }, 50);
+ const timeout = setTimeout(() => clearInterval(interval), 5000);
+ return () => {
+ clearInterval(interval);
+ clearTimeout(timeout);
+ };
+};
+
+const isTypingTarget = (target: EventTarget | null) => {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
+};
+
+interface TourForwardButtonProps {
+ label: string;
+ onClick: () => void;
+}
+
+const TourForwardButton = ({ label, onClick }: TourForwardButtonProps) => {
+ return (
+
+ );
+};
+
+interface TourDoneButtonProps {
+ label: string;
+ onClick: () => void;
+}
+
+const TourDoneButton = ({ label, onClick }: TourDoneButtonProps) => {
+ return (
+
+ );
+};
+
+export const TourShell = ({ steps, started, onEnd, stepRoutes, position, children }: TourShellProps) => {
+ const t = useI18n();
+ const router = useRouter();
+ const forwardActionRef = useRef<(() => void) | null>(null);
+
+ const navigateAndAdvance = useCallback(
+ (currentStepId: string | undefined, targetStepId: string | undefined, advance: () => void) => {
+ if (!stepRoutes || !targetStepId) {
+ advance();
+ return;
+ }
+
+ const currentRoute = currentStepId ? stepRoutes[currentStepId] : undefined;
+ const targetRoute = stepRoutes[targetStepId];
+
+ if (targetRoute && targetRoute !== currentRoute) {
+ router.push(targetRoute);
+ pollForElement(targetStepId, advance);
+ } else {
+ advance();
+ }
+ },
+ [router, stepRoutes],
+ );
+
+ const bindForwardAction = useCallback((controller: TourController, action: () => void) => {
+ forwardActionRef.current = action;
+ return action;
+ }, []);
+
+ useEffect(() => {
+ if (!started) {
+ forwardActionRef.current = null;
+ return;
+ }
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey || event.altKey) return;
+ if (isTypingTarget(event.target)) return;
+ event.preventDefault();
+ forwardActionRef.current?.();
+ };
+
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [started]);
+
+ return (
+ (
+ <>
+
+
+ {stepIndexFromController(controller) + 1}
+
+
+ /
+
+
+ {controller.tour.length}
+
+
+ {stepIndexFromController(controller) === 0 && (
+
+
+
+ )}
+ >
+ )}
+ nextStepNavigation={(controller) => {
+ const nextStep = stepAtOffset(controller, 1);
+ const action = bindForwardAction(controller, () => {
+ navigateAndAdvance(controller.currentStep?.id, nextStep?.id, () => controller.nextStep());
+ });
+ return ;
+ }}
+ endStepNavigation={(controller) => {
+ const action = bindForwardAction(controller, () => controller.endTour());
+ return ;
+ }}
+ prevStepNavigation={(controller) => (
+
+ )}
+ skipNavigation={(controller) => (
+
+ )}
+ withPrevButton
+ focusRevealProps={{
+ disableTargetInteraction: true,
+ popoverProps: {
+ position: position ?? { base: "bottom", sm: "right" },
+ width: 420,
+ shadow: "xl",
+ radius: "lg",
+ middlewares: { shift: { padding: 16 }, flip: true },
+ },
+ }}
+ cutoutPadding={12}
+ cutoutRadius={12}
+ >
+ {children}
+
+ );
+};
diff --git a/apps/nextjs/src/components/onboarding/tour-step-content.tsx b/apps/nextjs/src/components/onboarding/tour-step-content.tsx
new file mode 100644
index 000000000..05b7b4bd3
--- /dev/null
+++ b/apps/nextjs/src/components/onboarding/tour-step-content.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { Anchor, Divider, Group, Stack, Text, ThemeIcon } from "@mantine/core";
+import { IconExternalLink } from "@tabler/icons-react";
+
+import { useI18n } from "@homarr/translation/client";
+
+interface TourStepContentProps {
+ description: string;
+ documentationHref: string;
+ icon?: ReactNode;
+}
+
+export const TourStepContent = ({ description, documentationHref, icon }: TourStepContentProps) => {
+ const t = useI18n();
+
+ return (
+
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {description}
+
+
+
+
+
+
+ {t("onboardingTour.learnMore")}
+
+
+
+ );
+};
diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx
index 8faa8f6fa..71ec2b218 100644
--- a/apps/nextjs/src/components/user-avatar-menu.tsx
+++ b/apps/nextjs/src/components/user-avatar-menu.tsx
@@ -45,7 +45,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
window.location.assign(logoutUrl);
return;
}
- router.refresh();
+ router.push("/auth/login");
},
});
}, [logoutUrl, openModal, router]);
diff --git a/apps/nextjs/src/env.ts b/apps/nextjs/src/env.ts
index 62b49dee8..96f03e7b7 100644
--- a/apps/nextjs/src/env.ts
+++ b/apps/nextjs/src/env.ts
@@ -3,6 +3,7 @@ import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env"
export const env = createEnv({
server: {
UNSAFE_ENABLE_MOCK_INTEGRATION: createBooleanSchema(false),
+ DEMO_MODE: createBooleanSchema(false),
},
experimental__runtimeEnv: process.env,
});
diff --git a/apps/nextjs/src/react-app-env.d.ts b/apps/nextjs/src/react-app-env.d.ts
index 0357e4f19..ca9b2ac8c 100644
--- a/apps/nextjs/src/react-app-env.d.ts
+++ b/apps/nextjs/src/react-app-env.d.ts
@@ -2,3 +2,4 @@ declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";
+declare module "sql.js";
diff --git a/docs/README.md b/docs/README.md
index e7aeef585..42330bec6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,6 +19,9 @@
+
+ Live Demo 🎮
+ •
Install 💻
•
@@ -246,13 +249,20 @@
+
+
+
+ PeaNUT
+
+ |
+
Pi-hole
- |
-|
+ |
+
@@ -293,15 +303,15 @@
Radarr
- |
-
+ |
+
Readarr
- |
-|
+ |
+
@@ -342,15 +352,15 @@
Speedtest Tracker
- |
-
+ |
+
Tdarr
- |
-|
+ |
+
diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts
index 7e709f425..a8a09773e 100644
--- a/e2e/onboarding.spec.ts
+++ b/e2e/onboarding.spec.ts
@@ -31,6 +31,7 @@ describe("Onboarding", () => {
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
+ await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
@@ -72,6 +73,7 @@ describe("Onboarding", () => {
name: externalGroupName,
});
await actions.processSettingsStepAsync();
+ await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
diff --git a/e2e/shared/actions/onboarding-actions.ts b/e2e/shared/actions/onboarding-actions.ts
index 6bdb1d2a5..56c09aba7 100644
--- a/e2e/shared/actions/onboarding-actions.ts
+++ b/e2e/shared/actions/onboarding-actions.ts
@@ -51,4 +51,9 @@ export class OnboardingActions {
await this.page.waitForSelector("text=Analytics");
await this.page.locator("css=button[type='submit']").click();
}
+
+ public async processIntegrationsStepAsync() {
+ await this.page.waitForSelector("text=integrations you'd like to connect");
+ await this.page.locator("button", { hasText: "Skip for now" }).click();
+ }
}
diff --git a/e2e/shared/assertions/onboarding-assertions.ts b/e2e/shared/assertions/onboarding-assertions.ts
index 63d18ea7c..12cda67d0 100644
--- a/e2e/shared/assertions/onboarding-assertions.ts
+++ b/e2e/shared/assertions/onboarding-assertions.ts
@@ -3,7 +3,7 @@ import { eq } from "drizzle-orm";
import { expect } from "vitest";
import * as sqliteSchema from "../../../packages/db/schema/sqlite";
-import { OnboardingStep } from "../../../packages/definitions/src";
+import type { OnboardingStep } from "../../../packages/definitions/src";
import { credentialsAdminGroup } from "../../../packages/definitions/src/group";
import type { SqliteDatabase } from "../e2e-db";
@@ -57,6 +57,6 @@ export class OnboardingAssertions {
}
public async assertFinishStepVisibleAsync() {
- await this.page.waitForSelector("text=completed the setup", { timeout: 5000 });
+ await this.page.waitForSelector("text=completed the setup", { timeout: 15000 });
}
}
diff --git a/nginx.conf b/nginx.conf
index 4b82c8ca5..c1c082777 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -23,7 +23,7 @@ http {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
- client_max_body_size 32M;
+ client_max_body_size 256M;
}
}
}
diff --git a/package.json b/package.json
index 7c27c59b2..cc5af67e8 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "homarr",
- "version": "1.61.1",
+ "version": "1.64.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -78,7 +78,7 @@
"overrides": {
"@babel/helpers@<7.26.10": ">=7.29.7",
"@babel/runtime@<7.26.10": ">=7.29.7",
- "axios@>=1.0.0 <1.8.2": ">=1.16.1",
+ "axios@>=1.0.0 <1.8.2": ">=1.17.0",
"brace-expansion@>=2.0.0 <=2.0.1": ">=5.0.6",
"brace-expansion@>=1.0.0 <=1.1.11": ">=5.0.6",
"esbuild@<=0.24.2": ">=0.28.0",
@@ -88,13 +88,13 @@
"nanoid@>=4.0.0 <5.0.9": ">=5.1.11",
"prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.24.6",
- "react-is": "^19.2.6",
- "rollup@>=4.0.0 <4.22.4": ">=4.60.4",
+ "react-is": "^19.2.7",
+ "rollup@>=4.0.0 <4.22.4": ">=4.61.1",
"sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.2",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.2",
"tmp@<=0.2.3": ">=0.2.7",
- "vite@>=5.0.0 <=5.4.18": ">=8.0.14"
+ "vite@>=5.0.0 <=5.4.18": ">=8.0.16"
},
"patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch",
diff --git a/packages/api/package.json b/packages/api/package.json
index 7f42cc842..c7e74a1ca 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -13,7 +13,8 @@
"./server": "./src/server.ts",
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
- "./open-api": "./src/open-api.ts"
+ "./open-api": "./src/open-api.ts",
+ "./mcp": "./src/mcp.ts"
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
@@ -51,6 +52,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
+ "trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"zod": "catalog:"
},
diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts
index 7cc81cc57..ba26a264a 100644
--- a/packages/api/src/env.ts
+++ b/packages/api/src/env.ts
@@ -1,12 +1,14 @@
import { z } from "zod/v4";
-import { createEnv } from "@homarr/core/infrastructure/env";
+import { createBooleanSchema, createEnv } from "@homarr/core/infrastructure/env";
export const env = createEnv({
server: {
KUBERNETES_SERVICE_ACCOUNT_NAME: z.string().optional(),
+ DEMO_MODE: createBooleanSchema(false),
},
runtimeEnv: {
KUBERNETES_SERVICE_ACCOUNT_NAME: process.env.KUBERNETES_SERVICE_ACCOUNT_NAME,
+ DEMO_MODE: process.env.DEMO_MODE,
},
});
diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts
new file mode 100644
index 000000000..299a88e0b
--- /dev/null
+++ b/packages/api/src/mcp.ts
@@ -0,0 +1,36 @@
+export { createTRPCContext } from "./trpc";
+
+import { createTRPCRouter } from "./trpc";
+import { appRouter as appRouterForApps } from "./router/app";
+import { apiKeysRouter } from "./router/apiKeys";
+import { boardRouter } from "./router/board";
+import { dockerRouter } from "./router/docker/docker-router";
+import { iconsRouter } from "./router/icons";
+import { infoRouter } from "./router/info";
+import { integrationRouter } from "./router/integration/integration-router";
+import { inviteRouter } from "./router/invite";
+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 { smartHomeRouter } from "./router/widgets/smart-home";
+
+export const mcpRouter = createTRPCRouter({
+ app: appRouterForApps,
+ apiKeys: apiKeysRouter,
+ board: boardRouter,
+ docker: dockerRouter,
+ icon: iconsRouter,
+ info: infoRouter,
+ integration: integrationRouter,
+ invite: inviteRouter,
+ calendar: calendarRouter,
+ dnsHole: dnsHoleRouter,
+ downloads: downloadsRouter,
+ healthMonitoring: healthMonitoringRouter,
+ mediaRequests: mediaRequestsRouter,
+ mediaServer: mediaServerRouter,
+ smartHome: smartHomeRouter,
+});
diff --git a/packages/api/src/middlewares/docker.ts b/packages/api/src/middlewares/docker.ts
index 5a317b95a..e4f04f192 100644
--- a/packages/api/src/middlewares/docker.ts
+++ b/packages/api/src/middlewares/docker.ts
@@ -2,11 +2,11 @@ import { TRPCError } from "@trpc/server";
import { env } from "@homarr/docker/env";
-import { publicProcedure } from "../trpc";
+import { isDemoMode, publicProcedure } from "../trpc";
export const dockerMiddleware = () => {
return publicProcedure.use(async ({ next }) => {
- if (env.ENABLE_DOCKER) {
+ if (env.ENABLE_DOCKER || isDemoMode) {
return await next();
}
throw new TRPCError({
diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts
index d789582df..5c75cfbc2 100644
--- a/packages/api/src/open-api.ts
+++ b/packages/api/src/open-api.ts
@@ -3,6 +3,7 @@ import { generateOpenApiDocument } from "trpc-to-openapi";
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
import { appRouter } from "./router/app";
+import { boardRouter } from "./router/board";
import { infoRouter } from "./router/info";
import { inviteRouter } from "./router/invite";
import { userRouter } from "./router/user";
@@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc";
export const openApiRouter = createTRPCRouter({
appRouter,
+ boardRouter,
infoRouter,
inviteRouter,
userRouter,
diff --git a/packages/api/src/router/apiKeys.ts b/packages/api/src/router/apiKeys.ts
index 71efd98ca..26bbb4552 100644
--- a/packages/api/src/router/apiKeys.ts
+++ b/packages/api/src/router/apiKeys.ts
@@ -1,7 +1,8 @@
import { z } from "zod/v4";
+import { randomBytes } from "crypto";
+
import { hashPasswordAsync } from "@homarr/auth";
-import { createId } from "@homarr/common";
import { generateSecureRandomToken } from "@homarr/common/server";
import { db, eq } from "@homarr/db";
import { apiKeys } from "@homarr/db/schema";
@@ -9,39 +10,58 @@ import { apiKeys } from "@homarr/db/schema";
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
export const apiKeysRouter = createTRPCRouter({
- getAll: permissionRequiredProcedure.requiresPermission("admin").query(() => {
- return db.query.apiKeys.findMany({
- columns: {
- id: true,
- apiKey: false,
- },
- with: {
- user: {
- columns: {
- id: true,
- name: true,
- image: true,
- email: true,
+ getAll: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: { enabled: true, description: "List all API keys (admin only)" },
+ })
+ .query(() => {
+ return db.query.apiKeys.findMany({
+ columns: {
+ id: true,
+ apiKey: false,
+ },
+ with: {
+ user: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
},
},
+ });
+ }),
+ create: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description: "Create a new API key for the current user (admin only)",
},
- });
- }),
- create: permissionRequiredProcedure.requiresPermission("admin").mutation(async ({ ctx }) => {
- const randomToken = generateSecureRandomToken(64);
- const hashedRandomToken = await hashPasswordAsync(randomToken);
- const id = createId();
- await db.insert(apiKeys).values({
- id,
- apiKey: hashedRandomToken,
- userId: ctx.session.user.id,
- });
- return {
- apiKey: `${id}.${randomToken}`,
- };
- }),
+ })
+ .mutation(async ({ ctx }) => {
+ const id = randomBytes(4).toString("hex");
+ const token = generateSecureRandomToken(24);
+ const hashedToken = await hashPasswordAsync(token);
+ await db.insert(apiKeys).values({
+ id,
+ apiKey: hashedToken,
+ userId: ctx.session.user.id,
+ });
+ return {
+ apiKey: `${id}.${token}`,
+ };
+ }),
delete: permissionRequiredProcedure
.requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description: "Delete an API key by ID (admin only). REQUIRED: apiKeyId (string)",
+ },
+ })
.input(z.object({ apiKeyId: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.apiKeyId)).limit(1);
diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts
index 2f2445330..1b55d0260 100644
--- a/packages/api/src/router/app.ts
+++ b/packages/api/src/router/app.ts
@@ -21,7 +21,19 @@ export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(paginatedSchema)
.output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
- .meta({ openapi: { method: "GET", path: "/api/apps/paginated", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/apps/paginated",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: {
+ enabled: true,
+ description:
+ "List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page",
+ },
+ })
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
@@ -41,16 +53,40 @@ export const appRouter = createTRPCRouter({
all: protectedProcedure
.input(z.void())
.output(z.array(selectAppSchema))
- .meta({ openapi: { method: "GET", path: "/api/apps", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/apps",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "List all apps" },
+ })
.query(({ ctx }) => {
return ctx.db.query.apps.findMany({
orderBy: asc(apps.name),
});
}),
search: protectedProcedure
- .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
+ .input(
+ z.object({
+ query: z.string(),
+ limit: z.number().min(1).max(100).default(10),
+ }),
+ )
.output(z.array(selectAppSchema))
- .meta({ openapi: { method: "GET", path: "/api/apps/search", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/apps/search",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: {
+ enabled: true,
+ description: "Search apps by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10)",
+ },
+ })
.query(({ ctx, input }) => {
return ctx.db.query.apps.findMany({
where: like(apps.name, `%${input.query}%`),
@@ -62,7 +98,14 @@ export const appRouter = createTRPCRouter({
.input(z.void())
.output(
z.array(
- selectAppSchema.pick({ id: true, name: true, iconUrl: true, href: true, pingUrl: true, description: true }),
+ selectAppSchema.pick({
+ id: true,
+ name: true,
+ iconUrl: true,
+ href: true,
+ pingUrl: true,
+ description: true,
+ }),
),
)
.meta({
@@ -89,7 +132,15 @@ export const appRouter = createTRPCRouter({
byId: publicProcedure
.input(byIdSchema)
.output(selectAppSchema)
- .meta({ openapi: { method: "GET", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/apps/{id}",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "Get a single app by its ID. REQUIRED: id (app ID string)" },
+ })
.query(async ({ ctx, input }) => {
const repository = new AppRepository(ctx.db, ctx.session?.user ?? null);
const app = await repository.getByIdAsync(input.id);
@@ -111,7 +162,19 @@ export const appRouter = createTRPCRouter({
.requiresPermission("app-create")
.input(appManageSchema)
.output(z.object({ appId: z.string() }).and(selectAppSchema))
- .meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "POST",
+ path: "/api/apps",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: {
+ enabled: true,
+ description:
+ "Create a new app (bookmark/shortcut to a service). REQUIRED: name (string), iconUrl (icon URL string), href (app URL, http/https or blank). OPTIONAL: description (string or null), pingUrl (URL to check reachability, or empty string)",
+ },
+ })
.mutation(async ({ ctx, input }) => {
const id = createId();
const insertValues = {
@@ -146,7 +209,19 @@ export const appRouter = createTRPCRouter({
.requiresPermission("app-modify-all")
.input(convertIntersectionToZodObject(appEditSchema))
.output(z.void())
- .meta({ openapi: { method: "PATCH", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/apps/{id}",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: {
+ enabled: true,
+ description:
+ "Update an existing app. REQUIRED: id (app ID), name, iconUrl, href. OPTIONAL: description (string or null), pingUrl (URL or empty string)",
+ },
+ })
.mutation(async ({ ctx, input }) => {
const app = await ctx.db.query.apps.findFirst({
where: eq(apps.id, input.id),
@@ -173,7 +248,15 @@ export const appRouter = createTRPCRouter({
delete: permissionRequiredProcedure
.requiresPermission("app-full-all")
.output(z.void())
- .meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
+ .meta({
+ openapi: {
+ method: "DELETE",
+ path: "/api/apps/{id}",
+ tags: ["apps"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "Delete an app by ID. REQUIRED: id (app ID string)" },
+ })
.input(byIdSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(apps).where(eq(apps.id, input.id));
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 495d410c7..07ec44d07 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -19,6 +19,7 @@ import {
integrationGroupPermissions,
integrationItems,
integrationUserPermissions,
+ integrations,
itemLayouts,
items,
layouts,
@@ -39,6 +40,7 @@ import { importOldmarrAsync } from "@homarr/old-import";
import { importJsonFileSchema } from "@homarr/old-import/shared";
import { oldmarrConfigSchema } from "@homarr/old-schema";
import {
+ addItemToBoardSchema,
boardByNameSchema,
boardChangeVisibilitySchema,
boardCreateSchema,
@@ -48,6 +50,7 @@ import {
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
boardSaveSchema,
+ boardSummarySchema,
} from "@homarr/validation/board";
import { byIdSchema } from "@homarr/validation/common";
import { zodUnionFromArray } from "@homarr/validation/enums";
@@ -114,78 +117,89 @@ export const boardRouter = createTRPCRouter({
where: boardWhere,
});
}),
- getAllBoards: publicProcedure.query(async ({ ctx }) => {
- const userId = ctx.session?.user.id;
- const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
- where: eq(boardUserPermissions.userId, userId ?? ""),
- });
+ getAllBoards: publicProcedure
+ .input(z.void())
+ .output(z.array(boardSummarySchema))
+ .meta({
+ openapi: { method: "GET", path: "/api/boards", tags: ["boards"], protect: true },
+ mcp: {
+ enabled: true,
+ description:
+ "List all boards the current user can access. Returns id, name, logoImageUrl, isPublic, creator, isHome and isMobileHome flags",
+ },
+ })
+ .query(async ({ ctx }) => {
+ const userId = ctx.session?.user.id;
+ const permissionsOfCurrentUserWhenPresent = await ctx.db.query.boardUserPermissions.findMany({
+ where: eq(boardUserPermissions.userId, userId ?? ""),
+ });
- const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, userId ?? ""),
- with: {
- group: {
- with: {
- boardPermissions: {},
+ const permissionsOfCurrentUserGroupsWhenPresent = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, userId ?? ""),
+ with: {
+ group: {
+ with: {
+ boardPermissions: {},
+ },
},
},
- },
- });
- const boardIds = permissionsOfCurrentUserWhenPresent
- .map((permission) => permission.boardId)
- .concat(
- permissionsOfCurrentUserGroupsWhenPresent
- .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
- .flat(),
- );
+ });
+ const boardIds = permissionsOfCurrentUserWhenPresent
+ .map((permission) => permission.boardId)
+ .concat(
+ permissionsOfCurrentUserGroupsWhenPresent
+ .map((groupMember) => groupMember.group.boardPermissions.map((permission) => permission.boardId))
+ .flat(),
+ );
- const currentUserWhenPresent = await ctx.db.query.users.findFirst({
- where: eq(users.id, userId ?? ""),
- });
+ const currentUserWhenPresent = await ctx.db.query.users.findFirst({
+ where: eq(users.id, userId ?? ""),
+ });
- const dbBoards = await ctx.db.query.boards.findMany({
- columns: {
- id: true,
- name: true,
- logoImageUrl: true,
- isPublic: true,
- },
- with: {
- creator: {
- columns: {
- id: true,
- name: true,
- image: true,
- email: true,
+ const dbBoards = await ctx.db.query.boards.findMany({
+ columns: {
+ id: true,
+ name: true,
+ logoImageUrl: true,
+ isPublic: true,
+ },
+ with: {
+ creator: {
+ columns: {
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ },
+ },
+ userPermissions: {
+ where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where:
+ permissionsOfCurrentUserGroupsWhenPresent.length >= 1
+ ? inArray(
+ boardGroupPermissions.groupId,
+ permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
+ )
+ : undefined,
},
},
- userPermissions: {
- where: eq(boardUserPermissions.userId, ctx.session?.user.id ?? ""),
- },
- groupPermissions: {
- where:
- permissionsOfCurrentUserGroupsWhenPresent.length >= 1
- ? inArray(
- boardGroupPermissions.groupId,
- permissionsOfCurrentUserGroupsWhenPresent.map((groupMember) => groupMember.groupId),
- )
- : undefined,
- },
- },
- // Allow viewing all boards if the user has the permission
- where: ctx.session?.user.permissions.includes("board-view-all")
- ? undefined
- : or(
- eq(boards.isPublic, true),
- eq(boards.creatorId, ctx.session?.user.id ?? ""),
- boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
- ),
- });
- return dbBoards.map((board) => ({
- ...board,
- isHome: currentUserWhenPresent?.homeBoardId === board.id,
- isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
- }));
- }),
+ // Allow viewing all boards if the user has the permission
+ where: ctx.session?.user.permissions.includes("board-view-all")
+ ? undefined
+ : or(
+ eq(boards.isPublic, true),
+ eq(boards.creatorId, ctx.session?.user.id ?? ""),
+ boardIds.length > 0 ? inArray(boards.id, boardIds) : undefined,
+ ),
+ });
+ return dbBoards.map((board) => ({
+ ...board,
+ isHome: currentUserWhenPresent?.homeBoardId === board.id,
+ isMobileHome: currentUserWhenPresent?.mobileHomeBoardId === board.id,
+ }));
+ }),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
.query(async ({ ctx, input }) => {
@@ -262,7 +276,16 @@ export const boardRouter = createTRPCRouter({
}),
createBoard: permissionRequiredProcedure
.requiresPermission("board-create")
+ .meta({
+ openapi: { method: "POST", path: "/api/boards", tags: ["boards"], protect: true },
+ mcp: {
+ enabled: true,
+ description:
+ "Create a new board with a name, column count (1-24), and isPublic flag. Returns { boardId }. Requires board-create permission",
+ },
+ })
.input(boardCreateSchema)
+ .output(z.object({ boardId: z.string() }))
.mutation(async ({ ctx, input }) => {
const boardId = createId();
@@ -547,11 +570,22 @@ export const boardRouter = createTRPCRouter({
.set({ isPublic: input.visibility === "public" })
.where(eq(boards.id, input.id));
}),
- deleteBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
- await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
+ deleteBoard: protectedProcedure
+ .meta({
+ openapi: { method: "DELETE", path: "/api/boards/{id}", tags: ["boards"], protect: true },
+ mcp: {
+ enabled: true,
+ description:
+ "Delete a board by its ID. Requires full permission on the board. Use board_getAllBoards to find the board ID",
+ },
+ })
+ .input(z.object({ id: z.string() }))
+ .output(z.void())
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "full");
- await ctx.db.delete(boards).where(eq(boards.id, input.id));
- }),
+ await ctx.db.delete(boards).where(eq(boards.id, input.id));
+ }),
setHomeBoard: protectedProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "view");
@@ -613,7 +647,7 @@ export const boardRouter = createTRPCRouter({
boardId: board.id,
});
- const sortedLayouts = board.layouts.sort((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
+ const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
// Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
const layoutToClone =
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -1215,7 +1249,7 @@ export const boardRouter = createTRPCRouter({
});
return {
- inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
+ inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -1223,7 +1257,7 @@ export const boardRouter = createTRPCRouter({
user,
permission,
}))
- .sort((permissionA, permissionB) => {
+ .toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupBoardPermission
@@ -1234,7 +1268,7 @@ export const boardRouter = createTRPCRouter({
},
permission,
}))
- .sort((permissionA, permissionB) => {
+ .toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -1327,6 +1361,119 @@ export const boardRouter = createTRPCRouter({
const oldmarr = oldmarrConfigSchema.parse(JSON.parse(content));
await importOldmarrAsync(ctx.db, oldmarr, input.configuration);
}),
+ addItem: protectedProcedure
+ .meta({
+ openapi: { method: "POST", path: "/api/boards/items", tags: ["boards"], protect: true },
+ mcp: {
+ enabled: true,
+ description:
+ "Add a widget/app item to a board. Automatically places it in the first empty section at the next free grid position. Provide boardId (from board_getAllBoards), kind (widget type like 'app', 'weather', etc.), optional options map, and optional integrationIds array. Returns { itemId }",
+ },
+ })
+ .input(addItemToBoardSchema)
+ .output(z.object({ itemId: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
+
+ if (input.integrationIds.length > 0) {
+ const existing = await ctx.db.query.integrations.findMany({
+ columns: { id: true },
+ where: inArray(integrations.id, input.integrationIds),
+ });
+ const validIds = new Set(existing.map((row) => row.id));
+ const invalid = input.integrationIds.filter((id) => !validIds.has(id));
+ if (invalid.length > 0) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
+ }
+ }
+
+ const board = await ctx.db.query.boards.findFirst({
+ where: eq(boards.id, input.boardId),
+ with: {
+ sections: true,
+ layouts: true,
+ items: { with: { layouts: true } },
+ },
+ });
+
+ if (!board) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Board not found" });
+ }
+
+ const emptySection = board.sections
+ .filter((s) => s.kind === "empty")
+ .toSorted((a, b) => (a.yOffset ?? 0) - (b.yOffset ?? 0))[0];
+
+ if (!emptySection) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Board has no empty section to place items in" });
+ }
+
+ const itemId = createId();
+
+ await ctx.db.insert(items).values({
+ id: itemId,
+ boardId: input.boardId,
+ kind: input.kind,
+ options: superjson.stringify(input.options),
+ advancedOptions: emptySuperJSON,
+ });
+
+ const layoutRows: (typeof itemLayouts.$inferInsert)[] = [];
+
+ for (const layout of board.layouts) {
+ const existingInSection = board.items
+ .flatMap((item) => item.layouts)
+ .filter((il) => il.sectionId === emptySection.id && il.layoutId === layout.id);
+
+ const occupied: boolean[][] = [];
+ for (const il of existingInSection) {
+ for (let y = il.yOffset; y < il.yOffset + il.height; y++) {
+ while (occupied.length <= y) occupied.push(Array.from({ length: layout.columnCount }).fill(false));
+ for (let x = il.xOffset; x < il.xOffset + il.width; x++) {
+ occupied[y]![x] = true;
+ }
+ }
+ }
+
+ let placed = false;
+ for (let y = 0; y < 9999 && !placed; y++) {
+ if (!occupied[y]) occupied.push(Array.from({ length: layout.columnCount }).fill(false));
+ for (let x = 0; x < layout.columnCount && !placed; x++) {
+ if (!occupied[y]![x]) {
+ layoutRows.push({
+ itemId,
+ sectionId: emptySection.id,
+ layoutId: layout.id,
+ xOffset: x,
+ yOffset: y,
+ width: 1,
+ height: 1,
+ });
+ placed = true;
+ }
+ }
+ }
+
+ if (!placed) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Board section is full, no free grid position available",
+ });
+ }
+ }
+
+ if (layoutRows.length > 0) {
+ await ctx.db.insert(itemLayouts).values(layoutRows);
+ }
+
+ if (input.integrationIds.length > 0) {
+ await ctx.db
+ .insert(integrationItems)
+ .values(input.integrationIds.map((integrationId) => ({ itemId, integrationId })));
+ }
+
+ return { itemId };
+ }),
});
/**
@@ -1586,7 +1733,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
...otherBoardProperties,
layouts: layouts
.map(({ boardId: _, ...layout }) => layout)
- .sort((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
+ .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint),
sections: sections.map(({ collapseStates, ...section }) =>
parseSection({
...section,
@@ -1604,22 +1751,24 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
collapsed: collapseStates.at(0)?.collapsed ?? false,
}),
),
- items: items.map(({ integrations: itemIntegrations, ...item }) =>
- parseItem({
- ...item,
- layouts: item.layouts.map((layout) => ({
- xOffset: layout.xOffset,
- yOffset: layout.yOffset,
- width: layout.width,
- height: layout.height,
- layoutId: layout.layoutId,
- sectionId: layout.sectionId,
- })),
- integrationIds: itemIntegrations.map((item) => item.integration.id),
- advancedOptions: superjson.parse(item.advancedOptions),
- options: superjson.parse>(item.options),
- }),
- ),
+ items: items
+ .map(({ integrations: itemIntegrations, ...item }) =>
+ parseItem({
+ ...item,
+ layouts: item.layouts.map((layout) => ({
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ width: layout.width,
+ height: layout.height,
+ layoutId: layout.layoutId,
+ sectionId: layout.sectionId,
+ })),
+ integrationIds: itemIntegrations.map((item) => item.integration.id),
+ advancedOptions: superjson.parse(item.advancedOptions),
+ options: superjson.parse>(item.options),
+ }),
+ )
+ .filter((item) => item !== null),
};
};
@@ -1635,7 +1784,7 @@ const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
- throw new Error(result.error.message);
+ return null;
}
return result.data;
};
diff --git a/packages/api/src/router/docker/docker-router.ts b/packages/api/src/router/docker/docker-router.ts
index 4527482f0..0011545d0 100644
--- a/packages/api/src/router/docker/docker-router.ts
+++ b/packages/api/src/router/docker/docker-router.ts
@@ -4,7 +4,11 @@ import { z } from "zod/v4";
import type { Container, ContainerState, Docker, Port } from "@homarr/docker";
import { DockerSingleton } from "@homarr/docker";
-import { dockerContainersRequestHandler } from "@homarr/request-handler/docker";
+import {
+ dockerContainersRequestHandler,
+ getContainerLogsAsync,
+ streamContainerLogsAsync,
+} from "@homarr/request-handler/docker";
import { dockerMiddleware } from "../../middlewares/docker";
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
@@ -12,10 +16,18 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
export const dockerRouter = createTRPCRouter({
getContainers: permissionRequiredProcedure
.requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description: "List all Docker containers with their state, image, CPU/memory usage, and ports",
+ },
+ })
.concat(dockerMiddleware())
.query(async () => {
const innerHandler = dockerContainersRequestHandler.handler({});
- const result = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const result = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
const { data, timestamp } = result;
@@ -46,6 +58,12 @@ export const dockerRouter = createTRPCRouter({
}),
startAll: permissionRequiredProcedure
.requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description: "Start Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
+ },
+ })
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -61,6 +79,12 @@ export const dockerRouter = createTRPCRouter({
}),
stopAll: permissionRequiredProcedure
.requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description: "Stop Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
+ },
+ })
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -76,6 +100,13 @@ export const dockerRouter = createTRPCRouter({
}),
restartAll: permissionRequiredProcedure
.requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Restart Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
+ },
+ })
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -91,6 +122,13 @@ export const dockerRouter = createTRPCRouter({
}),
removeAll: permissionRequiredProcedure
.requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Remove/delete Docker containers. REQUIRED: ids (array of container ID strings from docker_getContainers)",
+ },
+ })
.concat(dockerMiddleware())
.input(z.object({ ids: z.array(z.string()) }))
.mutation(async ({ input }) => {
@@ -104,6 +142,91 @@ export const dockerRouter = createTRPCRouter({
const innerHandler = dockerContainersRequestHandler.handler({});
await innerHandler.invalidateAsync();
}),
+ logs: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Fetch logs from a Docker container. REQUIRED: id (container ID from docker_getContainers). OPTIONAL: tail (number 1-1000, default 200)",
+ },
+ })
+ .concat(dockerMiddleware())
+ .input(
+ z.object({
+ id: z.string(),
+ tail: z.number().min(1).max(1000).optional(),
+ }),
+ )
+ .query(async ({ input }) => {
+ const logs = await getContainerLogsAsync(input.id, input.tail ?? 200);
+
+ if (!logs) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Container not found",
+ });
+ }
+
+ return {
+ logs,
+ };
+ }),
+ subscribeLogs: permissionRequiredProcedure
+ .requiresPermission("admin")
+ .concat(dockerMiddleware())
+ .input(
+ z.object({
+ id: z.string(),
+ tail: z.number().min(1).max(1000).optional(),
+ }),
+ )
+ .subscription(({ input }) => {
+ return observable((emit) => {
+ let cleanupFn: (() => void) | undefined;
+ let isSubscribed = true;
+
+ const initializeStreamAsync = async () => {
+ try {
+ cleanupFn = await streamContainerLogsAsync(
+ input.id,
+ input.tail ?? 200,
+ (data) => {
+ if (isSubscribed) {
+ emit.next(data);
+ }
+ },
+ (err) => {
+ if (isSubscribed) {
+ emit.error(
+ new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: err.message,
+ }),
+ );
+ }
+ },
+ );
+ } catch (err) {
+ if (isSubscribed) {
+ emit.error(
+ new TRPCError({
+ code: "NOT_FOUND",
+ message: err instanceof Error ? err.message : "Container not found",
+ }),
+ );
+ }
+ }
+ };
+
+ void initializeStreamAsync();
+
+ return () => {
+ isSubscribed = false;
+ cleanupFn?.();
+ };
+ });
+ }),
});
const getContainerOrDefaultAsync = async (instance: Docker, id: string) => {
diff --git a/packages/api/src/router/icons.ts b/packages/api/src/router/icons.ts
index 04f15fc2d..e75eda8ab 100644
--- a/packages/api/src/router/icons.ts
+++ b/packages/api/src/router/icons.ts
@@ -5,26 +5,35 @@ import { iconsFindSchema } from "@homarr/validation/icons";
import { createTRPCRouter, publicProcedure } from "../trpc";
export const iconsRouter = createTRPCRouter({
- findIcons: publicProcedure.input(iconsFindSchema).query(async ({ ctx, input }) => {
- return {
- icons: await ctx.db.query.iconRepositories.findMany({
- with: {
- icons: {
- columns: {
- id: true,
- name: true,
- url: true,
+ findIcons: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Search for icons by name across all icon repositories. OPTIONAL: searchText (string to filter), limitPerGroup (number 1-500, default 12). Call with no arguments to browse all icons",
+ },
+ })
+ .input(iconsFindSchema)
+ .query(async ({ ctx, input }) => {
+ return {
+ icons: await ctx.db.query.iconRepositories.findMany({
+ with: {
+ icons: {
+ columns: {
+ id: true,
+ name: true,
+ url: true,
+ },
+ where:
+ (input.searchText?.length ?? 0) > 0
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
+ : undefined,
+ limit: input.limitPerGroup,
},
- where:
- (input.searchText?.length ?? 0) > 0
- ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
- : undefined,
- limit: input.limitPerGroup,
},
- },
- }),
- countIcons: await ctx.db.$count(icons),
- };
- }),
+ }),
+ countIcons: await ctx.db.$count(icons),
+ };
+ }),
});
diff --git a/packages/api/src/router/info.ts b/packages/api/src/router/info.ts
index 3c205963c..a0436ede3 100644
--- a/packages/api/src/router/info.ts
+++ b/packages/api/src/router/info.ts
@@ -1,16 +1,23 @@
import z from "zod/v4";
import packageJson from "../../../../package.json";
-import { createTRPCRouter, protectedProcedure } from "../trpc";
+import { createTRPCRouter, isDemoMode, publicProcedure, protectedProcedure } from "../trpc";
export const infoRouter = createTRPCRouter({
getInfo: protectedProcedure
.input(z.void())
.output(z.object({ version: z.string() }))
- .meta({ openapi: { method: "GET", path: "/api/info", tags: ["info"] } })
+ .meta({
+ openapi: { method: "GET", path: "/api/info", tags: ["info"] },
+ mcp: {
+ enabled: true,
+ description: "Get Homarr server version information",
+ },
+ })
.query(() => {
return {
version: packageJson.version,
};
}),
+ isDemoMode: publicProcedure.query(() => isDemoMode),
});
diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts
index 5591b74ed..c5dd9a9b6 100644
--- a/packages/api/src/router/integration/integration-router.ts
+++ b/packages/api/src/router/integration/integration-router.ts
@@ -1,6 +1,8 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
+import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
+import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -14,11 +16,9 @@ import {
integrations,
integrationSecrets,
integrationUserPermissions,
- searchEngines,
} from "@homarr/db/schema";
import type { IntegrationSecretKind } from "@homarr/definitions";
import {
- getIconUrl,
getIntegrationKindsByCategory,
getPermissionsWithParents,
integrationCategories,
@@ -33,6 +33,7 @@ import {
integrationSavePermissionsSchema,
integrationUpdateSchema,
} from "@homarr/validation/integration";
+import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
@@ -41,50 +42,75 @@ import { MissingSecretError, testConnectionAsync } from "./integration-test-conn
import { mapTestConnectionError } from "./map-test-connection-error";
const logger = createLogger({ module: "integrationRouter" });
+const mediaRequestSearchKinds = getIntegrationKindsByCategory("mediaSearch");
export const integrationRouter = createTRPCRouter({
- all: protectedProcedure.query(async ({ ctx }) => {
- const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
- where: eq(groupMembers.userId, ctx.session.user.id),
- });
-
- const integrations = await ctx.db.query.integrations.findMany({
- with: {
- userPermissions: {
- where: eq(integrationUserPermissions.userId, ctx.session.user.id),
- },
- groupPermissions: {
- where: inArray(
- integrationGroupPermissions.groupId,
- groupsOfCurrentUser.map((group) => group.groupId),
- ),
- },
+ getKinds: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "List all supported integration kinds (e.g. sonarr, radarr, overseerr, pihole, homeAssistant) with the secret fields each kind requires. Use this before creating an integration to know which 'kind' values are valid and what secrets to provide.",
},
- });
- return integrations
- .map((integration) => {
- const permissions = integration.userPermissions
- .map(({ permission }) => permission)
- .concat(integration.groupPermissions.map(({ permission }) => permission));
+ })
+ .query(() => {
+ return objectEntries(integrationDefs).map(([kind, def]) => ({
+ kind,
+ name: def.name,
+ category: def.category,
+ requiredSecrets: def.secretKinds,
+ }));
+ }),
+ all: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "List all configured integrations (connections to services like Sonarr, Radarr, Plex, etc.). Returns each integration's id, name, kind, url, and permissions. Use the 'id' field as 'integrationId' in other tools. Check permissions.hasUseAccess before reading data and permissions.hasInteractAccess before performing actions — false means the API key owner lacks that permission level for this integration, not an error",
+ },
+ })
+ .query(async ({ ctx }) => {
+ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session.user.id),
+ });
- return {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- url: integration.url,
- permissions: {
- hasUseAccess:
- permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
- hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
- hasFullAccess: permissions.includes("full"),
+ const integrations = await ctx.db.query.integrations.findMany({
+ with: {
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, ctx.session.user.id),
},
- };
- })
- .sort(
- (integrationA, integrationB) =>
- integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
- );
- }),
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId),
+ ),
+ },
+ },
+ });
+ return integrations
+ .map((integration) => {
+ const permissions = integration.userPermissions
+ .map(({ permission }) => permission)
+ .concat(integration.groupPermissions.map(({ permission }) => permission));
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ permissions: {
+ hasUseAccess:
+ permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
+ hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
+ hasFullAccess: permissions.includes("full"),
+ },
+ };
+ })
+ .toSorted(
+ (integrationA, integrationB) =>
+ integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
+ );
+ }),
allThatSupportSearch: protectedProcedure.query(async ({ ctx }) => {
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
where: eq(groupMembers.userId, ctx.session.user.id),
@@ -128,7 +154,7 @@ export const integrationRouter = createTRPCRouter({
},
};
})
- .sort(
+ .toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
@@ -179,13 +205,25 @@ export const integrationRouter = createTRPCRouter({
},
};
})
- .sort(
+ .toSorted(
(integrationA, integrationB) =>
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
);
}),
search: protectedProcedure
- .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10) }))
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Search integrations by name. REQUIRED: query (search string). OPTIONAL: limit (number, default 10). Returns matching integrations with id (use as integrationId), name, kind, url, and permissions",
+ },
+ })
+ .input(
+ z.object({
+ query: z.string(),
+ limit: z.number().min(1).max(100).default(10),
+ }),
+ )
.query(async ({ ctx, input }) => {
return await ctx.db.query.integrations.findMany({
where: like(integrations.name, `%${input.query}%`),
@@ -203,52 +241,68 @@ export const integrationRouter = createTRPCRouter({
},
});
}),
- byId: protectedProcedure.input(byIdSchema).query(async ({ ctx, input }) => {
- await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
- const integration = await ctx.db.query.integrations.findFirst({
- where: eq(integrations.id, input.id),
- with: {
- secrets: {
- columns: {
- kind: true,
- value: true,
- updatedAt: true,
- },
- },
- app: {
- columns: {
- id: true,
- name: true,
- iconUrl: true,
- href: true,
- },
- },
+ byId: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get integration details by ID, including secrets metadata and linked app. REQUIRED: id (integration ID string)",
},
- });
-
- if (!integration) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Integration not found",
+ })
+ .input(byIdSchema)
+ .query(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
+ with: {
+ secrets: {
+ columns: {
+ kind: true,
+ value: true,
+ updatedAt: true,
+ },
+ },
+ app: {
+ columns: {
+ id: true,
+ name: true,
+ iconUrl: true,
+ href: true,
+ },
+ },
+ },
});
- }
- return {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- url: integration.url,
- secrets: integration.secrets.map((secret) => ({
- kind: secret.kind,
- // Only return the value if the secret is public, so for example the username
- value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
- updatedAt: secret.updatedAt,
- })),
- app: integration.app,
- };
- }),
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ secrets: integration.secrets.map((secret) => ({
+ kind: secret.kind,
+ // Only return the value if the secret is public, so for example the username
+ value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
+ updatedAt: secret.updatedAt,
+ })),
+ app: integration.app,
+ };
+ }),
create: permissionRequiredProcedure
.requiresPermission("integration-create")
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Create a new integration (connection to an external service). REQUIRED fields: name, url (http/https), kind, secrets, attemptSearchEngineCreation. The 'secrets' field is REQUIRED and must be a non-empty array — call integration_getKinds first to see which secret kinds each integration type needs. Example for Radarr: secrets=[{kind:'apiKey', value:'your-radarr-api-key'}]. Example for Proxmox: secrets=[{kind:'tokenId', value:'...'}, {kind:'personalAccessToken', value:'...'}, {kind:'realm', value:'pam'}]. The connection is tested before saving — if secrets are wrong, an error is returned. Set attemptSearchEngineCreation to false unless explicitly requested. The 'app' field is optional — pass {id:'...'} to link to an existing app, or omit it.",
+ },
+ })
.input(integrationCreateSchema)
.mutation(async ({ ctx, input }) => {
logger.info("Creating integration", {
@@ -313,21 +367,6 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
url: input.url,
});
-
- if (
- input.attemptSearchEngineCreation &&
- integrationDefs[input.kind].category.flatMap((category) => category).includes("search")
- ) {
- const icon = getIconUrl(input.kind);
- await ctx.db.insert(searchEngines).values({
- id: createId(),
- name: input.name,
- integrationId,
- type: "fromIntegration",
- iconUrl: icon,
- short: await getNextValidShortNameForSearchEngineAsync(ctx.db, input.name),
- });
- }
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -431,22 +470,27 @@ export const integrationRouter = createTRPCRouter({
url: input.url,
});
}),
- delete: protectedProcedure.input(byIdSchema).mutation(async ({ ctx, input }) => {
- await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+ delete: protectedProcedure
+ .meta({
+ mcp: { enabled: true, description: "Delete an integration by ID. REQUIRED: id (integration ID string)" },
+ })
+ .input(byIdSchema)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
- const integration = await ctx.db.query.integrations.findFirst({
- where: eq(integrations.id, input.id),
- });
-
- if (!integration) {
- throw new TRPCError({
- code: "NOT_FOUND",
- message: "Integration not found",
+ const integration = await ctx.db.query.integrations.findFirst({
+ where: eq(integrations.id, input.id),
});
- }
- await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
- }),
+ if (!integration) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "Integration not found",
+ });
+ }
+
+ await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
+ }),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -495,7 +539,7 @@ export const integrationRouter = createTRPCRouter({
});
return {
- inherited: dbGroupPermissions.sort((permissionA, permissionB) => {
+ inherited: dbGroupPermissions.toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
users: userPermissions
@@ -503,7 +547,7 @@ export const integrationRouter = createTRPCRouter({
user,
permission,
}))
- .sort((permissionA, permissionB) => {
+ .toSorted((permissionA, permissionB) => {
return (permissionA.user.name ?? "").localeCompare(permissionB.user.name ?? "");
}),
groups: dbGroupIntegrationPermission
@@ -514,7 +558,7 @@ export const integrationRouter = createTRPCRouter({
},
permission,
}))
- .sort((permissionA, permissionB) => {
+ .toSorted((permissionA, permissionB) => {
return permissionA.group.name.localeCompare(permissionB.group.name);
}),
};
@@ -612,14 +656,190 @@ export const integrationRouter = createTRPCRouter({
});
}),
searchInIntegration: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Search within a specific integration (e.g. Overseerr, Jellyseerr). REQUIRED: integrationId (from integration_all), query (search string)",
+ },
+ })
.concat(createOneIntegrationMiddleware("query", ...getIntegrationKindsByCategory("search")))
.input(z.object({ integrationId: z.string(), query: z.string() }))
.query(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.searchAsync(encodeURI(input.query));
}),
+ mediaRequestSearchTargets: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description: "List integrations that support media request search (Overseerr, Jellyseerr, Seerr)",
+ },
+ })
+ .query(async ({ ctx }) => {
+ const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx);
+
+ return integrationsWithAccess.map((integration) => {
+ const permissions = constructIntegrationPermissions(integration, ctx.session);
+
+ return {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ permissions: {
+ hasInteractAccess: permissions.hasInteractAccess,
+ },
+ };
+ });
+ }),
+ searchMediaRequests: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Search for movies and TV shows across Overseerr/Jellyseerr/Seerr. REQUIRED: query (search string). OPTIONAL: integrationIds (array of integration IDs — omit to search all accessible Overseerr/Jellyseerr/Seerr integrations). Returns results with integrationId, mediaId, and mediaType for use with getMediaRequestOptions and requestMedia",
+ },
+ })
+ .input(
+ z.object({
+ query: z.string(),
+ integrationIds: z.array(z.string()).optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const query = input.query.trim();
+ if (query.length === 0) return [];
+
+ const integrationsWithAccess = await getAccessibleMediaRequestSearchIntegrationsAsync(ctx, input.integrationIds);
+
+ const results = await Promise.all(
+ integrationsWithAccess.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync({
+ id: integration.id,
+ name: integration.name,
+ url: integration.url,
+ kind: integration.kind as (typeof mediaRequestSearchKinds)[number],
+ externalUrl: integration.app?.href ?? null,
+ decryptedSecrets: integration.secrets.map((secret) => ({
+ ...secret,
+ value: decryptSecret(secret.value),
+ })),
+ });
+ const permissions = constructIntegrationPermissions(integration, ctx.session);
+
+ return await integrationInstance.searchAsync(encodeURI(query)).then((searchResults) =>
+ searchResults.map((result) => ({
+ ...result,
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ url: integration.url,
+ permissions: {
+ hasInteractAccess: permissions.hasInteractAccess,
+ },
+ },
+ })),
+ );
+ }),
+ );
+
+ return results.flat();
+ }),
+ getMediaRequestOptions: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get available options (seasons/episodes for TV) before requesting media. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number from searchMediaRequests), mediaType ('tv' or 'movie'). Required step for TV shows — for movies you can skip this and go straight to requestMedia",
+ },
+ })
+ .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr", "seerr"))
+ .input(mediaRequestOptionsSchema)
+ .query(async ({ ctx, input }) => {
+ const integration = await createIntegrationAsync(ctx.integration);
+ return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
+ }),
+ requestMedia: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Submit a media request to Overseerr/Jellyseerr/Seerr. REQUIRED: integrationId (Overseerr/Jellyseerr ID), mediaId (number), mediaType ('tv' or 'movie'). OPTIONAL: seasons (array of season numbers — for TV only, omit to request all). Workflow: searchMediaRequests → (getMediaRequestOptions for TV) → requestMedia",
+ },
+ })
+ .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr", "seerr"))
+ .input(mediaRequestRequestSchema)
+ .mutation(async ({ ctx, input }) => {
+ const integration = await createIntegrationAsync(ctx.integration);
+ return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
+ }),
});
+interface IntegrationRouterContext {
+ db: Database;
+ session: Parameters[2];
+}
+
+const getAccessibleMediaRequestSearchIntegrationsAsync = async (
+ ctx: IntegrationRouterContext,
+ integrationIds?: string[],
+) => {
+ const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
+ where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
+ });
+
+ const integrationsFromDb = await ctx.db.query.integrations.findMany({
+ where:
+ integrationIds && integrationIds.length > 0
+ ? and(inArray(integrations.id, integrationIds), inArray(integrations.kind, mediaRequestSearchKinds))
+ : inArray(integrations.kind, mediaRequestSearchKinds),
+ orderBy: asc(integrations.name),
+ with: {
+ app: true,
+ secrets: true,
+ items: {
+ with: {
+ item: true,
+ },
+ },
+ userPermissions: {
+ where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
+ },
+ groupPermissions: {
+ where: inArray(
+ integrationGroupPermissions.groupId,
+ groupsOfCurrentUser.map((group) => group.groupId),
+ ),
+ },
+ },
+ });
+
+ if (integrationIds && integrationsFromDb.length !== integrationIds.length) {
+ throw new TRPCError({
+ code: "NOT_FOUND",
+ message: "One or more media request search integrations were not found",
+ });
+ }
+
+ const integrationsWithAccess = await Promise.all(
+ integrationsFromDb.map(async (integration) => ({
+ integration,
+ hasAccess: await hasQueryAccessToIntegrationsAsync(ctx.db, [integration], ctx.session),
+ })),
+ );
+
+ if (integrationIds && integrationsWithAccess.some(({ hasAccess }) => !hasAccess)) {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "User does not have permission to query one or more media request search integrations",
+ });
+ }
+
+ return integrationsWithAccess.filter(({ hasAccess }) => hasAccess).map(({ integration }) => integration);
+};
+
interface UpdateSecretInput {
integrationId: string;
value: string;
@@ -640,35 +860,6 @@ interface AddSecretInput {
kind: IntegrationSecretKind;
}
-const getNextValidShortNameForSearchEngineAsync = async (db: Database, integrationName: string) => {
- const searchEngines = await db.query.searchEngines.findMany({
- columns: {
- short: true,
- },
- });
-
- const usedShortNames = searchEngines.flatMap((searchEngine) => searchEngine.short.toLowerCase());
- const nameByIntegrationName = integrationName.slice(0, 1).toLowerCase();
-
- if (!usedShortNames.includes(nameByIntegrationName)) {
- return nameByIntegrationName;
- }
-
- // 8 is max length constraint
- for (let i = 2; i < 9999999; i++) {
- const generatedName = `${nameByIntegrationName}${i}`;
- if (usedShortNames.includes(generatedName)) {
- continue;
- }
-
- return generatedName;
- }
-
- throw new Error(
- "Unable to automatically generate a short name. All possible variations were exhausted. Please disable the automatic creation and choose one later yourself.",
- );
-};
-
const addSecretAsync = async (db: Database, input: AddSecretInput) => {
await db.insert(integrationSecrets).values({
kind: input.kind,
diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts
index 1b767748d..bc31c0bdf 100644
--- a/packages/api/src/router/invite.ts
+++ b/packages/api/src/router/invite.ts
@@ -20,11 +20,21 @@ export const inviteRouter = createTRPCRouter({
id: true,
expirationDate: true,
})
- .extend({ creator: z.object({ name: z.string().nullable(), id: z.string() }) }),
+ .extend({
+ creator: z.object({ name: z.string().nullable(), id: z.string() }),
+ }),
),
)
.input(z.undefined())
- .meta({ openapi: { method: "GET", path: "/api/invites", tags: ["invites"], protect: true } })
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/invites",
+ tags: ["invites"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "List all user invites" },
+ })
.query(async ({ ctx }) => {
throwIfCredentialsDisabled();
return await ctx.db.query.invites.findMany({
@@ -50,7 +60,19 @@ export const inviteRouter = createTRPCRouter({
}),
)
.output(z.object({ id: z.string(), token: z.string() }))
- .meta({ openapi: { method: "POST", path: "/api/invites", tags: ["invites"], protect: true } })
+ .meta({
+ openapi: {
+ method: "POST",
+ path: "/api/invites",
+ tags: ["invites"],
+ protect: true,
+ },
+ mcp: {
+ enabled: true,
+ description:
+ "Create a new user invite link. REQUIRED: expirationDate (ISO date string for when the invite expires). Returns the invite ID and token",
+ },
+ })
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const id = createId();
@@ -76,7 +98,15 @@ export const inviteRouter = createTRPCRouter({
}),
)
.output(z.undefined())
- .meta({ openapi: { method: "DELETE", path: "/api/invites/{id}", tags: ["invites"], protect: true } })
+ .meta({
+ openapi: {
+ method: "DELETE",
+ path: "/api/invites/{id}",
+ tags: ["invites"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "Delete an invite by ID. REQUIRED: id (invite ID string)" },
+ })
.mutation(async ({ ctx, input }) => {
throwIfCredentialsDisabled();
const dbInvite = await ctx.db.query.invites.findFirst({
diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts
index 15f205d8b..242d861e4 100644
--- a/packages/api/src/router/onboard/onboard-queries.ts
+++ b/packages/api/src/router/onboard/onboard-queries.ts
@@ -76,6 +76,9 @@ const nextSteps: Partial {
+ const port = ports?.[0];
+ if (port?.PublicPort) {
+ const ip = port.IP && port.IP !== "0.0.0.0" && port.IP !== "::" ? port.IP : host;
+ return { url: `http://${ip}:${port.PublicPort}`, publishedPort: port.PublicPort };
+ }
+ return { url: "", publishedPort: null };
+};
+
export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => {
return await getOnboardingOrFallbackAsync(ctx.db);
@@ -33,4 +78,249 @@ export const onboardRouter = createTRPCRouter({
step: "start",
});
}),
+ createIntegration: onboardingProcedure
+ .requiresStep("integrations")
+ .input(
+ z.object({
+ name: z.string().nonempty().max(127),
+ url: z.string().nonempty(),
+ kind: zodEnumFromArray(integrationKinds),
+ secrets: z.array(
+ z.object({
+ kind: zodEnumFromArray(integrationSecretKinds),
+ value: z.string().nonempty(),
+ }),
+ ),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ const result = await testConnectionAsync({
+ id: "new",
+ name: input.name,
+ url: input.url,
+ kind: input.kind,
+ secrets: input.secrets,
+ }).catch((error) => {
+ if (!(error instanceof MissingSecretError)) throw error;
+
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: error.message,
+ });
+ });
+
+ if (!result.success) {
+ return { error: mapTestConnectionError(result.error) };
+ }
+
+ const integrationId = createId();
+ const appId = createId();
+
+ await ctx.db.insert(apps).values({
+ id: appId,
+ name: input.name,
+ iconUrl: getIconUrl(input.kind),
+ href: input.url,
+ pingUrl: input.url,
+ description: null,
+ });
+
+ await ctx.db.insert(integrations).values({
+ id: integrationId,
+ name: input.name,
+ url: input.url,
+ kind: input.kind,
+ appId,
+ });
+
+ if (input.secrets.length >= 1) {
+ await ctx.db.insert(integrationSecrets).values(
+ input.secrets.map((secret) => ({
+ kind: secret.kind,
+ value: encryptSecret(secret.value),
+ integrationId,
+ })),
+ );
+ }
+ }),
+ discoverDockerServices: onboardingProcedure.requiresStep("integrations").query(async ({ ctx }) => {
+ const emptyResult = {
+ integrations: [] as {
+ containerId: string;
+ containerName: string;
+ kind: IntegrationKind;
+ suggestedUrl: string;
+ publishedPort: number | null;
+ iconUrl: string | null;
+ }[],
+ apps: [] as {
+ containerId: string;
+ containerName: string;
+ suggestedUrl: string;
+ publishedPort: number | null;
+ iconUrl: string | null;
+ }[],
+ };
+
+ try {
+ const { DockerSingleton, dockerLabels } = await import("@homarr/docker");
+
+ const dockerInstances = DockerSingleton.getInstances();
+ if (dockerInstances.length === 0) {
+ return { status: "empty" as const, ...emptyResult };
+ }
+
+ const results = await Promise.allSettled(
+ dockerInstances.map(async ({ instance, host }) => {
+ const containerList = await instance.listContainers({ all: false });
+ return containerList.filter((c) => !(dockerLabels.hide in c.Labels)).map((c) => ({ ...c, host }));
+ }),
+ );
+
+ const containers = results.flatMap((r) => (r.status === "fulfilled" ? r.value : []));
+
+ if (containers.length === 0) {
+ return { status: "empty" as const, ...emptyResult };
+ }
+
+ const likeQueries = containers.map((c) => like(icons.name, `%${extractContainerImageName(c.Image)}%`));
+ const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
+
+ const seenKinds = new Set();
+ const discoveredIntegrations = emptyResult.integrations;
+ const discoveredApps = emptyResult.apps;
+
+ const cdnIconUrl = (slug: string) =>
+ `https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
+
+ const strictIconMatch = (imageName: string) => {
+ const normalized = imageName.toLowerCase().trim();
+ if (normalized.length < 3) return null;
+ const exact = dbIcons.find((icon) => icon.name.toLowerCase() === normalized);
+ if (exact) return exact.url;
+ return dbIcons.find((icon) => icon.name.toLowerCase().startsWith(normalized))?.url ?? null;
+ };
+
+ for (const container of containers) {
+ const imageName = extractContainerImageName(container.Image);
+ const containerName = container.Names[0]?.split("/")[1] ?? "Unknown";
+ const dbIcon = strictIconMatch(imageName);
+ const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase());
+
+ const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host);
+
+ const kind = matchIntegrationKindFromContainer({
+ image: container.Image,
+ name: containerName,
+ });
+
+ if (kind && !seenKinds.has(kind)) {
+ seenKinds.add(kind);
+ discoveredIntegrations.push({
+ containerId: container.Id,
+ containerName,
+ kind,
+ suggestedUrl,
+ publishedPort,
+ iconUrl,
+ });
+ } else if (!kind && dbIcon) {
+ discoveredApps.push({
+ containerId: container.Id,
+ containerName,
+ suggestedUrl,
+ publishedPort,
+ iconUrl,
+ });
+ }
+ }
+
+ return {
+ status: "success" as const,
+ integrations: discoveredIntegrations,
+ apps: discoveredApps,
+ };
+ } catch {
+ return { status: "unavailable" as const, ...emptyResult };
+ }
+ }),
+ createAppsFromDiscovery: onboardingProcedure
+ .requiresStep("integrations")
+ .input(
+ z.array(
+ z.object({
+ name: z.string().nonempty().max(127),
+ href: z.string().nullable(),
+ iconUrl: z.string().nullable(),
+ }),
+ ),
+ )
+ .mutation(async ({ ctx, input }) => {
+ if (input.length === 0) return;
+ const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
+ await ctx.db.insert(apps).values(
+ input.map((app) => ({
+ id: createId(),
+ name: app.name,
+ iconUrl: app.iconUrl ?? defaultIcon,
+ href: app.href,
+ description: null,
+ })),
+ );
+ }),
+ setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => {
+ const db = ctx.db;
+
+ const allIntegrations = await db.query.integrations.findMany();
+ const allApps = await db.query.apps.findMany();
+
+ const board = await db.query.boards.findFirst({
+ with: {
+ sections: true,
+ layouts: true,
+ },
+ });
+
+ if (!board) {
+ await nextOnboardingStepAsync(db, undefined);
+ return;
+ }
+
+ const everyoneGroupRow = await db.query.groups.findFirst({
+ where: eq(groups.name, everyoneGroup),
+ });
+ if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) {
+ await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id));
+ }
+
+ const section = board.sections.find((boardSection) => boardSection.kind === "empty");
+ const layout = board.layouts[0];
+
+ if (section && layout) {
+ const existingItems = await db.query.items.findMany({
+ where: eq(items.boardId, board.id),
+ });
+ for (const item of existingItems) {
+ await db.delete(integrationItems).where(eq(integrationItems.itemId, item.id));
+ await db.delete(itemLayouts).where(eq(itemLayouts.itemId, item.id));
+ }
+ if (existingItems.length > 0) {
+ await db.delete(items).where(eq(items.boardId, board.id));
+ }
+
+ await placeAllWidgetsAsync(
+ db,
+ {
+ boardId: board.id,
+ sectionId: section.id,
+ layoutId: layout.id,
+ columnCount: layout.columnCount,
+ },
+ allIntegrations,
+ allApps,
+ );
+ }
+
+ await nextOnboardingStepAsync(db, undefined);
+ }),
});
diff --git a/packages/api/src/router/search-engine/search-engine-router.ts b/packages/api/src/router/search-engine/search-engine-router.ts
index ddfc892a9..8c72baf6e 100644
--- a/packages/api/src/router/search-engine/search-engine-router.ts
+++ b/packages/api/src/router/search-engine/search-engine-router.ts
@@ -6,12 +6,9 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { asc, eq, like } from "@homarr/db";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import { searchEngines, users } from "@homarr/db/schema";
-import { createIntegrationAsync } from "@homarr/integrations";
import { byIdSchema, paginatedSchema, searchSchema } from "@homarr/validation/common";
import { searchEngineEditSchema, searchEngineManageSchema } from "@homarr/validation/search-engine";
-import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
-import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "searchEngineRouter" });
@@ -148,20 +145,6 @@ export const searchEngineRouter = createTRPCRouter({
limit: input.limit,
});
}),
- getMediaRequestOptions: protectedProcedure
- .concat(createOneIntegrationMiddleware("query", "jellyseerr", "overseerr"))
- .input(mediaRequestOptionsSchema)
- .query(async ({ ctx, input }) => {
- const integration = await createIntegrationAsync(ctx.integration);
- return await integration.getSeriesInformationAsync(input.mediaType, input.mediaId);
- }),
- requestMedia: protectedProcedure
- .concat(createOneIntegrationMiddleware("interact", "jellyseerr", "overseerr"))
- .input(mediaRequestRequestSchema)
- .mutation(async ({ ctx, input }) => {
- const integration = await createIntegrationAsync(ctx.integration);
- return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
- }),
create: permissionRequiredProcedure
.requiresPermission("search-engine-create")
.input(searchEngineManageSchema)
diff --git a/packages/api/src/router/test/docker/docker-router.spec.ts b/packages/api/src/router/test/docker/docker-router.spec.ts
index 941e696e6..cbea4063b 100644
--- a/packages/api/src/router/test/docker/docker-router.spec.ts
+++ b/packages/api/src/router/test/docker/docker-router.spec.ts
@@ -13,6 +13,14 @@ import { dockerRouter } from "../../docker/docker-router";
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
vi.mock("@homarr/request-handler/docker", () => ({
+ getContainerLogsAsync: async () => {
+ await Promise.resolve();
+ return "logs";
+ },
+ streamContainerLogsAsync: async () => {
+ await Promise.resolve();
+ return () => undefined;
+ },
dockerContainersRequestHandler: {
handler: () => ({
getCachedOrUpdatedDataAsync: async () => {
@@ -65,6 +73,8 @@ const validInputs: {
restartAll: { ids: ["1"] },
removeAll: { ids: ["1"] },
invalidate: undefined,
+ logs: { id: "1", tail: 200 },
+ subscribeLogs: { id: "1", tail: 200 },
};
describe("All procedures should only be accessible for users with admin permission", () => {
diff --git a/packages/api/src/router/test/integration/integration-router.spec.ts b/packages/api/src/router/test/integration/integration-router.spec.ts
index 17927b9c4..a7cdddfa2 100644
--- a/packages/api/src/router/test/integration/integration-router.spec.ts
+++ b/packages/api/src/router/test/integration/integration-router.spec.ts
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
-import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
+import { apps, integrations, integrationSecrets, integrationUserPermissions, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
@@ -59,6 +59,127 @@ describe("all should return all integrations", () => {
});
});
+describe("mediaRequestSearchTargets should return accessible media search integrations", () => {
+ test("with integration use access should only return media request search integrations", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-use-all"]),
+ });
+
+ await db.insert(integrations).values([
+ {
+ id: "1",
+ name: "Jellyseerr",
+ kind: "jellyseerr",
+ url: "http://jellyseerr.local",
+ },
+ {
+ id: "2",
+ name: "Jellyfin",
+ kind: "jellyfin",
+ url: "http://jellyfin.local",
+ },
+ ]);
+
+ const result = await caller.mediaRequestSearchTargets();
+
+ expect(result).toHaveLength(1);
+ expect(result[0]!.id).toBe("1");
+ expect(result[0]!.kind).toBe("jellyseerr");
+ });
+
+ test("without access should not return media request search integrations", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(),
+ });
+
+ await db.insert(integrations).values({
+ id: "1",
+ name: "Jellyseerr",
+ kind: "jellyseerr",
+ url: "http://jellyseerr.local",
+ });
+
+ const result = await caller.mediaRequestSearchTargets();
+
+ expect(result).toHaveLength(0);
+ });
+
+ test("with direct integration use access should return media request search integrations", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(),
+ });
+
+ await db.insert(integrations).values({
+ id: "1",
+ name: "Jellyseerr",
+ kind: "jellyseerr",
+ url: "http://jellyseerr.local",
+ });
+ await db.insert(users).values({ id: defaultUserId });
+ await db.insert(integrationUserPermissions).values({
+ integrationId: "1",
+ userId: defaultUserId,
+ permission: "use",
+ });
+
+ const result = await caller.mediaRequestSearchTargets();
+
+ expect(result).toHaveLength(1);
+ expect(result[0]!.id).toBe("1");
+ });
+});
+
+describe("searchMediaRequests should validate scoped integrations", () => {
+ test("with a non media search integration should throw not found", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(["integration-use-all"]),
+ });
+
+ await db.insert(integrations).values({
+ id: "1",
+ name: "Jellyfin",
+ kind: "jellyfin",
+ url: "http://jellyfin.local",
+ });
+
+ await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
+ "One or more media request search integrations were not found",
+ );
+ });
+
+ test("without query access to a scoped media search integration should throw forbidden", async () => {
+ const db = createDb();
+ const caller = integrationRouter.createCaller({
+ db,
+ deviceType: undefined,
+ session: defaultSessionWithPermissions(),
+ });
+
+ await db.insert(integrations).values({
+ id: "1",
+ name: "Jellyseerr",
+ kind: "jellyseerr",
+ url: "http://jellyseerr.local",
+ });
+
+ await expect(caller.searchMediaRequests({ query: "matrix", integrationIds: ["1"] })).rejects.toThrow(
+ "User does not have permission to query one or more media request search integrations",
+ );
+ });
+});
+
describe("byId should return an integration by id", () => {
test("with full access should return an integration by id", async () => {
const db = createDb();
@@ -208,7 +329,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.updatedAt).toEqual(fakeNow);
});
- test("with create integration access should create a new integration when creating search engine", async () => {
+ test("with create integration access should not create a search engine for media request search integrations", async () => {
const db = createDb();
const caller = integrationRouter.createCaller({
db,
@@ -243,12 +364,7 @@ describe("create should create a new integration", () => {
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
expect(dbSecret!.updatedAt).toEqual(fakeNow);
- expect(dbSearchEngine!.integrationId).toBe(dbIntegration!.id);
- expect(dbSearchEngine!.short).toBe("j");
- expect(dbSearchEngine!.name).toBe(input.name);
- expect(dbSearchEngine!.iconUrl).toBe(
- "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
- );
+ expect(dbSearchEngine).toBeUndefined();
});
test("with create integration access should create a new integration with new linked app", async () => {
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts
index 9c4f16c80..eee626026 100644
--- a/packages/api/src/router/user.ts
+++ b/packages/api/src/router/user.ts
@@ -32,6 +32,7 @@ import {
import { convertIntersectionToZodObject } from "../schema-merger";
import {
createTRPCRouter,
+ isDemoMode,
onboardingProcedure,
permissionRequiredProcedure,
protectedProcedure,
@@ -143,7 +144,19 @@ export const userRouter = createTRPCRouter({
}),
create: permissionRequiredProcedure
.requiresPermission("admin")
- .meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "POST",
+ path: "/api/users",
+ tags: ["users"],
+ protect: true,
+ },
+ mcp: {
+ enabled: true,
+ description:
+ "Create a new user account (admin only). REQUIRED: username (string), password (string), confirmPassword (must match password). OPTIONAL: email (string or null)",
+ },
+ })
.input(convertIntersectionToZodObject(userCreateSchema))
.output(z.void())
.mutation(async ({ ctx, input }) => {
@@ -158,7 +171,14 @@ export const userRouter = createTRPCRouter({
}),
setProfileImage: protectedProcedure
.output(z.void())
- .meta({ openapi: { method: "PUT", path: "/api/users/profileImage", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PUT",
+ path: "/api/users/profileImage",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.input(
z.object({
userId: z.string(),
@@ -204,8 +224,26 @@ export const userRouter = createTRPCRouter({
getAll: permissionRequiredProcedure
.requiresPermission("admin")
.input(z.void())
- .output(z.array(selectUserSchema.pick({ id: true, name: true, email: true, emailVerified: true, image: true })))
- .meta({ openapi: { method: "GET", path: "/api/users", tags: ["users"], protect: true } })
+ .output(
+ z.array(
+ selectUserSchema.pick({
+ id: true,
+ name: true,
+ email: true,
+ emailVerified: true,
+ image: true,
+ }),
+ ),
+ )
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/users",
+ tags: ["users"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "List all users (admin only)" },
+ })
.query(({ ctx }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -220,8 +258,24 @@ export const userRouter = createTRPCRouter({
// Is protected because also used in board access / integration access forms
selectable: protectedProcedure
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
- .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
- .meta({ openapi: { method: "GET", path: "/api/users/selectable", tags: ["users"], protect: true } })
+ .output(
+ z.array(
+ selectUserSchema.pick({
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ }),
+ ),
+ )
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/users/selectable",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.query(({ ctx, input }) => {
return ctx.db.query.users.findMany({
columns: {
@@ -241,8 +295,24 @@ export const userRouter = createTRPCRouter({
limit: z.number().min(1).max(100).default(10),
}),
)
- .output(z.array(selectUserSchema.pick({ id: true, name: true, image: true, email: true })))
- .meta({ openapi: { method: "POST", path: "/api/users/search", tags: ["users"], protect: true } })
+ .output(
+ z.array(
+ selectUserSchema.pick({
+ id: true,
+ name: true,
+ image: true,
+ email: true,
+ }),
+ ),
+ )
+ .meta({
+ openapi: {
+ method: "POST",
+ path: "/api/users/search",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.query(async ({ input, ctx }) => {
const dbUsers = await ctx.db.query.users.findMany({
columns: {
@@ -278,9 +348,19 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
+ completedManageTour: true,
+ completedBoardTour: true,
}),
)
- .meta({ openapi: { method: "GET", path: "/api/users/{userId}", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "GET",
+ path: "/api/users/{userId}",
+ tags: ["users"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "Get user details by user ID. REQUIRED: userId (string)" },
+ })
.query(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
@@ -304,6 +384,8 @@ export const userRouter = createTRPCRouter({
defaultSearchEngineId: true,
openSearchInNewTab: true,
ddgBangs: true,
+ completedManageTour: true,
+ completedBoardTour: true,
},
where: eq(users.id, input.userId),
});
@@ -320,7 +402,14 @@ export const userRouter = createTRPCRouter({
editProfile: protectedProcedure
.input(userEditProfileSchema)
.output(z.void())
- .meta({ openapi: { method: "PUT", path: "/api/users/profile", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PUT",
+ path: "/api/users/profile",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.mutation(async ({ input, ctx }) => {
// Only admins can view other users details
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
@@ -364,7 +453,15 @@ export const userRouter = createTRPCRouter({
delete: protectedProcedure
.input(z.object({ userId: z.string() }))
.output(z.void())
- .meta({ openapi: { method: "DELETE", path: "/api/users/{userId}", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "DELETE",
+ path: "/api/users/{userId}",
+ tags: ["users"],
+ protect: true,
+ },
+ mcp: { enabled: true, description: "Delete a user by ID. REQUIRED: userId (string)" },
+ })
.mutation(async ({ input, ctx }) => {
// Only admins and user itself can delete a user
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
@@ -379,7 +476,14 @@ export const userRouter = createTRPCRouter({
changePassword: protectedProcedure
.input(convertIntersectionToZodObject(userChangePasswordApiSchema))
.output(z.void())
- .meta({ openapi: { method: "PATCH", path: "/api/users/{userId}/changePassword", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/users/{userId}/changePassword",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.mutation(async ({ ctx, input }) => {
const user = ctx.session.user;
// Only admins can change other users' passwords
@@ -444,7 +548,14 @@ export const userRouter = createTRPCRouter({
changeHomeBoards: protectedProcedure
.input(convertIntersectionToZodObject(userChangeHomeBoardsSchema.and(z.object({ userId: z.string() }))))
.output(z.void())
- .meta({ openapi: { method: "PATCH", path: "/api/users/changeHome", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/users/changeHome",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.mutation(async ({ input, ctx }) => {
const user = ctx.session.user;
// Only admins can change other users passwords
@@ -510,14 +621,28 @@ export const userRouter = createTRPCRouter({
changeSearchPreferences: protectedProcedure
.input(convertIntersectionToZodObject(changeSearchPreferencesInputSchema))
.output(z.void())
- .meta({ openapi: { method: "PATCH", path: "/api/users/search-preferences", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/users/search-preferences",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.mutation(async ({ input, ctx }) => {
await changeSearchPreferencesAsync(ctx.db, ctx.session, input);
}),
changeColorScheme: protectedProcedure
.input(userChangeColorSchemeSchema)
.output(z.void())
- .meta({ openapi: { method: "PATCH", path: "/api/users/changeScheme", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/users/changeScheme",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.mutation(async ({ input, ctx }) => {
await ctx.db
.update(users)
@@ -542,7 +667,7 @@ export const userRouter = createTRPCRouter({
.set({
pingIconsEnabled: input.pingIconsEnabled,
})
- .where(eq(users.id, ctx.session.user.id));
+ .where(eq(users.id, input.id));
}),
changeDdgBangs: protectedProcedure
.input(convertIntersectionToZodObject(userDdgBangsSchema.and(byIdSchema)))
@@ -574,7 +699,14 @@ export const userRouter = createTRPCRouter({
changeFirstDayOfWeek: protectedProcedure
.input(convertIntersectionToZodObject(userFirstDayOfWeekSchema.and(byIdSchema)))
.output(z.void())
- .meta({ openapi: { method: "PATCH", path: "/api/users/firstDayOfWeek", tags: ["users"], protect: true } })
+ .meta({
+ openapi: {
+ method: "PATCH",
+ path: "/api/users/firstDayOfWeek",
+ tags: ["users"],
+ protect: true,
+ },
+ })
.mutation(async ({ input, ctx }) => {
// Only admins can change other users first day of week
if (!ctx.session.user.permissions.includes("admin") && ctx.session.user.id !== input.id) {
@@ -603,8 +735,48 @@ export const userRouter = createTRPCRouter({
.set({
firstDayOfWeek: input.firstDayOfWeek,
})
- .where(eq(users.id, ctx.session.user.id));
+ .where(eq(users.id, input.id));
}),
+ completeTour: protectedProcedure
+ .input(z.object({ tour: z.enum(["manage", "board"]) }))
+ .mutation(async ({ input, ctx }) => {
+ const columnByTour = {
+ manage: { completedManageTour: true },
+ board: { completedBoardTour: true },
+ } as const;
+
+ await ctx.db.update(users).set(columnByTour[input.tour]).where(eq(users.id, ctx.session.user.id));
+ }),
+ resetTours: protectedProcedure.mutation(async ({ ctx }) => {
+ await ctx.db
+ .update(users)
+ .set({
+ completedManageTour: false,
+ completedBoardTour: false,
+ })
+ .where(eq(users.id, ctx.session.user.id));
+ }),
+ getTourStatus: protectedProcedure.query(async ({ ctx }) => {
+ if (isDemoMode) {
+ return {
+ completedManageTour: true,
+ completedBoardTour: true,
+ };
+ }
+
+ const user = await ctx.db.query.users.findFirst({
+ columns: {
+ completedManageTour: true,
+ completedBoardTour: true,
+ },
+ where: eq(users.id, ctx.session.user.id),
+ });
+
+ return {
+ completedManageTour: user?.completedManageTour ?? false,
+ completedBoardTour: user?.completedBoardTour ?? false,
+ };
+ }),
});
const createUserAsync = async (db: Database, input: Omit, "groupIds">) => {
diff --git a/packages/api/src/router/widgets/audio-stats.ts b/packages/api/src/router/widgets/audio-stats.ts
new file mode 100644
index 000000000..d43876583
--- /dev/null
+++ b/packages/api/src/router/widgets/audio-stats.ts
@@ -0,0 +1,29 @@
+import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
+import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const audioStatsRouter = createTRPCRouter({
+ getStats: publicProcedure
+ .concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
+ .query(async ({ ctx }) => {
+ const { kind } = ctx.integration;
+
+ const fetchByKind = {
+ navidrome: async () => {
+ const handler = navidromeRequestHandler.handler({ ...ctx.integration, kind: "navidrome" as const }, {});
+ return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
+ },
+ audiobookshelf: async () => {
+ const handler = audiobookshelfRequestHandler.handler(
+ { ...ctx.integration, kind: "audiobookshelf" as const },
+ {},
+ );
+ return (await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false })).data;
+ },
+ } as const;
+
+ return { kind, data: await fetchByKind[kind]() };
+ }),
+});
diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts
new file mode 100644
index 000000000..d3b895d88
--- /dev/null
+++ b/packages/api/src/router/widgets/beszel.ts
@@ -0,0 +1,135 @@
+import { observable } from "@trpc/server/observable";
+import { z } from "zod/v4";
+
+import {
+ beszelAlertsRequestHandler,
+ beszelStatsRequestHandler,
+ beszelSystemsRequestHandler,
+} from "@homarr/request-handler/beszel";
+import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/beszel";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const beszelRouter = createTRPCRouter({
+ getSystems: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ systems: data,
+ updatedAt: timestamp,
+ };
+ }),
+ );
+ return results;
+ }),
+
+ subscribeSystems: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
+ .subscription(({ ctx }) => {
+ return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
+ const unsubscribes = ctx.integrations.map((integration) => {
+ const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
+ return innerHandler.subscribe((systems) => {
+ emit.next({
+ integrationId: integration.id,
+ systems,
+ timestamp: new Date(),
+ });
+ });
+ });
+ return () => {
+ unsubscribes.forEach((fn) => fn());
+ };
+ });
+ }),
+
+ getAlerts: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
+ .input(
+ z.object({
+ includeHistory: z.boolean().default(true),
+ maxHistoryItems: z.number().min(1).max(100).default(10),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
+ includeHistory: input.includeHistory,
+ maxHistoryItems: input.maxHistoryItems,
+ });
+ const systemsHandler = beszelSystemsRequestHandler.handler(integration, {});
+ const [alertsResult, systemsResult] = await Promise.all([
+ alertsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
+ systemsHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }),
+ ]);
+ const systemNameMap: Record = {};
+ for (const system of systemsResult.data) {
+ systemNameMap[system.id] = system.name;
+ }
+ return {
+ integrationId: integration.id,
+ alerts: alertsResult.data.alerts,
+ history: alertsResult.data.history,
+ systemNameMap,
+ updatedAt: alertsResult.timestamp,
+ };
+ }),
+ );
+ return results;
+ }),
+
+ subscribeAlerts: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
+ .input(
+ z.object({
+ includeHistory: z.boolean().default(true),
+ maxHistoryItems: z.number().min(1).max(100).default(10),
+ }),
+ )
+ .subscription(({ ctx, input }) => {
+ return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
+ const unsubscribes = ctx.integrations.map((integration) => {
+ const innerHandler = beszelAlertsRequestHandler.handler(integration, {
+ includeHistory: input.includeHistory,
+ maxHistoryItems: input.maxHistoryItems,
+ });
+ return innerHandler.subscribe((data) => {
+ emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
+ });
+ });
+ return () => {
+ unsubscribes.forEach((fn) => fn());
+ };
+ });
+ }),
+
+ getSystemStats: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
+ .input(
+ z.object({
+ systemId: z.string(),
+ timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
+ includeDocker: z.boolean().default(true),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ const integration = ctx.integrations[0];
+ if (!integration) return null;
+ const innerHandler = beszelStatsRequestHandler.handler(integration, {
+ systemId: input.systemId,
+ timePeriod: input.timePeriod,
+ includeDocker: input.includeDocker,
+ });
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return { integrationId: integration.id, ...data, updatedAt: timestamp };
+ }),
+});
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
index 5119cfc36..50ba71b3e 100644
--- a/packages/api/src/router/widgets/calendar.ts
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -18,6 +18,13 @@ const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get calendar events for upcoming and recent media releases. REQUIRED: integrationIds (array of integration IDs from integration_all, filter by kind sonarr/radarr/lidarr/readarr), year (number), month (number), releaseType (array of 'inCinemas'|'digitalRelease'|'physicalRelease'), showUnmonitored (boolean). Great for 'what's coming out this week/month?'",
+ },
+ })
.input(
z.object({
year: z.number(),
@@ -32,7 +39,9 @@ export const calendarRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const { integrationIds: _integrationIds, ...handlerInput } = input;
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
events: data,
diff --git a/packages/api/src/router/widgets/dns-hole.ts b/packages/api/src/router/widgets/dns-hole.ts
index 46b868435..93162a1e3 100644
--- a/packages/api/src/router/widgets/dns-hole.ts
+++ b/packages/api/src/router/widgets/dns-hole.ts
@@ -14,12 +14,21 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
export const dnsHoleRouter = createTRPCRouter({
summary: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get DNS blocking statistics from Pi-hole/AdGuard (queries, blocked, percentage). REQUIRED: integrationIds (array of Pi-hole/AdGuard integration IDs from integration_all)",
+ },
+ })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = dnsHoleRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integration: {
@@ -63,6 +72,13 @@ export const dnsHoleRouter = createTRPCRouter({
}),
enable: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Enable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single Pi-hole/AdGuard integration ID from integration_all)",
+ },
+ })
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("dnsHole")))
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
@@ -76,6 +92,13 @@ export const dnsHoleRouter = createTRPCRouter({
}),
disable: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Disable DNS blocking on Pi-hole/AdGuard. REQUIRED: integrationId (single integration ID). OPTIONAL: duration (number of seconds for temporary disable — omit for permanent disable)",
+ },
+ })
.input(
z.object({
duration: z.number().optional(),
diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts
index f5ed6fdb9..682decdc4 100644
--- a/packages/api/src/router/widgets/downloads.ts
+++ b/packages/api/src/router/widgets/downloads.ts
@@ -18,6 +18,13 @@ const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
export const downloadsRouter = createTRPCRouter({
getJobsAndStatuses: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get active download jobs and queue status from connected download clients (qBittorrent, SABnzbd, Transmission, Deluge, NZBGet). REQUIRED: integrationIds (array of download client integration IDs from integration_all). OPTIONAL: limitPerIntegration (number, default 50)",
+ },
+ })
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
@@ -25,7 +32,9 @@ export const downloadsRouter = createTRPCRouter({
ctx.integrations.map(async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integration: {
@@ -68,14 +77,23 @@ export const downloadsRouter = createTRPCRouter({
};
});
}),
- pause: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
- await Promise.all(
- ctx.integrations.map(async (integration) => {
- const integrationInstance = await createIntegrationAsync(integration);
- await integrationInstance.pauseQueueAsync();
- }),
- );
- }),
+ pause: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Pause all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
+ },
+ })
+ .concat(createDownloadClientIntegrationMiddleware("interact"))
+ .mutation(async ({ ctx }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.pauseQueueAsync();
+ }),
+ );
+ }),
pauseItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
@@ -87,14 +105,23 @@ export const downloadsRouter = createTRPCRouter({
}),
);
}),
- resume: protectedProcedure.concat(createDownloadClientIntegrationMiddleware("interact")).mutation(async ({ ctx }) => {
- await Promise.all(
- ctx.integrations.map(async (integration) => {
- const integrationInstance = await createIntegrationAsync(integration);
- await integrationInstance.resumeQueueAsync();
- }),
- );
- }),
+ resume: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Resume all download queues across connected download clients. REQUIRED: integrationIds (array of download client integration IDs from integration_all)",
+ },
+ })
+ .concat(createDownloadClientIntegrationMiddleware("interact"))
+ .mutation(async ({ ctx }) => {
+ await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const integrationInstance = await createIntegrationAsync(integration);
+ await integrationInstance.resumeQueueAsync();
+ }),
+ );
+ }),
resumeItem: protectedProcedure
.concat(createDownloadClientIntegrationMiddleware("interact"))
.input(z.object({ item: downloadClientItemSchema }))
diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts
index 46741ae1c..03856f5a0 100644
--- a/packages/api/src/router/widgets/health-monitoring.ts
+++ b/packages/api/src/router/widgets/health-monitoring.ts
@@ -9,6 +9,13 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
+ },
+ })
.concat(
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
@@ -16,7 +23,9 @@ export const healthMonitoringRouter = createTRPCRouter({
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integrationId: integration.id,
@@ -32,7 +41,11 @@ export const healthMonitoringRouter = createTRPCRouter({
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
)
.subscription(({ ctx }) => {
- return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
+ return observable<{
+ integrationId: string;
+ healthInfo: SystemHealthMonitoring;
+ timestamp: Date;
+ }>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integration of ctx.integrations) {
const innerHandler = systemInfoRequestHandler.handler(integration, {});
@@ -53,10 +66,19 @@ export const healthMonitoringRouter = createTRPCRouter({
});
}),
getClusterHealthStatus: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get Proxmox cluster health status including nodes, VMs, and resource usage. REQUIRED: integrationId (single Proxmox integration ID from integration_all)",
+ },
+ })
.concat(createOneIntegrationMiddleware("query", "proxmox", "mock"))
.query(async ({ ctx }) => {
const innerHandler = clusterInfoRequestHandler.handler(ctx.integration, {});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return data;
}),
subscribeClusterHealthStatus: publicProcedure
diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts
index e40c3f000..9596cf44e 100644
--- a/packages/api/src/router/widgets/index.ts
+++ b/packages/api/src/router/widgets/index.ts
@@ -3,9 +3,11 @@ import { lazy } from "@trpc/server";
import { createTRPCRouter } from "../../trpc";
export const widgetRouter = createTRPCRouter({
+ 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)),
notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)),
weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)),
app: lazy(() => import("./app").then((mod) => mod.appRouter)),
@@ -30,5 +32,8 @@ export const widgetRouter = createTRPCRouter({
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)),
+ ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
});
diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts
index a77c89b10..715ae1327 100644
--- a/packages/api/src/router/widgets/media-requests.ts
+++ b/packages/api/src/router/widgets/media-requests.ts
@@ -13,12 +13,21 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
export const mediaRequestsRouter = createTRPCRouter({
getLatestRequests: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get latest media requests from Overseerr/Jellyseerr with their status (pending, approved, declined, available). REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
+ },
+ })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integration: {
id: integration.id,
@@ -30,8 +39,13 @@ export const mediaRequestsRouter = createTRPCRouter({
}),
);
return results
- .flatMap(({ data, integration }) => data.map((request) => ({ ...request, integrationId: integration.id })))
- .sort((dataA, dataB) => {
+ .flatMap(({ data, integration }) =>
+ data.map((request) => ({
+ ...request,
+ integrationId: integration.id,
+ })),
+ )
+ .toSorted((dataA, dataB) => {
if (dataA.status === dataB.status) {
return dataB.createdAt.getTime() - dataA.createdAt.getTime();
}
@@ -69,12 +83,21 @@ export const mediaRequestsRouter = createTRPCRouter({
});
}),
getStats: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get media request statistics including total counts and top requesters. REQUIRED: integrationIds (array of Overseerr/Jellyseerr integration IDs from integration_all)",
+ },
+ })
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaRequestStatsRequestHandler.handler(integration, {});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integration: {
id: integration.id,
@@ -88,25 +111,42 @@ export const mediaRequestsRouter = createTRPCRouter({
return {
stats: results.flatMap((result) => result.data.stats),
users: results
- .map((result) => result.data.users.map((user) => ({ ...user, integration: result.integration })))
+ .map((result) =>
+ result.data.users.map((user) => ({
+ ...user,
+ integration: result.integration,
+ })),
+ )
.flat()
- .sort(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
+ .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA),
integrations: results.map((result) => result.integration),
};
}),
answerRequest: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Approve or decline a pending media request. REQUIRED: integrationId (single Overseerr/Jellyseerr integration ID), requestId (number from getLatestRequests), answer ('approve' or 'decline')",
+ },
+ })
.concat(createOneIntegrationMiddleware("interact", ...getIntegrationKindsByCategory("mediaRequest")))
- .input(z.object({ requestId: z.number(), answer: z.enum(["approve", "decline"]) }))
+ .input(
+ z.object({
+ requestId: z.number(),
+ answer: z.enum(["approve", "decline"]),
+ }),
+ )
.mutation(async ({ ctx: { integration }, input }) => {
const integrationInstance = await createIntegrationAsync(integration);
const innerHandler = mediaRequestListRequestHandler.handler(integration, {});
- if (input.answer === "approve") {
- await integrationInstance.approveRequestAsync(input.requestId);
- await innerHandler.invalidateAsync();
- return;
- }
- await integrationInstance.declineRequestAsync(input.requestId);
+ const answerActions = {
+ approve: (id: number) => integrationInstance.approveRequestAsync(id),
+ decline: (id: number) => integrationInstance.declineRequestAsync(id),
+ } as const;
+
+ await answerActions[input.answer](input.requestId);
await innerHandler.invalidateAsync();
}),
});
diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts
index 0e497c8ff..de61fef75 100644
--- a/packages/api/src/router/widgets/media-server.ts
+++ b/packages/api/src/router/widgets/media-server.ts
@@ -14,6 +14,13 @@ const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
export const mediaServerRouter = createTRPCRouter({
getCurrentStreams: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get currently active streams from Plex/Jellyfin/Emby media servers. REQUIRED: integrationIds (array of media server integration IDs from integration_all), showOnlyPlaying (boolean — true to filter to actively playing streams only)",
+ },
+ })
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
@@ -22,7 +29,9 @@ export const mediaServerRouter = createTRPCRouter({
const innerHandler = mediaServerRequestHandler.handler(integration, {
showOnlyPlaying: input.showOnlyPlaying,
});
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return {
integrationId: integration.id,
integrationKind: integration.kind,
diff --git a/packages/api/src/router/widgets/paperless-ngx.ts b/packages/api/src/router/widgets/paperless-ngx.ts
new file mode 100644
index 000000000..0fb81e72a
--- /dev/null
+++ b/packages/api/src/router/widgets/paperless-ngx.ts
@@ -0,0 +1,12 @@
+import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
+
+import { createOneIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const paperlessNgxRouter = createTRPCRouter({
+ getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
+ const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
+ const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ return data.data;
+ }),
+});
diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts
index 2dc9add89..dec3770a7 100644
--- a/packages/api/src/router/widgets/smart-home.ts
+++ b/packages/api/src/router/widgets/smart-home.ts
@@ -14,11 +14,20 @@ const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
export const smartHomeRouter = createTRPCRouter({
entityState: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get the current state of a Home Assistant entity (light, switch, sensor, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room', 'switch.fan')",
+ },
+ })
.input(z.object({ entityId: z.string() }))
.concat(createSmartHomeIntegrationMiddleware("query"))
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
- const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+ const { data } = await innerHandler.getCachedOrUpdatedDataAsync({
+ forceUpdate: false,
+ });
return data;
}),
subscribeEntityState: publicProcedure
@@ -42,6 +51,13 @@ export const smartHomeRouter = createTRPCRouter({
});
}),
switchEntity: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Toggle a Home Assistant entity (turn on/off a light, switch, etc.). REQUIRED: integrationId (Home Assistant integration ID from integration_all), entityId (e.g. 'light.living_room')",
+ },
+ })
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ entityId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
@@ -54,6 +70,13 @@ export const smartHomeRouter = createTRPCRouter({
return success;
}),
executeAutomation: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Trigger a Home Assistant automation by its ID. REQUIRED: integrationId (Home Assistant integration ID from integration_all), automationId (the automation entity ID)",
+ },
+ })
.concat(createSmartHomeIntegrationMiddleware("interact"))
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
diff --git a/packages/api/src/router/widgets/ups.ts b/packages/api/src/router/widgets/ups.ts
new file mode 100644
index 000000000..f1c27c434
--- /dev/null
+++ b/packages/api/src/router/widgets/ups.ts
@@ -0,0 +1,51 @@
+import { observable } from "@trpc/server/observable";
+
+import { getIntegrationKindsByCategory } from "@homarr/definitions";
+import type { UpsSummary } from "@homarr/integrations/types";
+import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const upsRouter = createTRPCRouter({
+ getSummaries: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = upsSummariesRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ summaries: data,
+ updatedAt: timestamp,
+ };
+ }),
+ );
+
+ return results;
+ }),
+ subscribeSummaries: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
+ .subscription(({ ctx }) => {
+ return observable<{ integrationId: string; summaries: UpsSummary[]; timestamp: Date }>((emit) => {
+ const unsubscribes = ctx.integrations.map((integration) => {
+ const innerHandler = upsSummariesRequestHandler.handler(integration, {});
+ return innerHandler.subscribe((summaries) => {
+ emit.next({
+ integrationId: integration.id,
+ summaries,
+ timestamp: new Date(),
+ });
+ });
+ });
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts
new file mode 100644
index 000000000..eb28f9e7e
--- /dev/null
+++ b/packages/api/src/router/widgets/uptime-kuma.ts
@@ -0,0 +1,55 @@
+import { observable } from "@trpc/server/observable";
+
+import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
+import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
+
+import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { createTRPCRouter, publicProcedure } from "../../trpc";
+
+export const uptimeKumaRouter = createTRPCRouter({
+ getDashboard: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
+ .query(async ({ ctx }) => {
+ const results = await Promise.all(
+ ctx.integrations.map(async (integration) => {
+ const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
+
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationUrl: integration.url,
+ dashboard: data,
+ updatedAt: timestamp,
+ };
+ }),
+ );
+
+ return results;
+ }),
+
+ subscribeToDashboard: publicProcedure
+ .concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
+ .subscription(({ ctx }) => {
+ return observable<{
+ integrationId: string;
+ dashboard: UptimeKumaDashboardData;
+ timestamp: Date;
+ }>((emit) => {
+ const unsubscribes = ctx.integrations.map((integration) => {
+ const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
+ return innerHandler.subscribe((dashboard) => {
+ emit.next({
+ integrationId: integration.id,
+ dashboard,
+ timestamp: new Date(),
+ });
+ });
+ });
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe());
+ };
+ });
+ }),
+});
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
new file mode 100644
index 000000000..4fc696aa7
--- /dev/null
+++ b/packages/api/src/test/mcp.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test, vi } from "vitest";
+import { extractToolsFromProcedures } from "trpc-to-mcp";
+
+import { appRouter } from "../router/app";
+import { infoRouter } from "../router/info";
+import { inviteRouter } from "../router/invite";
+import { createTRPCRouter } from "../trpc";
+
+vi.mock("@homarr/auth", () => ({}));
+
+const mcpTestRouter = createTRPCRouter({
+ app: appRouter,
+ info: infoRouter,
+ invite: inviteRouter,
+});
+
+test("MCP tools should be extractable from the router", () => {
+ const act = () => extractToolsFromProcedures(mcpTestRouter);
+
+ expect(act).not.toThrow();
+});
+
+test("MCP tools should contain expected procedures", () => {
+ const tools = extractToolsFromProcedures(mcpTestRouter);
+ const toolNames = tools.map((tool) => tool.name);
+
+ expect(tools.length).toBeGreaterThan(0);
+ expect(toolNames).toContain("app_all");
+ expect(toolNames).toContain("app_byId");
+ expect(toolNames).toContain("app_create");
+ expect(toolNames).toContain("info_getInfo");
+ expect(toolNames).toContain("invite_getAll");
+});
+
+test("MCP tools should have descriptions", () => {
+ const tools = extractToolsFromProcedures(mcpTestRouter);
+
+ for (const tool of tools) {
+ expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
+ }
+});
diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts
index bedd60f3d..0ef944669 100644
--- a/packages/api/src/trpc.ts
+++ b/packages/api/src/trpc.ts
@@ -8,6 +8,7 @@
*/
import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
+import type { McpMeta } from "trpc-to-mcp";
import type { OpenApiMeta } from "trpc-to-openapi";
import { ZodError } from "zod/v4";
@@ -18,6 +19,7 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions";
+import { env } from "./env";
import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries";
const logger = createLogger({ module: "trpc" });
@@ -38,7 +40,11 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
const session = opts.session;
const source = opts.headers.get("x-trpc-source") ?? "unknown";
- logger.info("Received tRPC request", { source, userId: session?.user.id, userName: session?.user.name });
+ logger.info("Received tRPC request", {
+ source,
+ userId: session?.user.id,
+ userName: session?.user.name,
+ });
return {
session,
@@ -55,7 +61,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
*/
const t = initTRPC
.context()
- .meta()
+ .meta()
.create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
@@ -87,6 +93,20 @@ export const createCallerFactory = t.createCallerFactory;
*/
export const createTRPCRouter = t.router;
+export const isDemoMode = env.DEMO_MODE;
+
+const enforceDemoModeReadOnly = t.middleware(({ ctx, next, type }) => {
+ if (env.DEMO_MODE && type === "mutation") {
+ throw new TRPCError({
+ code: "FORBIDDEN",
+ message: "Mutations are disabled in demo mode",
+ });
+ }
+ return next({ ctx });
+});
+
+const baseProcedure = env.DEMO_MODE ? t.procedure.use(enforceDemoModeReadOnly) : t.procedure;
+
/**
* Public (unauthed) procedure
*
@@ -94,19 +114,14 @@ export const createTRPCRouter = t.router;
* tRPC API. It does not guarantee that a user querying is authorized, but you
* can still access user session data if they are logged in
*/
-export const publicProcedure = t.procedure;
+export const publicProcedure = baseProcedure;
-/**
- * Reusable middleware that enforces users are logged in before running the
- * procedure
- */
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
ctx: {
- // infers the `session` as non-nullable
session: { ...ctx.session, user: ctx.session.user },
},
});
@@ -121,7 +136,7 @@ const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
*
* @see https://trpc.io/docs/procedures
*/
-export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
+export const protectedProcedure = baseProcedure.use(enforceUserIsAuthed);
/**
* Procedure that requires a specific permission
diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts
index 7550def44..f13cc1efc 100644
--- a/packages/common/src/url.ts
+++ b/packages/common/src/url.ts
@@ -1,7 +1,11 @@
import type { ReadonlyHeaders } from "next/dist/server/web/spec-extension/adapters/headers";
export const removeTrailingSlash = (path: string) => {
- return path.at(-1) === "/" ? path.substring(0, path.length - 1) : path;
+ let end = path.length;
+ while (end > 0 && path.charAt(end - 1) === "/") {
+ end--;
+ }
+ return path.slice(0, end);
};
export const extractBaseUrlFromHeaders = (
diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts
index 197a61c8e..4d09801d2 100644
--- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts
+++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts
@@ -10,6 +10,9 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC
(integration, itemOptions: Record) => {
const { kind } = integration;
+ if (kind === "beszel") {
+ return null as never;
+ }
if (kind !== "proxmox" && kind !== "mock" && kind !== "coolify") {
return systemInfoRequestHandler.handler({ ...integration, kind }, itemOptions);
}
diff --git a/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql b/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql
new file mode 100644
index 000000000..356540dc7
--- /dev/null
+++ b/packages/db/migrations/mysql/0039_add_user_onboarding_tour_status.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `user` ADD `completed_manage_tour` boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE `user` ADD `completed_board_tour` boolean DEFAULT false NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/mysql/meta/0039_snapshot.json b/packages/db/migrations/mysql/meta/0039_snapshot.json
new file mode 100644
index 000000000..6630bd286
--- /dev/null
+++ b/packages/db/migrations/mysql/meta/0039_snapshot.json
@@ -0,0 +1,2120 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "e7c06822-4b94-49ea-b685-acb08ee6b63e",
+ "prevId": "ddg-bangs-mysql-0038",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "apiKey_id": {
+ "name": "apiKey_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "app_id": {
+ "name": "app_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('fixed')"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('no-repeat')"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('cover')"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fa5252')"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fd7e14')"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('lg')"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "board_id": {
+ "name": "board_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "cron_job_configuration_name": {
+ "name": "cron_job_configuration_name",
+ "columns": ["name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_id": {
+ "name": "group_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "iconRepository_id": {
+ "name": "iconRepository_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "icon_id": {
+ "name": "icon_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_id": {
+ "name": "integration_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "invite_id": {
+ "name": "invite_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_id": {
+ "name": "item_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "layout_id": {
+ "name": "layout_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "LONGBLOB",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_id": {
+ "name": "media_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "onboarding_id": {
+ "name": "onboarding_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "search_engine_id": {
+ "name": "search_engine_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_id": {
+ "name": "section_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "serverSetting_setting_key": {
+ "name": "serverSetting_setting_key",
+ "columns": ["setting_key"]
+ }
+ },
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "session_session_token": {
+ "name": "session_session_token",
+ "columns": ["session_token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_id": {
+ "name": "user_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json
index 421c81699..f420759ec 100644
--- a/packages/db/migrations/mysql/meta/_journal.json
+++ b/packages/db/migrations/mysql/meta/_journal.json
@@ -274,6 +274,13 @@
"when": 1775400000000,
"tag": "0038_add_user_ddg_bangs",
"breakpoints": true
+ },
+ {
+ "idx": 39,
+ "version": "5",
+ "when": 1779199104477,
+ "tag": "0039_add_user_onboarding_tour_status",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql b/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql
new file mode 100644
index 000000000..dda51181e
--- /dev/null
+++ b/packages/db/migrations/postgresql/0005_add_user_onboarding_tour_status.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "user" ADD COLUMN "completed_manage_tour" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "user" ADD COLUMN "completed_board_tour" boolean DEFAULT false NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/postgresql/meta/0005_snapshot.json b/packages/db/migrations/postgresql/meta/0005_snapshot.json
new file mode 100644
index 000000000..759659315
--- /dev/null
+++ b/packages/db/migrations/postgresql/meta/0005_snapshot.json
@@ -0,0 +1,2016 @@
+{
+ "id": "2869b3b7-af54-4fbc-a953-766387ec2e3d",
+ "prevId": "ddg-bangs-pg-0004",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.apiKey": {
+ "name": "apiKey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.app": {
+ "name": "app",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardUserPermission": {
+ "name": "boardUserPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.board": {
+ "name": "board",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupMember": {
+ "name": "groupMember",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupPermission": {
+ "name": "groupPermission",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group": {
+ "name": "group",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.iconRepository": {
+ "name": "iconRepository",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.icon": {
+ "name": "icon",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration_item": {
+ "name": "integration_item",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationSecret": {
+ "name": "integrationSecret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration": {
+ "name": "integration",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite": {
+ "name": "invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item_layout": {
+ "name": "item_layout",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item": {
+ "name": "item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.layout": {
+ "name": "layout",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.onboarding": {
+ "name": "onboarding",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.search_engine": {
+ "name": "search_engine",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "nullsNotDistinct": false,
+ "columns": ["short"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_collapse_state": {
+ "name": "section_collapse_state",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_layout": {
+ "name": "section_layout",
+ "schema": "",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section": {
+ "name": "section",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.serverSetting": {
+ "name": "serverSetting",
+ "schema": "",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "nullsNotDistinct": false,
+ "columns": ["setting_key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verificationToken": {
+ "name": "verificationToken",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "schema": "",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json
index d0a3f7683..b6ecf835f 100644
--- a/packages/db/migrations/postgresql/meta/_journal.json
+++ b/packages/db/migrations/postgresql/meta/_journal.json
@@ -36,6 +36,13 @@
"when": 1775400000000,
"tag": "0004_add_user_ddg_bangs",
"breakpoints": true
+ },
+ {
+ "idx": 5,
+ "version": "7",
+ "when": 1779199097716,
+ "tag": "0005_add_user_onboarding_tour_status",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index a2dec94af..d491b6092 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -1,29 +1,38 @@
+import SuperJSON from "superjson";
+
import { createId, objectKeys } from "@homarr/common";
import {
createDocumentationLink,
credentialsAdminGroup,
+ defaultBookmarkApps,
everyoneGroup,
getIntegrationDefaultUrl,
getIntegrationName,
integrationDefs,
integrationKinds,
} from "@homarr/definitions";
+import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
-import { getMaxGroupPositionAsync } from "../queries";
+import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
insertServerSettingByKeyAsync,
updateServerSettingByKeyAsync,
} from "../queries/server-setting";
+
import {
+ apps,
boards,
groupMembers,
groupPermissions,
groups,
+ integrationItems,
integrations,
+ itemLayouts,
+ items,
layouts,
onboarding,
searchEngines,
@@ -32,14 +41,25 @@ import {
} from "../schema";
import type { Integration } from "../schema";
+const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
+
export const seedDataAsync = async (db: Database) => {
+ if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
+ console.warn(
+ "UNSAFE_ENABLE_MOCK_INTEGRATION is enabled: mock integration is available in the UI. Disable by setting UNSAFE_ENABLE_MOCK_INTEGRATION=false.",
+ );
+ }
+
await seedEveryoneGroupAsync(db);
await seedOnboardingAsync(db);
await seedServerSettingsAsync(db);
await seedDefaultSearchEnginesAsync(db);
await seedDefaultIntegrationsAsync(db);
+ await seedDefaultAppsAsync(db);
+ await seedDefaultBoardAsync(db);
+ await seedBoardWidgetsAsync(db);
- if (process.env.DEMO_MODE === "true") {
+ if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db);
}
};
@@ -209,6 +229,198 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
console.log(`Created ${createdCount} default integrations through seeding process`);
};
+const seedDefaultAppsAsync = async (db: Database) => {
+ const existingApps = await db.$count(apps);
+ if (existingApps > 0) {
+ console.log("Skipping seeding of default apps as some already exist");
+ return;
+ }
+
+ for (const app of defaultBookmarkApps) {
+ await db.insert(apps).values({
+ id: createId(),
+ name: app.name,
+ iconUrl: app.iconUrl,
+ href: app.href,
+ });
+ }
+ console.log(`Created ${defaultBookmarkApps.length} default apps through seeding process`);
+};
+
+const seedDefaultBoardAsync = async (db: Database) => {
+ const existingBoard = await db.query.boards.findFirst();
+
+ if (existingBoard) {
+ console.log("Skipping seeding of default board as one already exists");
+ return;
+ }
+
+ const boardId = createId();
+ await db.insert(boards).values({
+ id: boardId,
+ name: "dashboard",
+ isPublic: false,
+ });
+ await db.insert(sections).values({
+ id: createId(),
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ boardId,
+ });
+ await db.insert(layouts).values({
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 0,
+ boardId,
+ });
+
+ const everyoneGroupRow = await db.query.groups.findFirst({
+ where: eq(groups.name, everyoneGroup),
+ });
+ if (everyoneGroupRow) {
+ await db.update(groups).set({ homeBoardId: boardId }).where(eq(groups.id, everyoneGroupRow.id));
+ console.log("Set default board as home board for everyone group");
+ }
+
+ console.log("Created default board 'dashboard' through seed");
+};
+
+interface DemoWidget {
+ kind: WidgetKind;
+ width: number;
+ height: number;
+ needsIntegration: boolean;
+ options?: Record;
+}
+
+const demoApps = [
+ {
+ name: "Sonarr",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
+ href: "https://sonarr.tv",
+ },
+ {
+ name: "Radarr",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
+ href: "https://radarr.video",
+ },
+ {
+ name: "Plex",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
+ href: "https://plex.tv",
+ },
+ {
+ name: "Jellyfin",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/jellyfin.svg",
+ href: "https://jellyfin.org",
+ },
+ {
+ name: "Proxmox",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/proxmox.svg",
+ href: "https://proxmox.com",
+ },
+ {
+ name: "Pi-hole",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
+ href: "https://pi-hole.net",
+ },
+ {
+ name: "Grafana",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/grafana.svg",
+ href: "https://grafana.com",
+ },
+ {
+ name: "Portainer",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/portainer.svg",
+ href: "https://portainer.io",
+ },
+ {
+ name: "Home Assistant",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/home-assistant.svg",
+ href: "https://home-assistant.io",
+ },
+ {
+ name: "Nextcloud",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nextcloud.svg",
+ href: "https://nextcloud.com",
+ },
+ {
+ name: "qBittorrent",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
+ href: "https://qbittorrent.org",
+ },
+ {
+ name: "Overseerr",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
+ href: "https://overseerr.dev",
+ },
+] as const;
+
+const buildDemoWidgets = (appIds: string[]): DemoWidget[] => [
+ // Row 1: calendar + downloads + clock = 12
+ { kind: "calendar", width: 5, height: 3, needsIntegration: true },
+ { kind: "downloads", width: 5, height: 3, needsIntegration: true },
+ { kind: "clock", width: 2, height: 1, needsIntegration: false },
+ // Row 2: healthMonitoring + bookmarks = 12
+ { kind: "healthMonitoring", width: 6, height: 3, needsIntegration: true },
+ {
+ kind: "bookmarks",
+ width: 6,
+ height: 3,
+ needsIntegration: false,
+ options: { title: "Homelab", items: appIds, layout: "grid", openNewTab: true },
+ },
+ // Row 3: mediaServer + mediaTranscoding + dnsHoleSummary = 12
+ { kind: "mediaServer", width: 3, height: 2, needsIntegration: true },
+ { kind: "mediaTranscoding", width: 3, height: 2, needsIntegration: true },
+ { kind: "dnsHoleSummary", width: 6, height: 2, needsIntegration: true },
+ // Row 4: dnsHoleControls + mediaReleases + notifications = 12
+ { kind: "dnsHoleControls", width: 2, height: 1, needsIntegration: true },
+ { kind: "mediaReleases", width: 4, height: 2, needsIntegration: true },
+ { kind: "notifications", width: 6, height: 2, needsIntegration: true },
+ // Row 5: requestList + requestStats + indexerManager = 12
+ { kind: "mediaRequests-requestList", width: 4, height: 3, needsIntegration: true },
+ { kind: "mediaRequests-requestStats", width: 4, height: 2, needsIntegration: true },
+ { kind: "indexerManager", width: 4, height: 2, needsIntegration: true },
+ // Row 6: networkControllerSummary + networkControllerStatus + rssFeed = 12
+ { kind: "networkControllerSummary", width: 3, height: 2, needsIntegration: true },
+ { kind: "networkControllerStatus", width: 3, height: 2, needsIntegration: true },
+ {
+ kind: "rssFeed",
+ width: 6,
+ height: 2,
+ needsIntegration: false,
+ options: {
+ feedUrls: ["https://selfh.st/rss/", "https://hnrss.org/newest?q=self-hosted"],
+ maximumAmountPosts: 20,
+ textLinesClamp: 2,
+ hideDescription: false,
+ },
+ },
+ // Row 7-8: app widgets (2x1 each, 6 per row = 12)
+ ...appIds.map(
+ (appId): DemoWidget => ({
+ kind: "app",
+ width: 2,
+ height: 1,
+ needsIntegration: false,
+ options: { appId, openInNewTab: true, showTitle: true, pingEnabled: false },
+ }),
+ ),
+ // Row 9: beszelSystemGrid + beszelAlerts = 12
+ { kind: "beszelSystemGrid", width: 8, height: 3, needsIntegration: true },
+ { kind: "beszelAlerts", width: 4, height: 3, needsIntegration: true },
+ // Row 10: beszelSystemTable + beszelSystemStats = 12
+ { kind: "beszelSystemTable", width: 6, height: 3, needsIntegration: true },
+ { kind: "beszelSystemStats", width: 6, height: 4, needsIntegration: true },
+ // Row 11: notebook + dockerContainers + weather = 12
+ { kind: "notebook", width: 4, height: 4, needsIntegration: false },
+ { kind: "dockerContainers", width: 6, height: 2, needsIntegration: false },
+ { kind: "weather", width: 2, height: 1, needsIntegration: false },
+];
+
const seedDemoUserAsync = async (db: Database) => {
const existingUser = await db.query.users.findFirst({
where: eq(users.name, "demo"),
@@ -225,7 +437,7 @@ const seedDemoUserAsync = async (db: Database) => {
id: userId,
name: "demo",
email: "demo@example.com",
- password: "$2b$10$c5AaQb0oj32/bui5Z55PyOb7pY.iEkXYqgdD8MjS9LjSjLMeOkZu2",
+ password: "$2b$10$odRXt5e95kSQV5Axmk/FeO6GVOxuRQQ8NnRcBA78Wg4V3kZxPY68u",
});
const maxPosition = await getMaxGroupPositionAsync(db);
@@ -250,6 +462,27 @@ const seedDemoUserAsync = async (db: Database) => {
previousStep: "settings",
});
+ const integrationId = createId();
+ await db.insert(integrations).values({
+ id: integrationId,
+ name: "Demo Integration",
+ url: "https://demo.homarr.dev",
+ kind: "mock",
+ appId: null,
+ });
+
+ const appIds: string[] = [];
+ for (const app of demoApps) {
+ const appId = createId();
+ appIds.push(appId);
+ await db.insert(apps).values({
+ id: appId,
+ name: app.name,
+ iconUrl: app.iconUrl,
+ href: app.href,
+ });
+ }
+
const boardId = createId();
await db.insert(boards).values({
id: boardId,
@@ -257,23 +490,93 @@ const seedDemoUserAsync = async (db: Database) => {
isPublic: false,
creatorId: userId,
});
+
+ const sectionId = createId();
await db.insert(sections).values({
- id: createId(),
+ id: sectionId,
kind: "empty",
xOffset: 0,
yOffset: 0,
boardId,
});
+
+ const layoutId = createId();
await db.insert(layouts).values({
- id: createId(),
+ id: layoutId,
name: "Base",
columnCount: 12,
breakpoint: 0,
boardId,
});
+
+ const demoWidgets = buildDemoWidgets(appIds);
+ let xOffset = 0;
+ let yOffset = 0;
+ let rowMaxHeight = 0;
+ for (const widget of demoWidgets) {
+ if (xOffset + widget.width > 12) {
+ xOffset = 0;
+ yOffset += rowMaxHeight;
+ rowMaxHeight = 0;
+ }
+ rowMaxHeight = Math.max(rowMaxHeight, widget.height);
+ const itemId = createId();
+ await db.insert(items).values({
+ id: itemId,
+ boardId,
+ kind: widget.kind,
+ ...(widget.options ? { options: SuperJSON.stringify(widget.options) } : {}),
+ });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId,
+ xOffset,
+ yOffset,
+ width: widget.width,
+ height: widget.height,
+ });
+ if (widget.needsIntegration) {
+ await db.insert(integrationItems).values({
+ itemId,
+ integrationId,
+ });
+ }
+ xOffset += widget.width;
+ }
+
await db.update(users).set({ homeBoardId: boardId }).where(eq(users.id, userId));
console.log(
- "Your instance has enabled demo mode. Homarr therefore created example data for your demo. You can disable demo mode anytime by setting the env 'DEMO_MODE' to 'false' or unset it.",
+ "Demo mode enabled: created demo user, mock integration, and sample board with widgets. Disable by setting DEMO_MODE=false.",
);
};
+
+const seedBoardWidgetsAsync = async (db: Database) => {
+ const existingItems = await db.$count(items);
+ if (existingItems > 0) {
+ console.log("Skipping seeding of board widgets as some already exist");
+ return;
+ }
+
+ const board = await db.query.boards.findFirst({
+ with: { sections: true, layouts: true },
+ });
+ if (!board) return;
+
+ const section = board.sections.find((sec) => sec.kind === "empty");
+ const layout = board.layouts[0];
+ if (!section || !layout) return;
+
+ const allIntegrations = await db.query.integrations.findMany();
+ const allApps = await db.query.apps.findMany();
+
+ const count = await placeAllWidgetsAsync(
+ db,
+ { boardId: board.id, sectionId: section.id, layoutId: layout.id, columnCount: layout.columnCount },
+ allIntegrations,
+ allApps,
+ );
+
+ console.log(`Placed ${count} widgets on board`);
+};
diff --git a/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql b/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql
new file mode 100644
index 000000000..292ce4ece
--- /dev/null
+++ b/packages/db/migrations/sqlite/0037_add_user_onboarding_tour_status.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `user` ADD `completed_manage_tour` integer DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE `user` ADD `completed_board_tour` integer DEFAULT false NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/sqlite/meta/0037_snapshot.json b/packages/db/migrations/sqlite/meta/0037_snapshot.json
new file mode 100644
index 000000000..74827789c
--- /dev/null
+++ b/packages/db/migrations/sqlite/meta/0037_snapshot.json
@@ -0,0 +1,2035 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "f30b6535-b4d5-4501-8370-7a7605e28bbd",
+ "prevId": "ddg-bangs-sqlite-0036",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "columns": ["provider", "provider_account_id"],
+ "name": "account_provider_provider_account_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "columns": ["board_id", "group_id", "permission"],
+ "name": "boardGroupPermission_board_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "columns": ["board_id", "user_id", "permission"],
+ "name": "boardUserPermission_board_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "columns": ["group_id", "user_id"],
+ "name": "groupMember_group_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationGroupPermissions_integration_id_group_id_permission_pk": {
+ "columns": ["integration_id", "group_id", "permission"],
+ "name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "columns": ["item_id", "integration_id"],
+ "name": "integration_item_item_id_integration_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "columns": ["integration_id", "kind"],
+ "name": "integrationSecret_integration_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "columns": ["integration_id", "user_id", "permission"],
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "columns": ["item_id", "section_id", "layout_id"],
+ "name": "item_layout_item_id_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "columns": ["user_id", "section_id"],
+ "name": "section_collapse_state_user_id_section_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "columns": ["section_id", "layout_id"],
+ "name": "section_layout_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "columns": ["hostname", "thumbprint"],
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json
index 5543bbde0..2f4866c79 100644
--- a/packages/db/migrations/sqlite/meta/_journal.json
+++ b/packages/db/migrations/sqlite/meta/_journal.json
@@ -260,6 +260,13 @@
"when": 1775400000000,
"tag": "0036_add_user_ddg_bangs",
"breakpoints": true
+ },
+ {
+ "idx": 37,
+ "version": "6",
+ "when": 1779199046805,
+ "tag": "0037_add_user_onboarding_tour_status",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/queries/index.ts b/packages/db/queries/index.ts
index 13e90d6eb..368d7eaa2 100644
--- a/packages/db/queries/index.ts
+++ b/packages/db/queries/index.ts
@@ -1,3 +1,4 @@
export * from "./item";
export * from "./server-setting";
export * from "./group";
+export * from "./widget-placement";
diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts
new file mode 100644
index 000000000..1d0b529f9
--- /dev/null
+++ b/packages/db/queries/widget-placement.ts
@@ -0,0 +1,148 @@
+import superjson from "superjson";
+
+import { createId } from "@homarr/common";
+import {
+ defaultBookmarkApps,
+ defaultWidgetConfigs,
+ emptySuperJSON,
+ getWidgetKindsForIntegration,
+} from "@homarr/definitions";
+import type { WidgetKind } from "@homarr/definitions";
+
+import type { Database } from "..";
+import { integrationItems, itemLayouts, items } from "../schema";
+import type { Integration } from "../schema";
+
+const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
+
+interface BoardTarget {
+ boardId: string;
+ sectionId: string;
+ layoutId: string;
+ columnCount: number;
+}
+
+interface PlacementContext extends BoardTarget {
+ xOffset: number;
+ yOffset: number;
+ rowMaxHeight: number;
+}
+
+const placeWidgetAsync = async (
+ db: Database,
+ ctx: PlacementContext,
+ kind: WidgetKind,
+ linkedIntegrationIds: string[],
+ options?: string,
+ size?: { width: number; height: number },
+) => {
+ const itemWidth = size?.width ?? 2;
+ const itemHeight = size?.height ?? 2;
+
+ if (ctx.xOffset + itemWidth > ctx.columnCount) {
+ ctx.xOffset = 0;
+ ctx.yOffset += ctx.rowMaxHeight;
+ ctx.rowMaxHeight = 0;
+ }
+
+ const itemId = createId();
+ await db.insert(items).values({
+ id: itemId,
+ boardId: ctx.boardId,
+ kind,
+ options: options ?? emptySuperJSON,
+ advancedOptions: emptySuperJSON,
+ });
+
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId: ctx.sectionId,
+ layoutId: ctx.layoutId,
+ xOffset: ctx.xOffset,
+ yOffset: ctx.yOffset,
+ width: itemWidth,
+ height: itemHeight,
+ });
+
+ for (const integrationId of linkedIntegrationIds) {
+ await db.insert(integrationItems).values({ itemId, integrationId });
+ }
+
+ ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
+ ctx.xOffset += itemWidth;
+};
+
+interface App {
+ id: string;
+ name: string;
+}
+
+export const placeAllWidgetsAsync = async (
+ db: Database,
+ target: BoardTarget,
+ allIntegrations: Integration[],
+ allApps: App[],
+) => {
+ const ctx: PlacementContext = {
+ ...target,
+ xOffset: 0,
+ yOffset: 0,
+ rowMaxHeight: 0,
+ };
+
+ const placedWidgets = new Set();
+
+ for (const integration of allIntegrations) {
+ for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) {
+ if (placedWidgets.has(widgetKind)) continue;
+ const config = widgetConfigMap.get(widgetKind);
+ if (config?.skip) continue;
+ placedWidgets.add(widgetKind);
+
+ const matchingIds = allIntegrations
+ .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind))
+ .map((row) => row.id);
+
+ const options = config?.options ? superjson.stringify(config.options) : undefined;
+ await placeWidgetAsync(
+ db,
+ ctx,
+ widgetKind,
+ matchingIds,
+ options,
+ config && { width: config.width, height: config.height },
+ );
+ }
+ }
+
+ for (const app of allApps) {
+ await placeWidgetAsync(
+ db,
+ ctx,
+ "app",
+ [],
+ superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
+ { width: 1, height: 1 },
+ );
+ }
+
+ const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name));
+ const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id);
+
+ for (const config of defaultWidgetConfigs) {
+ if (config.skip || placedWidgets.has(config.kind)) continue;
+ placedWidgets.add(config.kind);
+
+ let options = config.options ? { ...config.options } : undefined;
+ if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) {
+ options = { ...options, items: bookmarkAppIds };
+ }
+
+ await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
+ width: config.width,
+ height: config.height,
+ });
+ }
+
+ return placedWidgets.size + allApps.length;
+};
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index 84c5325ed..7e2897c46 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -80,6 +80,8 @@ export const users = mysqlTable("user", {
colorScheme: varchar({ length: 5 }).$type().default("dark").notNull(),
firstDayOfWeek: tinyint().$type().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
+ completedManageTour: boolean().default(false).notNull(),
+ completedBoardTour: boolean().default(false).notNull(),
});
export const accounts = mysqlTable(
diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts
index 412916013..8ea445325 100644
--- a/packages/db/schema/postgresql.ts
+++ b/packages/db/schema/postgresql.ts
@@ -79,6 +79,8 @@ export const users = pgTable("user", {
colorScheme: varchar({ length: 5 }).$type().default("dark").notNull(),
firstDayOfWeek: smallint().$type().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: boolean().default(false).notNull(),
+ completedManageTour: boolean().default(false).notNull(),
+ completedBoardTour: boolean().default(false).notNull(),
});
export const accounts = pgTable(
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index 50671db85..b4fe5bbe0 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -62,6 +62,8 @@ export const users = sqliteTable("user", {
colorScheme: text().$type().default("dark").notNull(),
firstDayOfWeek: int().$type().default(1).notNull(), // Defaults to Monday
pingIconsEnabled: int({ mode: "boolean" }).default(false).notNull(),
+ completedManageTour: int({ mode: "boolean" }).default(false).notNull(),
+ completedBoardTour: int({ mode: "boolean" }).default(false).notNull(),
});
export const accounts = sqliteTable(
diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts
new file mode 100644
index 000000000..9dcba12d4
--- /dev/null
+++ b/packages/definitions/src/docker-integration-match.ts
@@ -0,0 +1,94 @@
+import { objectKeys } from "@homarr/common";
+
+import { integrationDefs } from "./integration";
+import type { IntegrationKind } from "./integration";
+
+export const extractContainerImageName = (image: string): string => image.split("/").at(-1)?.split(":").at(0) ?? "";
+
+const extractIconSlug = (iconUrl: string): string => {
+ const filename = iconUrl.split("/").pop() ?? "";
+ return filename.replace(/\.(svg|png)$/, "").toLowerCase();
+};
+
+export const integrationIconSlugs: Record = Object.fromEntries(
+ objectKeys(integrationDefs).map((kind) => [kind, extractIconSlug(integrationDefs[kind].iconUrl)]),
+) as Record;
+
+const integrationAliases: Partial> = {
+ piHole: ["pihole", "pi-hole"],
+ adGuardHome: ["adguardhome", "adguard-home", "adguard"],
+ homeAssistant: ["homeassistant", "home-assistant", "hass"],
+ openmediavault: ["omv"],
+ qBittorrent: ["qbittorrent"],
+ sabNzbd: ["sabnzbd"],
+ nzbGet: ["nzbget"],
+ unifiController: ["unifi", "unifi-controller"],
+ jellyseerr: ["jellyseerr"],
+ overseerr: ["overseerr"],
+ dashDot: ["dashdot", "dash-dot", "dash."],
+ speedtestTracker: ["speedtest-tracker"],
+ uptimeKuma: ["uptime-kuma"],
+ audiobookshelf: ["audiobookshelf"],
+ navidrome: ["navidrome"],
+ paperlessNgx: ["paperless-ngx", "paperless"],
+ searchCh: ["search-ch"],
+ gitHubContainerRegistry: ["ghcr"],
+ linuxServerIO: ["linuxserver"],
+ coolify: ["coolify"],
+ truenas: ["truenas"],
+};
+
+export const matchIntegrationKind = (search: string): IntegrationKind | null => {
+ const normalized = search.toLowerCase().trim();
+ if (!normalized) return null;
+
+ for (const kind of objectKeys(integrationDefs)) {
+ if (kind.toLowerCase() === normalized) return kind;
+ if (integrationDefs[kind].name.toLowerCase() === normalized) return kind;
+ }
+
+ for (const kind of objectKeys(integrationDefs)) {
+ if (integrationIconSlugs[kind] === normalized) return kind;
+ }
+
+ for (const [kind, aliases] of Object.entries(integrationAliases) as [IntegrationKind, readonly string[]][]) {
+ if (aliases.some((alias) => alias === normalized)) return kind;
+ }
+
+ for (const kind of objectKeys(integrationDefs)) {
+ if (normalized.includes(kind.toLowerCase())) return kind;
+ if (normalized.includes(integrationIconSlugs[kind])) return kind;
+ }
+
+ return null;
+};
+
+const notDockerDiscoverable = new Set([
+ "dockerHub",
+ "github",
+ "gitlab",
+ "npm",
+ "codeberg",
+ "linuxServerIO",
+ "gitHubContainerRegistry",
+ "quay",
+ "ical",
+ "searchCh",
+ "mock",
+]);
+
+interface ContainerMatchInput {
+ image: string;
+ name: string;
+}
+
+export const matchIntegrationKindFromContainer = (container: ContainerMatchInput): IntegrationKind | null => {
+ const imageName = extractContainerImageName(container.image);
+ const fromImage = matchIntegrationKind(imageName);
+ if (fromImage && !notDockerDiscoverable.has(fromImage)) return fromImage;
+
+ const fromName = matchIntegrationKind(container.name);
+ if (fromName && !notDockerDiscoverable.has(fromName)) return fromName;
+
+ return null;
+};
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index b97a34937..348bbf78d 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -157,6 +157,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/anchor"
| "/docs/integrations/aria2"
| "/docs/integrations/codeberg"
+ | "/docs/integrations/coolify"
| "/docs/integrations/dash-dot"
| "/docs/integrations/deluge"
| "/docs/integrations/docker-hub"
@@ -181,6 +182,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/open-media-vault"
| "/docs/integrations/opnsense"
| "/docs/integrations/overseerr"
+ | "/docs/integrations/peanut"
| "/docs/integrations/pi-hole"
| "/docs/integrations/plex"
| "/docs/integrations/prowlarr"
@@ -217,6 +219,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/bookmarks"
| "/docs/widgets/calendar"
| "/docs/widgets/clock"
+ | "/docs/widgets/coolify"
| "/docs/widgets/dns-hole-controls"
| "/docs/widgets/dns-hole-summary"
| "/docs/widgets/docker-containers"
@@ -247,6 +250,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/umami"
+ | "/docs/widgets/ups"
| "/docs/widgets/video"
| "/docs/widgets/weather"
| ""
diff --git a/packages/definitions/src/index.ts b/packages/definitions/src/index.ts
index 3eb639ba6..eb81e4d18 100644
--- a/packages/definitions/src/index.ts
+++ b/packages/definitions/src/index.ts
@@ -12,5 +12,8 @@ export * from "./docs";
export * from "./cookie";
export * from "./search-engine";
export * from "./onboarding";
+export * from "./widget-integration-map";
export * from "./emptysuperjson";
export * from "./hotkeys";
+export * from "./docker-integration-match";
+export * from "./integration-url-template";
diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts
new file mode 100644
index 000000000..bb8312c6e
--- /dev/null
+++ b/packages/definitions/src/integration-url-template.ts
@@ -0,0 +1,41 @@
+import { removeTrailingSlash } from "@homarr/common";
+
+import { integrationIconSlugs } from "./docker-integration-match";
+import type { IntegrationKind } from "./integration";
+import { getIntegrationDefaultPort } from "./integration";
+
+export type UrlTemplateMode = "hostPort" | "subdomain";
+
+const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind];
+
+const buildUrl = (slug: string, host: string, mode: UrlTemplateMode, port?: number): string => {
+ const modeBuilders: Record string> = {
+ subdomain: () => `https://${slug}.${host}`,
+ hostPort: () => (port ? `http://${host}:${port}` : `http://${host}`),
+ };
+ return modeBuilders[mode]();
+};
+
+export const buildIntegrationUrl = (
+ kind: IntegrationKind,
+ baseHost: string,
+ mode: UrlTemplateMode,
+ dockerPort?: number,
+): string => {
+ const host = removeTrailingSlash(baseHost.trim());
+ if (!host) return "";
+ const port = dockerPort ?? getIntegrationDefaultPort(kind);
+ return buildUrl(getSlugForKind(kind), host, mode, port ?? undefined);
+};
+
+export const buildAppUrl = (
+ containerName: string,
+ baseHost: string,
+ mode: UrlTemplateMode,
+ dockerPort?: number,
+): string => {
+ const host = removeTrailingSlash(baseHost.trim());
+ if (!host) return "";
+ const slug = containerName.toLowerCase().replace(/[^a-z0-9-]/g, "-");
+ return buildUrl(slug, host, mode, dockerPort);
+};
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index f90f81a38..eed534ea0 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -1,4 +1,4 @@
-import { objectKeys } from "@homarr/common";
+import { objectKeys, removeTrailingSlash } from "@homarr/common";
import type { AtLeastOneOf } from "@homarr/common/types";
import { createDocumentationLink } from "./docs";
@@ -17,6 +17,7 @@ export const integrationSecretKindObject = {
privateKey: { isPublic: false, multiline: true },
githubAppId: { isPublic: true, multiline: false },
githubInstallationId: { isPublic: true, multiline: false },
+ slug: { isPublic: true, multiline: false },
} satisfies Record;
export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
@@ -27,7 +28,9 @@ interface integrationDefinition {
secretKinds: AtLeastOneOf; // at least one secret kind set is required
category: AtLeastOneOf;
documentationUrl: string | null;
- defaultUrl?: string; // optional default URL for the integration
+ defaultUrl?: string;
+ defaultPort?: number;
+ apiKeySettingsPath?: string;
}
export const integrationDefs = {
@@ -37,6 +40,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg",
category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/sabnzbd"),
+ defaultPort: 8080,
+ apiKeySettingsPath: "/config/general/",
},
nzbGet: {
name: "NZBGet",
@@ -44,6 +49,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg",
category: ["downloadClient", "usenet"],
documentationUrl: createDocumentationLink("/docs/integrations/nzbget"),
+ defaultPort: 6789,
},
deluge: {
name: "Deluge",
@@ -51,6 +57,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/deluge.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/deluge"),
+ defaultPort: 8112,
},
transmission: {
name: "Transmission",
@@ -58,6 +65,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/transmission"),
+ defaultPort: 9091,
},
qBittorrent: {
name: "qBittorrent",
@@ -65,6 +73,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/qbittorrent.svg",
category: ["downloadClient", "torrent"],
documentationUrl: createDocumentationLink("/docs/integrations/q-bittorent"),
+ defaultPort: 8080,
+ apiKeySettingsPath: "/#/settings/webui",
},
aria2: {
name: "Aria2",
@@ -72,6 +82,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg",
category: ["downloadClient", "torrent", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/aria2"),
+ defaultPort: 6800,
},
slskd: {
name: "Slskd",
@@ -79,6 +90,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/slskd.svg",
category: ["downloadClient", "miscellaneous"],
documentationUrl: createDocumentationLink("/docs/integrations/slskd"),
+ defaultPort: 5030,
},
sonarr: {
name: "Sonarr",
@@ -86,6 +98,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
+ defaultPort: 8989,
+ apiKeySettingsPath: "/settings/general",
},
radarr: {
name: "Radarr",
@@ -93,6 +107,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
+ defaultPort: 7878,
+ apiKeySettingsPath: "/settings/general",
},
lidarr: {
name: "Lidarr",
@@ -100,6 +116,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/lidarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/lidarr"),
+ defaultPort: 8686,
+ apiKeySettingsPath: "/settings/general",
},
readarr: {
name: "Readarr",
@@ -107,6 +125,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/readarr.svg",
category: ["calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/readarr"),
+ defaultPort: 8787,
+ apiKeySettingsPath: "/settings/general",
},
prowlarr: {
name: "Prowlarr",
@@ -114,6 +134,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/prowlarr.svg",
category: ["indexerManager"],
documentationUrl: createDocumentationLink("/docs/integrations/prowlarr"),
+ defaultPort: 9696,
+ apiKeySettingsPath: "/settings/general",
},
jellyfin: {
name: "Jellyfin",
@@ -121,6 +143,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyfin"),
+ defaultPort: 8096,
+ apiKeySettingsPath: "/web/index.html#!/dashboard/keys",
},
emby: {
name: "Emby",
@@ -128,6 +152,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/emby"),
+ defaultPort: 8096,
},
plex: {
name: "Plex",
@@ -135,6 +160,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService", "mediaRelease"],
documentationUrl: createDocumentationLink("/docs/integrations/plex"),
+ defaultPort: 32400,
},
jellyseerr: {
name: "Jellyseerr",
@@ -142,6 +168,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/jellyseerr"),
+ defaultPort: 5055,
+ apiKeySettingsPath: "/settings",
},
seerr: {
name: "Seerr",
@@ -149,6 +177,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/seerr"),
+ defaultPort: 5055,
+ apiKeySettingsPath: "/settings",
},
overseerr: {
name: "Overseerr",
@@ -156,6 +186,8 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/overseerr.svg",
category: ["mediaSearch", "mediaRequest", "search"],
documentationUrl: createDocumentationLink("/docs/integrations/overseerr"),
+ defaultPort: 5055,
+ apiKeySettingsPath: "/settings",
},
piHole: {
name: "Pi-hole",
@@ -163,6 +195,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/pi-hole.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/pi-hole"),
+ defaultPort: 80,
},
adGuardHome: {
name: "AdGuard Home",
@@ -170,6 +203,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/adguard-home.svg",
category: ["dnsHole"],
documentationUrl: createDocumentationLink("/docs/integrations/adguard-home"),
+ defaultPort: 3000,
},
homeAssistant: {
name: "Home Assistant",
@@ -177,6 +211,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg",
category: ["smartHomeServer", "calendar"],
documentationUrl: createDocumentationLink("/docs/integrations/home-assistant"),
+ defaultPort: 8123,
},
openmediavault: {
name: "OpenMediaVault",
@@ -184,6 +219,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/openmediavault.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/open-media-vault"),
+ defaultPort: 80,
},
dashDot: {
name: "Dash.",
@@ -198,6 +234,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
category: ["healthMonitoring"],
documentationUrl: null,
+ defaultPort: 61208,
},
tdarr: {
name: "Tdarr",
@@ -205,6 +242,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/tdarr.png",
category: ["mediaTranscoding"],
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
+ defaultPort: 8265,
},
proxmox: {
name: "Proxmox",
@@ -212,6 +250,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/proxmox"),
+ defaultPort: 8006,
},
nextcloud: {
name: "Nextcloud",
@@ -219,6 +258,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nextcloud.svg",
category: ["calendar", "notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/nextcloud"),
+ defaultPort: 443,
},
unifiController: {
name: "Unifi Controller",
@@ -226,6 +266,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["networkController"],
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
+ defaultPort: 8443,
},
opnsense: {
name: "OPNsense",
@@ -233,6 +274,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg",
category: ["firewall"],
documentationUrl: createDocumentationLink("/docs/integrations/opnsense"),
+ defaultPort: 443,
},
github: {
name: "Github",
@@ -304,6 +346,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/ntfy.svg",
category: ["notifications"],
documentationUrl: createDocumentationLink("/docs/integrations/ntfy"),
+ defaultPort: 80,
},
ical: {
name: "iCal",
@@ -318,13 +361,15 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/anchor.svg",
category: ["notes"],
documentationUrl: createDocumentationLink("/docs/integrations/anchor"),
+ defaultPort: 8080,
},
truenas: {
name: "TrueNAS",
- secretKinds: [["username", "password"]],
+ secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/truenas.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
+ defaultPort: 80,
},
unraid: {
name: "Unraid",
@@ -332,20 +377,20 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/unraid.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/unraid"),
+ defaultPort: 80,
},
coolify: {
name: "Coolify",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg",
category: ["healthMonitoring"],
- // @ts-expect-error - docs page will be created when integration is merged
documentationUrl: createDocumentationLink("/docs/integrations/coolify"),
+ defaultPort: 8000,
},
searchCh: {
name: "Search.ch",
secretKinds: [[]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/search-ch.png",
- defaultUrl: "https://search.ch",
category: ["timetable"],
documentationUrl: createDocumentationLink("/docs/integrations/search-ch"),
},
@@ -355,6 +400,15 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg",
category: ["photoService"],
documentationUrl: createDocumentationLink("/docs/integrations/immich"),
+ defaultPort: 2283,
+ },
+ paperlessNgx: {
+ name: "Paperless-ngx",
+ secretKinds: [["apiKey"]],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/paperless-ngx.svg",
+ category: ["documents"],
+ documentationUrl: null,
+ defaultPort: 8000,
},
tracearr: {
name: "Tracearr",
@@ -362,6 +416,7 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/tracearr.svg",
category: ["mediaMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/tracearr"),
+ defaultPort: 7040,
},
speedtestTracker: {
name: "Speedtest Tracker",
@@ -369,6 +424,32 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/speedtest-tracker.png",
category: ["speedtest"],
documentationUrl: createDocumentationLink("/docs/integrations/speedtest-tracker"),
+ defaultPort: 80,
+ },
+ uptimeKuma: {
+ name: "Uptime Kuma",
+ secretKinds: [[], ["slug"], ["slug", "apiKey"], ["apiKey"]],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg",
+ category: ["uptimeMonitoring"],
+ documentationUrl: null,
+ defaultPort: 3001,
+ },
+ audiobookshelf: {
+ name: "Audiobookshelf",
+ secretKinds: [["apiKey"]],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/audiobookshelf.svg",
+ category: ["mediaLibrary"],
+ documentationUrl: null,
+ defaultPort: 13378,
+ apiKeySettingsPath: "/account",
+ },
+ navidrome: {
+ name: "Navidrome",
+ secretKinds: [["username", "password"]],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/navidrome.svg",
+ category: ["mediaLibrary"],
+ documentationUrl: null,
+ defaultPort: 4533,
},
umami: {
name: "Umami",
@@ -377,6 +458,22 @@ export const integrationDefs = {
category: ["analytics"],
defaultUrl: "https://api.umami.is/v1",
documentationUrl: createDocumentationLink("/docs/integrations/umami"),
+ defaultPort: 3000,
+ },
+ peaNut: {
+ name: "PeaNUT",
+ secretKinds: [["username", "password"], []],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/peanut.svg",
+ category: ["ups"],
+ documentationUrl: null,
+ },
+ beszel: {
+ name: "Beszel",
+ secretKinds: [["username", "password"]],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/beszel.svg",
+ category: ["healthMonitoring"],
+ documentationUrl: null,
+ defaultPort: 8090,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
@@ -396,6 +493,8 @@ export const integrationDefs = {
"networkController",
"notifications",
"smartHomeServer",
+ "ups",
+ "uptimeMonitoring",
],
documentationUrl: null,
},
@@ -418,6 +517,19 @@ export const getIntegrationDefaultUrl = (integration: IntegrationKind) => {
return "defaultUrl" in definition ? definition.defaultUrl : undefined;
};
+export const getIntegrationDefaultPort = (kind: IntegrationKind): number | undefined => {
+ const definition = integrationDefs[kind];
+ return "defaultPort" in definition ? definition.defaultPort : undefined;
+};
+
+export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => {
+ const definition = integrationDefs[kind];
+ if (!("apiKeySettingsPath" in definition)) return null;
+ const base = removeTrailingSlash(integrationUrl);
+ if (!base) return null;
+ return `${base}${definition.apiKeySettingsPath}`;
+};
+
/**
* Get all integration kinds that share a category, typed only by the kinds belonging to the category
* @param category Category to filter by, belonging to IntegrationCategory
@@ -470,6 +582,10 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
+ "ups",
+ "documents",
+ "mediaLibrary",
+ "uptimeMonitoring",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
diff --git a/packages/definitions/src/onboarding.ts b/packages/definitions/src/onboarding.ts
index d9a6ea03e..305a71f65 100644
--- a/packages/definitions/src/onboarding.ts
+++ b/packages/definitions/src/onboarding.ts
@@ -1,2 +1,2 @@
-export const onboardingSteps = ["start", "import", "user", "group", "settings", "finish"] as const;
+export const onboardingSteps = ["start", "import", "user", "group", "settings", "integrations", "finish"] as const;
export type OnboardingStep = (typeof onboardingSteps)[number];
diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts
new file mode 100644
index 000000000..21b7e0fe7
--- /dev/null
+++ b/packages/definitions/src/test/api-key-url.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+
+import { getIntegrationApiKeyUrl } from "../integration";
+
+describe("getIntegrationApiKeyUrl", () => {
+ it("builds URL for sonarr", () => {
+ expect(getIntegrationApiKeyUrl("http://192.168.1.10:8989", "sonarr")).toBe(
+ "http://192.168.1.10:8989/settings/general",
+ );
+ });
+
+ it("builds URL for jellyfin", () => {
+ expect(getIntegrationApiKeyUrl("http://media.local:8096", "jellyfin")).toBe(
+ "http://media.local:8096/web/index.html#!/dashboard/keys",
+ );
+ });
+
+ it("returns null for integrations without apiKeySettingsPath", () => {
+ expect(getIntegrationApiKeyUrl("http://localhost", "plex")).toBeNull();
+ });
+
+ it("returns null for empty URL", () => {
+ expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull();
+ });
+
+ it("strips trailing slash from base URL", () => {
+ expect(getIntegrationApiKeyUrl("http://sonarr.local/", "sonarr")).toBe("http://sonarr.local/settings/general");
+ });
+});
diff --git a/packages/definitions/src/test/docker-integration-match.spec.ts b/packages/definitions/src/test/docker-integration-match.spec.ts
new file mode 100644
index 000000000..2a5db3a14
--- /dev/null
+++ b/packages/definitions/src/test/docker-integration-match.spec.ts
@@ -0,0 +1,119 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ extractContainerImageName,
+ matchIntegrationKind,
+ matchIntegrationKindFromContainer,
+} from "../docker-integration-match";
+
+describe("extractContainerImageName", () => {
+ it("extracts name from full image reference", () => {
+ expect(extractContainerImageName("linuxserver/sonarr:latest")).toBe("sonarr");
+ });
+
+ it("extracts name from ghcr image", () => {
+ expect(extractContainerImageName("ghcr.io/org/radarr:v5")).toBe("radarr");
+ });
+
+ it("handles plain image name", () => {
+ expect(extractContainerImageName("jellyfin")).toBe("jellyfin");
+ });
+
+ it("handles empty string", () => {
+ expect(extractContainerImageName("")).toBe("");
+ });
+});
+
+describe("matchIntegrationKind", () => {
+ it("matches exact kind name", () => {
+ expect(matchIntegrationKind("sonarr")).toBe("sonarr");
+ });
+
+ it("matches case-insensitively", () => {
+ expect(matchIntegrationKind("Sonarr")).toBe("sonarr");
+ });
+
+ it("matches icon slug for pi-hole", () => {
+ expect(matchIntegrationKind("pi-hole")).toBe("piHole");
+ });
+
+ it("matches alias pihole", () => {
+ expect(matchIntegrationKind("pihole")).toBe("piHole");
+ });
+
+ it("matches alias adguardhome", () => {
+ expect(matchIntegrationKind("adguardhome")).toBe("adGuardHome");
+ });
+
+ it("matches alias homeassistant", () => {
+ expect(matchIntegrationKind("homeassistant")).toBe("homeAssistant");
+ });
+
+ it("matches substring in image name", () => {
+ expect(matchIntegrationKind("hotio-sonarr")).toBe("sonarr");
+ });
+
+ it("returns null for unknown", () => {
+ expect(matchIntegrationKind("nginx")).toBeNull();
+ });
+
+ it("returns null for empty string", () => {
+ expect(matchIntegrationKind("")).toBeNull();
+ });
+});
+
+describe("matchIntegrationKindFromContainer", () => {
+ it("matches from image name first", () => {
+ expect(
+ matchIntegrationKindFromContainer({
+ image: "linuxserver/sonarr:latest",
+ name: "my-sonarr",
+ }),
+ ).toBe("sonarr");
+ });
+
+ it("falls back to container name", () => {
+ expect(
+ matchIntegrationKindFromContainer({
+ image: "custom/myapp:latest",
+ name: "radarr",
+ }),
+ ).toBe("radarr");
+ });
+
+ it("returns null when neither matches", () => {
+ expect(
+ matchIntegrationKindFromContainer({
+ image: "nginx:latest",
+ name: "web-proxy",
+ }),
+ ).toBeNull();
+ });
+
+ it("excludes dockerHub even when image contains docker", () => {
+ expect(
+ matchIntegrationKindFromContainer({
+ image: "portainer/portainer-ce:latest",
+ name: "docker-proxy",
+ }),
+ ).toBeNull();
+ });
+
+ it("excludes ical from container matching", () => {
+ expect(
+ matchIntegrationKindFromContainer({
+ image: "ical-relay:latest",
+ name: "ical",
+ }),
+ ).toBeNull();
+ });
+
+ it("excludes github from container matching", () => {
+ expect(
+ matchIntegrationKindFromContainer({
+ image: "github-runner:latest",
+ name: "github-actions",
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts
new file mode 100644
index 000000000..05e7a4e9b
--- /dev/null
+++ b/packages/definitions/src/test/integration-url-template.spec.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from "vitest";
+
+import { getIntegrationDefaultPort } from "../integration";
+import { buildIntegrationUrl } from "../integration-url-template";
+
+describe("buildIntegrationUrl", () => {
+ it("builds subdomain URL", () => {
+ expect(buildIntegrationUrl("sonarr", "homelab.local", "subdomain")).toBe("https://sonarr.homelab.local");
+ });
+
+ it("builds hostPort URL with default port", () => {
+ expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1:8989");
+ });
+
+ it("uses docker port over default port", () => {
+ expect(buildIntegrationUrl("sonarr", "192.168.1.1", "hostPort", 9999)).toBe("http://192.168.1.1:9999");
+ });
+
+ it("builds hostPort URL without port when none known", () => {
+ expect(buildIntegrationUrl("mock", "192.168.1.1", "hostPort")).toBe("http://192.168.1.1");
+ });
+
+ it("strips trailing slashes from host", () => {
+ expect(buildIntegrationUrl("sonarr", "homelab.local/", "subdomain")).toBe("https://sonarr.homelab.local");
+ expect(buildIntegrationUrl("sonarr", "homelab.local///", "subdomain")).toBe("https://sonarr.homelab.local");
+ });
+
+ it("returns empty string for empty host", () => {
+ expect(buildIntegrationUrl("sonarr", "", "subdomain")).toBe("");
+ });
+
+ it("uses pi-hole slug for subdomain mode", () => {
+ expect(buildIntegrationUrl("piHole", "example.com", "subdomain")).toBe("https://pi-hole.example.com");
+ });
+
+ it("reads default ports from integration definitions", () => {
+ expect(getIntegrationDefaultPort("radarr")).toBe(7878);
+ expect(getIntegrationDefaultPort("prowlarr")).toBe(9696);
+ expect(getIntegrationDefaultPort("jellyfin")).toBe(8096);
+ });
+});
diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts
new file mode 100644
index 000000000..87898a49e
--- /dev/null
+++ b/packages/definitions/src/widget-integration-map.ts
@@ -0,0 +1,116 @@
+import { createDocumentationLink } from "./docs";
+import type { IntegrationKind } from "./integration";
+import { getIntegrationKindsByCategory } from "./integration";
+import type { WidgetKind } from "./widget";
+
+/**
+ * Static mapping of widget kinds to the integration kinds they support.
+ * Mirrors the supportedIntegrations declared in each widget definition
+ * but lives in @homarr/definitions so server-side code (API layer)
+ * can use it without importing @homarr/widgets.
+ */
+export const widgetIntegrationSupport: Partial> = {
+ dnsHoleSummary: getIntegrationKindsByCategory("dnsHole"),
+ dnsHoleControls: getIntegrationKindsByCategory("dnsHole"),
+ "smartHome-entityState": getIntegrationKindsByCategory("smartHomeServer"),
+ "smartHome-executeAutomation": getIntegrationKindsByCategory("smartHomeServer"),
+ mediaServer: getIntegrationKindsByCategory("mediaService"),
+ calendar: getIntegrationKindsByCategory("calendar"),
+ downloads: getIntegrationKindsByCategory("downloadClient"),
+ "mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"),
+ "mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"),
+ mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"),
+ networkControllerSummary: getIntegrationKindsByCategory("networkController"),
+ networkControllerStatus: getIntegrationKindsByCategory("networkController"),
+ indexerManager: getIntegrationKindsByCategory("indexerManager"),
+ healthMonitoring: getIntegrationKindsByCategory("healthMonitoring"),
+ firewall: getIntegrationKindsByCategory("firewall"),
+ notifications: getIntegrationKindsByCategory("notifications"),
+ mediaReleases: ["emby", "jellyfin", "plex"],
+ systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
+ systemDisks: ["dashDot", "openmediavault", "truenas", "unraid"],
+ coolify: ["coolify"],
+ timetable: ["searchCh"],
+ "immich-serverStats": ["immich"],
+ "immich-albumCarousel": ["immich"],
+ paperlessNgx: ["paperlessNgx"],
+ tracearr: ["tracearr"],
+ speedtestTracker: ["speedtestTracker"],
+ uptimeKuma: ["uptimeKuma"],
+ audioStats: ["navidrome", "audiobookshelf"],
+ anchorNote: ["anchor"],
+};
+
+export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+ const result: WidgetKind[] = [];
+ for (const [widgetKind, supportedIntegrations] of Object.entries(widgetIntegrationSupport)) {
+ if (supportedIntegrations.includes(integrationKind)) {
+ result.push(widgetKind as WidgetKind);
+ }
+ }
+ return result;
+};
+
+export interface DefaultWidgetConfig {
+ kind: WidgetKind;
+ width: number;
+ height: number;
+ options?: Record;
+ skip?: boolean;
+}
+
+export const defaultWidgetConfigs: DefaultWidgetConfig[] = [
+ { kind: "clock", width: 2, height: 1 },
+ { kind: "weather", width: 2, height: 1, options: { showCity: true, hasForecast: true, forecastDayCount: 3 } },
+ { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } },
+];
+
+export const generalWidgets: WidgetKind[] = defaultWidgetConfigs
+ .filter((config) => !config.skip)
+ .map((config) => config.kind);
+
+export const featuredIntegrations: readonly IntegrationKind[] = [
+ "sonarr",
+ "radarr",
+ "prowlarr",
+ "sabNzbd",
+ "qBittorrent",
+ "seerr",
+ "jellyfin",
+];
+
+export const hiddenFromOnboarding = new Set([
+ "searchCh",
+ "github",
+ "dockerHub",
+ "gitlab",
+ "npm",
+ "codeberg",
+ "linuxServerIO",
+ "gitHubContainerRegistry",
+ "quay",
+ "mock",
+]);
+
+export const defaultBookmarkApps = [
+ {
+ name: "Homarr Docs",
+ href: createDocumentationLink("/docs/getting-started"),
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
+ },
+ {
+ name: "Homarr GitHub",
+ href: "https://github.com/homarr-labs/homarr",
+ iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/svg/github.svg",
+ },
+ {
+ name: "Help Translate",
+ href: createDocumentationLink("/docs/community/translations"),
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
+ },
+ {
+ name: "Support Homarr",
+ href: "https://opencollective.com/homarr",
+ iconUrl: "https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets@latest/assets/opencollective.svg",
+ },
+];
diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts
index d6305a08e..cb541ff8f 100644
--- a/packages/definitions/src/widget.ts
+++ b/packages/definitions/src/widget.ts
@@ -35,8 +35,22 @@ export const widgetKinds = [
"timetable",
"immich-serverStats",
"immich-albumCarousel",
+ "paperlessNgx",
"tracearr",
"speedtestTracker",
+ "uptimeKuma",
+ "audioStats",
"umami",
+ "ups",
+ "beszelSystemTable",
+ "beszelSystemGrid",
+ "beszelAlerts",
+ "beszelSystemStats",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+
+export const widgetDefaultSizes: Partial> = {
+ uptimeKuma: { width: 2, height: 3 },
+ audioStats: { width: 2, height: 2 },
+ paperlessNgx: { width: 2, height: 2 },
+};
diff --git a/packages/docker/src/shared.ts b/packages/docker/src/shared.ts
index f50bd32c7..e98695278 100644
--- a/packages/docker/src/shared.ts
+++ b/packages/docker/src/shared.ts
@@ -11,3 +11,23 @@ export const containerStateColorMap = {
removing: "pink",
dead: "dark",
} satisfies Record;
+
+export const memoryUsageColor = (number: number, state: string): MantineColor => {
+ const mbUsage = number / 1024 / 1024;
+ if (mbUsage === 0 && state !== "running") return "red";
+ if (mbUsage < 128) return "green";
+ if (mbUsage < 256) return "yellow";
+ if (mbUsage < 512) return "orange";
+ return "red";
+};
+
+export const cpuUsageColor = (number: number, state: string): MantineColor => {
+ if (number === 0 && state !== "running") return "red";
+ if (number < 40) return "green";
+ if (number < 60) return "yellow";
+ if (number < 90) return "orange";
+ return "red";
+};
+
+export const safeValue = (value?: number, fallback = 0): number =>
+ value !== undefined && !isNaN(value) ? value : fallback;
diff --git a/packages/forms-collection/src/new-app/_form.tsx b/packages/forms-collection/src/new-app/_form.tsx
index 05c7e3f6f..2bce21034 100644
--- a/packages/forms-collection/src/new-app/_form.tsx
+++ b/packages/forms-collection/src/new-app/_form.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ChangeEventHandler } from "react";
-import { useEffect, useRef } from "react";
+import { useEffect, useImperativeHandle, useRef } from "react";
import { Button, Checkbox, Collapse, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useDebouncedValue, useDisclosure } from "@mantine/hooks";
import type { z } from "zod/v4";
@@ -17,6 +17,19 @@ import { findBestIconMatch } from "./icon-matcher";
type FormType = z.infer;
+const toFormValues = (values: FormType | undefined): FormType => ({
+ name: values?.name ?? "",
+ description: values?.description ?? "",
+ iconUrl: values?.iconUrl ?? "",
+ href: values?.href ?? "",
+ pingUrl: values?.pingUrl ?? "",
+});
+
+export interface AppFormHandle {
+ submit: () => Promise;
+ isDirty: () => boolean;
+}
+
interface AppFormProps {
showBackToOverview: boolean;
buttonLabels: {
@@ -24,8 +37,10 @@ interface AppFormProps {
submitAndCreateAnother?: string;
};
initialValues?: FormType;
- handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void;
+ handleSubmit: (values: FormType, redirect: boolean, afterSuccess?: () => void) => void | Promise;
isPending: boolean;
+ hideButtons?: boolean;
+ formRef?: React.Ref;
}
export const AppForm = ({
@@ -34,19 +49,31 @@ export const AppForm = ({
handleSubmit: originalHandleSubmit,
initialValues,
isPending,
+ hideButtons,
+ formRef,
}: AppFormProps) => {
const t = useI18n();
const form = useZodForm(appManageSchema, {
- initialValues: {
- name: initialValues?.name ?? "",
- description: initialValues?.description ?? "",
- iconUrl: initialValues?.iconUrl ?? "",
- href: initialValues?.href ?? "",
- pingUrl: initialValues?.pingUrl ?? "",
- },
+ initialValues: toFormValues(initialValues),
});
+ const initialValuesKey = [
+ initialValues?.name,
+ initialValues?.description,
+ initialValues?.iconUrl,
+ initialValues?.href,
+ initialValues?.pingUrl,
+ ].join("\0");
+
+ useEffect(() => {
+ if (!initialValues || form.isDirty()) {
+ return;
+ }
+
+ form.initialize(toFormValues(initialValues));
+ }, [initialValuesKey, form, initialValues]);
+
// Debounce the name value with 200ms delay
const [debouncedName] = useDebouncedValue(form.values.name, 200);
@@ -62,6 +89,29 @@ export const AppForm = ({
originalHandleSubmit(values, redirect, afterSuccess);
};
+ useImperativeHandle(
+ formRef,
+ () => ({
+ submit: () =>
+ new Promise((resolve) => {
+ form.onSubmit(
+ async (values) => {
+ try {
+ await Promise.resolve(handleSubmit(values));
+ form.initialize(values);
+ resolve(true);
+ } catch {
+ resolve(false);
+ }
+ },
+ () => resolve(false),
+ )();
+ }),
+ isDirty: () => form.isDirty(),
+ }),
+ [form, handleSubmit],
+ );
+
const [opened, { open, close }] = useDisclosure((initialValues?.pingUrl?.length ?? 0) > 0);
const handleClickDifferentUrlPing: ChangeEventHandler = () => {
@@ -92,32 +142,32 @@ export const AppForm = ({
}
}, [debouncedName, iconsData]);
- return (
-
+ )}
+
);
+
+ if (hideButtons) {
+ return formFields;
+ }
+
+ return ;
};
diff --git a/packages/integrations/package.json b/packages/integrations/package.json
index 88ea6892a..6e0c81039 100644
--- a/packages/integrations/package.json
+++ b/packages/integrations/package.json
@@ -47,12 +47,14 @@
"timezones-ical-library": "catalog:",
"tsdav": "catalog:",
"undici": "catalog:",
+ "ws": "catalog:",
"xml2js": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "catalog:",
+ "@types/ws": "catalog:",
"@types/xml2js": "catalog:",
"typescript": "catalog:"
}
diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts
new file mode 100644
index 000000000..441d7fe3a
--- /dev/null
+++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts
@@ -0,0 +1,133 @@
+import { ResponseError } from "@homarr/common/server";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import {
+ audiobookshelfLibrariesResponseSchema,
+ audiobookshelfLibraryStatsSchema,
+ audiobookshelfListeningStatsSchema,
+ audiobookshelfOnlineUsersResponseSchema,
+} from "./audiobookshelf-types";
+import type {
+ AudiobookshelfDashboardData,
+ AudiobookshelfLibrary,
+ AudiobookshelfLibraryStats,
+} from "./audiobookshelf-types";
+
+const mediaTypeCountField = {
+ book: "totalAudiobooks",
+ podcast: "totalPodcasts",
+} as const satisfies Record<
+ AudiobookshelfLibrary["mediaType"],
+ keyof Pick
+>;
+
+export class AudiobookshelfIntegration extends Integration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const healthResponse = await input.fetchAsync(this.url("/healthcheck"));
+
+ if (!healthResponse.ok) {
+ return TestConnectionError.StatusResult(healthResponse);
+ }
+
+ const authorizeResponse = await input.fetchAsync(this.url("/api/authorize"), {
+ method: "POST",
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!authorizeResponse.ok) {
+ return TestConnectionError.StatusResult(authorizeResponse);
+ }
+
+ return { success: true };
+ }
+
+ public async getDashboardDataAsync(): Promise {
+ const [libraries, listeningStats, activeSessions] = await Promise.all([
+ this.getLibrariesAsync(),
+ this.getListeningStatsAsync(),
+ this.getActiveSessionsCountAsync(),
+ ]);
+
+ const statsByLibrary = await Promise.all(
+ libraries.map(async (library) => ({
+ mediaType: library.mediaType,
+ stats: await this.getLibraryStatsAsync(library.id),
+ })),
+ );
+
+ const counts = {
+ totalAudiobooks: 0,
+ totalPodcasts: 0,
+ };
+
+ for (const item of statsByLibrary) {
+ const countField = mediaTypeCountField[item.mediaType];
+ counts[countField] += item.stats.totalItems;
+ }
+
+ return {
+ libraryCount: libraries.length,
+ totalAudiobooks: counts.totalAudiobooks,
+ totalPodcasts: counts.totalPodcasts,
+ totalListeningTimeSeconds: listeningStats.totalTime,
+ activeSessions,
+ };
+ }
+
+ private async getLibrariesAsync(): Promise {
+ const response = await this.getAsync("/api/libraries");
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return audiobookshelfLibrariesResponseSchema.parse(await response.json()).libraries;
+ }
+
+ private async getLibraryStatsAsync(libraryId: string): Promise {
+ const response = await this.getAsync(`/api/libraries/${libraryId}/stats`);
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return audiobookshelfLibraryStatsSchema.parse(await response.json());
+ }
+
+ private async getListeningStatsAsync() {
+ const response = await this.getAsync("/api/me/listening-stats");
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return audiobookshelfListeningStatsSchema.parse(await response.json());
+ }
+
+ private async getActiveSessionsCountAsync(): Promise {
+ const response = await this.getAsync("/api/users/online");
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return audiobookshelfOnlineUsersResponseSchema.parse(await response.json()).openSessions.length;
+ }
+
+ private async getAsync(path: `/api/${string}` | "/healthcheck") {
+ return await fetchWithTrustedCertificatesAsync(this.url(path), {
+ headers: this.getAuthHeaders(),
+ });
+ }
+
+ private getAuthHeaders(): Record {
+ return {
+ Authorization: `Bearer ${this.getSecretValue("apiKey")}`,
+ Accept: "application/json",
+ };
+ }
+}
diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts
new file mode 100644
index 000000000..495a24c08
--- /dev/null
+++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts
@@ -0,0 +1,36 @@
+import { z } from "zod/v4";
+
+export const audiobookshelfLibrarySchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ mediaType: z.enum(["book", "podcast"]),
+});
+
+export const audiobookshelfLibrariesResponseSchema = z.object({
+ libraries: z.array(audiobookshelfLibrarySchema),
+});
+
+export const audiobookshelfLibraryStatsSchema = z.object({
+ totalItems: z.number(),
+ totalDuration: z.number().optional(),
+ totalSize: z.number().optional(),
+});
+
+export const audiobookshelfListeningStatsSchema = z.object({
+ totalTime: z.number(),
+});
+
+export const audiobookshelfOnlineUsersResponseSchema = z.object({
+ openSessions: z.array(z.unknown()),
+});
+
+export interface AudiobookshelfDashboardData {
+ libraryCount: number;
+ totalAudiobooks: number;
+ totalPodcasts: number;
+ totalListeningTimeSeconds: number;
+ activeSessions: number;
+}
+
+export type AudiobookshelfLibrary = z.infer;
+export type AudiobookshelfLibraryStats = z.infer;
diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts
index 9e2466bea..51eb7fa64 100644
--- a/packages/integrations/src/base/creator.ts
+++ b/packages/integrations/src/base/creator.ts
@@ -1,7 +1,9 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
+import { BeszelIntegration } from "../beszel/beszel-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
+import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CodebergIntegration } from "../codeberg/codeberg-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
@@ -30,12 +32,15 @@ import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integrati
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
+import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { NPMIntegration } from "../npm/npm-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
+import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
+import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
@@ -47,6 +52,7 @@ import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-trac
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
+import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import type { Integration, IntegrationInput } from "./integration";
@@ -121,8 +127,14 @@ export const integrationCreators = {
glances: GlancesIntegration,
searchCh: SearchChIntegration,
immich: ImmichIntegration,
+ paperlessNgx: PaperlessNgxIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
+ audiobookshelf: AudiobookshelfIntegration,
+ navidrome: NavidromeIntegration,
umami: UmamiIntegration,
+ uptimeKuma: UptimeKumaIntegration,
+ peaNut: PeaNutIntegration,
+ beszel: BeszelIntegration,
} satisfies Record Promise]>;
type IntegrationInstanceOfKind = {
diff --git a/packages/integrations/src/base/integration.ts b/packages/integrations/src/base/integration.ts
index 22ad8b1ea..e129ca283 100644
--- a/packages/integrations/src/base/integration.ts
+++ b/packages/integrations/src/base/integration.ts
@@ -85,6 +85,16 @@ export abstract class Integration {
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
}
+ protected webSocketUrl(
+ path: `/${string}`,
+ queryParams?: Record,
+ ) {
+ const url = this.url(path, queryParams);
+ // http -> ws, https -> wss
+ url.protocol = url.protocol.replace("http", "ws");
+ return url;
+ }
+
public async testConnectionAsync(): Promise {
try {
const url = new URL(this.integration.url);
diff --git a/packages/integrations/src/beszel/beszel-integration.ts b/packages/integrations/src/beszel/beszel-integration.ts
new file mode 100644
index 000000000..eb646c5dc
--- /dev/null
+++ b/packages/integrations/src/beszel/beszel-integration.ts
@@ -0,0 +1,269 @@
+import { ResponseError } from "@homarr/common/server";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import type { SessionStore } from "../base/session-store";
+import { createSessionStore } from "../base/session-store";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import type {
+ BeszelAlert,
+ BeszelAlertHistory,
+ BeszelAuthResponse,
+ BeszelContainer,
+ BeszelContainerStatsRecord,
+ BeszelSmartDevice,
+ BeszelSystem,
+ BeszelSystemDetails,
+ BeszelSystemdService,
+ BeszelSystemStatsRecord,
+ CreateAlertInput,
+ PocketBaseListResponse,
+ UpdateAlertInput,
+} from "./beszel-types";
+
+const escapeFilterValue = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
+
+interface BeszelSession {
+ token: string;
+ userId: string;
+}
+
+export class BeszelIntegration extends Integration {
+ private readonly sessionStore: SessionStore;
+
+ constructor(integration: IntegrationInput) {
+ super(integration);
+ this.sessionStore = createSessionStore(integration);
+ }
+
+ private async authenticateAsync(): Promise {
+ const existingSession = await this.sessionStore.getAsync();
+ if (existingSession) {
+ return existingSession;
+ }
+
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ identity: this.getSecretValue("username"),
+ password: this.getSecretValue("password"),
+ }),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ const data = (await response.json()) as BeszelAuthResponse;
+ const session: BeszelSession = { token: data.token, userId: data.record.id };
+ await this.sessionStore.setAsync(session);
+ return session;
+ }
+
+ private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
+ let session = await this.authenticateAsync();
+
+ const doFetch = (token: string) =>
+ fetchWithTrustedCertificatesAsync(url, {
+ method: options.method ?? "GET",
+ headers: {
+ Authorization: token,
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
+ },
+ body: options.body,
+ });
+
+ let response = await doFetch(session.token);
+
+ if (response.status === 401) {
+ await this.sessionStore.clearAsync();
+ session = await this.authenticateAsync();
+ response = await doFetch(session.token);
+ }
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ return response;
+ }
+
+ public async getSystemsAsync(): Promise {
+ const response = await this.fetchWithAuthAsync(
+ this.url("/api/collections/systems/records", { perPage: "500", sort: "-updated" }),
+ );
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getSystemDetailsAsync(systemId: string): Promise {
+ const response = await this.fetchWithAuthAsync(
+ this.url(`/api/collections/system_details/records/${systemId}` as `/${string}`),
+ );
+ return (await response.json()) as BeszelSystemDetails;
+ }
+
+ public async getSystemStatsAsync(systemId: string, type = "1m", perPage = 60): Promise {
+ const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
+ const response = await this.fetchWithAuthAsync(
+ this.url("/api/collections/system_stats/records", {
+ filter,
+ sort: "-created",
+ perPage: String(perPage),
+ }),
+ );
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getContainersAsync(systemId: string): Promise {
+ const response = await this.fetchWithAuthAsync(
+ this.url("/api/collections/containers/records", {
+ filter: `system='${escapeFilterValue(systemId)}'`,
+ sort: "-updated",
+ perPage: "500",
+ }),
+ );
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getContainerStatsAsync(
+ systemId: string,
+ type = "1m",
+ perPage = 60,
+ ): Promise {
+ const filter = `system='${escapeFilterValue(systemId)}' && type='${escapeFilterValue(type)}'`;
+ const response = await this.fetchWithAuthAsync(
+ this.url("/api/collections/container_stats/records", {
+ filter,
+ sort: "-created",
+ perPage: String(perPage),
+ }),
+ );
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getSmartDevicesAsync(systemId: string): Promise {
+ const response = await this.fetchWithAuthAsync(
+ this.url("/api/collections/smart_devices/records", {
+ filter: `system='${escapeFilterValue(systemId)}'`,
+ }),
+ );
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getSystemdServicesAsync(systemId: string): Promise {
+ const response = await this.fetchWithAuthAsync(
+ this.url("/api/collections/systemd_services/records", {
+ filter: `system='${escapeFilterValue(systemId)}'`,
+ sort: "name",
+ }),
+ );
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getAlertsAsync(systemId?: string): Promise {
+ const params: Record = { perPage: "500" };
+ if (systemId) {
+ params.filter = `system='${escapeFilterValue(systemId)}'`;
+ }
+ const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records", params));
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async getAlertHistoryAsync(systemId?: string, perPage = 50): Promise {
+ const params: Record = {
+ sort: "-created",
+ perPage: String(perPage),
+ };
+ if (systemId) {
+ params.filter = `system='${escapeFilterValue(systemId)}'`;
+ }
+ const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts_history/records", params));
+ const data = (await response.json()) as PocketBaseListResponse;
+ return data.items;
+ }
+
+ public async createAlertAsync(systemId: string, input: CreateAlertInput): Promise {
+ const session = await this.authenticateAsync();
+ const response = await this.fetchWithAuthAsync(this.url("/api/collections/alerts/records"), {
+ method: "POST",
+ body: JSON.stringify({
+ system: systemId,
+ user: session.userId,
+ name: input.name,
+ value: input.value,
+ min: input.min ?? 0,
+ triggered: false,
+ }),
+ });
+ return (await response.json()) as BeszelAlert;
+ }
+
+ public async updateAlertAsync(alertId: string, input: UpdateAlertInput): Promise {
+ const response = await this.fetchWithAuthAsync(
+ this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`),
+ {
+ method: "PATCH",
+ body: JSON.stringify(input),
+ },
+ );
+ return (await response.json()) as BeszelAlert;
+ }
+
+ public async deleteAlertAsync(alertId: string): Promise {
+ await this.fetchWithAuthAsync(this.url(`/api/collections/alerts/records/${alertId}` as `/${string}`), {
+ method: "DELETE",
+ });
+ }
+
+ public async pauseSystemAsync(systemId: string): Promise {
+ await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
+ method: "PATCH",
+ body: JSON.stringify({ status: "paused" }),
+ });
+ }
+
+ public async resumeSystemAsync(systemId: string): Promise {
+ await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
+ method: "PATCH",
+ body: JSON.stringify({ status: "pending" }),
+ });
+ }
+
+ public async deleteSystemAsync(systemId: string): Promise {
+ await this.fetchWithAuthAsync(this.url(`/api/collections/systems/records/${systemId}` as `/${string}`), {
+ method: "DELETE",
+ });
+ }
+
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const response = await input.fetchAsync(this.url("/api/collections/users/auth-with-password"), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ identity: this.getSecretValue("username"),
+ password: this.getSecretValue("password"),
+ }),
+ });
+
+ if (!response.ok) {
+ return TestConnectionError.StatusResult({ status: response.status, url: response.url });
+ }
+
+ const data = (await response.json()) as BeszelAuthResponse;
+ if (!data.token) {
+ return TestConnectionError.UnauthorizedResult(401);
+ }
+
+ return { success: true };
+ }
+}
diff --git a/packages/integrations/src/beszel/beszel-types.ts b/packages/integrations/src/beszel/beszel-types.ts
new file mode 100644
index 000000000..52643c701
--- /dev/null
+++ b/packages/integrations/src/beszel/beszel-types.ts
@@ -0,0 +1,389 @@
+export interface BeszelAuthResponse {
+ token: string;
+ record: {
+ id: string;
+ email: string;
+ username: string;
+ verified: boolean;
+ };
+}
+
+export type BeszelSystemStatus = "up" | "down" | "paused" | "pending";
+
+/**
+ * Real-time system info snapshot from Beszel agent.
+ * Returned as `system.info` on each BeszelSystem record.
+ * All resource values are percentages or already-scaled summaries.
+ */
+export interface BeszelSystemInfo {
+ /** hostname */
+ h?: string;
+ /** CPU usage (%) */
+ cpu: number;
+ /** temperature (°C), overall */
+ t?: number;
+ /** CPU core count */
+ c?: number;
+ /** CPU thread count */
+ ct?: number;
+ /** CPU model string */
+ m?: string;
+ /** load average [1m, 5m, 15m] */
+ la?: [number, number, number];
+ /** OS identifier string */
+ o?: string;
+ /** uptime (seconds) */
+ u: number;
+ /** memory usage (%) */
+ mp: number;
+ /** disk usage (%) */
+ dp: number;
+ /** battery [level%, charging state] */
+ bat?: [number, number];
+ /** bandwidth — public interface (Mbps, legacy). Use bb when available */
+ b?: number;
+ /** bandwidth — public interface (bytes/s, newer format). Preferred over b */
+ bb?: number;
+ /** agent version */
+ v: string;
+ /** podman mode */
+ p?: boolean;
+ /** GPU usage (%) */
+ g?: number;
+ /** disk temperature (°C) */
+ dt?: number;
+ /** OS type identifier */
+ os?: number;
+ /** extra filesystem usage (%) keyed by mount path */
+ efs?: Record;
+ /** systemd services [running, total] */
+ sv?: [number, number];
+}
+
+export interface BeszelSystem {
+ id: string;
+ name: string;
+ host: string;
+ port: string;
+ status: BeszelSystemStatus;
+ info: BeszelSystemInfo;
+ users?: string[];
+ created: string;
+ updated: string;
+}
+
+export interface BeszelSystemDetails {
+ id: string;
+ system: string;
+ hostname: string;
+ kernel: string;
+ cores: number;
+ threads: number;
+ cpu: string;
+ os: number;
+ os_name: string;
+ arch: string;
+ memory: number;
+ podman: boolean;
+ updated: string;
+}
+
+/**
+ * Historical system stats record from Beszel PocketBase.
+ * Stored per-interval (1m, 10m, 20m, 120m, 480m depending on time range).
+ *
+ * Units:
+ * - CPU/GPU/memory/disk percentages: 0–100
+ * - Memory/disk absolute values (mu, du, etc.): bytes
+ * - Disk I/O (dr, dw): bytes/s
+ * - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
+ * - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
+ */
+export interface BeszelSystemStats {
+ /** CPU usage (%) */
+ cpu: number;
+ /** CPU usage max (%) */
+ cpum?: number;
+ /** per-core busy (%) */
+ cpub?: number[];
+ /** per-core speed (MHz) */
+ cpus?: number[];
+ /** load average [1m, 5m, 15m] */
+ la?: [number, number, number];
+ /** total memory (bytes) */
+ m: number;
+ /** memory used (bytes) */
+ mu: number;
+ /** memory usage (%) */
+ mp: number;
+ /** memory buffer/cache (bytes) */
+ mb: number;
+ /** memory max used (bytes) */
+ mm?: number;
+ /** memory zfs arc (bytes) */
+ mz?: number;
+ /** total swap (bytes) */
+ s: number;
+ /** swap used (bytes) */
+ su: number;
+ /** total disk (bytes) */
+ d: number;
+ /** disk used (bytes) */
+ du: number;
+ /** disk usage (%) */
+ dp: number;
+ /** disk read (bytes/s) */
+ dr: number;
+ /** disk write (bytes/s) */
+ dw: number;
+ /** disk read max (bytes/s) */
+ drm?: number;
+ /** disk write max (bytes/s) */
+ dwm?: number;
+ /** disk IOPS [read, write] */
+ dio?: [number, number];
+ /** disk IOPS max [read, write] */
+ diom?: [number, number];
+ /** network sent — all interfaces (bytes/s) */
+ ns: number;
+ /** network received — all interfaces (bytes/s) */
+ nr: number;
+ /** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
+ b?: [number, number];
+ /** network sent max (bytes/s) */
+ nsm?: number;
+ /** network received max (bytes/s) */
+ nrm?: number;
+ /** bandwidth max [sent, recv] (bytes/s) */
+ bm?: [number, number];
+ /** temperatures keyed by sensor name (°C) */
+ t?: Record;
+ /** extra filesystem stats keyed by mount path */
+ efs?: Record;
+ /** GPU stats keyed by device id */
+ g?: Record;
+ /** battery [level%, charging state] */
+ bat?: [number, number];
+ /** network interfaces [rx bytes/s, tx bytes/s, rx max, tx max] */
+ ni?: Record;
+}
+
+export interface BeszelExtraFsStats {
+ /** total disk (bytes) */
+ d: number;
+ /** disk used (bytes) */
+ du: number;
+ /** read (bytes/s) */
+ r: number;
+ /** write (bytes/s) */
+ w: number;
+ /** read max (bytes/s) */
+ rm: number;
+ /** write max (bytes/s) */
+ wm: number;
+ /** read (IOPS) */
+ rb: number;
+ /** write (IOPS) */
+ wb: number;
+ /** read max (IOPS) */
+ rbm: number;
+ /** write max (IOPS) */
+ wbm: number;
+}
+
+export interface BeszelGPUData {
+ /** GPU name/model */
+ n: string;
+ /** memory used (bytes) */
+ mu?: number;
+ /** memory total (bytes) */
+ mt?: number;
+ /** GPU utilization (%) */
+ u: number;
+ /** power draw (W) */
+ p?: number;
+ /** power peak (W) */
+ pp?: number;
+ /** encoder utilization keyed by engine name (%) */
+ e?: Record;
+}
+
+export interface BeszelSystemStatsRecord {
+ id: string;
+ system: string;
+ stats: BeszelSystemStats;
+ type: string;
+ created: string;
+ updated: string;
+}
+
+export interface BeszelContainer {
+ id: string;
+ system: string;
+ name: string;
+ image: string;
+ status: string;
+ health: number;
+ cpu: number;
+ memory: number;
+ net: number;
+ ports?: string;
+ updated: number;
+}
+
+/**
+ * Per-container resource stats from Beszel agent.
+ * One entry per container in a BeszelContainerStatsRecord.
+ *
+ * Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
+ */
+export interface BeszelContainerStats {
+ /** container name */
+ n: string;
+ /** CPU usage (%) */
+ c: number;
+ /** memory usage (bytes) */
+ m: number;
+ /** network sent (bytes/s) — legacy, use b when available */
+ ns?: number;
+ /** network received (bytes/s) — legacy, use b when available */
+ nr?: number;
+ /** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */
+ b?: [number, number];
+}
+
+export interface BeszelContainerStatsRecord {
+ id: string;
+ system: string;
+ stats: BeszelContainerStats[];
+ type: string;
+ created: string;
+ updated: string;
+}
+
+export interface BeszelSmartDevice {
+ id: string;
+ system: string;
+ name: string;
+ model: string;
+ state: string;
+ capacity: number;
+ temp: number;
+ firmware: string;
+ serial: string;
+ type: string;
+ hours: number;
+ cycles: number;
+ attributes: BeszelSmartAttribute[];
+ updated: string;
+}
+
+export interface BeszelSmartAttribute {
+ /** attribute ID */
+ id?: number;
+ /** attribute name */
+ n: string;
+ /** current value */
+ v: number;
+ /** worst value */
+ w?: number;
+ /** threshold */
+ t?: number;
+ /** raw value (numeric) */
+ rv?: number;
+ /** raw value (string) */
+ rs?: string;
+ /** when failed */
+ wf?: string;
+}
+
+export interface BeszelSystemdService {
+ id: string;
+ system: string;
+ name: string;
+ state: number;
+ sub: number;
+ cpu: number;
+ cpuPeak: number;
+ memory: number;
+ memPeak: number;
+ updated: number;
+}
+
+export interface BeszelAlert {
+ id: string;
+ user: string;
+ system: string;
+ name: string;
+ triggered: boolean;
+ value: number;
+ min: number;
+}
+
+export interface BeszelAlertHistory {
+ id: string;
+ alert: string;
+ user: string;
+ system: string;
+ name: string;
+ val: number;
+ created: string;
+ resolved?: string | null;
+}
+
+export interface CreateAlertInput {
+ name: string;
+ value: number;
+ min?: number;
+}
+
+export interface UpdateAlertInput {
+ value?: number;
+ min?: number;
+}
+
+export interface PocketBaseListResponse {
+ page: number;
+ perPage: number;
+ totalItems: number;
+ totalPages: number;
+ items: T[];
+}
+
+/**
+ * Normalized view model for a Beszel-monitored system.
+ * Constructed by mapToSystemRow() in request-handler from BeszelSystem + BeszelSystemDetails.
+ * This is the shape consumed by grid/table widgets — all values are pre-scaled for display.
+ */
+export interface BeszelSystemRow {
+ id: string;
+ name: string;
+ status: BeszelSystemStatus;
+ /** CPU usage (%) */
+ cpu: number;
+ /** memory usage (%) */
+ memory: number;
+ /** disk usage (%) */
+ disk: number;
+ /** GPU usage (%) */
+ gpu: number;
+ /** load average [1m, 5m, 15m] */
+ loadAvg: [number, number, number] | null;
+ /** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */
+ netBytes: number;
+ /** temperature (°C) */
+ temp: number | null;
+ /** battery [level%, charging state] */
+ battery: [number, number] | null;
+ /** running systemd services count */
+ services: number;
+ /** uptime (seconds) */
+ uptime: number;
+ agentVersion: string;
+ hostname: string;
+ cpuModel: string;
+ cores: number;
+ /** total memory (bytes) */
+ memoryTotal: number;
+ osName: string;
+}
diff --git a/packages/integrations/src/beszel/test/beszel-integration.spec.ts b/packages/integrations/src/beszel/test/beszel-integration.spec.ts
new file mode 100644
index 000000000..a68e1dbc8
--- /dev/null
+++ b/packages/integrations/src/beszel/test/beszel-integration.spec.ts
@@ -0,0 +1,226 @@
+// @vitest-environment node
+import { beforeAll, describe, expect, test, vi } from "vitest";
+
+vi.hoisted(() => {
+ process.env.SKIP_ENV_VALIDATION = "true";
+ process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
+ process.env.ENABLE_DNS_CACHING = "false";
+});
+
+const BESZEL_URL = process.env.BESZEL_TEST_URL ?? "http://localhost:8090";
+const BESZEL_EMAIL = process.env.BESZEL_TEST_EMAIL ?? "";
+const BESZEL_PASSWORD = process.env.BESZEL_TEST_PASSWORD ?? "";
+
+vi.mock("@homarr/redis", () => ({
+ createGetSetChannel: () => ({
+ getAsync: vi.fn().mockResolvedValue(null),
+ setAsync: vi.fn().mockResolvedValue(undefined),
+ removeAsync: vi.fn().mockResolvedValue(undefined),
+ }),
+}));
+
+vi.mock("@homarr/core/infrastructure/logs", () => ({
+ createLogger: () => ({
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ }),
+ ErrorWithMetadata: class extends Error {},
+}));
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: (url: URL | string, init?: RequestInit) => fetch(url, init),
+ createAxiosCertificateInstanceAsync: vi.fn().mockResolvedValue({}),
+ createCertificateAgentAsync: vi.fn().mockResolvedValue(undefined),
+}));
+
+vi.mock("@homarr/core/infrastructure/certificates", () => ({
+ getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
+ getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
+}));
+
+import { BeszelIntegration } from "../beszel-integration";
+
+function createBeszelIntegration() {
+ return new BeszelIntegration({
+ id: "test-beszel",
+ name: "Test Beszel",
+ url: BESZEL_URL,
+ externalUrl: null,
+ decryptedSecrets: [
+ { kind: "username", value: BESZEL_EMAIL },
+ { kind: "password", value: BESZEL_PASSWORD },
+ ],
+ });
+}
+
+const shouldRun = BESZEL_EMAIL && BESZEL_PASSWORD;
+
+describe.skipIf(!shouldRun)("BeszelIntegration (real instance)", () => {
+ let integration: BeszelIntegration;
+ let systemId: string;
+
+ beforeAll(() => {
+ integration = createBeszelIntegration();
+ });
+
+ describe("getSystemsAsync", () => {
+ test("returns an array of systems", async () => {
+ const systems = await integration.getSystemsAsync();
+ expect(systems).toBeInstanceOf(Array);
+ expect(systems.length).toBeGreaterThan(0);
+ });
+
+ test("each system has required fields", async () => {
+ const systems = await integration.getSystemsAsync();
+ const system = systems[0];
+ if (!system) {
+ throw new Error("Expected at least one system");
+ }
+ systemId = system.id;
+
+ expect(system.id).toBeDefined();
+ expect(system.name).toBeDefined();
+ expect(system.status).toMatch(/^(up|down|paused|pending)$/);
+ expect(system.info).toBeDefined();
+ expect(typeof system.info.cpu).toBe("number");
+ expect(typeof system.info.mp).toBe("number");
+ expect(typeof system.info.dp).toBe("number");
+ });
+ });
+
+ describe("getSystemDetailsAsync", () => {
+ test("returns system details with hardware info", async () => {
+ const systems = await integration.getSystemsAsync();
+ systemId = systems[0]?.id ?? "";
+
+ const details = await integration.getSystemDetailsAsync(systemId);
+
+ expect(details.hostname).toBeDefined();
+ expect(details.cpu).toBeDefined();
+ expect(typeof details.cores).toBe("number");
+ expect(details.cores).toBeGreaterThan(0);
+ expect(typeof details.memory).toBe("number");
+ expect(details.memory).toBeGreaterThan(0);
+ expect(details.os_name).toBeDefined();
+ });
+ });
+
+ describe("getSystemStatsAsync", () => {
+ test("returns time-series stats records", async () => {
+ const systems = await integration.getSystemsAsync();
+ systemId = systems[0]?.id ?? "";
+
+ const stats = await integration.getSystemStatsAsync(systemId, "1m", 5);
+
+ expect(stats).toBeInstanceOf(Array);
+ if (stats.length === 0) return;
+
+ const record = stats[0];
+ expect(record?.stats).toBeDefined();
+ expect(typeof record?.stats.cpu).toBe("number");
+ expect(typeof record?.stats.mp).toBe("number");
+ expect(typeof record?.stats.dp).toBe("number");
+ expect(record?.created).toBeDefined();
+ });
+ });
+
+ describe("getContainersAsync", () => {
+ test("returns container list", async () => {
+ const systems = await integration.getSystemsAsync();
+ systemId = systems[0]?.id ?? "";
+
+ const containers = await integration.getContainersAsync(systemId);
+ expect(containers).toBeInstanceOf(Array);
+
+ if (containers.length > 0) {
+ const container = containers[0];
+ expect(container?.name).toBeDefined();
+ expect(container?.image).toBeDefined();
+ expect(container?.status).toBeDefined();
+ expect(typeof container?.cpu).toBe("number");
+ expect(typeof container?.memory).toBe("number");
+ }
+ });
+ });
+
+ describe("alert CRUD", () => {
+ let alertId: string;
+
+ test("creates an alert", async () => {
+ const systems = await integration.getSystemsAsync();
+ systemId = systems[0]?.id ?? "";
+
+ const alert = await integration.createAlertAsync(systemId, {
+ name: "CPU",
+ value: 90,
+ min: 1,
+ });
+
+ expect(alert.id).toBeDefined();
+ expect(alert.name).toBe("CPU");
+ expect(alert.value).toBe(90);
+ expect(alert.system).toBe(systemId);
+ alertId = alert.id;
+ });
+
+ test("lists alerts including the created one", async () => {
+ const alerts = await integration.getAlertsAsync(systemId);
+ const found = alerts.find((a) => a.id === alertId);
+ expect(found).toBeDefined();
+ expect(found?.name).toBe("CPU");
+ });
+
+ test("updates alert threshold", async () => {
+ const updated = await integration.updateAlertAsync(alertId, { value: 95 });
+ expect(updated.value).toBe(95);
+ });
+
+ test("deletes alert", async () => {
+ await integration.deleteAlertAsync(alertId);
+ const alerts = await integration.getAlertsAsync(systemId);
+ const found = alerts.find((a) => a.id === alertId);
+ expect(found).toBeUndefined();
+ });
+ });
+
+ describe("system actions", () => {
+ test("pauses and resumes a system", async () => {
+ const systems = await integration.getSystemsAsync();
+ systemId = systems[0]?.id ?? "";
+
+ await integration.pauseSystemAsync(systemId);
+ const afterPause = await integration.getSystemsAsync();
+ const paused = afterPause.find((s) => s.id === systemId);
+ expect(paused?.status).toBe("paused");
+
+ await integration.resumeSystemAsync(systemId);
+ const afterResume = await integration.getSystemsAsync();
+ const resumed = afterResume.find((s) => s.id === systemId);
+ expect(resumed?.status).toMatch(/^(up|pending)$/);
+ });
+ });
+
+ describe("authentication", () => {
+ test("authenticates with valid credentials", async () => {
+ const systems = await integration.getSystemsAsync();
+ expect(systems.length).toBeGreaterThan(0);
+ });
+
+ test("fails with invalid credentials", async () => {
+ const badIntegration = new BeszelIntegration({
+ id: "test-bad",
+ name: "Bad Beszel",
+ url: BESZEL_URL,
+ externalUrl: null,
+ decryptedSecrets: [
+ { kind: "username", value: "wrong@example.com" },
+ { kind: "password", value: "wrongpassword" },
+ ],
+ });
+
+ await expect(badIntegration.getSystemsAsync()).rejects.toThrow();
+ });
+ });
+});
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index c7451265c..2f1c7d2df 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -33,7 +33,11 @@ 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";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -60,6 +64,7 @@ export type {
export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types";
export type { Notification } from "./interfaces/notifications/notification-types";
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
+export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
export type {
AnchorNote,
AnchorNotePermission,
@@ -69,6 +74,9 @@ export type {
} from "./anchor/anchor-types";
export type { TracearrDashboardData } from "./tracearr/tracearr-types";
export type { SpeedtestTrackerDashboardData } from "./speedtest-tracker/speedtest-tracker-types";
+export type { AudiobookshelfDashboardData } from "./audiobookshelf/audiobookshelf-types";
+export type { NavidromeDashboardData } from "./navidrome/navidrome-types";
+export type { UptimeKumaDashboardData } from "./uptime-kuma/uptime-kuma-types";
export type { UmamiVisitorStats } from "./umami/umami-types";
// Schemas
diff --git a/packages/integrations/src/interfaces/media-requests/media-request-types.ts b/packages/integrations/src/interfaces/media-requests/media-request-types.ts
index 4d7b094ff..d475764bf 100644
--- a/packages/integrations/src/interfaces/media-requests/media-request-types.ts
+++ b/packages/integrations/src/interfaces/media-requests/media-request-types.ts
@@ -12,12 +12,14 @@ interface SeriesInformation {
overview: string;
seasons: SerieSeason[];
posterPath: string;
+ requestedSeasons?: number[];
}
interface MovieInformation {
id: number;
overview: string;
posterPath: string;
+ requestedSeasons?: number[];
}
export type MediaInformation = SeriesInformation | MovieInformation;
diff --git a/packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts b/packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts
new file mode 100644
index 000000000..21510954d
--- /dev/null
+++ b/packages/integrations/src/interfaces/ups-summary/ups-summary-integration.ts
@@ -0,0 +1,5 @@
+import type { UpsSummary } from "./ups-summary-types";
+
+export interface IUpsSummaryIntegration {
+ getUpsSummariesAsync(): Promise;
+}
diff --git a/packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts b/packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts
new file mode 100644
index 000000000..6979de023
--- /dev/null
+++ b/packages/integrations/src/interfaces/ups-summary/ups-summary-types.ts
@@ -0,0 +1,32 @@
+/**
+ * Normalized UPS status, resolved from the raw NUT status flags by the integration so that
+ * widgets and future UPS integrations share a single, typed vocabulary.
+ */
+export const upsStatuses = ["online", "charging", "onBattery", "lowBattery", "unknown"] as const;
+
+export type UpsStatus = (typeof upsStatuses)[number];
+
+export interface UpsSummary {
+ /** Stable identifier of the UPS device on the NUT server */
+ id: string;
+ /** Human readable name, derived from the manufacturer and model */
+ name: string;
+ manufacturer: string | null;
+ model: string | null;
+ serial: string | null;
+ /** Normalized status, resolved from the raw NUT `ups.status` flags */
+ status: UpsStatus;
+ /** Battery charge in percent */
+ batteryCharge: number | null;
+ /** Estimated remaining runtime on battery, in seconds */
+ batteryRuntime: number | null;
+ batteryVoltage: number | null;
+ /** UPS load in percent */
+ load: number | null;
+ inputVoltage: number | null;
+ outputVoltage: number | null;
+ /** Real power drawn by the load, in watts */
+ power: number | null;
+ /** UPS temperature in degrees celsius */
+ temperature: number | null;
+}
diff --git a/packages/integrations/src/mock/data/beszel.ts b/packages/integrations/src/mock/data/beszel.ts
new file mode 100644
index 000000000..8788cf136
--- /dev/null
+++ b/packages/integrations/src/mock/data/beszel.ts
@@ -0,0 +1,343 @@
+import type {
+ BeszelAlert,
+ BeszelAlertHistory,
+ BeszelContainerStats,
+ BeszelContainerStatsRecord,
+ BeszelSystem,
+ BeszelSystemDetails,
+ BeszelSystemStats,
+ BeszelSystemStatsRecord,
+} from "../../beszel/beszel-types";
+
+const GB = 1024 * 1024 * 1024;
+const MB = 1024 * 1024;
+const KB = 1024;
+
+interface MockSystemDef {
+ name: string;
+ host: string;
+ os: string;
+ cpu: string;
+ cores: number;
+ mem: number;
+ arch: string;
+ baseCpu: number;
+ baseMem: number;
+ baseDisk: number;
+ containers: number[];
+}
+
+const mockSystems: MockSystemDef[] = [
+ {
+ name: "web-server",
+ host: "192.168.1.10",
+ os: "Ubuntu 24.04",
+ cpu: "AMD Ryzen 9 7950X",
+ cores: 16,
+ mem: 64 * GB,
+ arch: "amd64",
+ baseCpu: 35,
+ baseMem: 55,
+ baseDisk: 42,
+ containers: [0, 1, 2, 3, 5],
+ },
+ {
+ name: "nas-01",
+ host: "192.168.1.20",
+ os: "Debian 12",
+ cpu: "Intel Xeon E-2388G",
+ cores: 8,
+ mem: 32 * GB,
+ arch: "amd64",
+ baseCpu: 12,
+ baseMem: 70,
+ baseDisk: 68,
+ containers: [1, 2, 6],
+ },
+ {
+ name: "pi-cluster-1",
+ host: "192.168.1.30",
+ os: "Raspberry Pi OS",
+ cpu: "ARM Cortex-A76",
+ cores: 4,
+ mem: 8 * GB,
+ arch: "aarch64",
+ baseCpu: 45,
+ baseMem: 82,
+ baseDisk: 35,
+ containers: [0, 2, 7],
+ },
+ {
+ name: "media-server",
+ host: "192.168.1.40",
+ os: "Proxmox VE 8.2",
+ cpu: "Intel i7-13700K",
+ cores: 16,
+ mem: 128 * GB,
+ arch: "amd64",
+ baseCpu: 22,
+ baseMem: 48,
+ baseDisk: 55,
+ containers: [0, 1, 2, 3, 4, 5, 6, 7],
+ },
+ {
+ name: "backup-node",
+ host: "192.168.1.50",
+ os: "TrueNAS SCALE",
+ cpu: "Intel Xeon E5-2680",
+ cores: 12,
+ mem: 48 * GB,
+ arch: "amd64",
+ baseCpu: 8,
+ baseMem: 30,
+ baseDisk: 78,
+ containers: [1, 6],
+ },
+];
+
+const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [
+ { name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 },
+ { name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 },
+ { name: "redis", cpuBase: 3, memMB: 180, netKB: 500 },
+ { name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 },
+ { name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 },
+ { name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 },
+ { name: "minio", cpuBase: 3, memMB: 200, netKB: 600 },
+ { name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 },
+];
+
+const rand = (min: number, max: number) => min + Math.random() * (max - min);
+const randInt = (min: number, max: number) => Math.floor(rand(min, max));
+const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
+
+function smoothWalk(base: number, amplitude: number, steps: number, lo: number, hi: number): number[] {
+ const values: number[] = [];
+ let current = base + rand(-amplitude * 0.3, amplitude * 0.3);
+ for (let i = 0; i < steps; i++) {
+ const spike = Math.random() < 0.08 ? rand(-amplitude * 2, amplitude * 3) : 0;
+ current += rand(-amplitude, amplitude) + spike;
+ current = clamp(current, lo, hi);
+ values.push(current);
+ }
+ return values;
+}
+
+function resolveSystem(systemId: string): MockSystemDef {
+ const idx = Number(systemId.replace("mock-sys-", "")) - 1;
+ return mockSystems[idx] ?? mockSystems[0] ?? (mockSystems.find(Boolean) as MockSystemDef);
+}
+
+const typeToMinutes: Record = {
+ "1m": 1,
+ "10m": 10,
+ "20m": 20,
+ "120m": 120,
+ "480m": 480,
+};
+
+function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSystemStats[] {
+ const cpuWalk = smoothWalk(sys.baseCpu, 3, count, 1, 98);
+ const memPctWalk = smoothWalk(sys.baseMem, 1.5, count, 10, 95);
+ const diskPctWalk = smoothWalk(sys.baseDisk, 0.2, count, 5, 95);
+ const diskReadWalk = smoothWalk(5, 3, count, 0, 80);
+ const diskWriteWalk = smoothWalk(3, 2, count, 0, 50);
+ const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
+ const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
+
+ const memTotal = sys.mem;
+ const diskTotal = rand(200, 2000) * GB;
+
+ return cpuWalk.map((cpu, i) => {
+ const memPct = memPctWalk[i] ?? sys.baseMem;
+ const diskPct = diskPctWalk[i] ?? sys.baseDisk;
+ return {
+ cpu,
+ la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
+ m: memTotal,
+ mu: (memTotal * memPct) / 100,
+ mp: memPct,
+ mb: rand(0.5, 4) * GB,
+ s: 4 * GB,
+ su: rand(0, 2) * GB,
+ d: diskTotal,
+ du: (diskTotal * diskPct) / 100,
+ dp: diskPct,
+ dr: (diskReadWalk[i] ?? 5) * MB,
+ dw: (diskWriteWalk[i] ?? 3) * MB,
+ ns: (netSendWalk[i] ?? 500) * KB,
+ nr: (netRecvWalk[i] ?? 800) * KB,
+ b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
+ };
+ });
+}
+
+function generateContainerStatsSeries(count: number, profiles: typeof containerProfiles): BeszelContainerStats[][] {
+ const walks = profiles.map((p) => ({
+ profile: p,
+ cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50),
+ memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2),
+ netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4),
+ }));
+
+ return Array.from({ length: count }, (_, i) =>
+ walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
+ n: profile.name,
+ c: cpuWalk[i] ?? profile.cpuBase,
+ m: (memWalk[i] ?? profile.memMB) * MB,
+ b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
+ })),
+ );
+}
+
+function buildTimeSeries(
+ systemId: string,
+ count: number,
+ intervalMinutes: number,
+ type: string,
+ dataPoints: T[],
+): (T & { id: string; system: string; type: string; created: string; updated: string })[] {
+ const now = Date.now();
+ const items = dataPoints.slice(0, count).map((data, i) => {
+ const ts = new Date(now - (count - i) * intervalMinutes * 60_000).toISOString();
+ return { id: `mock-stat-${i}`, system: systemId, type, created: ts, updated: ts, ...data };
+ });
+ return items.toReversed();
+}
+
+const statusByIndex: Record = { 2: "paused", 4: "down" };
+
+export class BeszelMockService {
+ public async getSystemsAsync(): Promise {
+ return mockSystems.map((sys, i) => ({
+ id: `mock-sys-${i + 1}`,
+ name: sys.name,
+ host: sys.host,
+ port: "45876",
+ status: statusByIndex[i] ?? "up",
+ info: {
+ h: sys.name,
+ cpu: clamp(sys.baseCpu + rand(-5, 10), 0, 100),
+ c: sys.cores,
+ ct: sys.cores * 2,
+ m: sys.cpu,
+ la: [sys.baseCpu / 20, sys.baseCpu / 25, sys.baseCpu / 30] as [number, number, number],
+ o: sys.os,
+ u: randInt(86400, 2_000_000),
+ mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
+ dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
+ bb: rand(200, 3000) * KB,
+ v: "0.8.2",
+ g: i === 3 ? rand(15, 45) : 0,
+ dt: rand(32, 58),
+ sv: [randInt(8, 25), randInt(25, 40)] as [number, number],
+ },
+ users: [],
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ }));
+ }
+
+ public async getSystemDetailsAsync(systemId: string): Promise {
+ const sys = resolveSystem(systemId);
+ return {
+ id: systemId,
+ system: systemId,
+ hostname: sys.name,
+ kernel: "6.8.0-40-generic",
+ cores: sys.cores,
+ threads: sys.cores * 2,
+ cpu: sys.cpu,
+ os: 1,
+ os_name: sys.os,
+ arch: sys.arch,
+ memory: sys.mem,
+ podman: false,
+ updated: new Date().toISOString(),
+ };
+ }
+
+ public async getSystemStatsAsync(
+ systemId: string,
+ type: string,
+ perPage: number,
+ ): Promise {
+ const sys = resolveSystem(systemId);
+ const intervalMinutes = typeToMinutes[type] ?? 1;
+ const statsSeries = generateSystemStatsSeries(perPage, sys);
+ return buildTimeSeries(
+ systemId,
+ perPage,
+ intervalMinutes,
+ type,
+ statsSeries.map((stats) => ({ stats })),
+ );
+ }
+
+ public async getContainerStatsAsync(
+ systemId: string,
+ type: string,
+ perPage: number,
+ ): Promise {
+ const sys = resolveSystem(systemId);
+ const intervalMinutes = typeToMinutes[type] ?? 1;
+ const selectedProfiles = sys.containers
+ .map((i) => containerProfiles[i])
+ .filter(Boolean) as typeof containerProfiles;
+ const series = generateContainerStatsSeries(perPage, selectedProfiles);
+ return buildTimeSeries(
+ systemId,
+ perPage,
+ intervalMinutes,
+ type,
+ series.map((stats) => ({ stats })),
+ );
+ }
+
+ public async getAlertsAsync(): Promise {
+ return [
+ { id: "mock-alert-1", user: "mock-user", system: "mock-sys-1", name: "CPU", triggered: true, value: 90, min: 5 },
+ {
+ id: "mock-alert-2",
+ user: "mock-user",
+ system: "mock-sys-2",
+ name: "Memory",
+ triggered: false,
+ value: 80,
+ min: 10,
+ },
+ {
+ id: "mock-alert-3",
+ user: "mock-user",
+ system: "mock-sys-1",
+ name: "Disk",
+ triggered: false,
+ value: 85,
+ min: 15,
+ },
+ {
+ id: "mock-alert-4",
+ user: "mock-user",
+ system: "mock-sys-4",
+ name: "Temperature",
+ triggered: true,
+ value: 75,
+ min: 3,
+ },
+ ];
+ }
+
+ public async getAlertHistoryAsync(_systemId?: string, perPage = 10): Promise {
+ const now = Date.now();
+ const names = ["CPU", "Memory", "Disk", "Temperature", "Bandwidth"];
+ return Array.from({ length: Math.min(perPage, 5) }, (_, i) => ({
+ id: `mock-history-${i}`,
+ alert: `mock-alert-${(i % 4) + 1}`,
+ user: "mock-user",
+ system: `mock-sys-${(i % 5) + 1}`,
+ name: names[i % names.length] ?? "CPU",
+ val: rand(60, 95),
+ created: new Date(now - (i + 1) * 3_600_000).toISOString(),
+ resolved: i % 2 === 0 ? new Date(now - i * 1_800_000).toISOString() : null,
+ }));
+ }
+}
diff --git a/packages/integrations/src/mock/data/calendar.ts b/packages/integrations/src/mock/data/calendar.ts
index 530819dd3..4ebd36d6e 100644
--- a/packages/integrations/src/mock/data/calendar.ts
+++ b/packages/integrations/src/mock/data/calendar.ts
@@ -1,24 +1,130 @@
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
import type { CalendarEvent } from "../../interfaces/calendar/calendar-types";
-export class CalendarMockService implements ICalendarIntegration {
- public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise {
- const result = [homarrMeetup(start, end), titanicRelease(start, end), seriesRelease(start, end)];
- return await Promise.resolve(result);
- }
+interface EventTemplate {
+ title: string;
+ subTitle: string;
+ description: string;
+ dayOffset: number;
+ hours: number;
+ image: CalendarEvent["image"];
+ indicatorColor: string;
+ metadata?: CalendarEvent["metadata"];
+ location?: string;
+ links: CalendarEvent["links"];
}
-const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
- const startDate = randomDateBetween(start, end);
- const endDate = new Date(startDate.getTime() + 2 * 60 * 60 * 1000); // 2 hours later
- return {
+const eventTemplates: EventTemplate[] = [
+ {
+ title: "The Mandalorian",
+ subTitle: "Season 4",
+ description: "A lone bounty hunter in the outer reaches of the galaxy.",
+ dayOffset: 0,
+ hours: 20,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S4/E3", color: "red" },
+ },
+ indicatorColor: "blue",
+ links: [
+ {
+ href: "https://www.imdb.com/title/tt8111088/",
+ name: "IMDb",
+ color: "#f5c518",
+ isDark: false,
+ logo: "/images/apps/imdb.svg",
+ },
+ ],
+ },
+ {
+ title: "Silo",
+ subTitle: "Season 2",
+ description: "In a ruined and toxic future, thousands live in a giant underground silo.",
+ dayOffset: 3,
+ hours: 21,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/2xSzAuKMhERTcPA19SpFQtVrMC0.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S2/E8", color: "teal" },
+ },
+ indicatorColor: "teal",
+ links: [
+ {
+ href: "https://www.imdb.com/title/tt14688458/",
+ name: "IMDb",
+ color: "#f5c518",
+ isDark: false,
+ logo: "/images/apps/imdb.svg",
+ },
+ ],
+ },
+ {
+ title: "Dune: Part Two",
+ subTitle: "Digital Release",
+ description: "Paul Atreides unites with the Fremen to seek revenge against the conspirators.",
+ dayOffset: 5,
+ hours: 0,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ },
+ metadata: { type: "radarr", releaseType: "digitalRelease" },
+ indicatorColor: "cyan",
+ links: [
+ {
+ href: "https://www.imdb.com/title/tt15239678/",
+ name: "IMDb",
+ color: "#f5c518",
+ isDark: false,
+ logo: "/images/apps/imdb.svg",
+ },
+ ],
+ },
+ {
+ title: "Severance",
+ subTitle: "Season 2",
+ description: "Mark leads a team of office workers whose memories are surgically divided.",
+ dayOffset: 7,
+ hours: 22,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S2/E5", color: "violet" },
+ },
+ indicatorColor: "violet",
+ links: [],
+ },
+ {
+ title: "Oppenheimer",
+ subTitle: "Physical Release",
+ description: "The story of the development of the atomic bomb.",
+ dayOffset: 10,
+ hours: 0,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ },
+ metadata: { type: "radarr", releaseType: "physicalRelease" },
+ indicatorColor: "orange",
+ links: [
+ {
+ href: "https://www.imdb.com/title/tt15398776/",
+ name: "IMDb",
+ color: "#f5c518",
+ isDark: false,
+ logo: "/images/apps/imdb.svg",
+ },
+ ],
+ },
+ {
title: "Homarr Meetup",
subTitle: "",
description: "Yearly meetup of the Homarr community",
- startDate,
- endDate,
- image: null,
+ dayOffset: 14,
+ hours: 14,
location: "Mountains",
+ image: null,
indicatorColor: "#fa5252",
links: [
{
@@ -29,63 +135,79 @@ const homarrMeetup = (start: Date, end: Date): CalendarEvent => {
isDark: true,
},
],
- };
+ },
+ {
+ title: "The Bear",
+ subTitle: "Season 3",
+ description: "A young chef fights to transform a sandwich shop.",
+ dayOffset: 18,
+ hours: 3,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/sHFlYJhNbmpMOkr7JKGxFAnMPFm.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S3/E1", color: "yellow" },
+ },
+ indicatorColor: "yellow",
+ links: [],
+ },
+ {
+ title: "Foundation",
+ subTitle: "Season 3",
+ description: "A band of exiles discovers that the only way to save the Galactic Empire is to defy it.",
+ dayOffset: 21,
+ hours: 20,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/wSByZTwCPVYAjlqcVCBTCLZchkC.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ badge: { content: "S3/E1", color: "indigo" },
+ },
+ indicatorColor: "indigo",
+ links: [],
+ },
+ {
+ title: "Blade Runner 2099",
+ subTitle: "Premiere",
+ description: "A new chapter in the Blade Runner universe.",
+ dayOffset: 25,
+ hours: 21,
+ image: {
+ src: "https://image.tmdb.org/t/p/w300/63UMsIFOlCSz3uCPKtbgOdUr4pE.jpg",
+ aspectRatio: { width: 7, height: 12 },
+ },
+ metadata: { type: "radarr", releaseType: "digitalRelease" },
+ indicatorColor: "grape",
+ links: [],
+ },
+];
+
+const buildEventsForRange = (start: Date, end: Date): CalendarEvent[] => {
+ const rangeStart = new Date(start);
+ rangeStart.setDate(rangeStart.getDate() - 3);
+
+ return eventTemplates
+ .map((tpl) => {
+ const startDate = new Date(rangeStart);
+ startDate.setDate(startDate.getDate() + tpl.dayOffset);
+ startDate.setHours(tpl.hours, 0, 0, 0);
+
+ return {
+ title: tpl.title,
+ subTitle: tpl.subTitle,
+ description: tpl.description,
+ startDate,
+ endDate: tpl.location ? new Date(startDate.getTime() + 2 * 60 * 60 * 1000) : null,
+ image: tpl.image,
+ location: tpl.location ?? null,
+ indicatorColor: tpl.indicatorColor,
+ links: tpl.links,
+ ...(tpl.metadata ? { metadata: tpl.metadata } : {}),
+ };
+ })
+ .filter((event) => event.startDate >= start && event.startDate <= end);
};
-const titanicRelease = (start: Date, end: Date): CalendarEvent => ({
- title: "Titanic",
- subTitle: "A classic movie",
- description: "A tragic love story set on the ill-fated RMS Titanic.",
- startDate: randomDateBetween(start, end),
- endDate: null,
- image: {
- src: "https://image.tmdb.org/t/p/original/5bTWA20cL9LCIGNpde4Epc2Ijzn.jpg",
- aspectRatio: { width: 7, height: 12 },
- },
- location: null,
- metadata: {
- type: "radarr",
- releaseType: "inCinemas",
- },
- indicatorColor: "cyan",
- links: [
- {
- href: "https://www.imdb.com/title/tt0120338/",
- name: "IMDb",
- color: "#f5c518",
- isDark: false,
- logo: "/images/apps/imdb.svg",
- },
- ],
-});
-
-const seriesRelease = (start: Date, end: Date): CalendarEvent => ({
- title: "The Mandalorian",
- subTitle: "A Star Wars Series",
- description: "A lone bounty hunter in the outer reaches of the galaxy.",
- startDate: randomDateBetween(start, end),
- endDate: null,
- image: {
- src: "https://image.tmdb.org/t/p/original/sWgBv7LV2PRoQgkxwlibdGXKz1S.jpg",
- aspectRatio: { width: 7, height: 12 },
- badge: {
- content: "S1/E1",
- color: "red",
- },
- },
- location: null,
- indicatorColor: "blue",
- links: [
- {
- href: "https://www.imdb.com/title/tt8111088/",
- name: "IMDb",
- color: "#f5c518",
- isDark: false,
- logo: "/images/apps/imdb.svg",
- },
- ],
-});
-
-function randomDateBetween(start: Date, end: Date): Date {
- return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
+export class CalendarMockService implements ICalendarIntegration {
+ public async getCalendarEventsAsync(start: Date, end: Date, _includeUnmonitored: boolean): Promise {
+ return await Promise.resolve(buildEventsForRange(start, end));
+ }
}
diff --git a/packages/integrations/src/mock/data/download.ts b/packages/integrations/src/mock/data/download.ts
index 7d3ab0252..391315fe4 100644
--- a/packages/integrations/src/mock/data/download.ts
+++ b/packages/integrations/src/mock/data/download.ts
@@ -2,57 +2,163 @@ import type { DownloadClientJobsAndStatus } from "../../interfaces/downloads/dow
import type { IDownloadClientIntegration } from "../../interfaces/downloads/download-client-integration";
import type { DownloadClientItem } from "../../interfaces/downloads/download-client-items";
+const mockItems: Omit[] = [
+ {
+ name: "Big.Buck.Bunny.2008.1080p.BluRay.x264",
+ type: "torrent",
+ size: 4_700_000_000,
+ progress: 0.47,
+ state: "leeching",
+ downSpeed: 12_400_000,
+ upSpeed: 890_000,
+ sent: 420_000_000,
+ received: 2_209_000_000,
+ time: 201_000,
+ category: "movies",
+ },
+ {
+ name: "Ubuntu.24.04.1.LTS.Desktop.amd64.iso",
+ type: "torrent",
+ size: 5_800_000_000,
+ progress: 0.82,
+ state: "leeching",
+ downSpeed: 34_500_000,
+ upSpeed: 2_100_000,
+ sent: 1_200_000_000,
+ received: 4_756_000_000,
+ time: 30_000,
+ category: "iso",
+ },
+ {
+ name: "Sintel.2010.4K.DTS-HD.MA.5.1.mkv",
+ type: "torrent",
+ size: 18_200_000_000,
+ progress: 0.03,
+ state: "leeching",
+ downSpeed: 1_200_000,
+ upSpeed: 50_000,
+ sent: 30_000_000,
+ received: 546_000_000,
+ time: 14_880_000,
+ category: "movies",
+ },
+ {
+ name: "Arch.Linux.2024.12.01.x86_64.iso",
+ type: "torrent",
+ size: 1_100_000_000,
+ progress: 1,
+ state: "seeding",
+ downSpeed: 0,
+ upSpeed: 4_500_000,
+ sent: 3_300_000_000,
+ received: 1_100_000_000,
+ time: -7_200_000,
+ category: "iso",
+ },
+ {
+ name: "Tears.of.Steel.2012.1080p.DTS.mkv",
+ type: "torrent",
+ size: 7_340_000_000,
+ progress: 0,
+ state: "paused",
+ downSpeed: 0,
+ upSpeed: 0,
+ time: 0,
+ category: "movies",
+ },
+ {
+ name: "Cosmos.Laundromat.First.Cycle.2015.4K.mkv",
+ type: "torrent",
+ size: 12_000_000_000,
+ progress: 0.65,
+ state: "stalled",
+ downSpeed: 0,
+ upSpeed: 0,
+ sent: 200_000_000,
+ received: 7_800_000_000,
+ time: 0,
+ category: "movies",
+ },
+ {
+ name: "Spring.2019.4K.HDR.mkv",
+ type: "usenet",
+ size: 3_200_000_000,
+ progress: 0.91,
+ state: "downloading",
+ downSpeed: 45_000_000,
+ time: 6_500,
+ category: "movies",
+ },
+ {
+ name: "Agent.327.Operation.Barbershop.2017.1080p.mkv",
+ type: "usenet",
+ size: 1_800_000_000,
+ progress: 1,
+ state: "completed",
+ time: -3_600_000,
+ category: "movies",
+ },
+ {
+ name: "Caminandes.3.Llamigos.2016.1080p.mkv",
+ type: "torrent",
+ size: 890_000_000,
+ progress: 1,
+ state: "seeding",
+ downSpeed: 0,
+ upSpeed: 1_800_000,
+ sent: 2_670_000_000,
+ received: 890_000_000,
+ time: -86_400_000,
+ category: "movies",
+ },
+ {
+ name: "Hero.2024.S01.1080p.WEB-DL.DDP5.1",
+ type: "usenet",
+ size: 9_400_000_000,
+ progress: 0.22,
+ state: "downloading",
+ downSpeed: 28_000_000,
+ time: 244_000,
+ category: "tv",
+ },
+];
+
export class DownloadClientMockService implements IDownloadClientIntegration {
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise {
return await Promise.resolve({
status: {
- paused: Math.random() < 0.5,
+ paused: false,
rates: {
- down: Math.floor(Math.random() * 5000),
- up: Math.floor(Math.random() * 5000),
+ down: 121_100_000,
+ up: 9_340_000,
},
- types: ["torrent", "usenet"],
+ types: ["torrent", "usenet"] as const,
},
- items: Array.from({ length: 20 }, (_, index) => DownloadClientMockService.createItem(index)).slice(
- 0,
- input.limit,
- ),
+ items: mockItems.slice(0, input.limit).map((item, index) => ({
+ ...item,
+ id: `mock-${index}`,
+ index,
+ })),
});
}
public async pauseQueueAsync(): Promise {
- return await Promise.resolve();
+ await Promise.resolve();
}
public async pauseItemAsync(_item: DownloadClientItem): Promise {
- return await Promise.resolve();
+ await Promise.resolve();
}
public async resumeQueueAsync(): Promise {
- return Promise.resolve();
+ await Promise.resolve();
}
public async resumeItemAsync(_item: DownloadClientItem): Promise {
- return await Promise.resolve();
+ await Promise.resolve();
}
public async deleteItemAsync(_item: DownloadClientItem, _fromDisk: boolean): Promise {
- return await Promise.resolve();
- }
-
- private static createItem(index: number): DownloadClientItem {
- const progress = Math.random() < 0.5 ? Math.random() : 1;
- return {
- id: `item-${index}`,
- index,
- name: `Item ${index}`,
- type: Math.random() > 0.5 ? "torrent" : "usenet",
- progress,
- size: Math.floor(Math.random() * 10000) + 1,
- downSpeed: Math.floor(Math.random() * 5000),
- upSpeed: Math.floor(Math.random() * 5000),
- state: progress >= 1 ? "completed" : "downloading",
- time: 0,
- };
+ await Promise.resolve();
}
}
diff --git a/packages/integrations/src/mock/data/media-releases.ts b/packages/integrations/src/mock/data/media-releases.ts
index d820a9aa4..15504b225 100644
--- a/packages/integrations/src/mock/data/media-releases.ts
+++ b/packages/integrations/src/mock/data/media-releases.ts
@@ -13,17 +13,17 @@ export const mockMediaReleases: MediaRelease[] = [
title: "Inception",
subtitle: "A mind-bending thriller",
description:
- "A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
+ "A thief who steals corporate secrets through dream-sharing technology is given the task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/inception_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/ljsZTbVsrQSqZgWeep2B1QiDKuh.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/8ZTVqvKDQ8emSGUEMjsS4yHAwrp.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
- href: "https://example.com/inception",
+ href: "https://www.themoviedb.org/movie/27205-inception",
length: 148,
},
{
@@ -34,13 +34,13 @@ export const mockMediaReleases: MediaRelease[] = [
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/breaking_bad_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/ztkUQFLlC19CCMYHW73GM9hBNRh.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/tsRy63Mu5cu8etL1X7ZLyf7UP1M.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
- href: "https://example.com/breaking_bad",
+ href: "https://www.themoviedb.org/tv/1396-breaking-bad",
},
{
id: "3",
@@ -50,79 +50,86 @@ export const mockMediaReleases: MediaRelease[] = [
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/ram_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/mIfBmEBqA1ddSMFyMAvACvOQ6cL.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
- tags: ["Electronic", "Dance", "Pop", "Funk"],
- href: "https://example.com/ram",
+ tags: ["Electronic", "Funk"],
+ href: "https://www.daftpunk.com",
},
{
id: "4",
- type: "book",
- title: "The Great Gatsby",
- subtitle: "F. Scott Fitzgerald",
- description: "A novel about the American dream and the disillusionment that comes with it.",
- releaseDate: new Date("1925-04-10"),
+ type: "movie",
+ title: "The Dark Knight",
+ subtitle: "Theatrical Release",
+ description:
+ "Batman raises the stakes in his war on crime. With the help of Lt. Gordon and DA Harvey Dent, Batman sets out to dismantle organized crime.",
+ releaseDate: new Date("2008-07-18"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/gatsby_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/qJ2tW6WMUDux911BTUgMe1nV8Is.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/nMKdUUepR0i5zn0y1T4CsSB5ez.jpg",
},
- producer: "Scribner",
- price: 10.99,
- rating: "4.2/5",
- tags: ["Classic", "Fiction"],
- href: "https://example.com/gatsby",
+ producer: "Warner Bros.",
+ price: 12.99,
+ rating: "9.0/10",
+ tags: ["Action", "Crime", "Drama"],
+ href: "https://www.themoviedb.org/movie/155-the-dark-knight",
+ length: 152,
},
{
id: "5",
- type: "game",
- title: "The Legend of Zelda: Breath of the Wild",
- subtitle: "Nintendo Switch",
- description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
- releaseDate: new Date("2017-03-03"),
+ type: "movie",
+ title: "Interstellar",
+ subtitle: "Digital Release",
+ description: "A team of explorers travel through a wormhole in space in an attempt to ensure humanity's survival.",
+ releaseDate: new Date("2014-11-07"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/zelda_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/xJHokMbljXjADYdit5fK1DVfjko.jpg",
},
- producer: "Nintendo",
- price: 59.99,
- rating: "10/10",
- tags: ["Action", "Adventure"],
- href: "https://example.com/zelda",
+ producer: "Paramount",
+ price: 14.99,
+ rating: "8.7/10",
+ tags: ["Sci-Fi", "Adventure"],
+ href: "https://www.themoviedb.org/movie/157336-interstellar",
+ length: 169,
},
{
id: "6",
- type: "article",
- title: "The Rise of AI in Healthcare",
- subtitle: "Tech Innovations",
- description: "Exploring the impact of artificial intelligence on the healthcare industry.",
- releaseDate: new Date("2023-10-01"),
+ type: "tv",
+ title: "Severance",
+ subtitle: "S2E1 - Premiere",
+ description:
+ "Mark leads a team of office workers whose memories are surgically divided between work and personal lives.",
+ releaseDate: new Date("2025-01-17"),
imageUrls: {
- poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
- backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
+ poster: "https://image.tmdb.org/t/p/w300/pJAtR4gMFnalHCmT9cByPFgkZ6B.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/gvaAaDUbF41Z1NLuJsHPRHkv1Ie.jpg",
},
- producer: "Tech Innovations",
- rating: "4.8/5",
- tags: ["Technology", "Healthcare"],
- href: "https://example.com/ai_healthcare",
+ producer: "Apple TV+",
+ rating: "8.7/10",
+ tags: ["Thriller", "Sci-Fi"],
+ href: "https://www.themoviedb.org/tv/95396-severance",
},
{
id: "7",
- type: "video",
- title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
- releaseDate: new Date("2024-05-18T17:00:00Z"),
+ type: "movie",
+ title: "Dune: Part Two",
+ subtitle: "Theatrical Release",
+ description:
+ "Paul Atreides unites with the Fremen to seek revenge against the conspirators who destroyed his family.",
+ releaseDate: new Date("2024-03-01"),
imageUrls: {
- poster:
- "https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
- backdrop:
- "https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
+ poster: "https://image.tmdb.org/t/p/w300/8b8R8l88Qje9dn9OE8PY05Nez7.jpg",
+ backdrop: "https://image.tmdb.org/t/p/w780/xOMo8BRK7PfcJv9JCnx7s5hj0PX.jpg",
},
- producer: "PietSmiet",
- rating: "1K",
- tags: [],
- href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
+ producer: "Warner Bros.",
+ price: 19.99,
+ rating: "8.5/10",
+ tags: ["Sci-Fi", "Adventure"],
+ href: "https://www.themoviedb.org/movie/693134-dune-part-two",
+ length: 166,
},
];
diff --git a/packages/integrations/src/mock/data/media-server.ts b/packages/integrations/src/mock/data/media-server.ts
index 511d47f59..96640d1ae 100644
--- a/packages/integrations/src/mock/data/media-server.ts
+++ b/packages/integrations/src/mock/data/media-server.ts
@@ -1,36 +1,95 @@
import type { IMediaServerIntegration } from "../../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../../interfaces/media-server/media-server-types";
-export class MediaServerMockService implements IMediaServerIntegration {
- public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise {
- return await Promise.resolve(
- Array.from({ length: 10 }, (_, index) => MediaServerMockService.createSession(index)).filter(
- (session) => !options.showOnlyPlaying || session.currentlyPlaying !== null,
- ),
- );
- }
+const sessions: StreamSession[] = [
+ {
+ sessionId: "s-0",
+ sessionName: "Living Room TV",
+ user: { userId: "u-0", username: "thomas", profilePictureUrl: null },
+ currentlyPlaying: {
+ type: "movie",
+ name: "Interstellar",
+ seasonName: undefined,
+ episodeName: null,
+ albumName: null,
+ episodeCount: null,
+ metadata: null,
+ },
+ },
+ {
+ sessionId: "s-1",
+ sessionName: "Bedroom Chromecast",
+ user: { userId: "u-1", username: "sarah", profilePictureUrl: null },
+ currentlyPlaying: {
+ type: "video",
+ name: "Breaking Bad",
+ seasonName: "Season 5",
+ episodeName: "Ozymandias",
+ albumName: null,
+ episodeCount: null,
+ metadata: null,
+ },
+ },
+ {
+ sessionId: "s-2",
+ sessionName: "Office Desktop",
+ user: { userId: "u-2", username: "mike", profilePictureUrl: null },
+ currentlyPlaying: {
+ type: "movie",
+ name: "The Dark Knight",
+ seasonName: undefined,
+ episodeName: null,
+ albumName: null,
+ episodeCount: null,
+ metadata: null,
+ },
+ },
+ {
+ sessionId: "s-3",
+ sessionName: "iPad",
+ user: { userId: "u-3", username: "emily", profilePictureUrl: null },
+ currentlyPlaying: {
+ type: "video",
+ name: "Severance",
+ seasonName: "Season 2",
+ episodeName: "The We We Are",
+ albumName: null,
+ episodeCount: null,
+ metadata: null,
+ },
+ },
+ {
+ sessionId: "s-4",
+ sessionName: "Mobile",
+ user: { userId: "u-4", username: "alex", profilePictureUrl: null },
+ currentlyPlaying: {
+ type: "audio",
+ name: "Daft Punk",
+ seasonName: undefined,
+ episodeName: null,
+ albumName: "Random Access Memories",
+ episodeCount: null,
+ metadata: null,
+ },
+ },
+ {
+ sessionId: "s-5",
+ sessionName: "Guest Room",
+ user: { userId: "u-5", username: "guest", profilePictureUrl: null },
+ currentlyPlaying: {
+ type: "movie",
+ name: "Oppenheimer",
+ seasonName: undefined,
+ episodeName: null,
+ albumName: null,
+ episodeCount: null,
+ metadata: null,
+ },
+ },
+];
- private static createSession(index: number): StreamSession {
- return {
- sessionId: `session-${index}`,
- sessionName: `Session ${index}`,
- user: {
- userId: `user-${index}`,
- username: `User${index}`,
- profilePictureUrl: "/images/mock/avatar.jpg",
- },
- currentlyPlaying:
- Math.random() > 0.9 // 10% chance of being null (not currently playing)
- ? {
- type: "movie",
- name: `Movie ${index}`,
- seasonName: undefined,
- episodeName: null,
- albumName: null,
- episodeCount: null,
- metadata: null,
- }
- : null,
- };
+export class MediaServerMockService implements IMediaServerIntegration {
+ public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise {
+ return await Promise.resolve(sessions);
}
}
diff --git a/packages/integrations/src/mock/data/network-controller-summary.ts b/packages/integrations/src/mock/data/network-controller-summary.ts
index 7ffe80c71..59d54999a 100644
--- a/packages/integrations/src/mock/data/network-controller-summary.ts
+++ b/packages/integrations/src/mock/data/network-controller-summary.ts
@@ -4,27 +4,11 @@ import type { NetworkControllerSummary } from "../../types";
export class NetworkControllerSummaryMockService implements NetworkControllerSummaryIntegration {
public async getNetworkSummaryAsync(): Promise {
return await Promise.resolve({
- lan: {
- guests: 5,
- users: 10,
- status: "enabled",
- },
- vpn: {
- users: 3,
- status: "enabled",
- },
- wanStatus: "disabled",
- wifi: {
- status: "disabled",
- guests: 0,
- users: 0,
- },
- www: {
- latency: 22,
- status: "enabled",
- ping: 32,
- uptime: 3600,
- },
+ lan: { guests: 5, users: 18, status: "enabled" },
+ vpn: { users: 3, status: "enabled" },
+ wanStatus: "enabled",
+ wifi: { status: "enabled", guests: 2, users: 12 },
+ www: { latency: 22, status: "enabled", ping: 14, uptime: 864000 },
});
}
}
diff --git a/packages/integrations/src/mock/data/ups-summary.ts b/packages/integrations/src/mock/data/ups-summary.ts
new file mode 100644
index 000000000..36dce662a
--- /dev/null
+++ b/packages/integrations/src/mock/data/ups-summary.ts
@@ -0,0 +1,41 @@
+import type { IUpsSummaryIntegration } from "../../interfaces/ups-summary/ups-summary-integration";
+import type { UpsSummary } from "../../types";
+
+export class UpsSummaryMockService implements IUpsSummaryIntegration {
+ public async getUpsSummariesAsync(): Promise {
+ return await Promise.resolve([
+ {
+ id: "ups-rack-1",
+ name: "CyberPower OR1500",
+ manufacturer: "CyberPower",
+ model: "OR1500LCDRM1U",
+ serial: "CPS1500-001",
+ status: "online",
+ batteryCharge: 100,
+ batteryRuntime: 2280,
+ batteryVoltage: 27.3,
+ load: 42,
+ inputVoltage: 230,
+ outputVoltage: 230,
+ power: 315,
+ temperature: 29,
+ },
+ {
+ id: "ups-rack-2",
+ name: "APC Back-UPS Pro",
+ manufacturer: "APC",
+ model: "BR1500G",
+ serial: "APC1500-002",
+ status: "onBattery",
+ batteryCharge: 64,
+ batteryRuntime: 840,
+ batteryVoltage: 25.1,
+ load: 58,
+ inputVoltage: 0,
+ outputVoltage: 229,
+ power: 410,
+ temperature: 31,
+ },
+ ]);
+ }
+}
diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts
index f04959171..0804267b1 100644
--- a/packages/integrations/src/mock/mock-integration.ts
+++ b/packages/integrations/src/mock/mock-integration.ts
@@ -15,6 +15,7 @@ import type { IMediaServerIntegration } from "../interfaces/media-server/media-s
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { NetworkControllerSummaryIntegration } from "../interfaces/network-controller-summary/network-controller-summary-integration";
import type { ISmartHomeIntegration } from "../interfaces/smart-home/smart-home-integration";
+import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
import { CalendarMockService } from "./data/calendar";
import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monitoring";
import { DnsHoleMockService } from "./data/dns-hole";
@@ -28,6 +29,8 @@ import { NetworkControllerSummaryMockService } from "./data/network-controller-s
import { NotificationsMockService } from "./data/notifications";
import { SmartHomeMockService } from "./data/smart-home";
import { SystemHealthMonitoringMockService } from "./data/system-health-monitoring";
+import { BeszelMockService } from "./data/beszel";
+import { UpsSummaryMockService } from "./data/ups-summary";
export class MockIntegration
extends Integration
@@ -43,7 +46,8 @@ export class MockIntegration
IMediaServerIntegration,
IMediaTranscodingIntegration,
NetworkControllerSummaryIntegration,
- ISmartHomeIntegration
+ ISmartHomeIntegration,
+ IUpsSummaryIntegration
{
private static readonly dnsHole = new DnsHoleMockService();
private static readonly calendar = new CalendarMockService();
@@ -58,6 +62,8 @@ export class MockIntegration
private static readonly networkController = new NetworkControllerSummaryMockService();
private static readonly notifications = new NotificationsMockService();
private static readonly smartHome = new SmartHomeMockService();
+ private static readonly beszel = new BeszelMockService();
+ private static readonly upsSummary = new UpsSummaryMockService();
protected async testingAsync(_: IntegrationTestingInput): Promise {
return await Promise.resolve({
@@ -85,7 +91,7 @@ export class MockIntegration
// Health Monitoring Integrations
getSystemInfoAsync = MockIntegration.systemMonitoring.getSystemInfoAsync.bind(MockIntegration.systemMonitoring);
- getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.downloadClient);
+ getClusterInfoAsync = MockIntegration.clusterMonitoring.getClusterInfoAsync.bind(MockIntegration.clusterMonitoring);
// IndexerManagerIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
@@ -104,7 +110,7 @@ export class MockIntegration
declineRequestAsync = MockIntegration.mediaRequest.declineRequestAsync.bind(MockIntegration.mediaRequest);
// MediaServerIntegration
- getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaRequest);
+ getCurrentSessionsAsync = MockIntegration.mediaServer.getCurrentSessionsAsync.bind(MockIntegration.mediaServer);
// MediaTranscodingIntegration
getStatisticsAsync = MockIntegration.mediaTranscoding.getStatisticsAsync.bind(MockIntegration.mediaTranscoding);
@@ -123,4 +129,15 @@ export class MockIntegration
getEntityStateAsync = MockIntegration.smartHome.getEntityStateAsync.bind(MockIntegration.smartHome);
triggerAutomationAsync = MockIntegration.smartHome.triggerAutomationAsync.bind(MockIntegration.smartHome);
triggerToggleAsync = MockIntegration.smartHome.triggerToggleAsync.bind(MockIntegration.smartHome);
+
+ // UpsSummaryIntegration
+ getUpsSummariesAsync = MockIntegration.upsSummary.getUpsSummariesAsync.bind(MockIntegration.upsSummary);
+
+ // BeszelIntegration
+ getSystemsAsync = MockIntegration.beszel.getSystemsAsync.bind(MockIntegration.beszel);
+ getSystemDetailsAsync = MockIntegration.beszel.getSystemDetailsAsync.bind(MockIntegration.beszel);
+ getSystemStatsAsync = MockIntegration.beszel.getSystemStatsAsync.bind(MockIntegration.beszel);
+ getContainerStatsAsync = MockIntegration.beszel.getContainerStatsAsync.bind(MockIntegration.beszel);
+ getAlertsAsync = MockIntegration.beszel.getAlertsAsync.bind(MockIntegration.beszel);
+ getAlertHistoryAsync = MockIntegration.beszel.getAlertHistoryAsync.bind(MockIntegration.beszel);
}
diff --git a/packages/integrations/src/navidrome/navidrome-integration.ts b/packages/integrations/src/navidrome/navidrome-integration.ts
new file mode 100644
index 000000000..4d7609ea0
--- /dev/null
+++ b/packages/integrations/src/navidrome/navidrome-integration.ts
@@ -0,0 +1,171 @@
+import { ParseError, ResponseError } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import type { NavidromeDashboardData, NavidromeNowPlayingEntry, SubsonicResponseBody } from "./navidrome-types";
+import { subsonicResponseSchema } from "./navidrome-types";
+
+const logger = createLogger({ module: "navidromeIntegration" });
+
+const subsonicVersion = "1.16.1";
+const subsonicClient = "homarr";
+const albumListPageSize = 500;
+const maxAlbumListPages = 20;
+const emptyLibraryMessage = "Library not found or empty";
+
+const subsonicAuthErrorCodes = new Set([40, 41]);
+
+const asArray = (value: TValue | TValue[] | undefined): TValue[] =>
+ [value].flat().filter((item): item is TValue => item !== undefined);
+
+export class NavidromeIntegration extends Integration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const url = this.url("/rest/ping.view", this.getAuthParams());
+ const response = await input.fetchAsync(url);
+
+ if (!response.ok) {
+ return TestConnectionError.StatusResult(response);
+ }
+
+ const parsed = subsonicResponseSchema.safeParse(await response.json());
+
+ if (!parsed.success) {
+ return TestConnectionError.ParseResult(new ParseError("Invalid Subsonic response", { cause: parsed.error }));
+ }
+
+ const subsonicResponse = parsed.data["subsonic-response"];
+
+ if (subsonicResponse.status === "failed") {
+ const errorCode = subsonicResponse.error?.code;
+
+ if (errorCode !== undefined && subsonicAuthErrorCodes.has(errorCode)) {
+ return TestConnectionError.UnauthorizedResult(401);
+ }
+
+ return TestConnectionError.UnknownResult(new Error(subsonicResponse.error?.message ?? "Subsonic request failed"));
+ }
+
+ return { success: true };
+ }
+
+ public async getDashboardDataAsync(): Promise {
+ const [artistCount, libraryCounts, nowPlaying] = await Promise.all([
+ this.getArtistCountAsync(),
+ this.getAlbumAndSongCountsAsync(),
+ this.getNowPlayingAsync(),
+ ]);
+
+ return {
+ artistCount,
+ albumCount: libraryCounts.albumCount,
+ songCount: libraryCounts.songCount,
+ nowPlaying,
+ };
+ }
+
+ private async getArtistCountAsync(): Promise {
+ const response = await this.subsonicRequestAsync("/rest/getArtists.view", {}, { tolerateEmptyLibrary: true });
+ return asArray(response.artists?.index).reduce((count, index) => count + asArray(index.artist).length, 0);
+ }
+
+ private async getAlbumAndSongCountsAsync(): Promise<{ albumCount: number; songCount: number }> {
+ let offset = 0;
+ let albumCount = 0;
+ let songCount = 0;
+ let page = 0;
+
+ while (page < maxAlbumListPages) {
+ const response = await this.subsonicRequestAsync(
+ "/rest/getAlbumList2.view",
+ {
+ type: "alphabeticalByName",
+ size: albumListPageSize,
+ offset,
+ },
+ { tolerateEmptyLibrary: true },
+ );
+ const albums = asArray(response.albumList2?.album);
+
+ if (albums.length === 0) {
+ break;
+ }
+
+ albumCount += albums.length;
+
+ for (const album of albums) {
+ songCount += album.songCount ?? 0;
+ }
+
+ if (albums.length < albumListPageSize) {
+ break;
+ }
+
+ offset += albumListPageSize;
+ page += 1;
+ }
+
+ if (page >= maxAlbumListPages) {
+ logger.warn("Reached Navidrome album list page limit", {
+ integrationId: this.integration.id,
+ maxPages: maxAlbumListPages,
+ albumCount,
+ });
+ }
+
+ return { albumCount, songCount };
+ }
+
+ private async getNowPlayingAsync(): Promise {
+ const response = await this.subsonicRequestAsync("/rest/getNowPlaying.view", {}, { tolerateEmptyLibrary: true });
+
+ return asArray(response.nowPlaying?.entry).map((entry) => ({
+ title: entry.title ?? "",
+ artist: entry.artist ?? "",
+ album: entry.album ?? "",
+ username: entry.username ?? "",
+ playerName: entry.playerName ?? "",
+ }));
+ }
+
+ private async subsonicRequestAsync(
+ path: `/rest/${string}`,
+ params: Record = {},
+ options: { tolerateEmptyLibrary?: boolean } = {},
+ ): Promise {
+ const url = this.url(path, { ...this.getAuthParams(), ...params });
+ const response = await fetchWithTrustedCertificatesAsync(url);
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ const parsed = subsonicResponseSchema.parse(await response.json());
+ const subsonicResponse = parsed["subsonic-response"];
+
+ if (subsonicResponse.status === "failed") {
+ const message = subsonicResponse.error?.message ?? "Subsonic request failed";
+
+ if (options.tolerateEmptyLibrary && message === emptyLibraryMessage) {
+ return subsonicResponse;
+ }
+
+ throw new Error(message);
+ }
+
+ return subsonicResponse;
+ }
+
+ private getAuthParams(): Record {
+ return {
+ u: this.getSecretValue("username"),
+ p: this.getSecretValue("password"),
+ v: subsonicVersion,
+ c: subsonicClient,
+ f: "json",
+ };
+ }
+}
diff --git a/packages/integrations/src/navidrome/navidrome-types.ts b/packages/integrations/src/navidrome/navidrome-types.ts
new file mode 100644
index 000000000..a9e5067f2
--- /dev/null
+++ b/packages/integrations/src/navidrome/navidrome-types.ts
@@ -0,0 +1,71 @@
+import { z } from "zod/v4";
+
+const subsonicArtistSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ albumCount: z.number().optional(),
+});
+
+const subsonicIndexSchema = z.object({
+ name: z.string(),
+ artist: z.union([subsonicArtistSchema, z.array(subsonicArtistSchema)]).optional(),
+});
+
+const subsonicAlbumSchema = z.object({
+ id: z.string(),
+ name: z.string(),
+ songCount: z.number().optional(),
+});
+
+const subsonicNowPlayingEntrySchema = z.object({
+ title: z.string().optional(),
+ artist: z.string().optional(),
+ album: z.string().optional(),
+ username: z.string().optional(),
+ playerName: z.string().optional(),
+});
+
+export const subsonicResponseSchema = z.object({
+ "subsonic-response": z.object({
+ status: z.enum(["ok", "failed"]),
+ version: z.string().optional(),
+ error: z
+ .object({
+ code: z.number(),
+ message: z.string(),
+ })
+ .optional(),
+ artists: z
+ .object({
+ index: z.union([subsonicIndexSchema, z.array(subsonicIndexSchema)]).optional(),
+ })
+ .optional(),
+ albumList2: z
+ .object({
+ album: z.union([subsonicAlbumSchema, z.array(subsonicAlbumSchema)]).optional(),
+ })
+ .optional(),
+ nowPlaying: z
+ .object({
+ entry: z.union([subsonicNowPlayingEntrySchema, z.array(subsonicNowPlayingEntrySchema)]).optional(),
+ })
+ .optional(),
+ }),
+});
+
+export type SubsonicResponseBody = z.infer["subsonic-response"];
+
+export interface NavidromeNowPlayingEntry {
+ title: string;
+ artist: string;
+ album: string;
+ username: string;
+ playerName: string;
+}
+
+export interface NavidromeDashboardData {
+ artistCount: number;
+ albumCount: number;
+ songCount: number;
+ nowPlaying: NavidromeNowPlayingEntry[];
+}
diff --git a/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts
new file mode 100644
index 000000000..921169b1a
--- /dev/null
+++ b/packages/integrations/src/navidrome/test/navidrome-integration.spec.ts
@@ -0,0 +1,201 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationSecret } from "../../base/types";
+import { NavidromeIntegration } from "../navidrome-integration";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+const TEST_URL = "https://navidrome.example.com";
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+const secrets: IntegrationSecret[] = [
+ { kind: "username", value: "admin" },
+ { kind: "password", value: "secret" },
+];
+
+const toUrlString = (url: unknown): string => String(url);
+
+const createIntegration = () =>
+ new NavidromeIntegration({
+ id: "test-navidrome",
+ name: "Test Navidrome",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: secrets,
+ });
+
+const subsonicOk = >(data: T) =>
+ JSON.stringify({
+ "subsonic-response": { status: "ok", version: "1.16.1", ...data },
+ });
+
+const subsonicFailed = (message: string) =>
+ JSON.stringify({
+ "subsonic-response": { status: "failed", error: { code: 70, message } },
+ });
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("NavidromeIntegration.getDashboardDataAsync", () => {
+ test("paginates album list and counts songs", async () => {
+ const makeAlbums = (count: number, songCount: number) =>
+ Array.from({ length: count }, (_, i) => ({
+ id: `album-${i}`,
+ name: `Album ${i}`,
+ songCount,
+ }));
+
+ mockFetch.mockImplementation((url) => {
+ const urlStr = toUrlString(url);
+
+ if (urlStr.includes("getArtists")) {
+ return Promise.resolve(
+ new Response(subsonicOk({ artists: { index: [{ name: "A", artist: [{ id: "1", name: "Artist" }] }] } }), {
+ status: 200,
+ }),
+ ) as unknown as ReturnType;
+ }
+
+ if (urlStr.includes("getAlbumList2")) {
+ const offsetMatch = urlStr.match(/offset=(\d+)/);
+ const offset = Number(offsetMatch?.[1] ?? 0);
+
+ if (offset === 0) {
+ return Promise.resolve(
+ new Response(subsonicOk({ albumList2: { album: makeAlbums(500, 10) } }), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (offset === 500) {
+ return Promise.resolve(
+ new Response(subsonicOk({ albumList2: { album: makeAlbums(200, 5) } }), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ }
+
+ if (urlStr.includes("getNowPlaying")) {
+ return Promise.resolve(new Response(subsonicOk({ nowPlaying: {} }), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ }
+
+ return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ });
+
+ const integration = createIntegration();
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.albumCount).toBe(700);
+ expect(result.songCount).toBe(500 * 10 + 200 * 5);
+ expect(result.artistCount).toBe(1);
+ expect(result.nowPlaying).toEqual([]);
+ });
+
+ test("handles empty library gracefully", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlStr = toUrlString(url);
+
+ if (urlStr.includes("getArtists")) {
+ return Promise.resolve(
+ new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (urlStr.includes("getAlbumList2")) {
+ return Promise.resolve(
+ new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (urlStr.includes("getNowPlaying")) {
+ return Promise.resolve(
+ new Response(subsonicFailed("Library not found or empty"), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ });
+
+ const integration = createIntegration();
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.artistCount).toBe(0);
+ expect(result.albumCount).toBe(0);
+ expect(result.songCount).toBe(0);
+ expect(result.nowPlaying).toEqual([]);
+ });
+
+ test("throws on non-empty-library subsonic error", async () => {
+ mockFetch.mockImplementation(
+ () =>
+ Promise.resolve(new Response(subsonicFailed("Permission denied"), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >,
+ );
+
+ const integration = createIntegration();
+ await expect(integration.getDashboardDataAsync()).rejects.toThrow();
+ });
+
+ test("now playing maps entries correctly", async () => {
+ mockFetch.mockImplementation((url) => {
+ const urlStr = toUrlString(url);
+
+ if (urlStr.includes("getNowPlaying")) {
+ return Promise.resolve(
+ new Response(
+ subsonicOk({
+ nowPlaying: {
+ entry: { title: "Song", artist: "Band", album: "LP", username: "user1", playerName: "Chrome" },
+ },
+ }),
+ { status: 200 },
+ ),
+ ) as unknown as ReturnType;
+ }
+
+ if (urlStr.includes("getArtists")) {
+ return Promise.resolve(new Response(subsonicOk({ artists: {} }), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ }
+
+ if (urlStr.includes("getAlbumList2")) {
+ return Promise.resolve(new Response(subsonicOk({ albumList2: {} }), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ }
+
+ return Promise.resolve(new Response(subsonicOk({}), { status: 200 })) as unknown as ReturnType<
+ typeof fetchWithTrustedCertificatesAsync
+ >;
+ });
+
+ const integration = createIntegration();
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.nowPlaying).toHaveLength(1);
+ expect(result.nowPlaying[0]).toEqual({
+ title: "Song",
+ artist: "Band",
+ album: "LP",
+ username: "user1",
+ playerName: "Chrome",
+ });
+ });
+});
diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts
index 619af2541..643d2ac60 100644
--- a/packages/integrations/src/overseerr/overseerr-integration.ts
+++ b/packages/integrations/src/overseerr/overseerr-integration.ts
@@ -60,6 +60,9 @@ export class OverseerrIntegration
text: "overview" in result ? result.overview : undefined,
type: result.mediaType,
inLibrary: result.mediaInfo !== undefined,
+ availability: result.mediaInfo
+ ? this.mapAvailability(result.mediaInfo.status as UpstreamMediaAvailability, false)
+ : undefined,
}));
}
@@ -70,7 +73,12 @@ export class OverseerrIntegration
"X-Api-Key": this.getSecretValue("apiKey"),
},
});
- return await mediaInformationSchema.parseAsync(await response.json());
+ const data = await mediaInformationSchema.parseAsync(await response.json());
+ const requestedSeasons = [
+ ...new Set(data.mediaInfo?.requests?.flatMap((req) => req.seasons.map((s) => s.seasonNumber)) ?? []),
+ ];
+ const { mediaInfo: _strip, ...rest } = data;
+ return { ...rest, requestedSeasons };
}
/**
@@ -341,6 +349,22 @@ interface MovieInformation {
releaseDate: string;
}
+const mediaInfoRequestsSchema = z
+ .object({
+ requests: z
+ .array(
+ z.object({
+ seasons: z.array(
+ z.object({
+ seasonNumber: z.number(),
+ }),
+ ),
+ }),
+ )
+ .optional(),
+ })
+ .optional();
+
const mediaInformationSchema = z.union([
z.object({
id: z.number(),
@@ -355,14 +379,22 @@ const mediaInformationSchema = z.union([
),
numberOfSeasons: z.number(),
posterPath: z.string().startsWith("/"),
+ mediaInfo: mediaInfoRequestsSchema,
}),
z.object({
id: z.number(),
overview: z.string(),
posterPath: z.string().startsWith("/"),
+ mediaInfo: mediaInfoRequestsSchema,
}),
]);
+const searchMediaInfoSchema = z
+ .object({
+ status: z.number(),
+ })
+ .optional();
+
const searchSchema = z.object({
results: z
.array(
@@ -373,7 +405,7 @@ const searchSchema = z.object({
name: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
- mediaInfo: z.object({}).optional(),
+ mediaInfo: searchMediaInfoSchema,
}),
z.object({
id: z.number(),
@@ -381,14 +413,14 @@ const searchSchema = z.object({
title: z.string(),
posterPath: z.string().startsWith("/").endsWith(".jpg").nullable(),
overview: z.string(),
- mediaInfo: z.object({}).optional(),
+ mediaInfo: searchMediaInfoSchema,
}),
z.object({
id: z.number(),
mediaType: z.literal("person"),
name: z.string(),
profilePath: z.string().startsWith("/").endsWith(".jpg").nullable(),
- mediaInfo: z.object({}).optional(),
+ mediaInfo: searchMediaInfoSchema,
}),
]),
)
diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts
new file mode 100644
index 000000000..a6059c3e8
--- /dev/null
+++ b/packages/integrations/src/paperless-ngx/paperless-ngx-integration.ts
@@ -0,0 +1,67 @@
+import { ResponseError } from "@homarr/common/server";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import type { PaperlessNgxStats } from "./paperless-ngx-types";
+import { paperlessNgxPaginatedCountSchema, paperlessNgxStatisticsSchema } from "./paperless-ngx-types";
+
+export class PaperlessNgxIntegration extends Integration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const statisticsUrl = this.url("/api/statistics/");
+ const statisticsResponse = await input.fetchAsync(statisticsUrl, {
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!statisticsResponse.ok) {
+ return TestConnectionError.StatusResult(statisticsResponse);
+ }
+
+ return { success: true };
+ }
+
+ public async getStatsAsync(): Promise {
+ const [statistics, tags, correspondents, documentTypes] = await Promise.all([
+ this.fetchStatisticsAsync(),
+ this.fetchCountAsync("/api/tags/"),
+ this.fetchCountAsync("/api/correspondents/"),
+ this.fetchCountAsync("/api/document_types/"),
+ ]);
+
+ return {
+ documentsTotal: statistics.documents_total,
+ documentsInbox: statistics.documents_inbox,
+ correspondentsCount: correspondents,
+ tagsCount: tags,
+ documentTypesCount: documentTypes,
+ };
+ }
+
+ private async fetchAuthenticatedAsync(path: `/${string}`) {
+ const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
+ headers: this.getAuthHeaders(),
+ });
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+ return response;
+ }
+
+ private async fetchStatisticsAsync() {
+ const response = await this.fetchAuthenticatedAsync("/api/statistics/");
+ return paperlessNgxStatisticsSchema.parse(await response.json());
+ }
+
+ private async fetchCountAsync(path: `/${string}`) {
+ const response = await this.fetchAuthenticatedAsync(path);
+ return paperlessNgxPaginatedCountSchema.parse(await response.json()).count;
+ }
+
+ private getAuthHeaders(): Record {
+ return {
+ Authorization: `Token ${this.getSecretValue("apiKey")}`,
+ };
+ }
+}
diff --git a/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts b/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts
new file mode 100644
index 000000000..8a47ffcdb
--- /dev/null
+++ b/packages/integrations/src/paperless-ngx/paperless-ngx-types.ts
@@ -0,0 +1,21 @@
+import { z } from "zod/v4";
+
+export const paperlessNgxStatisticsSchema = z.object({
+ documents_total: z.number(),
+ documents_inbox: z
+ .number()
+ .nullable()
+ .transform((value) => value ?? 0),
+});
+
+export const paperlessNgxPaginatedCountSchema = z.object({
+ count: z.number(),
+});
+
+export interface PaperlessNgxStats {
+ documentsTotal: number;
+ documentsInbox: number;
+ correspondentsCount: number;
+ tagsCount: number;
+ documentTypesCount: number;
+}
diff --git a/packages/integrations/src/peanut/peanut-integration.ts b/packages/integrations/src/peanut/peanut-integration.ts
new file mode 100644
index 000000000..ad3fe6296
--- /dev/null
+++ b/packages/integrations/src/peanut/peanut-integration.ts
@@ -0,0 +1,103 @@
+import { ResponseError } from "@homarr/common/server";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import type { IUpsSummaryIntegration } from "../interfaces/ups-summary/ups-summary-integration";
+import type { UpsStatus, UpsSummary } from "../interfaces/ups-summary/ups-summary-types";
+import type { PeaNutDevice } from "./peanut-types";
+import { peaNutDevicesSchema } from "./peanut-types";
+
+export class PeaNutIntegration extends Integration implements IUpsSummaryIntegration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const response = await input.fetchAsync(this.url("/api/v1/devices"), {
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!response.ok) return TestConnectionError.StatusResult(response);
+
+ await response.json();
+
+ return { success: true };
+ }
+
+ public async getUpsSummariesAsync(): Promise {
+ const response = await fetchWithTrustedCertificatesAsync(this.url("/api/v1/devices", { meta: true }), {
+ headers: this.getAuthHeaders(),
+ });
+
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+
+ const devices = peaNutDevicesSchema.parse(await response.json());
+ return devices.map((device, index) => this.mapDeviceToSummary(device, index));
+ }
+
+ private mapDeviceToSummary(device: PeaNutDevice, index: number): UpsSummary {
+ const manufacturer = this.readString(device, "device.mfr") ?? this.readString(device, "ups.mfr");
+ const model = this.readString(device, "device.model") ?? this.readString(device, "ups.model");
+ const id = this.readString(device, "peanut.device_id") ?? `ups-${index}`;
+ const name = [manufacturer, model].filter(Boolean).join(" ") || id;
+
+ return {
+ id,
+ name,
+ manufacturer,
+ model,
+ serial: this.readString(device, "device.serial"),
+ status: this.parseStatus(this.readString(device, "ups.status")),
+ batteryCharge: this.readNumber(device, "battery.charge"),
+ batteryRuntime: this.readNumber(device, "battery.runtime"),
+ batteryVoltage: this.readNumber(device, "battery.voltage"),
+ load: this.readNumber(device, "ups.load"),
+ inputVoltage: this.readNumber(device, "input.voltage"),
+ outputVoltage: this.readNumber(device, "output.voltage"),
+ power: this.readNumber(device, "ups.realpower") ?? this.readNumber(device, "ups.power"),
+ temperature: this.readNumber(device, "ups.temperature"),
+ };
+ }
+
+ /**
+ * Resolves the raw NUT `ups.status` flags (e.g. "OL CHRG", "OB DISCHRG LB") into a single
+ * normalized status. The flags are checked in priority order so the most critical state wins.
+ */
+ private parseStatus(rawStatus: string | null): UpsStatus {
+ const flags = (rawStatus ?? "").toUpperCase().split(/\s+/).filter(Boolean);
+
+ if (flags.includes("LB")) return "lowBattery";
+ if (flags.includes("OB")) return "onBattery";
+ if (flags.includes("OL")) {
+ return flags.includes("CHRG") ? "charging" : "online";
+ }
+
+ return "unknown";
+ }
+
+ private readString(device: PeaNutDevice, key: string): string | null {
+ const value = device[key];
+ if (value === undefined || value === null) return null;
+ const stringValue = String(value).trim();
+ return stringValue.length > 0 ? stringValue : null;
+ }
+
+ private readNumber(device: PeaNutDevice, key: string): number | null {
+ const value = device[key];
+ if (value === undefined || value === null || value === "") return null;
+ const numberValue = typeof value === "number" ? value : Number(value);
+ return Number.isFinite(numberValue) ? numberValue : null;
+ }
+
+ private getAuthHeaders(): Record {
+ if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
+ const credentials = Buffer.from(`${this.getSecretValue("username")}:${this.getSecretValue("password")}`).toString(
+ "base64",
+ );
+ return { Authorization: `Basic ${credentials}` };
+ }
+
+ return {};
+ }
+}
diff --git a/packages/integrations/src/peanut/peanut-types.ts b/packages/integrations/src/peanut/peanut-types.ts
new file mode 100644
index 000000000..fab0626f0
--- /dev/null
+++ b/packages/integrations/src/peanut/peanut-types.ts
@@ -0,0 +1,10 @@
+import { z } from "zod/v4";
+
+// PeaNUT returns each device as a flat map of NUT variables (e.g. "ups.status",
+// "battery.charge"). Values are either strings or numbers depending on the
+// variable, so we accept both and coerce them when mapping to a UpsSummary.
+export const peaNutDeviceSchema = z.record(z.string(), z.union([z.string(), z.number()]));
+
+export const peaNutDevicesSchema = z.array(peaNutDeviceSchema);
+
+export type PeaNutDevice = z.infer;
diff --git a/packages/integrations/src/peanut/test/peanut-integration.spec.ts b/packages/integrations/src/peanut/test/peanut-integration.spec.ts
new file mode 100644
index 000000000..0fdb8e460
--- /dev/null
+++ b/packages/integrations/src/peanut/test/peanut-integration.spec.ts
@@ -0,0 +1,166 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationSecret } from "../../base/types";
+import { PeaNutIntegration } from "../peanut-integration";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+const TEST_URL = "https://peanut.example.com";
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+const mockDevicesResponse = (devices: Record[]) => {
+ mockFetch.mockImplementation(
+ () =>
+ Promise.resolve(
+ new Response(JSON.stringify(devices), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ ) as unknown as ReturnType,
+ );
+};
+
+const createIntegration = (decryptedSecrets: IntegrationSecret[] = []) =>
+ new PeaNutIntegration({
+ id: "test-peanut",
+ name: "Test PeaNUT",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets,
+ });
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("PeaNutIntegration getUpsSummariesAsync", () => {
+ test("maps NUT variables to a UPS summary and coerces string values to numbers", async () => {
+ mockDevicesResponse([
+ {
+ "device.mfr": "CyberPower",
+ "device.model": "CP1500EPFCLCD",
+ "device.serial": "ABC123",
+ "ups.status": "OL CHRG",
+ "ups.load": "23",
+ "ups.realpower": "120",
+ "ups.temperature": "31.5",
+ "battery.charge": "100",
+ "battery.runtime": "3720",
+ "battery.voltage": "27.3",
+ "input.voltage": "230",
+ "output.voltage": "229",
+ "peanut.device_id": "ups",
+ "peanut.server": "localhost:3493",
+ },
+ ]);
+
+ const [summary] = await createIntegration().getUpsSummariesAsync();
+
+ expect(summary).toStrictEqual({
+ id: "ups",
+ name: "CyberPower CP1500EPFCLCD",
+ manufacturer: "CyberPower",
+ model: "CP1500EPFCLCD",
+ serial: "ABC123",
+ status: "charging",
+ batteryCharge: 100,
+ batteryRuntime: 3720,
+ batteryVoltage: 27.3,
+ load: 23,
+ inputVoltage: 230,
+ outputVoltage: 229,
+ power: 120,
+ temperature: 31.5,
+ });
+ });
+
+ test("falls back when optional variables are missing", async () => {
+ mockDevicesResponse([
+ {
+ "ups.status": "OB DISCHRG LB",
+ "ups.power": "90",
+ "battery.charge": "15",
+ },
+ ]);
+
+ const [summary] = await createIntegration().getUpsSummariesAsync();
+
+ expect(summary).toMatchObject({
+ id: "ups-0",
+ name: "ups-0",
+ manufacturer: null,
+ model: null,
+ status: "lowBattery",
+ batteryCharge: 15,
+ batteryRuntime: null,
+ load: null,
+ inputVoltage: null,
+ outputVoltage: null,
+ power: 90,
+ temperature: null,
+ });
+ });
+
+ test("returns a summary per device", async () => {
+ mockDevicesResponse([
+ { "peanut.device_id": "ups-a", "ups.status": "OL" },
+ { "peanut.device_id": "ups-b", "ups.status": "OB" },
+ ]);
+
+ const summaries = await createIntegration().getUpsSummariesAsync();
+
+ expect(summaries.map((summary) => summary.id)).toStrictEqual(["ups-a", "ups-b"]);
+ });
+
+ test("normalizes NUT status flags into a typed status", async () => {
+ mockDevicesResponse([
+ { "ups.status": "OL" },
+ { "ups.status": "OL CHRG" },
+ { "ups.status": "OB DISCHRG" },
+ { "ups.status": "OB DISCHRG LB" },
+ { "ups.status": "OL LB" },
+ { "ups.status": "" },
+ ]);
+
+ const summaries = await createIntegration().getUpsSummariesAsync();
+
+ expect(summaries.map((summary) => summary.status)).toStrictEqual([
+ "online",
+ "charging",
+ "onBattery",
+ "lowBattery",
+ "lowBattery",
+ "unknown",
+ ]);
+ });
+});
+
+describe("PeaNutIntegration authentication", () => {
+ test("sends a Basic auth header when username and password are configured", async () => {
+ mockDevicesResponse([]);
+
+ await createIntegration([
+ { kind: "username", value: "admin" },
+ { kind: "password", value: "secret" },
+ ]).getUpsSummariesAsync();
+
+ const expected = `Basic ${Buffer.from("admin:secret").toString("base64")}`;
+ const [, requestInit] = mockFetch.mock.calls[0] ?? [];
+ expect(requestInit?.headers).toMatchObject({ Authorization: expected });
+ });
+
+ test("omits the auth header when no credentials are configured", async () => {
+ mockDevicesResponse([]);
+
+ await createIntegration().getUpsSummariesAsync();
+
+ const [, requestInit] = mockFetch.mock.calls[0] ?? [];
+ expect(requestInit?.headers).toStrictEqual({});
+ });
+});
diff --git a/packages/integrations/src/truenas/test/truenas-api.spec.ts b/packages/integrations/src/truenas/test/truenas-api.spec.ts
new file mode 100644
index 000000000..cc75bcc07
--- /dev/null
+++ b/packages/integrations/src/truenas/test/truenas-api.spec.ts
@@ -0,0 +1,79 @@
+import { describe, expect, test } from "vitest";
+
+import { trueNasApis } from "../truenas-api";
+
+describe("trueNasApis descriptors", () => {
+ test("expose the expected endpoints and handshake requirements", () => {
+ expect(trueNasApis.jsonRpc).toMatchObject({
+ kind: "jsonRpc",
+ path: "/api/current",
+ requiresSessionHandshake: false,
+ });
+ expect(trueNasApis.legacy).toMatchObject({
+ kind: "legacy",
+ path: "/websocket",
+ requiresSessionHandshake: true,
+ });
+ });
+});
+
+describe("jsonRpc api", () => {
+ const { buildRequest, matchResponse } = trueNasApis.jsonRpc;
+
+ test("builds a JSON-RPC 2.0 envelope", () => {
+ expect(buildRequest("id-1", "system.info", [])).toStrictEqual({
+ jsonrpc: "2.0",
+ id: "id-1",
+ method: "system.info",
+ params: [],
+ });
+ });
+
+ test("matches a result for the requested id", () => {
+ const result = matchResponse({ jsonrpc: "2.0", id: "id-1", result: true }, "id-1");
+ expect(result).toStrictEqual({ result: true });
+ });
+
+ test("returns the error when the response carries one", () => {
+ const error = { code: -32001, message: "Not authenticated" };
+ expect(matchResponse({ jsonrpc: "2.0", id: "id-1", error }, "id-1")).toStrictEqual({ error });
+ });
+
+ test("ignores responses for a different id", () => {
+ expect(matchResponse({ jsonrpc: "2.0", id: "other", result: true }, "id-1")).toBeUndefined();
+ });
+
+ test("ignores non JSON-RPC messages", () => {
+ expect(matchResponse({ msg: "result", id: "id-1", result: true }, "id-1")).toBeUndefined();
+ });
+});
+
+describe("legacy api", () => {
+ const { buildRequest, matchResponse } = trueNasApis.legacy;
+
+ test("builds a DDP method envelope", () => {
+ expect(buildRequest("id-1", "auth.login", ["user", "pass"])).toStrictEqual({
+ id: "id-1",
+ msg: "method",
+ method: "auth.login",
+ params: ["user", "pass"],
+ });
+ });
+
+ test("matches a result message for the requested id", () => {
+ expect(matchResponse({ msg: "result", id: "id-1", result: false }, "id-1")).toStrictEqual({ result: false });
+ });
+
+ test("returns the error when the result message carries one", () => {
+ const error = { error: 13, reason: "boom" };
+ expect(matchResponse({ msg: "result", id: "id-1", error }, "id-1")).toStrictEqual({ error });
+ });
+
+ test("ignores responses for a different id", () => {
+ expect(matchResponse({ msg: "result", id: "other", result: true }, "id-1")).toBeUndefined();
+ });
+
+ test("ignores non-result messages such as the session handshake", () => {
+ expect(matchResponse({ msg: "connected", session: "abc" }, "id-1")).toBeUndefined();
+ });
+});
diff --git a/packages/integrations/src/truenas/test/truenas-integration.spec.ts b/packages/integrations/src/truenas/test/truenas-integration.spec.ts
new file mode 100644
index 000000000..28ff3b8d1
--- /dev/null
+++ b/packages/integrations/src/truenas/test/truenas-integration.spec.ts
@@ -0,0 +1,264 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
+import type { IntegrationSecret } from "../../base/types";
+
+const ws = vi.hoisted(() => {
+ const sentPayloads: Record[] = [];
+ const constructedUrls: string[] = [];
+ // A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
+ const failures: { path: string; code?: string }[] = [];
+ let responder: (method: string, params: unknown[]) => unknown = () => undefined;
+
+ type Listener = (...args: unknown[]) => void;
+
+ class FakeWebSocket {
+ static readonly OPEN = 1;
+ public readyState = FakeWebSocket.OPEN;
+ private readonly listeners: Record = {};
+
+ constructor(url: URL | string) {
+ constructedUrls.push(url.toString());
+ setTimeout(() => {
+ const failure = failures.find((candidate) => url.toString().includes(candidate.path));
+ if (failure) {
+ const error: Error & { code?: string } = new Error(failure.code ?? "Unexpected server response: 404");
+ if (failure.code) error.code = failure.code;
+ this.dispatch("error", error);
+ } else {
+ this.dispatch("open");
+ }
+ }, 0);
+ }
+
+ on(event: string, listener: Listener) {
+ (this.listeners[event] ??= []).push(listener);
+ return this;
+ }
+
+ once(event: string, listener: Listener) {
+ const wrapper: Listener = (...args) => {
+ this.off(event, wrapper);
+ listener(...args);
+ };
+ return this.on(event, wrapper);
+ }
+
+ off(event: string, listener: Listener) {
+ this.listeners[event] = (this.listeners[event] ?? []).filter((candidate) => candidate !== listener);
+ return this;
+ }
+
+ send(raw: string) {
+ const payload = JSON.parse(raw) as Record;
+ sentPayloads.push(payload);
+
+ if (payload.msg === "connect") {
+ this.respond(JSON.stringify({ msg: "connected", session: "test-session" }));
+ return;
+ }
+
+ const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
+ const envelope =
+ payload.jsonrpc === "2.0"
+ ? { jsonrpc: "2.0", id: payload.id, result }
+ : { msg: "result", id: payload.id, result };
+ this.respond(JSON.stringify(envelope));
+ }
+
+ close() {
+ this.readyState = 3;
+ }
+
+ private respond(message: string) {
+ setTimeout(() => this.dispatch("message", message), 0);
+ }
+
+ private dispatch(event: string, ...args: unknown[]) {
+ // `off` replaces the array rather than mutating it, so iterating it directly is safe.
+ for (const listener of this.listeners[event] ?? []) listener(...args);
+ }
+ }
+
+ return {
+ FakeWebSocket,
+ sentPayloads,
+ constructedUrls,
+ failures,
+ setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
+ responder = fn;
+ },
+ };
+});
+
+vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
+vi.mock("@homarr/core/infrastructure/certificates", () => ({
+ getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
+ getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
+}));
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ createCustomCheckServerIdentity: () => () => undefined,
+}));
+
+import { TrueNasIntegration } from "../truenas-integration";
+
+const happyResponder = (method: string) => {
+ switch (method) {
+ case "auth.login":
+ case "auth.login_with_api_key":
+ return true;
+ case "system.info":
+ return {
+ version: "TrueNAS-SCALE-24.10",
+ hostname: "nas",
+ physmem: 16_000_000_000,
+ model: "Intel Xeon",
+ uptime_seconds: 3600,
+ };
+ case "reporting.get_data":
+ return [
+ {
+ name: "cpu",
+ identifier: "cpu",
+ aggregations: emptyAggregations(),
+ start: 0,
+ end: 1,
+ legend: [],
+ data: [[1000, 12]],
+ },
+ {
+ name: "memory",
+ identifier: "memory",
+ aggregations: emptyAggregations(),
+ start: 0,
+ end: 1,
+ legend: [],
+ data: [[1000, 8_000_000_000]],
+ },
+ {
+ name: "cputemp",
+ identifier: "cputemp",
+ aggregations: emptyAggregations(),
+ start: 0,
+ end: 1,
+ legend: [],
+ data: [[1000, 45]],
+ },
+ ];
+ case "pool.query":
+ return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
+ case "interface.query":
+ return [{ id: "eth0", name: "eth0" }];
+ case "reporting.netdata_get_data":
+ return [{ name: "interface", identifier: "eth0", data: [[1000, 100, 200]] }];
+ default:
+ return null;
+ }
+};
+
+const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
+
+let integrationCounter = 0;
+const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
+ new TrueNasIntegration({
+ id: `truenas-${(integrationCounter += 1)}`,
+ name: "TrueNAS Test",
+ url: "https://truenas.local",
+ externalUrl: null,
+ decryptedSecrets,
+ });
+
+const credentials: IntegrationSecret[] = [
+ { kind: "username", value: "root" },
+ { kind: "password", value: "secret" },
+];
+
+describe("TrueNasIntegration", () => {
+ beforeEach(() => {
+ ws.sentPayloads.length = 0;
+ ws.constructedUrls.length = 0;
+ ws.failures.length = 0;
+ ws.setResponder(happyResponder);
+ });
+
+ test("fetches and maps system info over the JSON-RPC API", async () => {
+ const integration = createIntegration(credentials);
+
+ const result = await integration.getSystemInfoAsync();
+
+ expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
+ // JSON-RPC does not use the legacy "connect" session handshake.
+ expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
+
+ expect(result).toMatchObject({
+ version: "TrueNAS-SCALE-24.10",
+ cpuModelName: "Intel Xeon",
+ cpuTemp: 45,
+ memAvailableInBytes: 16_000_000_000,
+ memUsedInBytes: 8_000_000_000,
+ uptime: 3600,
+ rebootRequired: false,
+ availablePkgUpdates: 0,
+ loadAverage: null,
+ gpu: [],
+ network: { up: 20_000, down: 10_000 },
+ fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
+ smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
+ });
+ });
+
+ test("authenticates with an API key when one is configured", async () => {
+ const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
+
+ await integration.getSystemInfoAsync();
+
+ const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
+ expect(authPayload).toMatchObject({ method: "auth.login_with_api_key", params: ["api-key-123"] });
+ });
+
+ test("authenticates with username and password when no API key is configured", async () => {
+ const integration = createIntegration(credentials);
+
+ await integration.getSystemInfoAsync();
+
+ const authPayload = ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."));
+ expect(authPayload).toMatchObject({ method: "auth.login", params: ["root", "secret"] });
+ });
+
+ test("falls back to the legacy websocket API when the JSON-RPC endpoint is unavailable", async () => {
+ ws.failures.push({ path: "/api/current" });
+ const integration = createIntegration(credentials);
+
+ const result = await integration.getSystemInfoAsync();
+
+ expect(ws.constructedUrls).toContain("wss://truenas.local/api/current");
+ expect(ws.constructedUrls).toContain("wss://truenas.local/websocket");
+ // The legacy API requires the "connect" session handshake before authenticating.
+ expect(ws.sentPayloads[0]).toMatchObject({ msg: "connect" });
+ expect(ws.sentPayloads.find((payload) => String(payload.method).startsWith("auth."))).toMatchObject({
+ msg: "method",
+ method: "auth.login",
+ });
+ expect(result.version).toBe("TrueNAS-SCALE-24.10");
+ });
+
+ test("rejects when authentication is not successful", async () => {
+ ws.setResponder((method) => (method.startsWith("auth.") ? false : happyResponder(method)));
+ const integration = createIntegration(credentials);
+
+ await expect(integration.getSystemInfoAsync()).rejects.toThrow();
+ });
+
+ test("surfaces a self-signed certificate as a certificate request error without falling back", async () => {
+ ws.failures.push({ path: "/api/current", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
+ ws.failures.push({ path: "/websocket", code: "DEPTH_ZERO_SELF_SIGNED_CERT" });
+ const integration = createIntegration(credentials);
+
+ const error = await integration.getSystemInfoAsync().catch((caught: unknown) => caught);
+
+ expect(error).toBeInstanceOf(IntegrationRequestError);
+ expect((error as IntegrationRequestError).cause).toMatchObject({ type: "certificate", reason: "untrusted" });
+ // A certificate failure is endpoint-independent, so the legacy API must not be tried.
+ expect(ws.constructedUrls).not.toContain("wss://truenas.local/websocket");
+ });
+});
diff --git a/packages/integrations/src/truenas/truenas-api.ts b/packages/integrations/src/truenas/truenas-api.ts
new file mode 100644
index 000000000..322c3e856
--- /dev/null
+++ b/packages/integrations/src/truenas/truenas-api.ts
@@ -0,0 +1,50 @@
+export type TrueNasApiKind = "jsonRpc" | "legacy";
+
+export type TrueNasResponse = { result: unknown } | { error: unknown };
+
+export interface TrueNasApi {
+ kind: TrueNasApiKind;
+ /** Websocket path appended to the integration URL. */
+ path: `/${string}`;
+ /** The legacy DDP API requires a "connect" handshake before authentication, the JSON-RPC API does not. */
+ requiresSessionHandshake: boolean;
+ /** Builds the request envelope for a method call. */
+ buildRequest: (id: string, method: string, params: unknown[]) => Record;
+ /**
+ * Returns the response payload when `message` is the reply to `id`,
+ * otherwise `undefined` so the caller keeps waiting for a later message.
+ */
+ matchResponse: (message: Record, id: string) => TrueNasResponse | undefined;
+}
+
+/**
+ * JSON-RPC 2.0 API introduced in TrueNAS SCALE 24.10 (Electric Eel) and the
+ * legacy DDP-style websocket API used before it. Both expose the same
+ * middleware methods, so only the transport envelope differs.
+ * @see https://www.truenas.com/docs/api/scale_rest_api.html
+ * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
+ */
+export const trueNasApis = {
+ jsonRpc: {
+ kind: "jsonRpc",
+ path: "/api/current",
+ requiresSessionHandshake: false,
+ buildRequest: (id: string, method: string, params: unknown[]) => ({ jsonrpc: "2.0", id, method, params }),
+ matchResponse: (message: Record, id: string) => {
+ if (message.id !== id || message.jsonrpc !== "2.0") return undefined;
+ if (message.error != null) return { error: message.error };
+ return { result: message.result };
+ },
+ },
+ legacy: {
+ kind: "legacy",
+ path: "/websocket",
+ requiresSessionHandshake: true,
+ buildRequest: (id: string, method: string, params: unknown[]) => ({ id, msg: "method", method, params }),
+ matchResponse: (message: Record, id: string) => {
+ if (message.id !== id || message.msg !== "result") return undefined;
+ if (message.error != null) return { error: message.error };
+ return { result: message.result };
+ },
+ },
+} satisfies Record;
diff --git a/packages/integrations/src/truenas/truenas-client.ts b/packages/integrations/src/truenas/truenas-client.ts
new file mode 100644
index 000000000..e0f04f447
--- /dev/null
+++ b/packages/integrations/src/truenas/truenas-client.ts
@@ -0,0 +1,315 @@
+import type { RawData } from "ws";
+import { WebSocket } from "ws";
+import z from "zod";
+
+import { createId } from "@homarr/common";
+import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
+import {
+ getAllTrustedCertificatesAsync,
+ getTrustedCertificateHostnamesAsync,
+} from "@homarr/core/infrastructure/certificates";
+import { createCustomCheckServerIdentity } from "@homarr/core/infrastructure/http";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import type { TrueNasApi } from "./truenas-api";
+import { trueNasApis } from "./truenas-api";
+
+const logger = createLogger({ module: "trueNasClient" });
+
+const REQUEST_TIMEOUT_MS = 5000;
+
+type CertificateOptions = IntegrationTestingInput["options"];
+
+export type TrueNasCredentials = { apiKey: string } | { username: string; password: string };
+
+/**
+ * Handles talking to a TrueNAS server: opening a websocket with Homarr's
+ * trusted certificates, negotiating the API variant, authenticating, and
+ * sending method requests. Connections are reused across the per-request
+ * integration instances and reconnected transparently when the server drops
+ * an idle socket.
+ */
+export class TrueNasClient {
+ // Keyed by integration id. The value is the in-flight connection promise so concurrent
+ // callers reuse a single socket instead of racing to open duplicates.
+ private static readonly connectionMap = new Map>();
+ private static readonly apiMap = new Map();
+
+ constructor(
+ private readonly id: string,
+ private readonly webSocketUrl: (path: `/${string}`) => URL,
+ private readonly credentials: TrueNasCredentials,
+ ) {}
+
+ /** Sends an authenticated request, reusing the cached connection while it is open. */
+ public async requestAsync(method: string, params: unknown[] = []): Promise {
+ const socket = await this.getSocketAsync();
+ return socket.requestAsync(method, params);
+ }
+
+ /**
+ * Opens a connection with the provided certificate options, authenticates,
+ * and closes it. Used by the connection test, which may run with updated
+ * credentials, so any cached connection is dropped and the API re-detected.
+ */
+ public async testAsync(certificate: CertificateOptions): Promise {
+ this.evictConnection();
+ TrueNasClient.apiMap.delete(this.id);
+
+ const socket = await this.openSocketAsync(certificate);
+ try {
+ await this.authenticateAsync(socket);
+ } finally {
+ socket.close();
+ }
+ }
+
+ private getSocketAsync(): Promise {
+ let connection = TrueNasClient.connectionMap.get(this.id);
+ if (!connection) {
+ connection = this.connectAndAuthenticateAsync();
+ TrueNasClient.connectionMap.set(this.id, connection);
+
+ const evict = () => {
+ if (TrueNasClient.connectionMap.get(this.id) === connection) {
+ TrueNasClient.connectionMap.delete(this.id);
+ }
+ };
+ // Reconnect on the next request once this attempt fails or the server drops the socket.
+ connection.then((socket) => socket.onClose(evict), evict);
+ }
+
+ return connection;
+ }
+
+ private async connectAndAuthenticateAsync(): Promise {
+ const socket = await this.openSocketAsync(await resolveCertificateOptionsAsync());
+ try {
+ await this.authenticateAsync(socket);
+ } catch (error) {
+ // Never keep an unauthenticated socket, it would be reused without re-authenticating.
+ socket.close();
+ throw error;
+ }
+ return socket;
+ }
+
+ private evictConnection() {
+ const connection = TrueNasClient.connectionMap.get(this.id);
+ if (!connection) return;
+
+ TrueNasClient.connectionMap.delete(this.id);
+ connection.then((socket) => socket.close()).catch(() => undefined);
+ }
+
+ /**
+ * Connects using the JSON-RPC API (TrueNAS 24.10+) when available and falls
+ * back to the legacy websocket API otherwise. A missing JSON-RPC endpoint
+ * surfaces as a plain protocol error; certificate, connection, DNS and
+ * timeout errors are endpoint-independent and are never retried.
+ */
+ private async openSocketAsync(certificate: CertificateOptions): Promise {
+ const cachedApi = TrueNasClient.apiMap.get(this.id);
+ if (cachedApi) {
+ return openTrueNasSocketAsync(this.webSocketUrl(cachedApi.path), cachedApi, certificate);
+ }
+
+ try {
+ const socket = await openTrueNasSocketAsync(
+ this.webSocketUrl(trueNasApis.jsonRpc.path),
+ trueNasApis.jsonRpc,
+ certificate,
+ );
+ TrueNasClient.apiMap.set(this.id, trueNasApis.jsonRpc);
+ return socket;
+ } catch (error) {
+ if (error instanceof RequestError) throw error;
+
+ logger.debug("JSON-RPC API unavailable, falling back to the legacy websocket API", {
+ error: error instanceof Error ? error.message : undefined,
+ });
+ const socket = await openTrueNasSocketAsync(
+ this.webSocketUrl(trueNasApis.legacy.path),
+ trueNasApis.legacy,
+ certificate,
+ );
+ TrueNasClient.apiMap.set(this.id, trueNasApis.legacy);
+ return socket;
+ }
+ }
+
+ /**
+ * Authenticates with an API key when one is configured, otherwise with the
+ * username and password. Both methods work on either API.
+ * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
+ */
+ private async authenticateAsync(socket: TrueNasSocket): Promise {
+ const response =
+ "apiKey" in this.credentials
+ ? await socket.requestAsync("auth.login_with_api_key", [this.credentials.apiKey])
+ : await socket.requestAsync("auth.login", [this.credentials.username, this.credentials.password]);
+
+ const success = await z.boolean().parseAsync(response);
+ if (!success) throw new ResponseError({ status: 401 });
+ }
+}
+
+/**
+ * Wraps a single websocket connection and speaks whichever TrueNAS API
+ * ({@link trueNasApis}) was negotiated when it was opened.
+ */
+class TrueNasSocket {
+ constructor(
+ private readonly webSocket: WebSocket,
+ private readonly api: TrueNasApi,
+ ) {}
+
+ public close() {
+ this.webSocket.close();
+ }
+
+ public onClose(listener: () => void) {
+ this.webSocket.once("close", listener);
+ }
+
+ /**
+ * The legacy DDP API requires a "connect" handshake to obtain a session
+ * before any method can be called.
+ * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
+ */
+ public registerSessionAsync(): Promise {
+ return new Promise((resolve, reject) => {
+ const handler = (raw: RawData) => {
+ const message = parseMessage(raw);
+ if (message?.msg === "connected") {
+ this.webSocket.off("message", handler);
+ resolve();
+ } else if (message?.msg === "failed") {
+ this.webSocket.off("message", handler);
+ reject(new Error("Unable to establish TrueNAS session"));
+ }
+ };
+
+ this.webSocket.on("message", handler);
+ this.webSocket.send(JSON.stringify({ msg: "connect", version: "1", support: ["1"] }));
+ });
+ }
+
+ /**
+ * Sends a method request and resolves with its result. Rejects after
+ * {@link REQUEST_TIMEOUT_MS} when no matching response was received.
+ */
+ public requestAsync(method: string, params: unknown[] = []): Promise {
+ return new Promise((resolve, reject) => {
+ const id = createId();
+
+ const cleanup = () => {
+ clearTimeout(timeoutId);
+ this.webSocket.off("message", handler);
+ this.webSocket.off("close", onClose);
+ this.webSocket.off("error", onError);
+ };
+
+ const handler = (raw: RawData) => {
+ const message = parseMessage(raw);
+ if (!message) return;
+
+ const response = this.api.matchResponse(message, id);
+ if (!response) return;
+
+ cleanup();
+ if ("error" in response) {
+ reject(new Error(`TrueNAS method "${method}" failed: ${JSON.stringify(response.error)}`));
+ return;
+ }
+ logger.debug("Received method response", { id, method, api: this.api.kind });
+ resolve(response.result);
+ };
+
+ // A dropped connection would otherwise leave the request hanging until the timeout below.
+ const onClose = () => {
+ cleanup();
+ reject(
+ new RequestError(
+ { type: "connection", reason: "reset", code: "ECONNRESET" },
+ { cause: new Error("Connection closed before a response was received") },
+ ),
+ );
+ };
+ const onError = (error: Error) => {
+ cleanup();
+ reject(error);
+ };
+
+ const timeoutId = setTimeout(() => {
+ cleanup();
+ reject(
+ new RequestError(
+ {
+ type: "timeout",
+ reason: "aborted",
+ code: "ECONNABORTED",
+ },
+ { cause: new Error("Canceled request after 5 seconds") },
+ ),
+ );
+ }, REQUEST_TIMEOUT_MS);
+
+ this.webSocket.on("message", handler);
+ this.webSocket.once("close", onClose);
+ this.webSocket.once("error", onError);
+ logger.debug("Sending method request", { id, method, api: this.api.kind });
+ this.webSocket.send(JSON.stringify(this.api.buildRequest(id, method, params)));
+ });
+ }
+}
+
+/**
+ * Opens a websocket using Homarr's trusted certificates and performs the
+ * session handshake when the API requires it. TLS, connection, DNS and timeout
+ * failures are translated into {@link RequestError}s so the connection test can
+ * offer to trust a self-signed certificate, exactly like the HTTP integrations.
+ */
+const openTrueNasSocketAsync = async (
+ url: URL,
+ api: TrueNasApi,
+ certificate: CertificateOptions,
+): Promise => {
+ logger.debug("Connecting to TrueNAS websocket", { url: url.toString(), api: api.kind });
+
+ const webSocket = await new Promise((resolve, reject) => {
+ const socket = new WebSocket(url, {
+ ca: certificate.ca,
+ // The @types/ws contract types this as returning a boolean, but ws forwards it to
+ // tls.connect which uses Node's `Error | undefined` contract that our helper follows.
+ checkServerIdentity: certificate.checkServerIdentity as never,
+ });
+
+ socket.once("open", () => resolve(socket));
+ socket.once("error", (error: Error & { code?: string }) => {
+ const matched = error.code ? matchErrorCode(error.code) : undefined;
+ reject(matched ? new RequestError(matched, { cause: error }) : error);
+ });
+ });
+
+ const socket = new TrueNasSocket(webSocket, api);
+ if (api.requiresSessionHandshake) await socket.registerSessionAsync();
+ return socket;
+};
+
+const resolveCertificateOptionsAsync = async (): Promise => ({
+ ca: await getAllTrustedCertificatesAsync(),
+ checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
+});
+
+const parseMessage = (raw: RawData): Record | undefined => {
+ try {
+ const parsed: unknown = JSON.parse(raw.toString());
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
+ ? (parsed as Record)
+ : undefined;
+ } catch {
+ return undefined;
+ }
+};
diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts
index 8d64f848c..adbe32b3b 100644
--- a/packages/integrations/src/truenas/truenas-integration.ts
+++ b/packages/integrations/src/truenas/truenas-integration.ts
@@ -1,8 +1,6 @@
import dayjs from "dayjs";
import z from "zod";
-import { createId } from "@homarr/common";
-import { RequestError, ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationTestingInput } from "../base/integration";
@@ -10,252 +8,21 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
+import type { TrueNasCredentials } from "./truenas-client";
+import { TrueNasClient } from "./truenas-client";
const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
- private static webSocketMap = new Map();
-
- private wsUrl() {
- const url = super.url("/websocket");
- url.protocol = url.protocol.replace("http", "ws");
- return url;
- }
-
- private get webSocket() {
- return TrueNasIntegration.webSocketMap.get(this.integration.id) ?? null;
- }
-
- protected async testingAsync(_input: IntegrationTestingInput): Promise {
- const webSocket = await this.connectWebSocketAsync();
- await this.registerSessionAsync(webSocket);
- await this.authenticateWebSocketAsync(webSocket);
-
- // Remove current socket connection so we can authenticate with updated credentials
- TrueNasIntegration.webSocketMap.delete(this.integration.id);
+ private client?: TrueNasClient;
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ await this.getClient().testAsync(input.options);
return { success: true };
}
- /**
- * TrueNAS API uses WebSocket. This function connects to the socket
- * and resolves the promise if the connection was successful.
- * @see https://www.truenas.com/docs/api/scale_websocket_api.html
- */
- private async connectWebSocketAsync(): Promise {
- logger.debug("Connecting to websocket server", {
- url: this.wsUrl(),
- });
- const webSocket = new WebSocket(this.wsUrl());
-
- return new Promise((resolve, reject) => {
- webSocket.onopen = () => {
- logger.debug("Connected to websocket server", {
- url: this.wsUrl(),
- });
- resolve(webSocket);
- };
-
- webSocket.onerror = () => {
- reject(new Error("Failed to connect"));
- };
- });
- }
-
- /**
- * Before authentication, a session must be obtained from the server using the "connect" event.
- * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
- */
- private async registerSessionAsync(webSocket: WebSocket): Promise {
- return new Promise((resolve, reject) => {
- const subscribe = (event: MessageEvent) => {
- const data = JSON.parse(event.data) as { msg: string };
- if (data.msg === "connected") {
- webSocket.removeEventListener("message", subscribe);
- resolve();
- } else if (data.msg === "failed") {
- webSocket.removeEventListener("message", subscribe);
- reject(new Error("Unable to establish connection"));
- }
- };
-
- webSocket.addEventListener("message", subscribe);
- webSocket.send(
- JSON.stringify({
- msg: "connect",
- version: "1", // this must be number, not string
- support: ["1"], // this must be number, not string
- }),
- );
- });
- }
-
- /**
- * After a session was obtained, the session can be authenticated.
- * @see https://www.truenas.com/docs/api/scale_websocket_api.html#websocket_protocol
- */
- private async authenticateWebSocketAsync(webSocket?: WebSocket): Promise {
- logger.debug("Authenticating with username and password", {
- url: this.wsUrl(),
- });
- const response = await this.requestAsync(
- "auth.login",
- [this.getSecretValue("username"), this.getSecretValue("password")],
- webSocket,
- );
- const result = await z.boolean().parseAsync(response);
- if (!result) throw new ResponseError({ status: 401 });
- logger.debug("Authenticated successfully with username and password", {
- url: this.wsUrl(),
- });
- }
-
- private async getPoolsAsync() {
- logger.debug("Retrieving pools", {
- url: this.wsUrl(),
- });
-
- const response = await this.requestAsync("pool.query", [
- [],
- {
- extra: {
- is_upgraded: true,
- },
- },
- ]);
- const result = await poolSchema.parseAsync(response);
- // Offline / exported pools have null values for allocated, size and free, so we filter them out
- // See https://github.com/homarr-labs/homarr/issues/5194
- const activePools = result
- .map((pool) => {
- if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
- return {
- ...pool,
- allocated: pool.allocated,
- size: pool.size,
- free: pool.free,
- };
- }
-
- return null;
- })
- .filter((pool) => pool !== null);
- logger.debug("Retrieved pools", {
- url: this.wsUrl(),
- totalCount: result.length,
- activeCount: activePools.length,
- });
- return activePools;
- }
-
- /**
- * Retrieves data using the reporting method
- * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
- */
- private async getReportingAsync(): Promise {
- logger.debug("Retrieving reporting data", {
- url: this.wsUrl(),
- });
-
- const response = await this.requestAsync("reporting.get_data", [
- [
- {
- name: "cpu",
- },
- {
- name: "memory",
- },
- {
- name: "cputemp",
- },
- ],
- {
- aggregate: true,
- start: dayjs().add(-5, "minutes").unix(),
- end: dayjs().unix(),
- },
- ]);
- const result = await z.array(reportingItemSchema).parseAsync(response);
-
- logger.debug("Retrieved reporting data", {
- url: this.wsUrl(),
- count: result.length,
- });
- return result;
- }
-
- /**
- * Retrieves a list of all available network interfaces
- * @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
- */
- private async getNetworkInterfacesAsync(): Promise> {
- logger.debug("Retrieving available network-interfaces", {
- url: this.wsUrl(),
- });
-
- const response = await this.requestAsync("interface.query", [
- [], // no filters
- {},
- ]);
- const result = await networkInterfaceSchema.parseAsync(response);
-
- logger.debug("Retrieved available network-interfaces", {
- url: this.wsUrl(),
- count: result.length,
- });
- return result;
- }
-
- /**
- * Retrieves reporting network data of the last 5 minutes
- * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
- */
- private async getReportingNetdataAsync(): Promise> {
- const networkInterfaces = await this.getNetworkInterfacesAsync();
-
- logger.debug("Retrieving reporting network data", {
- url: this.wsUrl(),
- });
-
- const response = await this.requestAsync("reporting.netdata_get_data", [
- networkInterfaces.map((networkInterface) => ({
- name: "interface",
- identifier: networkInterface.id,
- })),
- {
- start: dayjs().add(-5, "minutes").unix(),
- end: dayjs().unix(),
- },
- ]);
- const result = await reportingNetDataSchema.parseAsync(response);
-
- logger.debug("Retrieved reporting-network-data", {
- url: this.wsUrl(),
- count: result.length,
- });
- return result;
- }
-
- /**
- * Retrieves information about the system
- * @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
- */
- private async getSystemInformationAsync(): Promise> {
- logger.debug("Retrieving system-information", {
- url: this.wsUrl(),
- });
-
- const response = await this.requestAsync("system.info");
- const result = await systemInfoSchema.parseAsync(response);
-
- logger.debug("Retrieved system-information", {
- url: this.wsUrl(),
- });
- return result;
- }
-
public async getSystemInfoAsync(): Promise {
const systemInformation = await this.getSystemInformationAsync();
const reporting = await this.getReportingAsync();
@@ -272,7 +39,7 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return {
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
- cpuTemp: Math.max(...cpuTempData.filter((_item, i) => i > 0)),
+ cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
fileSystem: datasets.map((dataset) => ({
@@ -301,87 +68,164 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
};
}
- /**
- * Send a request through websocket and return response
- * Times out after 5 seconds when no response was received.
- * @param method json-rpc method to call
- * @param params array of parameters
- * @param webSocketOverride override of webSocket, helpful for not storing the connection
- * @returns result of json-rpc call
- */
- private async requestAsync(method: string, params: unknown[] = [], webSocketOverride?: WebSocket) {
- let webSocket = webSocketOverride ?? this.webSocket;
- if (webSocket?.readyState !== WebSocket.OPEN) {
- logger.debug("Connecting to websocket", {
- url: this.wsUrl(),
- });
- // We can only land here with static webSocket
- webSocket = await this.connectWebSocketAsync();
- await this.registerSessionAsync(webSocket);
-
- TrueNasIntegration.webSocketMap.set(this.integration.id, webSocket);
- await this.authenticateWebSocketAsync();
- }
-
- return await new Promise((resolve, reject) => {
- const id = createId();
- const handler = (event: MessageEvent) => {
- const data = JSON.parse(event.data) as Record;
- if (data.msg !== "result") return;
- if (data.id !== id) return;
-
- clearTimeout(timeoutId);
- webSocket.removeEventListener("message", handler);
- logger.debug("Received method response", {
- id,
- method,
- url: this.wsUrl(),
- });
- resolve(data.result);
- };
- const timeoutId = setTimeout(() => {
- webSocket.removeEventListener("message", handler);
- reject(
- new RequestError(
- {
- type: "timeout",
- reason: "aborted",
- code: "ECONNABORTED",
- },
- { cause: new Error("Canceled request after 5 seconds") },
- ),
- );
- }, 5000);
-
- webSocket.addEventListener("message", handler);
-
- logger.debug("Sending method request", {
- id,
- method,
- url: this.wsUrl(),
- });
-
- webSocket.send(
- JSON.stringify({
- id,
- msg: "method",
- method,
- params,
- }),
- );
- });
+ private getClient(): TrueNasClient {
+ return (this.client ??= this.createClient());
}
- private extractNetworkTrafficData = (data: z.infer, index = 1 | 2) => {
+ private createClient(): TrueNasClient {
+ const credentials: TrueNasCredentials = this.hasSecretValue("apiKey")
+ ? { apiKey: this.getSecretValue("apiKey") }
+ : { username: this.getSecretValue("username"), password: this.getSecretValue("password") };
+
+ return new TrueNasClient(this.integration.id, (path) => this.webSocketUrl(path), credentials);
+ }
+
+ private async requestAsync(method: string, params: unknown[] = []): Promise {
+ return this.getClient().requestAsync(method, params);
+ }
+
+ private async getPoolsAsync() {
+ logger.debug("Retrieving pools", { url: this.integration.url });
+
+ const response = await this.requestAsync("pool.query", [
+ [],
+ {
+ extra: {
+ is_upgraded: true,
+ },
+ },
+ ]);
+ const result = await poolSchema.parseAsync(response);
+ // Offline / exported pools have null values for allocated, size and free, so we filter them out
+ // See https://github.com/homarr-labs/homarr/issues/5194
+ const activePools = result
+ .map((pool) => {
+ if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
+ return {
+ ...pool,
+ allocated: pool.allocated,
+ size: pool.size,
+ free: pool.free,
+ };
+ }
+
+ return null;
+ })
+ .filter((pool) => pool !== null);
+ logger.debug("Retrieved pools", {
+ url: this.integration.url,
+ totalCount: result.length,
+ activeCount: activePools.length,
+ });
+ return activePools;
+ }
+
+ /**
+ * Retrieves data using the reporting method
+ * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
+ */
+ private async getReportingAsync(): Promise {
+ logger.debug("Retrieving reporting data", { url: this.integration.url });
+
+ const response = await this.requestAsync("reporting.get_data", [
+ [
+ {
+ name: "cpu",
+ },
+ {
+ name: "memory",
+ },
+ {
+ name: "cputemp",
+ },
+ ],
+ {
+ aggregate: true,
+ start: dayjs().add(-5, "minutes").unix(),
+ end: dayjs().unix(),
+ },
+ ]);
+ const result = await z.array(reportingItemSchema).parseAsync(response);
+
+ logger.debug("Retrieved reporting data", {
+ url: this.integration.url,
+ count: result.length,
+ });
+ return result;
+ }
+
+ /**
+ * Retrieves a list of all available network interfaces
+ * @see https://www.truenas.com/docs/core/13.0/api/core_websocket_api.html#interface
+ */
+ private async getNetworkInterfacesAsync(): Promise> {
+ logger.debug("Retrieving available network-interfaces", { url: this.integration.url });
+
+ const response = await this.requestAsync("interface.query", [
+ [], // no filters
+ {},
+ ]);
+ const result = await networkInterfaceSchema.parseAsync(response);
+
+ logger.debug("Retrieved available network-interfaces", {
+ url: this.integration.url,
+ count: result.length,
+ });
+ return result;
+ }
+
+ /**
+ * Retrieves reporting network data of the last 5 minutes
+ * @see https://www.truenas.com/docs/api/scale_websocket_api.html#reporting
+ */
+ private async getReportingNetdataAsync(): Promise> {
+ const networkInterfaces = await this.getNetworkInterfacesAsync();
+
+ logger.debug("Retrieving reporting network data", { url: this.integration.url });
+
+ const response = await this.requestAsync("reporting.netdata_get_data", [
+ networkInterfaces.map((networkInterface) => ({
+ name: "interface",
+ identifier: networkInterface.id,
+ })),
+ {
+ start: dayjs().add(-5, "minutes").unix(),
+ end: dayjs().unix(),
+ },
+ ]);
+ const result = await reportingNetDataSchema.parseAsync(response);
+
+ logger.debug("Retrieved reporting-network-data", {
+ url: this.integration.url,
+ count: result.length,
+ });
+ return result;
+ }
+
+ /**
+ * Retrieves information about the system
+ * @see https://www.truenas.com/docs/api/scale_websocket_api.html#system
+ */
+ private async getSystemInformationAsync(): Promise> {
+ logger.debug("Retrieving system-information", { url: this.integration.url });
+
+ const response = await this.requestAsync("system.info");
+ const result = await systemInfoSchema.parseAsync(response);
+
+ logger.debug("Retrieved system-information", { url: this.integration.url });
+ return result;
+ }
+
+ private extractNetworkTrafficData = (data: z.infer, index: 1 | 2) => {
return data.reduce((acc, current) => acc + (current.data.at(-1)?.at(index) ?? 0), 0);
};
- private extractLatestReportingData(data: ReportingItem[], key: ReportingItem["identifier"]) {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const dataObject = data.find((item) => item.identifier === key)!;
- // TODO: check why the below sorting is done, because right now it compares number[] with number[]?
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- return dataObject.data.sort((item1, item2) => (item1 > item2 ? -1 : 1))[0]!;
+ private extractLatestReportingData(data: ReportingItem[], identifier: ReportingItem["identifier"]) {
+ const item = data.find((reportingItem) => reportingItem.identifier === identifier);
+ // Reporting rows are [timestamp, ...values] in ascending time order, so the last row is the most recent reading.
+ const latest = item?.data.at(-1);
+ if (!latest) throw new Error(`TrueNAS returned no "${identifier}" reporting data`);
+ return latest;
}
}
diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts
index 10ae96ee4..1ee395dfa 100644
--- a/packages/integrations/src/types.ts
+++ b/packages/integrations/src/types.ts
@@ -3,6 +3,7 @@ export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
+export * from "./interfaces/ups-summary/ups-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
@@ -16,4 +17,9 @@ export * from "./anchor/anchor-types";
export * from "./interfaces/timetable/timetable-types";
export * from "./tracearr/tracearr-types";
export * from "./speedtest-tracker/speedtest-tracker-types";
+export * from "./uptime-kuma/uptime-kuma-types";
+export * from "./audiobookshelf/audiobookshelf-types";
+export * from "./navidrome/navidrome-types";
+export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
+export * from "./beszel/beszel-types";
diff --git a/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts b/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts
new file mode 100644
index 000000000..f21c522b6
--- /dev/null
+++ b/packages/integrations/src/uptime-kuma/test/uptime-kuma-integration.spec.ts
@@ -0,0 +1,162 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationSecret } from "../../base/types";
+import { UptimeKumaIntegration } from "../uptime-kuma-integration";
+import { uptimeKumaHeartbeatCategoryMap } from "../uptime-kuma-types";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
+
+const TEST_URL = "https://uptime.example.com";
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+const toUrlString = (url: unknown): string => String(url);
+
+const createIntegration = (secrets: IntegrationSecret[] = []) =>
+ new UptimeKumaIntegration({
+ id: "test-uptime-kuma",
+ name: "Test Uptime Kuma",
+ url: TEST_URL,
+ externalUrl: null,
+ decryptedSecrets: secrets,
+ });
+
+beforeEach(() => {
+ mockFetch.mockReset();
+});
+
+describe("uptimeKumaHeartbeatCategoryMap", () => {
+ test("maps status 0 (down) to 'down'", () => {
+ expect(uptimeKumaHeartbeatCategoryMap[0]).toBe("down");
+ });
+
+ test("maps status 1 (up) to 'up'", () => {
+ expect(uptimeKumaHeartbeatCategoryMap[1]).toBe("up");
+ });
+
+ test("maps status 2 (pending) to 'paused'", () => {
+ expect(uptimeKumaHeartbeatCategoryMap[2]).toBe("paused");
+ });
+
+ test("maps status 3 (maintenance) to 'paused'", () => {
+ expect(uptimeKumaHeartbeatCategoryMap[3]).toBe("paused");
+ });
+
+ test("returns undefined for unknown status codes", () => {
+ expect(uptimeKumaHeartbeatCategoryMap[99]).toBeUndefined();
+ });
+});
+
+describe("UptimeKumaIntegration.getDashboardDataAsync", () => {
+ const statusPageResponse = {
+ publicGroupList: [
+ {
+ id: 1,
+ name: "Services",
+ monitorList: [
+ { id: 10, name: "Web" },
+ { id: 20, name: "API" },
+ { id: 30, name: "DB" },
+ ],
+ },
+ ],
+ };
+
+ const heartbeatResponse = {
+ heartbeatList: {
+ "10": [{ status: 1, time: "2025-01-01" }],
+ "20": [{ status: 0, time: "2025-01-01" }],
+ "30": [{ status: 2, time: "2025-01-01" }],
+ },
+ uptimeList: {
+ "10_24": 0.995,
+ "20_24": 0.5,
+ "30_24": 0.8,
+ },
+ };
+
+ const mockResponses = () => {
+ mockFetch.mockImplementation((url) => {
+ const urlStr = toUrlString(url);
+
+ if (urlStr.includes("/heartbeat/")) {
+ return Promise.resolve(
+ new Response(JSON.stringify(heartbeatResponse), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ return Promise.resolve(
+ new Response(JSON.stringify(statusPageResponse), { status: 200 }),
+ ) as unknown as ReturnType;
+ });
+ };
+
+ test("aggregates monitor counts correctly", async () => {
+ mockResponses();
+ const integration = createIntegration([{ kind: "slug", value: "default" }]);
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.totalMonitors).toBe(3);
+ expect(result.upCount).toBe(1);
+ expect(result.downCount).toBe(1);
+ expect(result.pausedCount).toBe(1);
+ });
+
+ test("computes average uptime from uptimeList", async () => {
+ mockResponses();
+ const integration = createIntegration([{ kind: "slug", value: "default" }]);
+ const result = await integration.getDashboardDataAsync();
+
+ const expectedAverage = ((0.995 + 0.5 + 0.8) * 100) / 3;
+ expect(result.averageUptimePercent).toBeCloseTo(expectedAverage, 5);
+ });
+
+ test("defaults unknown heartbeat status to 'down'", async () => {
+ const unknownHeartbeat = {
+ heartbeatList: { "10": [{ status: 99, time: "2025-01-01" }] },
+ uptimeList: { "10_24": 1 },
+ };
+
+ mockFetch.mockImplementation((url) => {
+ const urlStr = toUrlString(url);
+ if (urlStr.includes("/heartbeat/")) {
+ return Promise.resolve(
+ new Response(JSON.stringify(unknownHeartbeat), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+ return Promise.resolve(
+ new Response(
+ JSON.stringify({
+ publicGroupList: [{ id: 1, name: "G", monitorList: [{ id: 10, name: "Svc" }] }],
+ }),
+ { status: 200 },
+ ),
+ ) as unknown as ReturnType;
+ });
+
+ const integration = createIntegration([{ kind: "slug", value: "default" }]);
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.monitors[0]?.status).toBe("down");
+ expect(result.downCount).toBe(1);
+ });
+
+ test("handles empty monitor list gracefully", async () => {
+ mockFetch.mockImplementation(
+ () =>
+ Promise.resolve(
+ new Response(JSON.stringify({ publicGroupList: [], heartbeatList: {}, uptimeList: {} }), { status: 200 }),
+ ) as unknown as ReturnType,
+ );
+
+ const integration = createIntegration([{ kind: "slug", value: "default" }]);
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.totalMonitors).toBe(0);
+ expect(result.averageUptimePercent).toBe(0);
+ });
+});
diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts
new file mode 100644
index 000000000..ce494e599
--- /dev/null
+++ b/packages/integrations/src/uptime-kuma/uptime-kuma-integration.ts
@@ -0,0 +1,140 @@
+import { ParseError, ResponseError } from "@homarr/common/server";
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import type { IntegrationTestingInput } from "../base/integration";
+import { Integration } from "../base/integration";
+import { TestConnectionError } from "../base/test-connection/test-connection-error";
+import type { TestingResult } from "../base/test-connection/test-connection-service";
+import {
+ uptimeKumaHeartbeatCategoryMap,
+ uptimeKumaHeartbeatResponseSchema,
+ uptimeKumaStatusPageResponseSchema,
+} from "./uptime-kuma-types";
+import type { UptimeKumaDashboardData, UptimeKumaMonitor, UptimeKumaMonitorCategory } from "./uptime-kuma-types";
+
+export class UptimeKumaIntegration extends Integration {
+ protected async testingAsync(input: IntegrationTestingInput): Promise {
+ const statusPageResponse = await input.fetchAsync(this.statusPageUrl());
+
+ if (!statusPageResponse.ok) {
+ return TestConnectionError.StatusResult(statusPageResponse);
+ }
+
+ const statusPageParsed = uptimeKumaStatusPageResponseSchema.safeParse(await statusPageResponse.json());
+
+ if (!statusPageParsed.success) {
+ return TestConnectionError.ParseResult(
+ new ParseError("Invalid Uptime Kuma status page response", { cause: statusPageParsed.error }),
+ );
+ }
+
+ const heartbeatResponse = await input.fetchAsync(this.heartbeatUrl());
+
+ if (!heartbeatResponse.ok) {
+ return TestConnectionError.StatusResult(heartbeatResponse);
+ }
+
+ const heartbeatParsed = uptimeKumaHeartbeatResponseSchema.safeParse(await heartbeatResponse.json());
+
+ if (!heartbeatParsed.success) {
+ return TestConnectionError.ParseResult(
+ new ParseError("Invalid Uptime Kuma heartbeat response", { cause: heartbeatParsed.error }),
+ );
+ }
+
+ return { success: true };
+ }
+
+ public async getDashboardDataAsync(): Promise {
+ const [statusPageResponse, heartbeatResponse] = await Promise.all([
+ this.getStatusPageAsync(),
+ this.getHeartbeatAsync(),
+ ]);
+
+ const statusPage = uptimeKumaStatusPageResponseSchema.parse(await statusPageResponse.json());
+ const heartbeat = uptimeKumaHeartbeatResponseSchema.parse(await heartbeatResponse.json());
+
+ const monitors = statusPage.publicGroupList.flatMap((group) =>
+ group.monitorList.map((monitor) => this.mapMonitor(monitor, heartbeat)),
+ );
+
+ return this.buildDashboardData(monitors);
+ }
+
+ private mapMonitor(
+ monitor: { id: number; name: string },
+ heartbeat: { heartbeatList: Record; uptimeList: Record },
+ ): UptimeKumaMonitor {
+ const heartbeats = heartbeat.heartbeatList[String(monitor.id)] ?? [];
+ const latestHeartbeat = heartbeats.at(-1);
+ const uptimeKey = `${monitor.id}_24`;
+ const uptimeRaw = heartbeat.uptimeList[uptimeKey];
+ const uptimePercent24h = typeof uptimeRaw === "number" ? uptimeRaw * 100 : null;
+ const status: UptimeKumaMonitorCategory =
+ (latestHeartbeat && (uptimeKumaHeartbeatCategoryMap[latestHeartbeat.status] ?? "down")) || "paused";
+
+ return {
+ id: monitor.id,
+ name: monitor.name,
+ status,
+ uptimePercent24h,
+ };
+ }
+
+ private buildDashboardData(monitors: UptimeKumaMonitor[]): UptimeKumaDashboardData {
+ const counts = monitors.reduce(
+ (acc, monitor) => {
+ acc[monitor.status] += 1;
+ return acc;
+ },
+ { up: 0, down: 0, paused: 0 },
+ );
+
+ const uptimeValues = monitors
+ .map((monitor) => monitor.uptimePercent24h)
+ .filter((value): value is number => value !== null);
+
+ const averageUptimePercent = uptimeValues.reduce((sum, value) => sum + value, 0) / Math.max(uptimeValues.length, 1);
+
+ return {
+ totalMonitors: monitors.length,
+ upCount: counts.up,
+ downCount: counts.down,
+ pausedCount: counts.paused,
+ averageUptimePercent,
+ monitors,
+ };
+ }
+
+ private async fetchOkAsync(url: string | URL) {
+ const response = await fetchWithTrustedCertificatesAsync(url);
+ if (!response.ok) {
+ throw new ResponseError(response);
+ }
+ return response;
+ }
+
+ private getStatusPageAsync() {
+ return this.fetchOkAsync(this.statusPageUrl());
+ }
+
+ private getHeartbeatAsync() {
+ return this.fetchOkAsync(this.heartbeatUrl());
+ }
+
+ private statusPageUrl() {
+ return this.url(`/api/status-page/${this.getSlug()}`);
+ }
+
+ private heartbeatUrl() {
+ return this.url(`/api/status-page/heartbeat/${this.getSlug()}`);
+ }
+
+ private getSlug() {
+ if (this.hasSecretValue("slug")) {
+ return this.getSecretValue("slug").toLowerCase();
+ }
+
+ return "default";
+ }
+}
diff --git a/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts b/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts
new file mode 100644
index 000000000..f029754a8
--- /dev/null
+++ b/packages/integrations/src/uptime-kuma/uptime-kuma-types.ts
@@ -0,0 +1,62 @@
+import { z } from "zod";
+
+export const uptimeKumaHeartbeatStatus = {
+ down: 0,
+ up: 1,
+ pending: 2,
+ maintenance: 3,
+} as const;
+
+export type UptimeKumaMonitorCategory = "up" | "down" | "paused";
+
+export const uptimeKumaHeartbeatCategoryMap: Record = {
+ [uptimeKumaHeartbeatStatus.down]: "down",
+ [uptimeKumaHeartbeatStatus.up]: "up",
+ [uptimeKumaHeartbeatStatus.pending]: "paused",
+ [uptimeKumaHeartbeatStatus.maintenance]: "paused",
+};
+
+export const uptimeKumaStatusPageMonitorSchema = z.object({
+ id: z.number().int(),
+ name: z.string(),
+});
+
+export const uptimeKumaStatusPageGroupSchema = z.object({
+ id: z.number().int(),
+ name: z.string(),
+ monitorList: z.array(uptimeKumaStatusPageMonitorSchema),
+});
+
+export const uptimeKumaStatusPageResponseSchema = z.object({
+ publicGroupList: z.array(uptimeKumaStatusPageGroupSchema),
+});
+
+export const uptimeKumaHeartbeatEntrySchema = z.object({
+ status: z.number().int(),
+ time: z.string(),
+ ping: z.number().nullable().optional(),
+});
+
+export const uptimeKumaHeartbeatResponseSchema = z.object({
+ heartbeatList: z.record(z.string(), z.array(uptimeKumaHeartbeatEntrySchema)),
+ uptimeList: z.record(z.string(), z.number()),
+});
+
+export interface UptimeKumaMonitor {
+ id: number;
+ name: string;
+ status: UptimeKumaMonitorCategory;
+ uptimePercent24h: number | null;
+}
+
+export interface UptimeKumaDashboardData {
+ totalMonitors: number;
+ upCount: number;
+ downCount: number;
+ pausedCount: number;
+ averageUptimePercent: number;
+ monitors: UptimeKumaMonitor[];
+}
+
+export type UptimeKumaStatusPageResponse = z.infer;
+export type UptimeKumaHeartbeatResponse = z.infer;
diff --git a/packages/modals-collection/src/apps/app-select-modal.tsx b/packages/modals-collection/src/apps/app-select-modal.tsx
index e5530d341..6ac726ac0 100644
--- a/packages/modals-collection/src/apps/app-select-modal.tsx
+++ b/packages/modals-collection/src/apps/app-select-modal.tsx
@@ -1,11 +1,12 @@
import { useMemo, useState } from "react";
-import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
-import { IconPlus, IconSearch } from "@tabler/icons-react";
+import { Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
+import { IconPlus } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
-import { createModal, useModalAction } from "@homarr/modals";
+import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
import { useI18n } from "@homarr/translation/client";
+import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
@@ -29,96 +30,119 @@ export const AppSelectModal = createModal(({ actions, inner
);
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
- if (innerProps.onSelect) {
- innerProps.onSelect(app);
- }
+ innerProps.onSelect?.(app);
actions.closeModal();
};
const handleAddNewApp = () => {
openQuickAddAppModal({
onClose(app) {
- if (innerProps.onSelect) {
- innerProps.onSelect(app);
- }
+ innerProps.onSelect?.(app);
actions.closeModal();
},
});
};
return (
-
- setSearch(event.currentTarget.value)}
- leftSection={}
- placeholder={`${t("app.action.select.search")}...`}
- data-autofocus
- onKeyDown={(event) => {
- if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
- handleSelect(filteredApps[0]);
- }
- }}
- />
+ {
+ if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
+ handleSelect(filteredApps[0]);
+ }
+ }}
+ >
+ {innerProps.withCreate && (
+ e.currentTarget.style.setProperty("--_hover-opacity", "1")}
+ onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
+ >
+
+
+
+
+ {t("app.action.create.title")}
+
+
+
+ {t("app.action.create.description")}
+
+
+
+
+
+
+ )}
-
- {innerProps.withCreate && (
-
-
-
-
-
-
-
-
- {t("app.action.create.title")}
-
-
- {t("app.action.create.description")}
-
-
-
-
-
-
- )}
+ {filteredApps.map((app) => (
+ e.currentTarget.style.setProperty("--_hover-opacity", "1")}
+ onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
+ >
+
+
+
+
+ {app.name}
+
+
+
+
+ {app.description ?? ""}
+
+
+
+
+
+
+
+ ))}
- {filteredApps.map((app) => (
-
-
-
-
-
-
-
-
- {app.name}
-
-
- {app.description ?? ""}
-
-
-
-
-
-
- ))}
-
- {filteredApps.length === 0 && !isPending && (
-
-
- {t("app.action.select.noResults")}
-
-
- )}
-
-
+ {filteredApps.length === 0 && !isPending && (
+
+ {t("app.action.select.noResults")}
+
+ )}
+
);
}).withOptions({
defaultTitle: (t) => t("app.action.select.title"),
- size: "xl",
+ size: modalSizeSelect,
});
diff --git a/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx b/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx
index 617bf6403..04e9f6f8b 100644
--- a/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx
+++ b/packages/modals-collection/src/apps/quick-add-app/quick-add-app-modal.tsx
@@ -3,7 +3,7 @@ import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { AppForm } from "@homarr/forms-collection";
-import { createModal } from "@homarr/modals";
+import { createModal, modalSizeForm } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
@@ -54,4 +54,5 @@ export const QuickAddAppModal = createModal(({ actions, i
defaultTitle(t) {
return t("board.action.quickCreateApp.modal.title");
},
+ size: modalSizeForm,
});
diff --git a/packages/modals-collection/src/search-engines/request-media-modal.tsx b/packages/modals-collection/src/search-engines/request-media-modal.tsx
index 89306f3af..c75647969 100644
--- a/packages/modals-collection/src/search-engines/request-media-modal.tsx
+++ b/packages/modals-collection/src/search-engines/request-media-modal.tsx
@@ -1,6 +1,6 @@
import { useMemo } from "react";
-import { Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
-import type { MRT_ColumnDef } from "mantine-react-table";
+import { Badge, Button, Group, Image, LoadingOverlay, Stack, Text } from "@mantine/core";
+import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
import { MRT_Table } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
@@ -16,13 +16,13 @@ interface RequestMediaModalProps {
}
export const RequestMediaModal = createModal(({ actions, innerProps }) => {
- const { data, isPending: isPendingQuery } = clientApi.searchEngine.getMediaRequestOptions.useQuery({
+ const { data, isPending: isPendingQuery } = clientApi.integration.getMediaRequestOptions.useQuery({
integrationId: innerProps.integrationId,
mediaId: innerProps.mediaId,
mediaType: innerProps.mediaType,
});
- const { mutate, isPending: isPendingMutation } = clientApi.searchEngine.requestMedia.useMutation({
+ const { mutate, isPending: isPendingMutation } = clientApi.integration.requestMedia.useMutation({
onSuccess() {
actions.closeModal();
showSuccessNotification({
@@ -33,6 +33,7 @@ export const RequestMediaModal = createModal(({ actions,
const isPending = isPendingQuery || isPendingMutation;
const t = useI18n();
+ const requestedSeasons = new Set(data?.requestedSeasons ?? []);
const columns = useMemo[]>(
() => [
@@ -44,8 +45,18 @@ export const RequestMediaModal = createModal(({ actions,
accessorKey: "episodeCount",
header: t("search.engine.media.request.modal.table.header.episodes"),
},
+ {
+ id: "status",
+ header: t("search.engine.media.request.modal.table.header.status"),
+ Cell: ({ row }) =>
+ requestedSeasons.has(row.original.seasonNumber) ? (
+
+ {t("search.engine.media.request.modal.table.status.requested")}
+
+ ) : null,
+ },
],
- [],
+ [data?.requestedSeasons],
);
const table = useTranslatedMantineReactTable({
@@ -56,7 +67,7 @@ export const RequestMediaModal = createModal(({ actions,
enablePagination: false,
enableSorting: false,
enableSelectAll: true,
- enableRowSelection: true,
+ enableRowSelection: (row: MRT_Row) => !requestedSeasons.has(row.original.seasonNumber),
mantineTableProps: {
highlightOnHover: false,
striped: "odd",
diff --git a/packages/modals/index.ts b/packages/modals/index.ts
index f7a07beae..196451a8e 100644
--- a/packages/modals/index.ts
+++ b/packages/modals/index.ts
@@ -1,2 +1,4 @@
export { ModalProvider, useModalAction, useConfirmModal } from "./src";
export { createModal } from "./src/creator";
+export { ModalFormFooter } from "./src/modal-form-footer";
+export { modalSizeForm, modalSizeSelect } from "./src/modal-sizes";
diff --git a/packages/modals/src/modal-form-footer.tsx b/packages/modals/src/modal-form-footer.tsx
new file mode 100644
index 000000000..f274b2dd4
--- /dev/null
+++ b/packages/modals/src/modal-form-footer.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { Button, Group } from "@mantine/core";
+
+import { useI18n } from "@homarr/translation/client";
+
+interface ModalFormFooterProps {
+ onCancel: () => void;
+ submitLabel?: string;
+ cancelLabel?: string;
+ loading?: boolean;
+ leftSection?: ReactNode;
+}
+
+const ActionButtons = ({
+ onCancel,
+ submitLabel,
+ cancelLabel,
+ loading,
+}: Pick) => {
+ const t = useI18n();
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export const ModalFormFooter = ({ onCancel, submitLabel, cancelLabel, loading, leftSection }: ModalFormFooterProps) => {
+ const actionButtons = (
+
+ );
+
+ if (leftSection) {
+ return (
+
+ {leftSection}
+
+ {actionButtons}
+
+
+ );
+ }
+
+ return {actionButtons};
+};
diff --git a/packages/modals/src/modal-sizes.ts b/packages/modals/src/modal-sizes.ts
new file mode 100644
index 000000000..cf631943c
--- /dev/null
+++ b/packages/modals/src/modal-sizes.ts
@@ -0,0 +1 @@
+export { modalSizeForm, modalSizeSelect } from "@homarr/ui";
diff --git a/packages/notifications/src/index.tsx b/packages/notifications/src/index.tsx
index 071496b20..41bb16964 100644
--- a/packages/notifications/src/index.tsx
+++ b/packages/notifications/src/index.tsx
@@ -1,6 +1,6 @@
import type { NotificationData } from "@mantine/notifications";
import { notifications } from "@mantine/notifications";
-import { IconCheck, IconX } from "@tabler/icons-react";
+import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
export const showSuccessNotification = (props: NotificationData) =>
notifications.show({
@@ -15,3 +15,10 @@ export const showErrorNotification = (props: NotificationData) =>
color: "red",
icon: ,
});
+
+export const showWarningNotification = (props: NotificationData) =>
+ notifications.show({
+ ...props,
+ color: "yellow",
+ icon: ,
+ });
diff --git a/packages/old-import/src/components/initial-oldmarr-import.tsx b/packages/old-import/src/components/initial-oldmarr-import.tsx
index a87a8f651..92aa301a5 100644
--- a/packages/old-import/src/components/initial-oldmarr-import.tsx
+++ b/packages/old-import/src/components/initial-oldmarr-import.tsx
@@ -69,7 +69,7 @@ export const InitialOldmarrImport = ({ file, analyseResult }: InitialOldmarrImpo
};
return (
-
+
{
diff --git a/packages/old-import/src/components/initial/board-selection-card.tsx b/packages/old-import/src/components/initial/board-selection-card.tsx
index 4665ec2cd..6f178f669 100644
--- a/packages/old-import/src/components/initial/board-selection-card.tsx
+++ b/packages/old-import/src/components/initial/board-selection-card.tsx
@@ -38,7 +38,7 @@ export const BoardSelectionCard = ({ selections, updateSelections }: BoardSelect
}
return (
-
+
diff --git a/packages/old-import/src/components/initial/import-settings-card.tsx b/packages/old-import/src/components/initial/import-settings-card.tsx
index 1a2809d76..f230ff921 100644
--- a/packages/old-import/src/components/initial/import-settings-card.tsx
+++ b/packages/old-import/src/components/initial/import-settings-card.tsx
@@ -17,7 +17,7 @@ interface ImportSettingsCardProps {
export const ImportSettingsCard = ({ settings, updateSetting }: ImportSettingsCardProps) => {
const tImportSettings = useScopedI18n("init.step.import.importSettings");
return (
-
+
{tImportSettings("title")}
diff --git a/packages/old-import/src/components/initial/import-summary-card.tsx b/packages/old-import/src/components/initial/import-summary-card.tsx
index 9993b3ac2..1000b35a6 100644
--- a/packages/old-import/src/components/initial/import-summary-card.tsx
+++ b/packages/old-import/src/components/initial/import-summary-card.tsx
@@ -13,7 +13,7 @@ interface ImportSummaryCardProps {
export const ImportSummaryCard = ({ counts, onSubmit, loading }: ImportSummaryCardProps) => {
const tSummary = useScopedI18n("init.step.import.summary");
return (
-
+
{tSummary("title")}
diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts
index 32b1f25c3..b407d0e45 100644
--- a/packages/old-import/src/widgets/options.ts
+++ b/packages/old-import/src/widgets/options.ts
@@ -44,6 +44,7 @@ const optionMapping: OptionMapping = {
hideIcon: (oldOptions) => oldOptions.items.some((item) => item.hideIcon),
hideHostname: (oldOptions) => oldOptions.items.some((item) => item.hideHostname),
openNewTab: (oldOptions) => oldOptions.items.some((item) => item.openNewTab),
+ withBorder: () => undefined,
},
calendar: {
releaseType: (oldOptions) => [oldOptions.radarrReleaseType],
diff --git a/packages/request-handler/src/audiobookshelf.ts b/packages/request-handler/src/audiobookshelf.ts
new file mode 100644
index 000000000..bb4b2d83c
--- /dev/null
+++ b/packages/request-handler/src/audiobookshelf.ts
@@ -0,0 +1,19 @@
+import dayjs from "dayjs";
+
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler<
+ AudiobookshelfDashboardData,
+ "audiobookshelf",
+ Record
+>({
+ async requestAsync(integration) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getDashboardDataAsync();
+ },
+ cacheDuration: dayjs.duration(10, "minutes"),
+ queryKey: "audiobookshelfDashboard",
+});
diff --git a/packages/request-handler/src/beszel.ts b/packages/request-handler/src/beszel.ts
new file mode 100644
index 000000000..053edf52b
--- /dev/null
+++ b/packages/request-handler/src/beszel.ts
@@ -0,0 +1,122 @@
+import dayjs from "dayjs";
+
+import { createIntegrationAsync } from "@homarr/integrations";
+import type {
+ BeszelAlert,
+ BeszelAlertHistory,
+ BeszelContainerStatsRecord,
+ BeszelSystem,
+ BeszelSystemDetails,
+ BeszelSystemRow,
+ BeszelSystemStatsRecord,
+} from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export type { BeszelSystemRow } from "@homarr/integrations/types";
+
+/**
+ * Maps raw Beszel API responses (BeszelSystem + BeszelSystemDetails) into a
+ * normalized BeszelSystemRow for widget consumption.
+ *
+ * BeszelSystemInfo uses short field names (see beszel-types.ts for full docs):
+ * cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version,
+ * bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
+ * sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
+ */
+function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
+ const info = system.info;
+ return {
+ id: system.id,
+ name: system.name,
+ status: system.status,
+ cpu: info.cpu,
+ memory: info.mp,
+ disk: info.dp,
+ gpu: info.g ?? 0,
+ loadAvg: info.la ?? null,
+ // bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
+ netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
+ temp: info.dt ?? null,
+ battery: info.bat ?? null,
+ services: info.sv?.[0] ?? 0,
+ uptime: info.u,
+ agentVersion: info.v,
+ hostname: details?.hostname ?? info.h ?? system.name,
+ cpuModel: details?.cpu ?? info.m ?? "Unknown",
+ cores: details?.cores ?? info.ct ?? info.c ?? 1,
+ memoryTotal: details?.memory ?? 0,
+ osName: details?.os_name ?? "",
+ };
+}
+
+export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
+ BeszelSystemRow[],
+ "beszel" | "mock",
+ Record
+>({
+ async requestAsync(integration) {
+ const instance = await createIntegrationAsync(integration);
+ const systems = await instance.getSystemsAsync();
+ const enriched = await Promise.all(
+ systems.map(async (system) => {
+ const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
+ return mapToSystemRow(system, details);
+ }),
+ );
+ return enriched;
+ },
+ cacheDuration: dayjs.duration(5, "seconds"),
+ queryKey: "beszelSystems",
+});
+
+export interface BeszelAlertsData {
+ alerts: BeszelAlert[];
+ history: BeszelAlertHistory[];
+}
+
+export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
+ BeszelAlertsData,
+ "beszel" | "mock",
+ { includeHistory: boolean; maxHistoryItems: number }
+>({
+ async requestAsync(integration, input) {
+ const instance = await createIntegrationAsync(integration);
+ const alerts = await instance.getAlertsAsync();
+ const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
+ return { alerts, history };
+ },
+ cacheDuration: dayjs.duration(15, "seconds"),
+ queryKey: "beszelAlerts",
+});
+
+const timePeriodConfig: Record = {
+ "1h": { type: "1m", perPage: 60 },
+ "12h": { type: "10m", perPage: 72 },
+ "24h": { type: "20m", perPage: 72 },
+ "1w": { type: "120m", perPage: 84 },
+ "30d": { type: "480m", perPage: 90 },
+};
+
+export interface BeszelStatsData {
+ systemStats: BeszelSystemStatsRecord[];
+ containerStats: BeszelContainerStatsRecord[];
+}
+
+export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
+ BeszelStatsData,
+ "beszel" | "mock",
+ { systemId: string; timePeriod: string; includeDocker: boolean }
+>({
+ async requestAsync(integration, input) {
+ const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
+ const instance = await createIntegrationAsync(integration);
+ const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
+ const containerStats = input.includeDocker
+ ? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
+ : [];
+ return { systemStats, containerStats };
+ },
+ cacheDuration: dayjs.duration(30, "seconds"),
+ queryKey: "beszelStats",
+});
diff --git a/packages/request-handler/src/docker-log-decode.spec.ts b/packages/request-handler/src/docker-log-decode.spec.ts
new file mode 100644
index 000000000..b82dfd833
--- /dev/null
+++ b/packages/request-handler/src/docker-log-decode.spec.ts
@@ -0,0 +1,53 @@
+import { describe, expect, test } from "vitest";
+
+import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
+
+const encodeDockerLogFrame = (streamType: number, message: string) => {
+ const payload = Buffer.from(message, "utf-8");
+ const header = Buffer.alloc(8);
+ header.writeUInt8(streamType, 0);
+ header.writeUInt32BE(payload.length, 4);
+ return Buffer.concat([header, payload]);
+};
+
+describe("decodeDockerLogs", () => {
+ test("decodes multiplexed docker log frames", () => {
+ const logs = Buffer.concat([encodeDockerLogFrame(1, "hello "), encodeDockerLogFrame(2, "world")]);
+
+ expect(decodeDockerLogs(logs)).toBe("hello world");
+ });
+
+ test("returns plain text logs unchanged", () => {
+ expect(decodeDockerLogs("plain text log")).toBe("plain text log");
+ });
+
+ test("falls back to raw buffer text for non-multiplexed output", () => {
+ expect(decodeDockerLogs(Buffer.from("raw docker output", "utf-8"))).toBe("raw docker output");
+ });
+});
+
+describe("createDockerLogStreamProcessor", () => {
+ test("emits decoded chunks from a stream", () => {
+ const messages: string[] = [];
+ const processChunk = createDockerLogStreamProcessor(
+ (data) => messages.push(data),
+ () => undefined,
+ 1024,
+ );
+
+ const chunk = encodeDockerLogFrame(1, "streamed log");
+ expect(processChunk(chunk)).toBe(true);
+ expect(messages).toEqual(["streamed log"]);
+ });
+
+ test("returns false when message size exceeds limit", () => {
+ const processChunk = createDockerLogStreamProcessor(
+ () => undefined,
+ () => undefined,
+ 4,
+ );
+
+ const chunk = encodeDockerLogFrame(1, "this message is too long");
+ expect(processChunk(chunk)).toBe(false);
+ });
+});
diff --git a/packages/request-handler/src/docker-log-decode.ts b/packages/request-handler/src/docker-log-decode.ts
new file mode 100644
index 000000000..a069091d6
--- /dev/null
+++ b/packages/request-handler/src/docker-log-decode.ts
@@ -0,0 +1,89 @@
+const DOCKER_STREAM_HEADER_SIZE = 8;
+const DOCKER_STREAM_TYPES = new Set([1, 2]);
+
+export const decodeDockerLogs = (logs: Buffer | string) => {
+ if (typeof logs === "string") {
+ return logs;
+ }
+
+ if (logs.length === 0) {
+ return "";
+ }
+
+ let cursor = 0;
+ const parts: string[] = [];
+
+ while (cursor < logs.length) {
+ if (cursor + DOCKER_STREAM_HEADER_SIZE > logs.length) {
+ break;
+ }
+
+ const streamType = logs.readUInt8(cursor);
+ if (!DOCKER_STREAM_TYPES.has(streamType)) {
+ return logs.toString("utf-8");
+ }
+
+ const length = logs.readUInt32BE(cursor + 4);
+ const start = cursor + DOCKER_STREAM_HEADER_SIZE;
+ const end = start + length;
+ if (end > logs.length) {
+ break;
+ }
+
+ parts.push(logs.subarray(start, end).toString("utf-8"));
+ cursor = end;
+ }
+
+ const decoded = parts.join("");
+ if (decoded.length === 0) {
+ return logs.toString("utf-8");
+ }
+
+ return decoded;
+};
+
+export const createDockerLogStreamProcessor = (
+ onData: (data: string) => void,
+ onError: (err: Error) => void,
+ maxMessageSize: number,
+) => {
+ let buffer = Buffer.alloc(0);
+
+ const processChunk = (chunk: Buffer) => {
+ buffer = Buffer.concat([buffer, chunk]);
+
+ while (buffer.length > 0) {
+ if (buffer.length < DOCKER_STREAM_HEADER_SIZE) {
+ break;
+ }
+
+ const streamType = buffer.readUInt8(0);
+ if (!DOCKER_STREAM_TYPES.has(streamType)) {
+ onData(buffer.toString("utf-8"));
+ buffer = Buffer.alloc(0);
+ break;
+ }
+
+ const length = buffer.readUInt32BE(4);
+ if (length > maxMessageSize) {
+ onError(
+ new Error(`Docker log message size (${length} bytes) exceeds maximum allowed (${maxMessageSize} bytes)`),
+ );
+ return false;
+ }
+
+ const fullMessageSize = DOCKER_STREAM_HEADER_SIZE + length;
+ if (buffer.length < fullMessageSize) {
+ break;
+ }
+
+ const payload = buffer.subarray(DOCKER_STREAM_HEADER_SIZE, fullMessageSize);
+ onData(payload.toString("utf-8"));
+ buffer = buffer.subarray(fullMessageSize);
+ }
+
+ return true;
+ };
+
+ return processChunk;
+};
diff --git a/packages/request-handler/src/docker.ts b/packages/request-handler/src/docker.ts
index a31eed56c..2d228a598 100644
--- a/packages/request-handler/src/docker.ts
+++ b/packages/request-handler/src/docker.ts
@@ -1,5 +1,6 @@
+import type { Readable } from "node:stream";
import dayjs from "dayjs";
-import type { ContainerInfo, ContainerStats } from "dockerode";
+import type { Container, ContainerInfo, ContainerStats } from "dockerode";
import type Dockerode from "dockerode";
import { bestMatch } from "@homarr/common";
@@ -7,23 +8,260 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
-import type { ContainerState } from "@homarr/docker";
+import { extractContainerImageName } from "@homarr/definitions";
+import type { ContainerState, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
+import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
+const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
+
+const port = (privatePort: number, publicPort: number, type: string): Port => ({
+ IP: "0.0.0.0",
+ PrivatePort: privatePort,
+ PublicPort: publicPort,
+ Type: type,
+});
+
+const mockContainers: {
+ id: string;
+ name: string;
+ host: string;
+ state: ContainerState;
+ image: string;
+ iconUrl: string;
+ cpuUsage: number;
+ memoryUsage: number;
+ ports: Port[];
+}[] = [
+ {
+ id: "a1b2c3d4e5f6",
+ name: "sonarr",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/sonarr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/sonarr.svg",
+ cpuUsage: 2.3,
+ memoryUsage: 256 * 1024 * 1024,
+ ports: [port(8989, 8989, "tcp")],
+ },
+ {
+ id: "b2c3d4e5f6a7",
+ name: "radarr",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/radarr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/radarr.svg",
+ cpuUsage: 1.8,
+ memoryUsage: 220 * 1024 * 1024,
+ ports: [port(7878, 7878, "tcp")],
+ },
+ {
+ id: "c3d4e5f6a7b8",
+ name: "plex",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/plex:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/plex.svg",
+ cpuUsage: 12.5,
+ memoryUsage: 1024 * 1024 * 1024,
+ ports: [port(32400, 32400, "tcp")],
+ },
+ {
+ id: "d4e5f6a7b8c9",
+ name: "qbittorrent",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/qbittorrent:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/qbittorrent.svg",
+ cpuUsage: 5.1,
+ memoryUsage: 380 * 1024 * 1024,
+ ports: [port(8080, 8080, "tcp")],
+ },
+ {
+ id: "e5f6a7b8c9d0",
+ name: "prowlarr",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/prowlarr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/prowlarr.svg",
+ cpuUsage: 0.8,
+ memoryUsage: 120 * 1024 * 1024,
+ ports: [port(9696, 9696, "tcp")],
+ },
+ {
+ id: "f6a7b8c9d0e1",
+ name: "overseerr",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/overseerr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/overseerr.svg",
+ cpuUsage: 1.2,
+ memoryUsage: 180 * 1024 * 1024,
+ ports: [port(5055, 5055, "tcp")],
+ },
+ {
+ id: "a7b8c9d0e1f2",
+ name: "homarr",
+ host: "local",
+ state: "running",
+ image: "ghcr.io/homarr-labs/homarr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
+ cpuUsage: 3.4,
+ memoryUsage: 290 * 1024 * 1024,
+ ports: [port(7575, 7575, "tcp")],
+ },
+ {
+ id: "b8c9d0e1f2a3",
+ name: "pihole",
+ host: "local",
+ state: "running",
+ image: "pihole/pihole:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/pi-hole.svg",
+ cpuUsage: 0.5,
+ memoryUsage: 95 * 1024 * 1024,
+ ports: [port(80, 80, "tcp"), port(53, 53, "udp")],
+ },
+ {
+ id: "c9d0e1f2a3b4",
+ name: "nginx-proxy",
+ host: "local",
+ state: "running",
+ image: "jc21/nginx-proxy-manager:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/nginx-proxy-manager.svg",
+ cpuUsage: 0.3,
+ memoryUsage: 65 * 1024 * 1024,
+ ports: [port(443, 443, "tcp"), port(81, 81, "tcp")],
+ },
+ {
+ id: "d0e1f2a3b4c5",
+ name: "watchtower",
+ host: "local",
+ state: "running",
+ image: "containrrr/watchtower:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/watchtower.svg",
+ cpuUsage: 0.1,
+ memoryUsage: 30 * 1024 * 1024,
+ ports: [],
+ },
+ {
+ id: "e1f2a3b4c5d6",
+ name: "tdarr",
+ host: "local",
+ state: "exited",
+ image: "ghcr.io/haveagitgat/tdarr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/tdarr.svg",
+ cpuUsage: 0,
+ memoryUsage: 0,
+ ports: [],
+ },
+ {
+ id: "f2a3b4c5d6e7",
+ name: "bazarr",
+ host: "local",
+ state: "running",
+ image: "lscr.io/linuxserver/bazarr:latest",
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/bazarr.svg",
+ cpuUsage: 0.6,
+ memoryUsage: 110 * 1024 * 1024,
+ ports: [port(6767, 6767, "tcp")],
+ },
+];
+
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
async requestAsync() {
+ if (isDemoMode) {
+ return mockContainers;
+ }
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),
});
-const extractImage = (container: ContainerInfo) => container.Image.split("/").at(-1)?.split(":").at(0) ?? "";
+const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
+
+const findContainerByIdAsync = async (id: string) => {
+ const dockerInstances = DockerSingleton.getInstances();
+ const containers = await Promise.all(
+ dockerInstances.map(async ({ instance }) => {
+ const container = instance.getContainer(id);
+
+ return await new Promise((resolve) => {
+ container.inspect((err, data) => {
+ if (err || !data) {
+ resolve(null);
+ } else {
+ resolve(container);
+ }
+ });
+ });
+ }),
+ );
+
+ return containers.find((container) => container) ?? null;
+};
+
+export const getContainerLogsAsync = async (id: string, tail = 200) => {
+ const container = await findContainerByIdAsync(id);
+ if (!container) {
+ return null;
+ }
+
+ const rawLogs = await container.logs({
+ tail,
+ stdout: true,
+ stderr: true,
+ follow: false,
+ });
+
+ return decodeDockerLogs(rawLogs);
+};
+
+export const streamContainerLogsAsync = async (
+ id: string,
+ tail: number,
+ onData: (data: string) => void,
+ onError: (err: Error) => void,
+) => {
+ const container = await findContainerByIdAsync(id);
+ if (!container) {
+ onError(new Error("Container not found"));
+ return () => undefined;
+ }
+
+ const stream = (await container.logs({
+ tail,
+ stdout: true,
+ stderr: true,
+ follow: true,
+ })) as Readable;
+
+ const MAX_MESSAGE_SIZE = 1024 * 1024;
+ const processChunk = createDockerLogStreamProcessor(onData, onError, MAX_MESSAGE_SIZE);
+
+ const handleChunk = (chunk: Buffer) => {
+ const shouldContinue = processChunk(chunk);
+ if (!shouldContinue) {
+ stream.removeListener("data", handleChunk);
+ stream.removeListener("error", onError);
+ stream.destroy();
+ }
+ };
+
+ stream.on("data", handleChunk);
+ stream.on("error", onError);
+
+ return () => {
+ stream.removeListener("data", handleChunk);
+ stream.removeListener("error", onError);
+ stream.destroy();
+ };
+};
async function getContainersWithStatsAsync() {
const dockerInstances = DockerSingleton.getInstances();
@@ -117,8 +355,6 @@ export function calculateMemoryUsage(stats: ContainerStats): number {
return 0;
}
- // memory usage by default includes cache, which should not be shown as it is also not shown with docker stats command
- // See https://docs.docker.com/reference/cli/docker/container/stats/ how it is / was calculated
return (
stats.memory_stats.usage -
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
diff --git a/packages/request-handler/src/health-monitoring.ts b/packages/request-handler/src/health-monitoring.ts
index 98d0cce3d..640eb6b97 100644
--- a/packages/request-handler/src/health-monitoring.ts
+++ b/packages/request-handler/src/health-monitoring.ts
@@ -8,7 +8,7 @@ import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
SystemHealthMonitoring,
- Exclude, "proxmox" | "coolify">,
+ Exclude, "proxmox" | "coolify" | "beszel">,
Record
>({
async requestAsync(integration, _input) {
diff --git a/packages/request-handler/src/navidrome.ts b/packages/request-handler/src/navidrome.ts
new file mode 100644
index 000000000..b59f852a8
--- /dev/null
+++ b/packages/request-handler/src/navidrome.ts
@@ -0,0 +1,19 @@
+import dayjs from "dayjs";
+
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { NavidromeDashboardData } from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export const navidromeRequestHandler = createCachedIntegrationRequestHandler<
+ NavidromeDashboardData,
+ "navidrome",
+ Record
+>({
+ async requestAsync(integration) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getDashboardDataAsync();
+ },
+ cacheDuration: dayjs.duration(10, "minutes"),
+ queryKey: "navidromeDashboard",
+});
diff --git a/packages/request-handler/src/paperless-ngx.ts b/packages/request-handler/src/paperless-ngx.ts
new file mode 100644
index 000000000..a3793658f
--- /dev/null
+++ b/packages/request-handler/src/paperless-ngx.ts
@@ -0,0 +1,19 @@
+import dayjs from "dayjs";
+
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { PaperlessNgxStats } from "@homarr/integrations";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler<
+ PaperlessNgxStats,
+ "paperlessNgx",
+ Record
+>({
+ async requestAsync(integration) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getStatsAsync();
+ },
+ cacheDuration: dayjs.duration(15, "minute"),
+ queryKey: "paperless-ngx-stats",
+});
diff --git a/packages/request-handler/src/ups.ts b/packages/request-handler/src/ups.ts
new file mode 100644
index 000000000..acc97eb6d
--- /dev/null
+++ b/packages/request-handler/src/ups.ts
@@ -0,0 +1,20 @@
+import dayjs from "dayjs";
+
+import type { IntegrationKindByCategory } from "@homarr/definitions";
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { UpsSummary } from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
+ UpsSummary[],
+ IntegrationKindByCategory<"ups">,
+ Record
+>({
+ async requestAsync(integration, _input) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getUpsSummariesAsync();
+ },
+ cacheDuration: dayjs.duration(1, "minute"),
+ queryKey: "upsSummaries",
+});
diff --git a/packages/request-handler/src/uptime-kuma.ts b/packages/request-handler/src/uptime-kuma.ts
new file mode 100644
index 000000000..39149c137
--- /dev/null
+++ b/packages/request-handler/src/uptime-kuma.ts
@@ -0,0 +1,19 @@
+import dayjs from "dayjs";
+
+import { createIntegrationAsync } from "@homarr/integrations";
+import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
+
+import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
+
+export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler<
+ UptimeKumaDashboardData,
+ "uptimeKuma",
+ Record
+>({
+ async requestAsync(integration, _input) {
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getDashboardDataAsync();
+ },
+ cacheDuration: dayjs.duration(1, "minute"),
+ queryKey: "uptimeKumaDashboard",
+});
diff --git a/packages/settings/index.ts b/packages/settings/index.ts
index 123d6a3ea..aded6d905 100644
--- a/packages/settings/index.ts
+++ b/packages/settings/index.ts
@@ -1 +1,2 @@
export * from "./src/context";
+export * from "./src/preferences/definitions";
diff --git a/packages/settings/src/preferences/definitions.ts b/packages/settings/src/preferences/definitions.ts
new file mode 100644
index 000000000..528ce8aba
--- /dev/null
+++ b/packages/settings/src/preferences/definitions.ts
@@ -0,0 +1,78 @@
+export const userPreferenceDefinitions = [
+ {
+ key: "colorScheme",
+ kind: "select",
+ guest: true,
+ mutationGroup: "colorScheme",
+ aliases: ["theme", "dark", "light", "auto"],
+ },
+ { key: "locale", kind: "select", guest: true, mutationGroup: "locale", aliases: ["language", "translation"] },
+ {
+ key: "defaultSearchEngineId",
+ kind: "searchEngine",
+ guest: false,
+ mutationGroup: "searchPreferences",
+ aliases: ["search", "search engine"],
+ },
+ {
+ key: "openSearchInNewTab",
+ kind: "boolean",
+ guest: false,
+ mutationGroup: "searchPreferences",
+ aliases: ["search", "new tab", "external"],
+ },
+ {
+ key: "ddgBangs",
+ kind: "boolean",
+ guest: false,
+ mutationGroup: "searchPreferences",
+ aliases: ["duckduckgo", "bangs", "search"],
+ },
+ {
+ key: "firstDayOfWeek",
+ kind: "select",
+ guest: false,
+ mutationGroup: "firstDayOfWeek",
+ aliases: ["calendar", "week", "weekday"],
+ },
+ {
+ key: "homeBoardId",
+ kind: "board",
+ guest: false,
+ mutationGroup: "homeBoards",
+ aliases: ["home", "board", "dashboard"],
+ },
+ {
+ key: "mobileHomeBoardId",
+ kind: "board",
+ guest: false,
+ mutationGroup: "homeBoards",
+ aliases: ["mobile", "home", "board", "dashboard"],
+ },
+ {
+ key: "pingIconsEnabled",
+ kind: "boolean",
+ guest: false,
+ mutationGroup: "pingIconsEnabled",
+ aliases: ["ping", "icon", "status"],
+ },
+ {
+ key: "fullPreferencesPage",
+ kind: "link",
+ guest: false,
+ mutationGroup: null,
+ aliases: ["settings", "manage", "profile"],
+ },
+] as const;
+
+export type UserPreferenceDefinition = (typeof userPreferenceDefinitions)[number];
+export type UserPreferenceKey = UserPreferenceDefinition["key"];
+export type UserPreferenceKind = UserPreferenceDefinition["kind"];
+export type UserPreferenceMutationGroup = Exclude;
+
+export const userPreferenceDefinitionByKey = Object.fromEntries(
+ userPreferenceDefinitions.map((definition) => [definition.key, definition]),
+) as Record;
+
+export const visiblePreferenceDefinitions = (isAuthenticated: boolean) =>
+ userPreferenceDefinitions.filter((definition) => definition.guest || isAuthenticated);
diff --git a/packages/spotlight/package.json b/packages/spotlight/package.json
index 39868e02e..b9c16aef8 100644
--- a/packages/spotlight/package.json
+++ b/packages/spotlight/package.json
@@ -29,6 +29,7 @@
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/modals": "workspace:^0.1.0",
"@homarr/modals-collection": "workspace:^0.1.0",
+ "@homarr/notifications": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx
index f7c98c519..3d5427506 100644
--- a/packages/spotlight/src/components/actions/items/children-action-item.tsx
+++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx
@@ -33,7 +33,10 @@ export const ChildrenActionItem = ({ childrenOptions, action, query, setChildren
diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx
index eb48e04b2..e6a802c26 100644
--- a/packages/spotlight/src/components/actions/items/group-action-item.tsx
+++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx
@@ -44,6 +44,9 @@ export const SpotlightGroupActionItem =
setQuery(interaction.query);
setTimeout(() => selectAction(0, spotlightStore));
} else if (interaction.type === "mode") {
+ if (interaction.query !== undefined) {
+ setQuery(interaction.query);
+ }
setMode(interaction.mode);
} else if (interaction.type === "children") {
setChildrenOptions(interaction);
@@ -58,7 +61,8 @@ export const SpotlightGroupActionItem =
interaction.type !== "mode" &&
interaction.type !== "children" &&
interaction.type !== "none" &&
- interaction.type !== "setQuery"
+ interaction.type !== "setQuery" &&
+ (interaction.type !== "javaScript" || interaction.closeSpotlightOnTrigger !== false)
}
className={classes.spotlightAction}
>
diff --git a/packages/spotlight/src/components/spotlight.tsx b/packages/spotlight/src/components/spotlight.tsx
index 4ca086f3b..89b1b0d63 100644
--- a/packages/spotlight/src/components/spotlight.tsx
+++ b/packages/spotlight/src/components/spotlight.tsx
@@ -1,24 +1,33 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
-import { useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActionIcon, Center, Group, Kbd } from "@mantine/core";
import { Spotlight as MantineSpotlight } from "@mantine/spotlight";
-import { IconSearch, IconX } from "@tabler/icons-react";
+import { IconArrowLeft, IconSearch, IconX } from "@tabler/icons-react";
+import { useSetAtom } from "jotai";
import { hotkeys } from "@homarr/definitions";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
+import type { OpenMediaRequestSearchOptions } from "../index";
import type { inferSearchInteractionOptions } from "../lib/interaction";
import type { SearchMode } from "../lib/mode";
import { searchModes } from "../modes";
-import { useHomeEmptyGroups } from "../modes/help/home-empty-groups";
-import { selectAction, spotlightStore } from "../spotlight-store";
+import { useHomeEmptyGroupsWithPreferences } from "../modes/help/home-empty-groups";
+import {
+ mediaRequestSearchEvent,
+ mediaRequestSearchScopeAtom,
+ selectAction,
+ spotlightActions,
+ spotlightStore,
+} from "../spotlight-store";
import { SpotlightChildrenActions } from "./actions/children-actions";
import { SpotlightActionGroups } from "./actions/groups/action-group";
type SearchModeKey = keyof TranslationObject["search"]["mode"];
+type ChildrenOptions = inferSearchInteractionOptions<"children">;
const defaultMode = "home";
export const Spotlight = () => {
@@ -31,7 +40,6 @@ export const Spotlight = () => {
return null;
}
- // We use the "key" below to prevent the 'Different amounts of hooks' error
return (
|