fix(custom-widgets): refine v2 authoring
This commit is contained in:
124 files changed
+2089
-913
No files matched your search
@@ -4,7 +4,7 @@ export const CUSTOM_WIDGET_MANTINE_VERSION = "9.4.1";
|
||||
|
||||
const AUTHORING_PROMPT = `You create Homarr Custom JSX v2 widgets. Return exactly two fenced blocks: a json block containing one complete widget object with template set to "__HOMARR_TEMPLATE__", then a jsx block containing the readable template.
|
||||
|
||||
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, optional local state, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
|
||||
Use $schema "homarr-custom-widget-v2". A widget contains metadata, inline API sources, named requests, an options schema, defaults, and one JSX template. Never include credentials. Use one source named "default" unless the widget genuinely combines APIs.
|
||||
|
||||
This Homarr release uses Mantine ${CUSTOM_WIDGET_MANTINE_VERSION}. When connected, read homarr://custom-widgets/schema and homarr://custom-widgets/components instead of guessing the installed surface.
|
||||
|
||||
@@ -12,12 +12,12 @@ Queries and actions may use GET, POST, PUT, PATCH, or DELETE. Set kind to query
|
||||
|
||||
Source authentication is exactly one of {"type":"none"}, {"type":"bearer"}, {"type":"basic"}, {"type":"apiKeyHeader","headerName":"X-API-Key"}, or {"type":"apiKeyQuery","parameterName":"api_key"}. Credentials are configured separately by the user.
|
||||
|
||||
Templates can read data, status, options, and state. Bind supported Mantine controls with bind="stateName" instead of event handlers. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
|
||||
Templates can read data, status, options, and inputs. Bind supported Mantine controls with bind="search" and read the temporary value as inputs.search. Bound inputs live only in memory and reset when the widget reloads; saved configuration belongs in options. Use Mantine Core, Dates, and Charts components. Do not use imports, hooks, refs, event callbacks, component/renderRoot, arbitrary portals, raw HTML, fetch, eval, npm packages, or credentials. Design for compact and wide dashboard sizes with useful loading, empty, and error states.
|
||||
|
||||
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath. Local state types are string, number, boolean, date, string[], number[], or date[].
|
||||
Options use a restricted object JSON Schema. x-homarr controls include text, textarea, number, switch, select, multi-select, slider, date, time, color, icon, url, duration, timeZone, and json. Dynamic selects use x-homarr.optionsSource with requestId, optional itemsPath for wrapped arrays, valuePath, and labelPath.
|
||||
|
||||
Widget shape:
|
||||
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/path", "parameters":{}, "queryTemplate":{}, "bodyTemplate":{}, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "stateSchema":{}, "defaultState":{}, "template":"__HOMARR_TEMPLATE__" }
|
||||
{ "$schema":"homarr-custom-widget-v2", "name":"...", "description":"...", "iconUrl":"https://...", "sources":[{ "id":"default", "name":"API", "baseUrl":"https://host", "networkScope":"public|private|loopback", "auth":{ "type":"none|bearer|basic|apiKeyHeader|apiKeyQuery" } }], "requests":[{ "id":"data", "sourceId":"default", "kind":"query|action", "method":"GET|POST|PUT|PATCH|DELETE", "pathTemplate":"/api/{id}", "parameters":{ "id":"string", "limit":"number" }, "queryTemplate":{ "limit":{ "$param":"limit" } }, "auth":"inherit|none", "minimumBoardPermission":"view|modify|full", "trigger":"load|manual", "confirmation":{ "title":"...", "message":"...", "destructive":false }, "invalidates":[] }], "optionsSchema":{ "type":"object", "properties":{}, "additionalProperties":false }, "defaultOptions":{}, "template":"__HOMARR_TEMPLATE__" }
|
||||
|
||||
Keep the result concise, accessible, responsive, and valid. Preserve unrelated fields when fixing an existing widget.`;
|
||||
|
||||
@@ -33,7 +33,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
|
||||
6. Use customWidget_secretSet only for a credential the user supplied and only with dedicated permission. Otherwise call customWidget_secretRequestUser and poll its completion status; never receive or repeat the plaintext.
|
||||
7. Call customWidget_create only after validation, request tests, action simulation, and visual inspection pass.
|
||||
|
||||
Use inline sources, named queries/actions, restricted JSON Schema options, session-local typed state, declarative bind controls, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
|
||||
Use inline sources, named queries/actions, restricted JSON Schema options, temporary declarative bind controls exposed through inputs, and safe Mantine JSX. Use kind, not method, to distinguish reads from user-triggered changes. Never include credentials, imports, hooks, refs, callbacks, browser requests, arbitrary JavaScript, npm packages, polymorphic roots, or arbitrary portals. Make the widget responsive, accessible, loading-aware, empty-aware, and error-aware. The live schema and component resources are authoritative for the installed Homarr release.`;
|
||||
|
||||
export function buildCustomWidgetMcpPrompt(request?: string | null, documentationUrl?: string | null) {
|
||||
const sections = [CUSTOM_WIDGET_MCP_AUTHORING_PROMPT];
|
||||
@@ -91,7 +91,7 @@ function redactValue(value: unknown, key = ""): unknown {
|
||||
);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (["sources", "requests", "optionsSchema", "defaultOptions", "stateSchema", "defaultState"].includes(key)) {
|
||||
if (["sources", "requests", "optionsSchema", "defaultOptions"].includes(key)) {
|
||||
try {
|
||||
return redactValue(JSON.parse(value) as unknown);
|
||||
} catch {
|
||||
|
||||
@@ -7,12 +7,15 @@ import { customJsxExamples } from "./examples";
|
||||
import { customJsxTablerIconNames } from "./tabler-icons";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.0.0";
|
||||
export const CUSTOM_WIDGET_SKILL_DOWNLOAD_URL = "https://homarr.dev/downloads/homarr-custom-widget-2.0.0.zip";
|
||||
export const CUSTOM_WIDGET_SKILL_SHA256 = "5571b74b2faafe8a9e230231c1915193e889c7afa95acef38a1f70befe14a9e1";
|
||||
export const CUSTOM_WIDGET_SKILLS_SH_URL = "https://www.skills.sh/homarr-labs/homarr/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
|
||||
"https://github.com/homarr-labs/homarr/tree/v2/.agents/skills/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
|
||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = `---
|
||||
name: homarr-custom-widget
|
||||
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, local state, and Mantine JSX.
|
||||
description: Author, validate, preview, and install safe Homarr Custom JSX v2 widgets from API documentation. Use when creating or fixing Homarr custom widgets, including multi-source dashboards, queries, actions, options, temporary inputs, and Mantine JSX.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
version: "2.0.0"
|
||||
@@ -35,7 +38,7 @@ Create one widget at a time. Repeat the workflow when the user asks for several
|
||||
|
||||
## Output rules
|
||||
|
||||
- Use inline sources, named requests, definition-owned options, session-local state, and one safe JSX template.
|
||||
- Use inline sources, named requests, definition-owned options, temporary bound inputs, and one safe JSX template.
|
||||
- Use \`kind\`, not the HTTP method, to distinguish reads from user-triggered actions.
|
||||
- Never put a credential in JSX, options, headers, URLs, examples, exports, logs, or chat output.
|
||||
- Use declarative \`bind\` controls and Homarr request components; never emit imports, hooks, callbacks, browser requests, or arbitrary JavaScript.
|
||||
@@ -50,8 +53,9 @@ export function getCustomWidgetSkill() {
|
||||
name: "homarr-custom-widget",
|
||||
version: CUSTOM_WIDGET_SKILL_VERSION,
|
||||
skillMd: CUSTOM_WIDGET_SKILL_MD,
|
||||
downloadUrl: CUSTOM_WIDGET_SKILL_DOWNLOAD_URL,
|
||||
sha256: CUSTOM_WIDGET_SKILL_SHA256,
|
||||
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
|
||||
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
|
||||
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
|
||||
export interface BundledCustomWidget {
|
||||
id: "seed-dog-facts" | "seed-currency-exchange" | "seed-jellyfin" | "seed-pokedex";
|
||||
widget: HomarrCustomWidgetV2;
|
||||
}
|
||||
|
||||
export const BUNDLED_CUSTOM_WIDGETS: readonly BundledCustomWidget[] = [
|
||||
{
|
||||
id: "seed-dog-facts",
|
||||
widget: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Random Dog Fact",
|
||||
description: "Displays a random fun fact about dogs.",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Dog API",
|
||||
baseUrl: "https://dogapi.dog",
|
||||
networkScope: "public",
|
||||
auth: { type: "none" },
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "fact",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/facts",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
cacheTtlSeconds: 30,
|
||||
},
|
||||
],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
template: `<Stack gap="sm" p="sm" h="100%" justify="center">
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>Dog fact</Text>
|
||||
<RefreshButton />
|
||||
</Group>
|
||||
{status.fact?.loading ? <Skeleton height={72} radius="md" /> :
|
||||
status.fact?.error ? <Alert color="red">{status.fact.error}</Alert> :
|
||||
<Text size="sm">{data.fact?.data?.[0]?.attributes?.body ?? "No dog fact was returned."}</Text>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seed-currency-exchange",
|
||||
widget: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Currency Exchange",
|
||||
description: "Converts an amount using European Central Bank exchange rates.",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Frankfurter",
|
||||
baseUrl: "https://api.frankfurter.dev",
|
||||
networkScope: "public",
|
||||
auth: { type: "none" },
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "rates",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/v1/latest",
|
||||
parameters: { from: "string", to: "string", amount: "number" },
|
||||
queryTemplate: {
|
||||
from: { $param: "from" },
|
||||
to: { $param: "to" },
|
||||
amount: { $param: "amount" },
|
||||
},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
cacheTtlSeconds: 300,
|
||||
},
|
||||
],
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { type: "string", title: "From currency", minLength: 3, maxLength: 3 },
|
||||
to: { type: "string", title: "Target currencies", description: "Comma-separated currency codes" },
|
||||
amount: { type: "number", title: "Amount", minimum: 0 },
|
||||
},
|
||||
required: ["from", "to", "amount"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
defaultOptions: { from: "JPY", to: "EUR,USD", amount: 50 },
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Group justify="space-between"><Text fw={700}>{options.amount} {options.from}</Text><Badge>{data.rates?.date ?? "Latest"}</Badge></Group>
|
||||
{status.rates?.loading ? <Skeleton height={80} radius="md" /> : status.rates?.error ? <Alert color="red">{status.rates.error}</Alert> :
|
||||
<SimpleGrid cols={{ base: 1, xs: 2 }}>
|
||||
{Object.entries(data.rates?.rates ?? {}).map(entry => <Paper key={entry[0]} withBorder p="sm"><Text size="xs" c="dimmed">{entry[0]}</Text><Text fw={700} size="xl">{entry[1]}</Text></Paper>)}
|
||||
</SimpleGrid>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seed-jellyfin",
|
||||
widget: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Jellyfin Library",
|
||||
description: "Counts movies, series, episodes, and songs in a Jellyfin library.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "Jellyfin",
|
||||
baseUrl: "http://jellyfin.local",
|
||||
networkScope: "private",
|
||||
auth: { type: "apiKeyHeader", headerName: "X-Emby-Token" },
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "counts",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/Items/Counts",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
cacheTtlSeconds: 60,
|
||||
},
|
||||
],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Group justify="space-between"><Text fw={700}>Jellyfin library</Text><RefreshButton /></Group>
|
||||
{status.counts?.loading ? <Skeleton height={96} radius="md" /> : status.counts?.error ? <Alert color="red">{status.counts.error}</Alert> :
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }}>
|
||||
{[{ label: "Movies", value: data.counts?.MovieCount }, { label: "Series", value: data.counts?.SeriesCount }, { label: "Episodes", value: data.counts?.EpisodeCount }, { label: "Songs", value: data.counts?.SongCount }].map(item => <Paper key={item.label} withBorder p="sm"><Text size="xs" c="dimmed">{item.label}</Text><Text fw={700} size="xl">{item.value ?? 0}</Text></Paper>)}
|
||||
</SimpleGrid>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seed-pokedex",
|
||||
widget: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Pokédex",
|
||||
description: "Browse Pokémon and open a responsive detail view.",
|
||||
iconUrl: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
|
||||
sources: [
|
||||
{
|
||||
id: "default",
|
||||
name: "PokeAPI",
|
||||
baseUrl: "https://pokeapi.co",
|
||||
networkScope: "public",
|
||||
auth: { type: "none" },
|
||||
},
|
||||
],
|
||||
requests: [
|
||||
{
|
||||
id: "pokemon",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/pokemon",
|
||||
parameters: { limit: "number" },
|
||||
queryTemplate: { limit: { $param: "limit" }, offset: 0 },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
cacheTtlSeconds: 300,
|
||||
},
|
||||
{
|
||||
id: "detail",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/v2/pokemon/{name}",
|
||||
parameters: { name: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "manual",
|
||||
cacheTtlSeconds: 300,
|
||||
},
|
||||
],
|
||||
optionsSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
limit: {
|
||||
type: "number",
|
||||
title: "Pokémon to load",
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
"x-homarr": { control: "number" },
|
||||
},
|
||||
},
|
||||
required: ["limit"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
defaultOptions: { limit: 20 },
|
||||
template: `<Stack gap="sm" p="sm">
|
||||
<Group justify="space-between"><Title order={3}>Pokédex</Title><Badge color="red">{data.pokemon?.count ?? 0} Pokémon</Badge></Group>
|
||||
{status.pokemon?.loading ? <Skeleton height={180} radius="md" /> : status.pokemon?.error ? <Alert color="red">{status.pokemon.error}</Alert> :
|
||||
<Stack gap="xs">{(data.pokemon?.results ?? []).map(pokemon => <Paper key={pokemon.name} withBorder p="xs"><Group justify="space-between"><Text fw={600} tt="capitalize">{pokemon.name}</Text><SubFetch requestId="detail" params={{ name: pokemon.name }} trigger="manual"><Stack gap="xs"><SubData path="sprites.front_default" as="Image" alt={pokemon.name} w={80} h={80} /><SubData path="name" as="Title" order={4} /><SubData path="types" as="Code" /></Stack></SubFetch></Group></Paper>)}</Stack>}
|
||||
</Stack>`,
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
@@ -65,3 +65,24 @@ export const customJsxBindableComponentNames: ReadonlySet<string> = new Set([
|
||||
"YearPicker",
|
||||
"YearPickerInput",
|
||||
]);
|
||||
|
||||
export type CustomJsxBindingType = "string" | "number" | "boolean" | "string[]" | "number[]";
|
||||
|
||||
const booleanBindingComponents = new Set(["Checkbox", "HoverCard", "Menu", "Popover", "Switch"]);
|
||||
const numberBindingComponents = new Set(["NumberInput", "Pagination", "Rating", "Slider", "Stepper"]);
|
||||
const stringArrayBindingComponents = new Set(["Checkbox.Group", "MultiSelect", "Switch.Group", "TagsInput"]);
|
||||
|
||||
export function getCustomJsxBindingType(
|
||||
componentName: string,
|
||||
props: Readonly<Record<string, unknown>> = {},
|
||||
): CustomJsxBindingType | null {
|
||||
if (!customJsxBindableComponentNames.has(componentName)) return null;
|
||||
if (booleanBindingComponents.has(componentName)) return "boolean";
|
||||
if (numberBindingComponents.has(componentName)) return "number";
|
||||
if (componentName === "RangeSlider") return "number[]";
|
||||
if (stringArrayBindingComponents.has(componentName)) return "string[]";
|
||||
if (["Calendar", "DatePicker"].includes(componentName) && ["multiple", "range"].includes(String(props.type)))
|
||||
return "string[]";
|
||||
if (componentName === "Chip.Group" && props.multiple === true) return "string[]";
|
||||
return "string";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { validateCustomJsxTemplate } from "../jsx/analyzer";
|
||||
import { isCredentialKeyName, validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
|
||||
import { validateCredentialFreeExport, validatePrototypeKeys } from "./definition-security";
|
||||
import { validateCustomWidgetOptions } from "./options";
|
||||
import { customWidgetOptionsSchemaSchema } from "./options-schema";
|
||||
import { customJsxRequestSchema, customWidgetIdentifierSchema, customWidgetSourceSchema } from "./request-schema";
|
||||
@@ -12,35 +12,11 @@ export * from "./options-schema";
|
||||
export * from "./request-schema";
|
||||
export type CustomWidgetOptionsSchema = z.infer<typeof customWidgetOptionsSchemaSchema>;
|
||||
|
||||
export const customWidgetStateTypes = [
|
||||
"string",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
"string[]",
|
||||
"number[]",
|
||||
"date[]",
|
||||
] as const;
|
||||
export const customWidgetBindingIdentifierSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/u, "Binding names must start with a letter and contain letters, numbers, - or _");
|
||||
export const customWidgetStateSchemaSchema = z
|
||||
.record(customWidgetBindingIdentifierSchema, z.enum(customWidgetStateTypes))
|
||||
.superRefine((schema, ctx) => {
|
||||
for (const key of Object.keys(schema)) {
|
||||
if (isCredentialKeyName(key)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: "Credentials must use source authentication, not local state",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export type CustomWidgetStateSchema = z.infer<typeof customWidgetStateSchemaSchema>;
|
||||
|
||||
export const customJsxTemplateSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -93,10 +69,9 @@ export const customWidgetDefinitionSchema = z
|
||||
requests: z.array(customJsxRequestSchema).max(64),
|
||||
optionsSchema: customWidgetOptionsSchemaSchema,
|
||||
defaultOptions: z.record(z.string(), z.unknown()),
|
||||
stateSchema: customWidgetStateSchemaSchema.optional(),
|
||||
defaultState: z.record(z.string(), z.unknown()).optional(),
|
||||
template: customJsxTemplateSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((definition, ctx) => {
|
||||
for (const issue of validateCustomWidgetOptions(definition.optionsSchema, definition.defaultOptions)) {
|
||||
ctx.addIssue({
|
||||
@@ -106,11 +81,10 @@ export const customWidgetDefinitionSchema = z
|
||||
});
|
||||
}
|
||||
const sourceIds = validateSourceIds(definition.sources, ctx);
|
||||
validateDefaultState(definition.stateSchema ?? {}, definition.defaultState ?? {}, ctx);
|
||||
const requests = validateRequests(definition, sourceIds, ctx);
|
||||
validateDynamicOptions(definition.optionsSchema, requests, ctx);
|
||||
validateTemplateRequests(definition.template, requests, ctx);
|
||||
validateTemplateStateBindings(definition.template, definition.stateSchema ?? {}, ctx);
|
||||
validateTemplateBindings(definition.template, ctx);
|
||||
validateCredentialFreeExport(definition, ctx);
|
||||
validatePrototypeKeys(definition, ctx);
|
||||
});
|
||||
@@ -160,28 +134,6 @@ function validateDynamicOptions(
|
||||
}
|
||||
}
|
||||
|
||||
function validateDefaultState(
|
||||
schema: NonNullable<Definition["stateSchema"]>,
|
||||
state: NonNullable<Definition["defaultState"]>,
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
for (const [key, value] of Object.entries(state)) {
|
||||
const type = schema[key];
|
||||
if (!type) ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Unknown state key: ${key}` });
|
||||
else if (!matchesStateType(type, value)) {
|
||||
ctx.addIssue({ code: "custom", path: ["defaultState", key], message: `Expected ${type}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStateType(type: CustomWidgetStateSchema[string], value: unknown) {
|
||||
if (type === "date") return typeof value === "string";
|
||||
if (type === "string[]" || type === "date[]")
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
if (type === "number[]") return Array.isArray(value) && value.every((entry) => typeof entry === "number");
|
||||
return typeof value === type;
|
||||
}
|
||||
|
||||
function validateRequests(definition: Definition, sourceIds: Set<string>, ctx: z.RefinementCtx) {
|
||||
const requests = new Map<string, CustomJsxRequest>();
|
||||
definition.requests.forEach((request, index) => {
|
||||
@@ -259,14 +211,12 @@ function validateTemplateRequests(template: string, requests: Map<string, Custom
|
||||
}
|
||||
}
|
||||
|
||||
function validateTemplateStateBindings(template: string, stateSchema: CustomWidgetStateSchema, ctx: z.RefinementCtx) {
|
||||
function validateTemplateBindings(template: string, ctx: z.RefinementCtx) {
|
||||
for (const match of template.matchAll(/<[A-Z][A-Za-z0-9.]*(?:\s[^<>]*?)?\bbind\s*=\s*(?:"([^"]+)"|'([^']+)'|\{)/gu)) {
|
||||
const stateName = match[1] ?? match[2];
|
||||
if (!stateName) {
|
||||
ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal state name" });
|
||||
} else if (!Object.hasOwn(stateSchema, stateName)) {
|
||||
ctx.addIssue({ code: "custom", path: ["template"], message: `bind references unknown state '${stateName}'` });
|
||||
}
|
||||
const inputName = match[1] ?? match[2];
|
||||
if (!inputName) ctx.addIssue({ code: "custom", path: ["template"], message: "bind must use a literal input name" });
|
||||
else if (!customWidgetBindingIdentifierSchema.safeParse(inputName).success)
|
||||
ctx.addIssue({ code: "custom", path: ["template"], message: `Invalid bind input name '${inputName}'` });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +230,8 @@ export const customWidgetUpdateSchema = z
|
||||
.extend({
|
||||
id: z.string().min(1),
|
||||
secrets: customWidgetSecretsInputSchema.optional(),
|
||||
});
|
||||
})
|
||||
.strict();
|
||||
export const customWidgetImportSchema = customWidgetDefinitionSchema;
|
||||
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
|
||||
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
|
||||
|
||||
@@ -11,7 +11,6 @@ const blockedObjectKeys = new Set(["__proto__", "prototype", "constructor"]);
|
||||
|
||||
interface SecurityDefinition {
|
||||
defaultOptions: Record<string, unknown>;
|
||||
defaultState?: Record<string, unknown>;
|
||||
requests: CustomJsxRequest[];
|
||||
template: string;
|
||||
iconUrl?: string;
|
||||
@@ -26,9 +25,6 @@ export function validateCredentialFreeExport(definition: SecurityDefinition, ctx
|
||||
if (containsCredentialLikeValue(definition.defaultOptions)) {
|
||||
ctx.addIssue({ code: "custom", path: ["defaultOptions"], message: "Default options cannot contain credentials" });
|
||||
}
|
||||
if (containsCredentialLikeValue(definition.defaultState)) {
|
||||
ctx.addIssue({ code: "custom", path: ["defaultState"], message: "Default state cannot contain credentials" });
|
||||
}
|
||||
definition.requests.forEach((request, index) => {
|
||||
if (
|
||||
containsCredentialLikeValue(request.queryTemplate) ||
|
||||
@@ -68,7 +64,6 @@ export function validatePrototypeKeys(definition: SecurityDefinition, ctx: Refin
|
||||
const values: Array<[Array<string | number>, unknown]> = [
|
||||
[["optionsSchema"], definition.optionsSchema],
|
||||
[["defaultOptions"], definition.defaultOptions],
|
||||
[["defaultState"], definition.defaultState],
|
||||
];
|
||||
definition.requests.forEach((request, index) => {
|
||||
values.push([["requests", index, "queryTemplate"], request.queryTemplate]);
|
||||
|
||||
@@ -31,8 +31,6 @@ export const CUSTOM_WIDGET_STARTER: HomarrCustomWidgetV2 = {
|
||||
requests: [],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
stateSchema: {},
|
||||
defaultState: {},
|
||||
template: CUSTOM_JSX_STARTER,
|
||||
};
|
||||
|
||||
@@ -68,8 +66,6 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
|
||||
],
|
||||
optionsSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaultOptions: {},
|
||||
stateSchema: {},
|
||||
defaultState: {},
|
||||
template: `<Stack gap="xs" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{data.status.name ?? "Service"}</Text>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
|
||||
import { customWidgetImportSchema } from "./custom-jsx-schema";
|
||||
|
||||
export interface ImportReview {
|
||||
@@ -10,53 +11,201 @@ export interface ImportReview {
|
||||
hasActions: boolean;
|
||||
}
|
||||
|
||||
function extractFencedBlock(text: string, acceptedLanguages: readonly string[]): string | undefined {
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const fenceStart = text.indexOf("```", cursor);
|
||||
if (fenceStart === -1) return undefined;
|
||||
const headerStart = fenceStart + 3;
|
||||
const headerEnd = text.indexOf("\n", headerStart);
|
||||
if (headerEnd === -1) return undefined;
|
||||
const contentStart = headerEnd + 1;
|
||||
const fenceEnd = text.indexOf("```", contentStart);
|
||||
if (fenceEnd === -1) return undefined;
|
||||
const language = text.slice(headerStart, headerEnd).trim().toLowerCase();
|
||||
if (acceptedLanguages.includes(language)) return text.slice(contentStart, fenceEnd);
|
||||
cursor = fenceEnd + 3;
|
||||
}
|
||||
return undefined;
|
||||
export interface CustomWidgetImportIssue {
|
||||
code: string;
|
||||
path?: Array<string | number>;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): Record<string, unknown> | null {
|
||||
export type CustomWidgetParseResult =
|
||||
| { success: true; widget: HomarrCustomWidgetV2; warnings: CustomWidgetImportIssue[] }
|
||||
| { success: false; issues: CustomWidgetImportIssue[] };
|
||||
|
||||
interface FencedBlock {
|
||||
language: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MAX_IMPORT_ISSUES = 8;
|
||||
|
||||
function extractFencedBlocks(text: string): FencedBlock[] {
|
||||
const blocks: FencedBlock[] = [];
|
||||
for (const match of text.matchAll(/```([^\n`]*)\n([\s\S]*?)```/gu)) {
|
||||
blocks.push({ language: (match[1] ?? "").trim().toLowerCase(), content: match[2] ?? "" });
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function jsonSyntaxIssue(error: unknown, source: string): CustomWidgetImportIssue {
|
||||
const message = error instanceof Error ? error.message : "The response is not valid JSON";
|
||||
const position = /position\s+(\d+)/iu.exec(message)?.[1];
|
||||
if (!position) return { code: "INVALID_JSON", message };
|
||||
const offset = Number(position);
|
||||
const prefix = source.slice(0, offset);
|
||||
const line = prefix.split("\n").length;
|
||||
const column = offset - prefix.lastIndexOf("\n");
|
||||
return { code: "INVALID_JSON", message: `${message} (line ${line}, column ${column})` };
|
||||
}
|
||||
|
||||
function zodIssues(error: { issues: Array<{ path: PropertyKey[]; message: string; code: string }> }) {
|
||||
return error.issues.slice(0, MAX_IMPORT_ISSUES).map((issue) => ({
|
||||
code: issue.code === "unrecognized_keys" ? "UNSUPPORTED_FIELD" : "INVALID_WIDGET",
|
||||
path: issue.path.map((part) => (typeof part === "symbol" ? String(part) : part)),
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
function removedStateIssues(value: Record<string, unknown>): CustomWidgetImportIssue[] {
|
||||
return ["stateSchema", "defaultState"].flatMap((field) =>
|
||||
Object.hasOwn(value, field)
|
||||
? [
|
||||
{
|
||||
code: "REMOVED_LOCAL_STATE",
|
||||
path: [field],
|
||||
message: `${field} is not supported. Use bind with inputs for temporary values or widget options for saved configuration.`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function isTemplatePlaceholder(value: unknown) {
|
||||
return typeof value === "string" && value.replace(/[\\*_`\s]/gu, "").toUpperCase() === "HOMARRTEMPLATE";
|
||||
}
|
||||
|
||||
function normalizeAiRequestParameters(value: Record<string, unknown>, warnings: CustomWidgetImportIssue[]) {
|
||||
if (!Array.isArray(value.requests)) return;
|
||||
value.requests = value.requests.map((candidate, requestIndex) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
|
||||
const request = { ...(candidate as Record<string, unknown>) };
|
||||
if (!request.parameters || typeof request.parameters !== "object" || Array.isArray(request.parameters))
|
||||
return request;
|
||||
const parameters = { ...(request.parameters as Record<string, unknown>) };
|
||||
for (const [name, parameter] of Object.entries(parameters)) {
|
||||
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) continue;
|
||||
const keys = Object.keys(parameter);
|
||||
const type = (parameter as Record<string, unknown>).type;
|
||||
if (keys.length === 1 && ["string", "number", "boolean"].includes(String(type))) {
|
||||
parameters[name] = type;
|
||||
warnings.push({
|
||||
code: "NORMALIZED_PARAMETER_TYPE",
|
||||
path: ["requests", requestIndex, "parameters", name],
|
||||
message: `Converted { type: "${String(type)}" } to "${String(type)}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
request.parameters = parameters;
|
||||
return request;
|
||||
});
|
||||
}
|
||||
|
||||
type ParsedWidgetValue =
|
||||
| { success: true; value: Record<string, unknown> }
|
||||
| { success: false; issue: CustomWidgetImportIssue };
|
||||
|
||||
function parseWidgetValue(source: string): ParsedWidgetValue {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(source);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return {
|
||||
success: false,
|
||||
issue: { code: "INVALID_WIDGET", message: "The response must contain one widget JSON object." },
|
||||
};
|
||||
}
|
||||
return { success: true, value: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
return { success: false, issue: jsonSyntaxIssue(error, source) };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboard(text: string): HomarrCustomWidgetV2 | null {
|
||||
const result = parseCustomWidgetClipboardDetailed(text);
|
||||
return result.success ? result.widget : null;
|
||||
}
|
||||
|
||||
export function parseCustomWidgetClipboardDetailed(
|
||||
text: string,
|
||||
): { success: true; widget: Record<string, unknown> } | { success: false; error: string } {
|
||||
try {
|
||||
const jsonBlock = extractFencedBlock(text, ["", "json"]);
|
||||
const jsxBlock = extractFencedBlock(text, ["jsx", "tsx"])?.trim();
|
||||
const value: unknown = JSON.parse(jsonBlock ?? text);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return { success: false, error: "The response must contain one widget JSON object" };
|
||||
}
|
||||
const widget = { ...(value as Record<string, unknown>) };
|
||||
if (jsxBlock && (widget.template === "__HOMARR_TEMPLATE__" || !widget.template)) widget.template = jsxBlock;
|
||||
const parsed = customWidgetImportSchema.safeParse(widget);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
return {
|
||||
success: false,
|
||||
error: `${issue?.path.map(String).join(".") || "widget"}: ${issue?.message ?? "Invalid widget"}`,
|
||||
};
|
||||
}
|
||||
return { success: true, widget };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : "The response is not valid JSON" };
|
||||
export function parseCustomWidgetClipboardDetailed(text: string): CustomWidgetParseResult {
|
||||
const blocks = extractFencedBlocks(text);
|
||||
const jsonBlock = blocks.find((block) => block.language === "json" || block.language === "");
|
||||
const jsxBlock = blocks.find((block) => block.language === "jsx" || block.language === "tsx");
|
||||
const source = jsonBlock?.content ?? text;
|
||||
const parsedValue = parseWidgetValue(source);
|
||||
if (!parsedValue.success) return { success: false, issues: [parsedValue.issue] };
|
||||
const widget = { ...parsedValue.value };
|
||||
if (jsonBlock && isTemplatePlaceholder(widget.template) && !jsxBlock) {
|
||||
return {
|
||||
success: false,
|
||||
issues: [
|
||||
{
|
||||
code: "MISSING_JSX_BLOCK",
|
||||
path: ["template"],
|
||||
message: "The manifest uses a template placeholder but no fenced JSX block was provided.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (jsxBlock && isTemplatePlaceholder(widget.template)) widget.template = jsxBlock.content.trim();
|
||||
const removedIssues = removedStateIssues(widget);
|
||||
if (removedIssues.length > 0) return { success: false, issues: removedIssues };
|
||||
const parsed = customWidgetImportSchema.safeParse(widget);
|
||||
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
|
||||
return { success: true, widget: parsed.data, warnings: [] };
|
||||
}
|
||||
|
||||
export function parseCustomWidgetAiResponse(text: string): CustomWidgetParseResult {
|
||||
const blocks = extractFencedBlocks(text);
|
||||
const jsonBlocks = blocks.filter((block) => block.language === "json" || block.language === "");
|
||||
const jsxBlocks = blocks.filter((block) => block.language === "jsx" || block.language === "tsx");
|
||||
const formatIssues: CustomWidgetImportIssue[] = [];
|
||||
if (jsonBlocks.length !== 1)
|
||||
formatIssues.push({ code: "AI_JSON_BLOCK_REQUIRED", message: "Expected exactly one fenced json block." });
|
||||
if (jsxBlocks.length !== 1)
|
||||
formatIssues.push({ code: "AI_JSX_BLOCK_REQUIRED", message: "Expected exactly one fenced jsx block." });
|
||||
const source = jsonBlocks[0]?.content ?? text;
|
||||
const parsedValue = parseWidgetValue(source);
|
||||
if (!parsedValue.success) {
|
||||
if (/\\_/u.test(source))
|
||||
formatIssues.push({
|
||||
code: "INVALID_JSON_ESCAPE",
|
||||
message: 'JSON does not allow \\_ escapes. The template placeholder must be "__HOMARR_TEMPLATE__".',
|
||||
});
|
||||
if (/"(?:stateSchema|defaultState)"\s*:/u.test(source))
|
||||
formatIssues.push({
|
||||
code: "REMOVED_LOCAL_STATE",
|
||||
message: "stateSchema and defaultState are not supported; use bind with inputs or widget options.",
|
||||
});
|
||||
return { success: false, issues: [parsedValue.issue, ...formatIssues].slice(0, MAX_IMPORT_ISSUES) };
|
||||
}
|
||||
const widget = { ...parsedValue.value };
|
||||
const removedIssues = removedStateIssues(widget);
|
||||
if (removedIssues.length > 0) formatIssues.push(...removedIssues);
|
||||
const warnings: CustomWidgetImportIssue[] = [];
|
||||
normalizeAiRequestParameters(widget, warnings);
|
||||
if (jsxBlocks[0]) {
|
||||
if (isTemplatePlaceholder(widget.template) || !widget.template) widget.template = jsxBlocks[0].content.trim();
|
||||
else if (widget.template !== jsxBlocks[0].content.trim())
|
||||
formatIssues.push({
|
||||
code: "AI_TEMPLATE_PLACEHOLDER_REQUIRED",
|
||||
path: ["template"],
|
||||
message: 'The manifest template must be "__HOMARR_TEMPLATE__" when a separate JSX block is returned.',
|
||||
});
|
||||
}
|
||||
if (formatIssues.length > 0) return { success: false, issues: formatIssues.slice(0, MAX_IMPORT_ISSUES) };
|
||||
const parsed = customWidgetImportSchema.safeParse(widget);
|
||||
if (!parsed.success) return { success: false, issues: zodIssues(parsed.error) };
|
||||
return { success: true, widget: parsed.data, warnings };
|
||||
}
|
||||
|
||||
export function looksLikeCustomWidgetClipboard(text: string) {
|
||||
return /homarr-custom-widget|```(?:json|jsx|tsx)?\s*\n|"\$schema"\s*:/iu.test(text);
|
||||
}
|
||||
|
||||
export function formatCustomWidgetImportIssues(issues: readonly CustomWidgetImportIssue[]) {
|
||||
const visible = issues.slice(0, 3).map((issue) => {
|
||||
const path = issue.path?.length ? `${issue.path.join(".")}: ` : "";
|
||||
return `${path}${issue.message}`;
|
||||
});
|
||||
const remaining = issues.length - visible.length;
|
||||
return `${visible.join(" ")}${remaining > 0 ? ` (+${remaining} more)` : ""}`;
|
||||
}
|
||||
|
||||
export function getImportReview(value: Record<string, unknown> | null): ImportReview | null {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
export * from "./ai-prompt";
|
||||
export * from "./authoring-resources";
|
||||
export * from "./bundled-widgets";
|
||||
export * from "./component-registry";
|
||||
export * from "./examples";
|
||||
export * from "./import";
|
||||
export * from "./options";
|
||||
export * from "./schema";
|
||||
export * from "./schema-references";
|
||||
export * from "./tabler-icons";
|
||||
@@ -0,0 +1,126 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { customWidgetOptionsSchemaSchema } from "./options-schema";
|
||||
import { customJsxRequestSchema } from "./request-schema";
|
||||
|
||||
let requestsSchema: Record<string, unknown> | undefined;
|
||||
let optionsSchema: Record<string, unknown> | undefined;
|
||||
let defaultOptionsSchema: Record<string, unknown> | undefined;
|
||||
|
||||
export function getCustomWidgetRequestsJsonSchema() {
|
||||
requestsSchema ??= {
|
||||
...z.toJSONSchema(customJsxRequestSchema.array()),
|
||||
title: "Named Custom Widget queries and actions",
|
||||
};
|
||||
return requestsSchema;
|
||||
}
|
||||
|
||||
export function getCustomWidgetOptionsJsonSchema() {
|
||||
optionsSchema ??= {
|
||||
...z.toJSONSchema(customWidgetOptionsSchemaSchema),
|
||||
title: "Custom Widget options schema",
|
||||
};
|
||||
return optionsSchema;
|
||||
}
|
||||
|
||||
export function getCustomWidgetDefaultOptionsJsonSchema() {
|
||||
defaultOptionsSchema ??= {
|
||||
...z.toJSONSchema(z.record(z.string(), z.unknown())),
|
||||
title: "Custom Widget default option values",
|
||||
description: "Values must validate against the widget options schema.",
|
||||
};
|
||||
return defaultOptionsSchema;
|
||||
}
|
||||
|
||||
export const CUSTOM_WIDGET_REQUEST_EXAMPLES = {
|
||||
minimal: [
|
||||
{
|
||||
id: "data",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/status",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
},
|
||||
],
|
||||
full: [
|
||||
{
|
||||
id: "environments",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "GET",
|
||||
pathTemplate: "/api/environments",
|
||||
parameters: {},
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "load",
|
||||
cacheTtlSeconds: 60,
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
sourceId: "default",
|
||||
kind: "query",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/search",
|
||||
parameters: { query: "string", limit: "number" },
|
||||
queryTemplate: { limit: { $param: "limit" } },
|
||||
bodyTemplate: { term: { $param: "query" } },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "view",
|
||||
trigger: "manual",
|
||||
cacheTtlSeconds: 30,
|
||||
},
|
||||
{
|
||||
id: "restart",
|
||||
sourceId: "default",
|
||||
kind: "action",
|
||||
method: "POST",
|
||||
pathTemplate: "/api/containers/{id}/restart",
|
||||
parameters: { id: "string" },
|
||||
auth: "inherit",
|
||||
minimumBoardPermission: "modify",
|
||||
trigger: "manual",
|
||||
confirmation: {
|
||||
title: "Restart container",
|
||||
message: "Restart this container now?",
|
||||
confirmLabel: "Restart",
|
||||
},
|
||||
invalidates: ["search"],
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const CUSTOM_WIDGET_OPTIONS_EXAMPLES = {
|
||||
minimal: {
|
||||
schema: { type: "object", properties: {}, additionalProperties: false },
|
||||
defaults: {},
|
||||
},
|
||||
full: {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", title: "Title", "x-homarr": { control: "text" } },
|
||||
limit: { type: "number", title: "Result limit", minimum: 1, maximum: 100 },
|
||||
environmentId: {
|
||||
type: "number",
|
||||
title: "Environment",
|
||||
"x-homarr": {
|
||||
control: "select",
|
||||
optionsSource: {
|
||||
requestId: "environments",
|
||||
itemsPath: "items",
|
||||
valuePath: "Id",
|
||||
labelPath: "Name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["limit"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
defaults: { title: "Overview", limit: 20 },
|
||||
},
|
||||
} as const;
|
||||
Reference in new issue
Block a user