fix(custom-widgets): address review feedback

This commit is contained in:
Thomas Camlong committed 2026-07-12 15:08:59 +02:00
1 parent c43836e85a
commit c5703e7e53
9 files changed
+93 -63

No files matched your search

@@ -46,6 +46,7 @@ import {
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { CUSTOM_JSX_METHOD_COLORS } from "@homarr/widgets/custom-api/custom-jsx-display";
import { useScopedI18n } from "@homarr/translation/client";
import { displayComponents } from "@homarr/widgets/custom-api/component";
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
@@ -95,8 +96,7 @@ interface CustomWidgetPreviewProps {
onSampleDataChange?: (value: unknown) => void;
}
const METHOD_COLORS: Record<string, string> = { DELETE: "red", GET: "blue" };
const methodColor = (method: string) => METHOD_COLORS[method] ?? "orange";
const methodColor = (method: string) => CUSTOM_JSX_METHOD_COLORS[method] ?? "gray";
function journalEntryColor(entry: { simulated: boolean; status: number | null }): string {
if (entry.simulated) return "yellow";
@@ -7,7 +7,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { eq, or } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
customWidgetAuthTypes,
@@ -107,7 +107,6 @@ export const customWidgetRouter = createTRPCRouter({
.query(() => getImportJsonSchema()),
validate: adminProcedure
.input(z.object({ widget: z.unknown() }))
.meta({
mcp: {
enabled: true,
@@ -115,6 +114,7 @@ export const customWidgetRouter = createTRPCRouter({
"Validate a complete custom-widget JSON draft without saving or making network requests. REQUIRED: widget (the homarr-custom-widget-v3 object). Returns structured issue paths and messages so an agent can correct the draft and validate again.",
},
})
.input(z.object({ widget: z.unknown() }))
.query(({ input }) => {
const result = customWidgetImportSchema.safeParse(input.widget);
if (!result.success) {
@@ -161,7 +161,6 @@ export const customWidgetRouter = createTRPCRouter({
}),
byId: adminProcedure
.input(z.object({ id: z.string() }))
.meta({
mcp: {
enabled: true,
@@ -169,6 +168,7 @@ export const customWidgetRouter = createTRPCRouter({
"Get one complete custom-widget definition for iterative editing. REQUIRED: id. Stored secret values are never returned; only their presence is reported.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
@@ -198,23 +198,26 @@ export const customWidgetRouter = createTRPCRouter({
};
}),
available: protectedProcedure.input(z.object({ boardId: z.string() })).query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return ctx.db.query.customWidgetDefinitions.findMany({
where: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
columns: {
id: true,
name: true,
description: true,
iconUrl: true,
displayType: true,
},
});
}),
available: protectedProcedure
.input(z.object({ boardId: z.string(), currentId: z.string().optional() }))
.query(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
return ctx.db.query.customWidgetDefinitions.findMany({
where: input.currentId
? or(eq(customWidgetDefinitions.enabled, true), eq(customWidgetDefinitions.id, input.currentId))
: eq(customWidgetDefinitions.enabled, true),
orderBy: (table, { asc }) => asc(table.name),
columns: {
id: true,
name: true,
description: true,
iconUrl: true,
displayType: true,
},
});
}),
create: adminProcedure
.input(customWidgetCreateSchema)
.meta({
mcp: {
enabled: true,
@@ -222,6 +225,7 @@ export const customWidgetRouter = createTRPCRouter({
"Create a validated custom widget. Admin only. Call customWidget_schema and customWidget_validate first. For Custom JSX use jsxApiVersion 2, named requests, a GET base method, and no inline credentials.",
},
})
.input(customWidgetCreateSchema)
.mutation(async ({ ctx, input }) => {
const id = createId();
@@ -256,7 +260,6 @@ export const customWidgetRouter = createTRPCRouter({
}),
update: adminProcedure
.input(customWidgetUpdateSchema)
.meta({
mcp: {
enabled: true,
@@ -264,6 +267,7 @@ export const customWidgetRouter = createTRPCRouter({
"Update an existing custom widget. Admin only. REQUIRED: id. Read it with customWidget_byId, preserve unrelated fields, validate the resulting complete draft, then send the changed fields. Omit secrets to preserve stored credentials.",
},
})
.input(customWidgetUpdateSchema)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
@@ -354,7 +358,6 @@ export const customWidgetRouter = createTRPCRouter({
}),
readTemplate: adminProcedure
.input(z.object({ id: z.string() }))
.meta({
mcp: {
enabled: true,
@@ -362,6 +365,7 @@ export const customWidgetRouter = createTRPCRouter({
"Read the JSX template of a custom widget definition as plain text. Returns the template string separately from the full widget config, making it easier to inspect and edit.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
@@ -396,6 +400,13 @@ export const customWidgetRouter = createTRPCRouter({
}),
writeTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
},
})
.input(
z
.object({
@@ -410,13 +421,6 @@ export const customWidgetRouter = createTRPCRouter({
message: "Provide template or templateLines, not both",
}),
)
.meta({
mcp: {
enabled: true,
description:
"Update only the JSX template of a custom widget definition. Accepts either a single template string or templateLines (array of strings joined with newlines). Validates the template AST before saving. This avoids needing to send the full widget JSON for template-only edits.",
},
})
.mutation(async ({ ctx, input }) => {
const resolvedTemplate =
input.templateLines !== undefined ? input.templateLines.join("\n") : (input.template ?? "");
@@ -467,6 +471,13 @@ export const customWidgetRouter = createTRPCRouter({
}),
patchTemplate: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
},
})
.input(
z.object({
id: z.string(),
@@ -483,13 +494,6 @@ export const customWidgetRouter = createTRPCRouter({
.max(100),
}),
)
.meta({
mcp: {
enabled: true,
description:
"Patch selected lines of a Custom JSX template without rewriting the whole template. First call customWidget_readTemplate and pass its revision as expectedRevision. Each edit uses a 1-based startLine, deleteCount, and replacementLines. Edits are applied atomically, then the complete template and named-request references are validated before saving. A stale revision is rejected so the agent can re-read and retry safely.",
},
})
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
@@ -545,7 +549,6 @@ export const customWidgetRouter = createTRPCRouter({
}),
export: adminProcedure
.input(z.object({ id: z.string() }))
.meta({
mcp: {
enabled: true,
@@ -553,6 +556,7 @@ export const customWidgetRouter = createTRPCRouter({
"Export one custom widget as a homarr-custom-widget-v3 object suitable for validation, modification, and re-import. REQUIRED: id. Secrets are excluded.",
},
})
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
@@ -583,7 +587,6 @@ export const customWidgetRouter = createTRPCRouter({
}),
import: adminProcedure
.input(customWidgetImportSchema)
.meta({
mcp: {
enabled: true,
@@ -591,6 +594,7 @@ export const customWidgetRouter = createTRPCRouter({
"Create a custom widget directly from a complete homarr-custom-widget-v3 import object. Admin only. Use customWidget_validate first. This is the preferred creation tool for AI-generated authoring bundles because secrets are omitted and configured separately.",
},
})
.input(customWidgetImportSchema)
.mutation(async ({ ctx, input }) => {
const id = createId();
@@ -614,6 +618,13 @@ export const customWidgetRouter = createTRPCRouter({
}),
preview: adminProcedure
.meta({
mcp: {
enabled: true,
description:
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
},
})
.input(
z.object({
url: z.string().url(),
@@ -627,13 +638,6 @@ export const customWidgetRouter = createTRPCRouter({
definitionId: z.string().optional(),
}),
)
.meta({
mcp: {
enabled: true,
description:
"Test a custom-widget draft through Homarr's hardened preview executor. Admin only. GET only; actions remain simulated. REQUIRED: url, method GET, authType, displayType, displayConfig. OPTIONAL: definitionId reuses that saved widget's stored credentials; omit secrets whenever possible. Returns sanitized response data, HTTP status, and a short-lived preview session for named query testing.",
},
})
.mutation(async ({ ctx, input }) => {
const secrets = [...input.secrets];
@@ -744,7 +748,6 @@ export const customWidgetRouter = createTRPCRouter({
}),
previewQuery: adminProcedure
.input(previewSessionRequestSchema)
.meta({
mcp: {
enabled: true,
@@ -752,6 +755,7 @@ export const customWidgetRouter = createTRPCRouter({
"Run one named GET query from a short-lived custom-widget preview session. REQUIRED: sessionId from customWidget_preview, requestId declared as a query, and typed params. Uses the hardened executor and returns sanitized response data for another validate/update iteration.",
},
})
.input(previewSessionRequestSchema)
.query(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
@@ -813,7 +817,6 @@ export const customWidgetRouter = createTRPCRouter({
),
previewJournal: adminProcedure
.input(z.object({ sessionId: z.string().min(1) }))
.meta({
mcp: {
enabled: true,
@@ -821,17 +824,18 @@ export const customWidgetRouter = createTRPCRouter({
"Read the redacted request journal for a custom-widget preview session. REQUIRED: sessionId. Use it to inspect statuses and durations; credentials and parameter values are never returned.",
},
})
.input(z.object({ sessionId: z.string().min(1) }))
.query(async ({ ctx, input }) => getPreviewJournal(input.sessionId, ctx.session.user.id)),
simulatePreviewAction: adminProcedure
.input(previewSessionRequestSchema)
.meta({
mcp: {
enabled: true,
description:
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. This MCP tool never enables or executes live actions.",
"Validate and simulate one named custom-widget action without sending a network request. REQUIRED: sessionId from customWidget_preview, action requestId, and typed params. Confirms parameter substitution and records a simulated journal entry. Returns requiredPermission as the action's minimum board permission (view, modify, or full), not as an execution result. This MCP tool never enables or executes live actions.",
},
})
.input(previewSessionRequestSchema)
.mutation(async ({ ctx, input }) => {
const session = await getPreviewSession(input.sessionId, ctx.session.user.id);
const request = session.requests.find(
+5 -1
View File
@@ -291,7 +291,11 @@ const componentSafeProps: Readonly<Record<string, readonly string[]>> = {
function documentationUrl(packageName: CustomJsxComponentPackage, name: string): string {
if (packageName === "@homarr/widgets") return "https://homarr.dev/docs/management/custom-widgets/";
const section = packageName === "@mantine/core" ? "core" : packageName === "@mantine/charts" ? "charts" : "dates";
return `https://mantine.dev/${section}/${name.split(".")[0]?.toLowerCase() ?? ""}/`;
const slug = (name.split(".")[0] ?? "")
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
.toLowerCase();
return `https://mantine.dev/${section}/${slug}/`;
}
const describe = (
+1 -1
View File
@@ -441,7 +441,7 @@ const customJsxTemplateSchema = z
}
})
.describe(
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct network access is unavailable; v2 templates reference named requests.",
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Direct fetch and XMLHttpRequest access are unavailable; v2 templates reference named requests.",
);
const customJsxDisplayConfigV1Schema = z.object({
@@ -18,14 +18,13 @@ export const WidgetCustomWidgetSelectInput = ({
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const board = useOptionalBoard();
const currentValue = form.values.options[property] as string;
const { data, isLoading } = clientApi.customWidget.available.useQuery(
{ boardId: board?.id ?? "" },
{ boardId: board?.id ?? "", currentId: currentValue || undefined },
{ enabled: board !== null },
);
const [search, setSearch] = useState("");
const currentValue = form.values.options[property] as string;
const definitions = useMemo(() => (data ?? []).map((def) => ({ value: def.id, label: def.name })), [data]);
const filteredOptions = useMemo(
@@ -20,7 +20,7 @@ function appendParseError(prev: string[], message: string): string[] {
return [...prev, message];
}
const METHOD_COLORS: Record<string, string> = {
export const CUSTOM_JSX_METHOD_COLORS: Record<string, string> = {
GET: "blue",
POST: "orange",
PUT: "yellow",
@@ -173,7 +173,7 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
{capability.id}
</Text>
<Group gap={4} wrap="nowrap">
<Badge size="xs" color={METHOD_COLORS[capability.method] ?? "gray"} variant="light">
<Badge size="xs" color={CUSTOM_JSX_METHOD_COLORS[capability.method] ?? "gray"} variant="light">
{capability.method}
</Badge>
<Badge size="xs" color="gray" variant="outline">
@@ -185,7 +185,7 @@ export function SubFetch({
const result = query.data as RequestResult | undefined;
const requestError =
(query.error ? t("requestFailed") : null) ??
(query.error && !result ? t("requestFailed") : null) ??
(!result?.ok ? (result?.error ?? `${t("requestFailed")} (${result?.status ?? 0})`) : null);
if (!result && query.isPending) {
@@ -239,13 +239,25 @@ function isSafeUrl(url: unknown): boolean {
function SafeAnchor(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(Anchor as never, { ...sanitizeCustomJsxProps(props), href: safeHref });
const sanitized = sanitizeCustomJsxProps(props);
const opensNewWindow = sanitized.target === "_blank";
return createElement(Anchor as never, {
...sanitized,
href: safeHref,
rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel,
});
}
function SafeNavLink(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(NavLink as never, { ...sanitizeCustomJsxProps(props), href: safeHref });
const sanitized = sanitizeCustomJsxProps(props);
const opensNewWindow = sanitized.target === "_blank";
return createElement(NavLink as never, {
...sanitized,
href: safeHref,
rel: opensNewWindow ? "noopener noreferrer" : sanitized.rel,
});
}
function SafeButton(props: Record<string, unknown>) {
@@ -301,7 +313,13 @@ function SafeCalendar(props: Record<string, unknown>) {
}
function SafeMiniCalendar(props: Record<string, unknown>) {
return createElement(MiniCalendar as never, { ...sanitizeCustomJsxProps(props), static: true });
return createElement(MiniCalendar as never, {
...sanitizeCustomJsxProps(props),
style: { pointerEvents: "none" },
previousControlProps: { disabled: true, tabIndex: -1 },
nextControlProps: { disabled: true, tabIndex: -1 },
getDayProps: () => ({ disabled: true, tabIndex: -1 }),
});
}
function SafeTree(props: Record<string, unknown>) {
@@ -309,7 +327,12 @@ function SafeTree(props: Record<string, unknown>) {
}
function SafeDatePicker(props: Record<string, unknown>) {
return createElement(DatePicker as never, { ...sanitizeCustomJsxProps(props), static: true });
return createElement(DatePicker as never, {
...sanitizeCustomJsxProps(props),
style: { pointerEvents: "none" },
enableKeyboardNavigation: false,
getDayProps: () => ({ disabled: true, tabIndex: -1 }),
});
}
function createSafeDateComponent(component: ComponentType<never>) {
@@ -730,7 +753,7 @@ safeDate.toLocaleTimeString = createSafeCallable((v: string | number, locale?: s
safeDate.getTime = createSafeCallable((v: string | number) => new Date(v).getTime());
safeDate.getYear = createSafeCallable((v: string | number) => new Date(v).getFullYear());
safeDate.getMonth = createSafeCallable((v: string | number) => new Date(v).getMonth());
safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getDay());
safeDate.getDay = createSafeCallable((v: string | number) => new Date(v).getUTCDay());
Object.freeze(safeDate);
export const SAFE_BINDINGS = (apiData: unknown) => ({
@@ -838,7 +838,7 @@ class Interpreter {
case "charAt":
return { handled: true, value: receiver.charAt(numericArgument(args[0], 0)) };
case "split": {
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 100)));
const limit = Math.max(0, Math.min(2_000, numericArgument(args[1], 2_000)));
const split = args[0] === undefined ? [receiver] : receiver.split(String(args[0]), limit);
this.budget.collection(split.length);
return { handled: true, value: split };