diff --git a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx index 496eb7f5c..800c00131 100644 --- a/apps/docs/docs/management/custom-widgets/troubleshooting.mdx +++ b/apps/docs/docs/management/custom-widgets/troubleshooting.mdx @@ -4,6 +4,10 @@ The definition was deleted or reset. Remove the broken tile from the board and install or create a current Custom JSX v2 definition. There is no legacy recovery or migration wizard. +## Invalid stored definition + +The Custom Widgets management page isolates definitions that no longer match the current schema instead of failing the entire page. Invalid definitions show their validation issues and can be deleted, but cannot be edited, enabled, exported, duplicated, published, or selected for a board. Recreate or import a valid definition; Homarr does not silently migrate unreleased Custom JSX formats. + ## Validation fails Use the first schema/JSX diagnostic and its path. Ensure source/request IDs are unique, every request references a source, parameters are declared, defaults match the options schema, and network components reference the correct request kind. diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index 710f133d7..9f7562fad 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -27,6 +27,7 @@ interface WidgetRef { id: string; name: string; enabled: boolean; + valid: boolean; } export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { @@ -106,20 +107,24 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { - } - disabled={duplicateMutation.isPending} - > - {t("action.duplicate")} - - void handleExport()} leftSection={}> - {t("action.export")} - - }> - {t("action.publishWorkshop")} - - + {widget.valid && ( + <> + } + disabled={duplicateMutation.isPending} + > + {t("action.duplicate")} + + void handleExport()} leftSection={}> + {t("action.export")} + + }> + {t("action.publishWorkshop")} + + + + )} } @@ -130,7 +135,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { - + {widget.valid && } ); }; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx index be555a2f8..49ee25f53 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-list.tsx @@ -68,8 +68,10 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) { return ( @@ -86,15 +88,40 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) { {widget.name} + {!widget.valid && ( + + {t("page.list.invalidDefinitionDescription")} + + )} {widget.sources[0] && ( {widget.sources.map((source) => source.origin).join(" · ")} )} - - JSX · {widget.requestCount} - + {widget.valid ? ( + + JSX · {widget.requestCount} + + ) : ( + (issue.path ? `${issue.path}: ${issue.message}` : issue.message)) + .join("\n")} + multiline + maw={520} + > + } + style={{ flexShrink: 0 }} + > + {t("page.list.invalidDefinition")} + + + )} {widget.missingSecrets.length > 0 && ( - - - - - - - + {widget.valid && ( + <> + + + + + + + + )} + diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts index 2bac91918..62f5b5dd3 100644 --- a/packages/api/src/router/custom-widget/custom-widget-router.ts +++ b/packages/api/src/router/custom-widget/custom-widget-router.ts @@ -18,7 +18,11 @@ import { metadataProcedures } from "./metadata-procedures"; import { previewActionProcedures } from "./preview-action-procedures"; import { previewBaseProcedures } from "./preview-base-procedures"; import { previewQueryProcedures } from "./preview-query-procedures"; -import { parseStoredCustomWidgetDefinition, serializeCustomWidgetDefinition } from "./stored-definition"; +import { + parseStoredCustomWidgetDefinition, + safeParseStoredCustomWidgetDefinition, + serializeCustomWidgetDefinition, +} from "./stored-definition"; import { templateProcedures } from "./template-procedures"; import { transferProcedures } from "./transfer-procedures"; import { assertSecretSources, requiredSecretKinds } from "./secret-policy"; @@ -136,6 +140,19 @@ export const customWidgetRouter = createTRPCRouter({ toggleEnabled: manageProcedure .input(z.object({ id: z.string(), enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { + if (input.enabled) { + const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ + where: eq(customWidgetDefinitions.id, input.id), + }); + if (!definition) throw new TRPCError({ code: "NOT_FOUND" }); + if (!safeParseStoredCustomWidgetDefinition(definition).success) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid custom widget definition cannot be enabled", + }); + } + } + await ctx.db .update(customWidgetDefinitions) .set({ enabled: input.enabled, updatedAt: new Date() }) diff --git a/packages/api/src/router/custom-widget/management-queries.ts b/packages/api/src/router/custom-widget/management-queries.ts index e72b07f78..8437625e2 100644 --- a/packages/api/src/router/custom-widget/management-queries.ts +++ b/packages/api/src/router/custom-widget/management-queries.ts @@ -2,19 +2,21 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod/v4"; import { decryptSecret } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; import { eq, or } from "@homarr/db"; import { boards, customWidgetDefinitions } from "@homarr/db/schema"; import { getCustomWidgetSecretRequirements } from "@homarr/custom-widgets/core"; import { permissionRequiredProcedure, protectedProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; -import { parseStoredCustomWidgetDefinition } from "./stored-definition"; +import { parseStoredCustomWidgetDefinition, safeParseStoredCustomWidgetDefinition } from "./stored-definition"; import { executeCustomWidgetRequest } from "./request-executor"; import { hashRuntimeParams, renderRequestBody, renderRequestTarget } from "./request-manifest"; import { acquireCustomWidgetRequestLimit } from "./request-limits"; import { getCustomWidgetCacheVersion } from "./cache-version"; const manageProcedure = permissionRequiredProcedure.requiresPermission("custom-widget-manage"); +const logger = createLogger({ module: "custom-widget:management" }); export const managementQueryProcedures = { list: manageProcedure @@ -25,7 +27,29 @@ export const managementQueryProcedures = { with: { secrets: true }, }); return definitions.map((definition) => { - const widget = parseStoredCustomWidgetDefinition(definition); + const result = safeParseStoredCustomWidgetDefinition(definition); + if (!result.success) { + logger.warn("Skipped parsing invalid custom widget definition", { + id: definition.id, + issueCount: result.issues.length, + }); + return { + id: definition.id, + name: definition.name, + description: definition.description ?? undefined, + iconUrl: definition.iconUrl ?? undefined, + sources: [], + requestCount: 0, + missingSecrets: [], + defaultOptions: {}, + updatedAt: definition.updatedAt, + enabled: definition.enabled, + valid: false as const, + validationIssues: result.issues, + }; + } + + const widget = result.widget; const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`)); return { id: definition.id, @@ -46,6 +70,8 @@ export const managementQueryProcedures = { defaultOptions: widget.defaultOptions, updatedAt: definition.updatedAt, enabled: definition.enabled, + valid: true as const, + validationIssues: [], }; }); }), @@ -84,8 +110,14 @@ export const managementQueryProcedures = { : eq(customWidgetDefinitions.enabled, true), orderBy: (table, { asc }) => asc(table.name), }); - return definitions.map((definition) => { - const widget = parseStoredCustomWidgetDefinition(definition); + return definitions.flatMap((definition) => { + const result = safeParseStoredCustomWidgetDefinition(definition); + if (!result.success) { + logger.warn("Excluded invalid custom widget definition from board picker", { id: definition.id }); + return []; + } + + const widget = result.widget; return { id: definition.id, name: definition.name, diff --git a/packages/api/src/router/custom-widget/stored-definition.ts b/packages/api/src/router/custom-widget/stored-definition.ts index c8106cecc..5d1c8cb2c 100644 --- a/packages/api/src/router/custom-widget/stored-definition.ts +++ b/packages/api/src/router/custom-widget/stored-definition.ts @@ -4,6 +4,20 @@ import { customWidgetDefinitionSchema } from "@homarr/custom-widgets/core"; import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core"; import type { CustomWidgetDefinition } from "@homarr/db/schema"; +type StoredCustomWidgetDefinition = Pick< + CustomWidgetDefinition, + "name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template" +>; + +export interface StoredCustomWidgetIssue { + path?: string; + message: string; +} + +export type StoredCustomWidgetParseResult = + | { success: true; widget: HomarrCustomWidgetV2 } + | { success: false; issues: StoredCustomWidgetIssue[] }; + export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2) { return { name: definition.name, @@ -17,12 +31,7 @@ export function serializeCustomWidgetDefinition(definition: HomarrCustomWidgetV2 }; } -export function parseStoredCustomWidgetDefinition( - definition: Pick< - CustomWidgetDefinition, - "name" | "description" | "iconUrl" | "sources" | "requests" | "optionsSchema" | "defaultOptions" | "template" - >, -): HomarrCustomWidgetV2 { +export function parseStoredCustomWidgetDefinition(definition: StoredCustomWidgetDefinition): HomarrCustomWidgetV2 { return customWidgetDefinitionSchema.parse({ $schema: "homarr-custom-widget-v2", name: definition.name, @@ -35,3 +44,26 @@ export function parseStoredCustomWidgetDefinition( template: definition.template, }); } + +export function safeParseStoredCustomWidgetDefinition( + definition: StoredCustomWidgetDefinition, +): StoredCustomWidgetParseResult { + try { + return { success: true, widget: parseStoredCustomWidgetDefinition(definition) }; + } catch (error) { + if (error instanceof Error && "issues" in error && Array.isArray(error.issues)) { + return { + success: false, + issues: error.issues.slice(0, 10).map((issue: { path?: PropertyKey[]; message?: unknown }) => ({ + path: issue.path?.map(String).join("."), + message: typeof issue.message === "string" ? issue.message : "Invalid stored widget value", + })), + }; + } + + return { + success: false, + issues: [{ message: "Stored widget data could not be read" }], + }; + } +} diff --git a/packages/api/src/router/test/custom-widget/stored-definition.spec.ts b/packages/api/src/router/test/custom-widget/stored-definition.spec.ts new file mode 100644 index 000000000..2c171f0ed --- /dev/null +++ b/packages/api/src/router/test/custom-widget/stored-definition.spec.ts @@ -0,0 +1,49 @@ +import { parse as parseSuperJson, stringify as stringifySuperJson } from "superjson"; +import { describe, expect, test } from "vitest"; + +import { BUNDLED_CUSTOM_WIDGETS, CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core"; + +import { + safeParseStoredCustomWidgetDefinition, + serializeCustomWidgetDefinition, +} from "../../custom-widget/stored-definition"; + +describe("stored custom widget definitions", () => { + test("parses a valid stored definition", () => { + const result = safeParseStoredCustomWidgetDefinition(serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER)); + + expect(result).toMatchObject({ success: true, widget: { $schema: "homarr-custom-widget-v2" } }); + }); + + test("returns all useful issues for a stale load request instead of throwing", () => { + const currency = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-currency-exchange"); + if (!currency) throw new Error("Currency Exchange bundled widget is missing"); + + const stored = serializeCustomWidgetDefinition(currency.widget); + const requests = parseSuperJson(stored.requests) as Array>; + delete requests[0]?.optionsBinding; + + const result = safeParseStoredCustomWidgetDefinition({ + ...stored, + requests: stringifySuperJson(requests), + }); + + expect(result).toEqual({ + success: false, + issues: expect.arrayContaining([ + expect.objectContaining({ path: "requests.0.optionsBinding.from" }), + expect.objectContaining({ path: "requests.0.optionsBinding.to" }), + expect.objectContaining({ path: "requests.0.optionsBinding.amount" }), + ]), + }); + }); + + test("contains malformed serialized data", () => { + const stored = serializeCustomWidgetDefinition(CUSTOM_WIDGET_STARTER); + + expect(safeParseStoredCustomWidgetDefinition({ ...stored, requests: "not-json" })).toEqual({ + success: false, + issues: [{ message: "Stored widget data could not be read" }], + }); + }); +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 29305b8ab..8c613f218 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -723,6 +723,8 @@ "title": "Custom Widgets", "noResults": "No custom widgets yet. Create your first one!", "loading": "Loading...", + "invalidDefinition": "Invalid definition", + "invalidDefinitionDescription": "This stored widget no longer matches the current Custom Widget schema. Delete it and recreate or import a valid definition.", "missingCredentials": "{count, plural, one {Credential missing} other {# credentials missing}}", "missingCredentialsDescription": "{count, plural, one {This widget still needs one credential.} other {This widget still needs # credentials.}} Edit it to configure the encrypted values." },