fix assistant widget configuration

This commit is contained in:
ajnart committed 2026-08-03 16:43:08 +02:00
1 parent bd3e4c33f3
commit 35673f113e
20 files changed
+762 -12

No files matched your search

+39 -2
View File
@@ -36,6 +36,9 @@ import {
getPermissionsWithChildren,
getPermissionsWithParents,
widgetDefaultSizes,
widgetIntegrationLimits,
widgetIntegrationSupport,
widgetKindsWithOptionalIntegrations,
widgetKinds,
} from "@homarr/definitions";
import { importOldmarrAsync } from "@homarr/old-import";
@@ -63,6 +66,7 @@ import { sectionSchema, sharedItemSchema } from "@homarr/validation/shared";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
import { throwIfActionForbiddenAsync } from "./board/board-access";
import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { throwIfActionForbiddenAsync as throwIfIntegrationActionForbiddenAsync } from "./integration/integration-access";
export const boardRouter = createTRPCRouter({
exists: permissionRequiredProcedure
@@ -1485,7 +1489,7 @@ export const boardRouter = createTRPCRouter({
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. To create a formatted dashboard note, use kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Notebook content may use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images, but must not contain scripts, styles, iframes, event handlers, or unsafe URL protocols. Returns { itemId }",
"Add a widget/app item to a board after configure_widget has reviewed it. Automatically places it in the first empty section at the next free grid position. Use the configure_widget result's boardId, kind, options, and integrationIds exactly. Integration IDs must be accessible to the current user. To create a formatted dashboard note, configure kind 'notebook' with options { content: Tiptap-compatible HTML, showToolbar: boolean, allowReadOnlyCheck: boolean }. Returns { itemId }",
},
})
.input(addItemToBoardSchema)
@@ -1493,9 +1497,28 @@ export const boardRouter = createTRPCRouter({
.mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
const supportedIntegrations = widgetIntegrationSupport[input.kind];
const integrationLimit = widgetIntegrationLimits[input.kind] ?? Infinity;
if (input.integrationIds.length > integrationLimit) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${input.kind} supports at most ${integrationLimit} integration${integrationLimit === 1 ? "" : "s"}`,
});
}
if (supportedIntegrations === undefined && input.integrationIds.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} does not support integrations` });
}
if (
supportedIntegrations !== undefined &&
input.integrationIds.length === 0 &&
!widgetKindsWithOptionalIntegrations.has(input.kind)
) {
throw new TRPCError({ code: "BAD_REQUEST", message: `${input.kind} requires an integration` });
}
if (input.integrationIds.length > 0) {
const existing = await ctx.db.query.integrations.findMany({
columns: { id: true },
columns: { id: true, kind: true },
where: inArray(integrations.id, input.integrationIds),
});
const validIds = new Set(existing.map((row) => row.id));
@@ -1503,6 +1526,20 @@ export const boardRouter = createTRPCRouter({
if (invalid.length > 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid integration IDs: ${invalid.join(", ")}` });
}
await Promise.all(
input.integrationIds.map((integrationId) =>
throwIfIntegrationActionForbiddenAsync(ctx, eq(integrations.id, integrationId), "use"),
),
);
const incompatible = existing.filter((integration) => !supportedIntegrations?.includes(integration.kind));
if (incompatible.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `${input.kind} does not support integration kind${incompatible.length === 1 ? "" : "s"}: ${incompatible.map((integration) => integration.kind).join(", ")}`,
});
}
}
const board = await ctx.db.query.boards.findFirst({
@@ -0,0 +1,153 @@
// @vitest-environment node
import { parse } from "superjson";
import { describe, expect, test } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import { boards, integrations, integrationUserPermissions, items, layouts, sections, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { boardRouter } from "../board";
const createBoardCallerAsync = async () => {
const db = createDb();
const userId = createId();
const boardId = createId();
await db.insert(users).values({ id: userId });
await db.insert(boards).values({ id: boardId, name: "Home", creatorId: userId });
await db.insert(layouts).values({
id: createId(),
name: "Base",
boardId,
columnCount: 12,
breakpoint: 0,
});
await db.insert(sections).values({ id: createId(), boardId, kind: "empty", xOffset: 0, yOffset: 0 });
const session = {
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
} satisfies Session;
return { db, userId, boardId, caller: boardRouter.createCaller({ db, deviceType: undefined, session }) };
};
describe("board.addItem", () => {
test("preserves configured notebook content when placing the widget", async () => {
const { db, boardId, caller } = await createBoardCallerAsync();
const content = "<h2>Self-host Plex</h2><p>Install Docker and configure your media volumes.</p>";
const result = await caller.addItem({
boardId,
kind: "notebook",
options: { content, showToolbar: true, allowReadOnlyCheck: true },
integrationIds: [],
});
const item = await db.query.items.findFirst({ where: (table, { eq }) => eq(table.id, result.itemId) });
expect(item?.kind).toBe("notebook");
expect(parse(item?.options ?? "{}")).toEqual({
content,
showToolbar: true,
allowReadOnlyCheck: true,
});
});
test("accepts an integration the current user can use", async () => {
const { db, userId, boardId, caller } = await createBoardCallerAsync();
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Plex",
kind: "plex",
url: "https://plex.example.com",
});
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
await expect(
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
).resolves.toMatchObject({ itemId: expect.any(String) });
});
test("rejects an integration the current user cannot use", async () => {
const { db, boardId, caller } = await createBoardCallerAsync();
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Private Plex",
kind: "plex",
url: "https://private.example.com",
});
await expect(
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
).rejects.toThrow("Integration not found");
await expect(db.$count(items)).resolves.toBe(0);
});
test("rejects a missing required integration", async () => {
const { db, boardId, caller } = await createBoardCallerAsync();
await expect(caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [] })).rejects.toThrow(
"mediaServer requires an integration",
);
await expect(db.$count(items)).resolves.toBe(0);
});
test("allows an explicitly optional integration to be omitted", async () => {
const { boardId, caller } = await createBoardCallerAsync();
await expect(caller.addItem({ boardId, kind: "calendar", options: {}, integrationIds: [] })).resolves.toMatchObject(
{ itemId: expect.any(String) },
);
});
test("rejects an incompatible integration kind", async () => {
const { db, userId, boardId, caller } = await createBoardCallerAsync();
const integrationId = createId();
await db.insert(integrations).values({
id: integrationId,
name: "Sonarr",
kind: "sonarr",
url: "https://sonarr.example.com",
});
await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
await expect(
caller.addItem({ boardId, kind: "mediaServer", options: {}, integrationIds: [integrationId] }),
).rejects.toThrow("mediaServer does not support integration kind: sonarr");
await expect(db.$count(items)).resolves.toBe(0);
});
test("rejects more integrations than the widget supports", async () => {
const { db, userId, boardId, caller } = await createBoardCallerAsync();
const navidromeId = createId();
const audiobookshelfId = createId();
await db.insert(integrations).values([
{ id: navidromeId, name: "Navidrome", kind: "navidrome", url: "https://music.example.com" },
{
id: audiobookshelfId,
name: "Audiobookshelf",
kind: "audiobookshelf",
url: "https://books.example.com",
},
]);
await db.insert(integrationUserPermissions).values([
{ integrationId: navidromeId, userId, permission: "use" },
{ integrationId: audiobookshelfId, userId, permission: "use" },
]);
await expect(
caller.addItem({
boardId,
kind: "audioStats",
options: {},
integrationIds: [navidromeId, audiobookshelfId],
}),
).rejects.toThrow("audioStats supports at most 1 integration");
await expect(db.$count(items)).resolves.toBe(0);
});
});
@@ -27,9 +27,13 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["emby", "jellyfin", "plex"],
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
systemResources: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
systemDisks: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
beszelAlerts: ["beszel", "mock"],
beszelSystemGrid: ["beszel", "mock"],
beszelSystemStats: ["beszel", "mock"],
beszelSystemTable: ["beszel", "mock"],
coolify: ["coolify"],
"immich-serverStats": ["immich"],
"immich-albumCarousel": ["immich"],
@@ -44,6 +48,16 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
umami: ["umami"],
ups: getIntegrationKindsByCategory("ups"),
};
/** Widgets that remain useful without a configured integration. */
export const widgetKindsWithOptionalIntegrations = new Set<WidgetKind>(["calendar"]);
/** Widgets that support fewer integrations than the default unlimited selection. */
export const widgetIntegrationLimits: Partial<Record<WidgetKind, number>> = {
audioStats: 1,
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+17
View File
@@ -1750,6 +1750,23 @@
"remoteCssTitle": "This CSS loads a remote resource",
"remoteCssDescription": "Imported stylesheets and remote URLs contact another server when the board loads. Review them before continuing."
},
"configureWidget": {
"title": "Review widget",
"preparing": "Preparing widget settings",
"review": "Review widget and integrations",
"ready": "Widget configuration ready",
"cancelled": "Widget was not added",
"automaticContinue": "Accepting the proposed widget configuration automatically",
"loadErrorTitle": "Integrations could not be loaded",
"loadErrorDescription": "Homarr could not load the integrations available for this widget.",
"retry": "Try again",
"noIntegrationTitle": "A compatible integration is required",
"noIntegrationDescription": "Configure an integration supported by this widget before adding it to the dashboard.",
"manageIntegrations": "Manage integrations",
"skip": "Do not add",
"errorTitle": "Widget settings could not be prepared",
"errorDescription": "The model did not provide a complete widget configuration. No widget was added."
},
"approvalRequired": "Approval required",
"complete": "Complete",
"denied": "Denied",
+5 -1
View File
@@ -128,5 +128,9 @@ export const addItemToBoardSchema = z.object({
boardId: z.string(),
kind: zodEnumFromArray(widgetKinds),
options: z.record(z.string(), z.unknown()).default({}),
integrationIds: z.array(z.string()).default([]),
integrationIds: z
.array(z.string())
.max(32)
.refine((ids) => new Set(ids).size === ids.length)
.default([]),
});
@@ -54,6 +54,9 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
customError: zodErrorMap(t),
});
const { definition } = widgetImports[innerProps.kind];
const integrationSupport = "supportedIntegrations" in definition;
const integrationsRequired =
integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false);
const options = definition.createOptions(innerProps.settings) as Record<string, OptionsBuilderResult[string]>;
const optionsSuperRefine = (options as Record<symbol, unknown>)[OPTIONS_SUPER_REFINE] as
| ((data: Record<string, unknown>, ctx: z.RefinementCtx) => void)
@@ -78,7 +81,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
validate: schemaResolver(
z.object({
options: optionsSchema,
integrationIds: z.array(z.string()),
integrationIds: integrationsRequired ? z.array(z.string()).min(1) : z.array(z.string()),
advancedOptions: z.object({
customCssClasses: z.array(z.string()),
borderColor: z.string(),
@@ -128,6 +131,7 @@ export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, i
((widgetImports[innerProps.kind].definition as { maxIntegrations?: number }).maxIntegrations ?? Infinity) >
1
}
withAsterisk={integrationsRequired}
{...form.getInputProps("integrationIds")}
/>
)}