fix(custom-widgets): isolate invalid definitions

This commit is contained in:
Thomas Camlong committed 2026-07-20 18:32:48 +02:00
1 parent a49b0869f7
commit 80d364c050
8 files changed
+223 -49

No files matched your search

@@ -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.
@@ -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 }) => {
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
disabled={duplicateMutation.isPending}
>
{t("action.duplicate")}
</Menu.Item>
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
{t("action.publishWorkshop")}
</Menu.Item>
<Menu.Divider />
{widget.valid && (
<>
<Menu.Item
onClick={handleDuplicate}
leftSection={<IconCopy {...iconProps} />}
disabled={duplicateMutation.isPending}
>
{t("action.duplicate")}
</Menu.Item>
<Menu.Item onClick={() => void handleExport()} leftSection={<IconDownload {...iconProps} />}>
{t("action.export")}
</Menu.Item>
<Menu.Item onClick={publishControls.open} leftSection={<IconBuildingStore {...iconProps} />}>
{t("action.publishWorkshop")}
</Menu.Item>
<Menu.Divider />
</>
)}
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
@@ -130,7 +135,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
</Menu.Item>
</Menu.Dropdown>
</Menu>
<WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />
{widget.valid && <WorkshopPublishModal opened={publishOpened} onClose={publishControls.close} widget={widget} />}
</>
);
};
@@ -68,8 +68,10 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
return (
<Card
padding="sm"
withBorder={!widget.valid}
bd={!widget.valid ? "1px solid var(--mantine-color-red-6)" : undefined}
style={{
opacity: widget.enabled ? 1 : 0.55,
opacity: widget.valid && !widget.enabled ? 0.55 : 1,
transition: "opacity 150ms ease",
}}
>
@@ -86,15 +88,40 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
<Text size="sm" fw={600} lineClamp={1} style={{ minWidth: 0 }}>
{widget.name}
</Text>
{!widget.valid && (
<Text size="xs" c="red">
{t("page.list.invalidDefinitionDescription")}
</Text>
)}
{widget.sources[0] && (
<Text size="xs" c="dimmed" ff="monospace" lineClamp={1} style={{ wordBreak: "break-all", minWidth: 0 }}>
{widget.sources.map((source) => source.origin).join(" · ")}
</Text>
)}
</Stack>
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
{widget.valid ? (
<Badge color="pink" size="sm" variant="light" style={{ flexShrink: 0 }}>
JSX · {widget.requestCount}
</Badge>
) : (
<Tooltip
label={widget.validationIssues
.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message))
.join("\n")}
multiline
maw={520}
>
<Badge
color="red"
size="sm"
variant="light"
leftSection={<IconAlertTriangle size={12} />}
style={{ flexShrink: 0 }}
>
{t("page.list.invalidDefinition")}
</Badge>
</Tooltip>
)}
{widget.missingSecrets.length > 0 && (
<Tooltip
label={t("page.list.missingCredentialsDescription", { count: widget.missingSecrets.length })}
@@ -115,25 +142,31 @@ function CustomWidgetCard({ widget }: { widget: WidgetDef }) {
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: widget.id, name: widget.name, enabled: widget.enabled }} />
{widget.valid && (
<>
<Tooltip label={widget.enabled ? t("action.disable") : t("action.enable")}>
<Switch
size="sm"
checked={widget.enabled}
onChange={handleToggle}
disabled={toggleMutation.isPending}
aria-label={widget.enabled ? t("action.disable") : t("action.enable")}
/>
</Tooltip>
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${widget.id}`}
variant="subtle"
color="gray"
aria-label={t("action.edit")}
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
</>
)}
<CustomWidgetRowActions
widget={{ id: widget.id, name: widget.name, enabled: widget.enabled, valid: widget.valid }}
/>
</Group>
</Group>
</Card>
@@ -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() })
@@ -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,
@@ -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" }],
};
}
}
@@ -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<Record<string, unknown>>;
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" }],
});
});
});
+2
View File
@@ -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."
},