fix(custom-widgets): isolate invalid definitions
This commit is contained in:
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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user