feat: custom widgets (#5889)

This commit is contained in:
Thomas authored and GitHub committed 2026-06-10 18:58:14 +02:00
1 parent 48e1844b5b
commit aecbca2b81
69 files changed
+14620 -142

No files matched your search

@@ -0,0 +1,19 @@
import { Alert, Anchor, Text } from "@mantine/core";
import { IconFlask } from "@tabler/icons-react";
const FEEDBACK_URL = "https://github.com/homarr-labs/homarr/discussions/5916";
export function CustomWidgetBetaBanner() {
return (
<Alert variant="light" color="yellow" title="Custom Widgets are in Beta" icon={<IconFlask />}>
<Text size="sm">
This feature is experimental and will probably change a lot in the future. If you encounter bugs, wish it did
more, or find something confusing, then please complain to us as much as possible! Only feedback posted on the{" "}
<Anchor href={FEEDBACK_URL} target="_blank" fw={600}>
official discussion thread
</Anchor>{" "}
will be taken into account. Happy Hacking!
</Text>
</Alert>
);
}
@@ -0,0 +1,343 @@
"use client";
import { Button, Popover, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconCopy, IconSparkles } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
const PROMPT_HEADER = `You are helping configure a Homarr custom widget. Homarr is a self-hosted dashboard that can display data from any API endpoint as a widget.
## Your Task
Generate a valid JSON configuration for a Homarr custom widget based on the user's description and the API response below. If you need clarification, ask specific questions.
## Output Format
- Output the JSON inside a \`\`\`json code block for syntax highlighting. If \`\`\`json is not supported, use a generic \`\`\` code block instead.
- Do NOT include any text before or after the JSON block unless you have clarifying questions.
- If your environment supports structured/JSON output mode, use it.
## JSON Schema
The output must conform to this JSON Schema:
`;
const PROMPT_RULES = `
## Key Rules
- The \`displayConfig.type\` field MUST match the \`displayType\` field exactly
- All \`jsonPath\` fields use JSONPath syntax (e.g. \`$.data.count\`, \`$.items[*].name\`)
- The \`$schema\` field should be \`"homarr-custom-widget-v2"\`
- Do NOT include secrets/passwords in the output — those are configured separately in the UI
- \`url\` must be a full URL including protocol (e.g. \`https://...\`)
- For arrays of items, inspect the API response to find the correct paths
## Display Type Guide
- \`singleValue\`: Show one prominent number/text (e.g. total count, status)
- \`keyValue\`: Show labeled pairs like "CPU: 45%" — use \`mappings\` array
- \`table\`: Show tabular data from an array — needs \`tablePath\` for the array and \`columns\` for headers
- \`statGrid\`: Grid of stat cards with optional colors — great for dashboards (e.g. "Movies: 38, Series: 63")
- \`progressBars\`: Visual progress bars with value/max — for storage, quotas, etc.
- \`statusIndicator\`: Green/red dots based on value matching — for service health checks
- \`countGrid\`: Simple grid of counts — like statGrid but simpler, no colors
- \`raw\`: Raw JSON display — for debugging or complex data
- \`actionButton\`: A button that triggers the API call on click — for POST/PUT actions
- \`customJsx\`: Custom JSX layout using whitelisted Mantine components — full creative control over presentation. Set \`displayConfig.template\` to a JSX string. Access API data via \`{data.fieldName}\` bindings (e.g. \`{data.name}\`, \`{data.items[0].title}\`).
## Custom JSX (\`customJsx\`) — Available Mantine Components
When using \`customJsx\`, the template supports whitelisted Mantine UI components. These are React components from the Mantine v9 library. Use their props as documented below.
### Layout Components
- **Stack** — Vertical flex container. Props: \`gap\` (xs|sm|md|lg|xl or number), \`align\` (stretch|center|flex-start|flex-end), \`justify\`
- **Group** — Horizontal flex container. Props: \`gap\`, \`justify\` (flex-start|center|space-between|space-around|flex-end), \`wrap\` (wrap|nowrap), \`grow\` (boolean)
- **Flex** — Generic flex container. Props: \`direction\` (row|column), \`gap\`, \`align\`, \`justify\`, \`wrap\`
- **Grid** + **Grid.Col** — CSS grid layout. Grid props: \`gutter\` (gap). Grid.Col props: \`span\` (1-12 or "auto"), \`offset\`
- **SimpleGrid** — Auto-column grid. Props: \`cols\` (number), \`spacing\`, \`verticalSpacing\`
- **Center** — Centers content. Props: \`inline\` (boolean)
- **Space** — Empty spacing. Props: \`h\` (height), \`w\` (width)
- **Container** — Max-width wrapper. Props: \`size\` (xs|sm|md|lg|xl), \`fluid\` (boolean)
- **AspectRatio** — Maintains aspect ratio. Props: \`ratio\` (number, e.g. 16/9)
### Typography Components
- **Text** — Body text. Props: \`size\` (xs|sm|md|lg|xl), \`fw\` (font-weight number, e.g. 700), \`c\` (color: "dimmed"|"red"|"blue"|...), \`ta\` (text-align), \`tt\` (text-transform: "capitalize"|"uppercase"|"lowercase"), \`td\` (text-decoration), \`lineClamp\` (number), \`truncate\` (boolean), \`span\` (renders as span)
- **Title** — Heading. Props: \`order\` (1-6, maps to h1-h6), \`size\`, \`c\`, \`ta\`, \`tt\`
- **Code** — Inline code. Props: \`color\`, \`block\` (boolean for code block)
- **Highlight** — Text with highlighted substring. Props: \`highlight\` (string or string[])
- **Mark** — Highlighted text background. Props: \`color\`
- **Kbd** — Keyboard key display
- **Blockquote** — Styled quote. Props: \`color\`, \`cite\`, \`icon\`
- **Anchor** — Link. Props: \`href\`, \`target\`, \`underline\` ("always"|"hover"|"never")
- **NumberFormatter** — Formatted number display. Props: \`value\` (number), \`prefix\` (e.g. "$"), \`suffix\` (e.g. "%"), \`thousandSeparator\` (boolean or string), \`decimalScale\` (number)
### Data Display Components
- **Badge** — Label badge. Props: \`color\`, \`variant\` (filled|light|outline|dot|default), \`size\` (xs|sm|md|lg|xl), \`radius\`, \`tt\`
- **Card** + **Card.Section** — Content card. Card props: \`shadow\` (xs|sm|md|lg|xl), \`padding\`/\`p\`, \`radius\`, \`withBorder\` (boolean). Card.Section props: \`withBorder\`, \`inheritPadding\`
- **Paper** — Surface container. Props: \`shadow\`, \`p\`, \`radius\`, \`withBorder\`
- **Alert** — Notification box. Props: \`color\`, \`variant\` (filled|light|outline), \`title\`, \`icon\`
- **ThemeIcon** — Icon container. Props: \`color\`, \`variant\`, \`size\`, \`radius\`
- **ColorSwatch** — Color preview. Props: \`color\` (CSS color string)
- **Table** + **Table.Thead**, **Table.Tbody**, **Table.Tr**, **Table.Th**, **Table.Td** — HTML table. Table props: \`striped\` (boolean), \`withTableBorder\`, \`withColumnBorders\`, \`highlightOnHover\`
- **List** + **List.Item** — Ordered/unordered list. List props: \`type\` ("ordered"|"unordered"), \`icon\`, \`spacing\`
- **Timeline** + **Timeline.Item** — Vertical timeline. Timeline props: \`active\` (number), \`bulletSize\`. Item props: \`title\`, \`bullet\`
- **Accordion** + **.Item** + **.Control** + **.Panel** — Collapsible sections. Item props: \`value\` (string). Control: clickable header. Panel: content.
- **Indicator** — Dot indicator on content. Props: \`color\`, \`position\`, \`size\`, \`label\`, \`processing\` (boolean)
- **Pill** — Removable tag. Props: \`size\`, \`withRemoveButton\`
- **Spoiler** — Show more/less. Props: \`maxHeight\` (number), \`showLabel\`, \`hideLabel\`
### Feedback Components
- **Progress** + **Progress.Section** — Progress bar. Progress props: \`value\` (0-100), \`color\`, \`size\`, \`radius\`, \`striped\`, \`animated\`. Section props: \`value\`, \`color\`
- **RingProgress** — Circular progress. Props: \`sections\` (array of {value, color}), \`size\`, \`thickness\`, \`label\` (ReactNode)
- **Skeleton** — Loading placeholder. Props: \`height\`, \`width\`, \`radius\`, \`visible\` (boolean)
- **Loader** — Spinner. Props: \`size\`, \`color\`, \`type\` (oval|bars|dots)
### Media Components
- **Image** — Responsive image. Props: \`src\`, \`alt\`, \`w\` (width), \`h\` (height), \`radius\`, \`fit\` (cover|contain|fill)
- **Avatar** + **Avatar.Group** — User avatar. Props: \`src\`, \`alt\`, \`size\` (xs|sm|md|lg|xl or number), \`radius\` (sm|xl), \`color\`
- **BackgroundImage** — Background image container. Props: \`src\`, \`radius\`
- **Tooltip** — Hover tooltip. Props: \`label\` (string), \`position\` (top|bottom|left|right), \`withArrow\`
- **Divider** — Horizontal/vertical rule. Props: \`orientation\` (horizontal|vertical), \`label\`, \`labelPosition\`, \`size\`
- **ScrollArea** — Scrollable container. Props: \`h\` (height), \`type\` (auto|always|scroll|hover)
### Charts (@mantine/charts)
- **AreaChart**, **BarChart**, **LineChart** — Props: \`data\` (array of objects), \`dataKey\` (x-axis key), \`series\` (array of {name, color}), \`h\` (height), \`curveType\` (for Area/Line)
- **DonutChart**, **PieChart** — Props: \`data\` (array of {name, value, color}), \`h\`, \`withLabels\`, \`withLabelsLine\`
- **RadarChart** — Props: \`data\`, \`dataKey\`, \`series\`
- **RadialBarChart** — Props: \`data\` (array of {name, value, color}), \`h\`
- **Sparkline** — Props: \`data\` (flat number array), \`h\`, \`w\`, \`color\`, \`curveType\`
### Common Prop Patterns
- **Spacing/sizing:** Most components accept \`p\` (padding), \`m\` (margin), \`px\`/\`py\`/\`mx\`/\`my\`/\`pt\`/\`pb\`/\`pl\`/\`pr\`/\`mt\`/\`mb\`/\`ml\`/\`mr\` with values xs|sm|md|lg|xl or numbers
- **Colors:** Use Mantine color names: "red", "blue", "green", "orange", "violet", "pink", "cyan", "grape", "teal", "yellow", "lime", "indigo", "gray", "dark". Add shade suffix: "red.6", "blue.4"
- **Styles:** Use \`style={{...}}\` for inline CSS. E.g. \`style={{flex: 1, minWidth: 0}}\`
- **Conditional rendering:** Use ternaries: \`{condition ? <Text>Yes</Text> : <Text>No</Text>}\`
- **Responsive:** Most size/spacing props accept object: \`{{ base: "sm", md: "lg" }}\`
### Interactive Components (built-in, state-managed)
These components manage their own internal state — they provide interactivity without event handlers:
**PaginatedList** — Paginates its children with prev/next buttons.
Props: \`pageSize\` (number, default 6)
Usage: Wrap a \`.map()\` expression to paginate results.
\`\`\`jsx
<PaginatedList pageSize={8}>
{data.results.map(item =>
<Card withBorder p="xs" mb="xs">
<Text>{item.name}</Text>
</Card>
)}
</PaginatedList>
\`\`\`
**TabsContainer + TabPanel** — Tabbed interface with automatic tab switching.
TabsContainer props: \`defaultTab\` (string, optional — defaults to first tab)
TabPanel props: \`value\` (string, required — unique tab ID), \`label\` (string, optional — tab display text)
\`\`\`jsx
<TabsContainer defaultTab="overview">
<TabPanel value="overview" label="Overview">
<Text>{data.description}</Text>
</TabPanel>
<TabPanel value="stats" label="Statistics">
<Text>Total: {data.count}</Text>
</TabPanel>
</TabsContainer>
\`\`\`
**Collapsible** — Expandable/collapsible section with a title.
Props: \`title\` (string, required), \`defaultOpen\` (boolean, default false)
\`\`\`jsx
<Collapsible title="Details" defaultOpen={true}>
<Text>{data.details}</Text>
</Collapsible>
\`\`\`
**StatBar** — A horizontal stat bar with label/value (great for RPG-style stats, progress).
Props: \`value\` (number), \`max\` (number, default 100), \`label\` (string), \`color\` (Mantine color)
\`\`\`jsx
<StatBar label="HP" value={45} max={100} color="red" />
<StatBar label="ATK" value={80} max={255} color="orange" />
\`\`\`
**TypeBadge** — A colored badge that maps common type names to colors (normal, fire, water, electric, grass, ice, fighting, poison, ground, flying, psychic, bug, rock, ghost, dragon, dark, steel, fairy).
Props: \`type\` (string), \`size\` (xs|sm|md|lg|xl, default sm)
\`\`\`jsx
<Group gap="xs">
<TypeBadge type="fire" />
<TypeBadge type="flying" />
</Group>
\`\`\`
**Available bindings in templates:**
- \`data\` — the full API response JSON object
- \`String(v)\`, \`Number(v)\`, \`Boolean(v)\` — type coercion helpers
- \`Math.round\`, \`Math.floor\`, \`Math.ceil\`, \`Math.abs\`, \`Math.min\`, \`Math.max\`, \`Math.pow\`, \`Math.sqrt\`, \`Math.PI\`
- \`JSON.stringify(v)\`, \`Array.isArray(v)\`, \`Object.keys(v)\`, \`Object.values(v)\`, \`Object.entries(v)\`
- Expression arrows for \`.map()\`, \`.filter()\`, \`.slice()\` — e.g. \`{data.items.map(item => <Text>{item.name}</Text>)}\`
- Ternaries for conditionals — e.g. \`{data.count > 0 ? "active" : "idle"}\`
**FORBIDDEN keywords (template will be rejected):** constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch
**Chart data formats:**
- BarChart/LineChart/AreaChart: \`data={data.items}\` + \`dataKey="month"\` (x-axis key) + \`series={[{ name: "count", color: "blue" }]}\` where series \`name\` matches a numeric key in each data row
- DonutChart/PieChart: \`data={data.items}\` where each item is \`{ name: "Label", value: 42, color: "blue" }\` — NO dataKey or series
- Sparkline: \`data={[1,2,3]}\` — flat number array
- NumberFormatter: \`<NumberFormatter value={data.price} thousandSeparator prefix="$" />\`
### Full Pokémon Card Example (demonstrating TypeBadge, StatBar, TabsContainer, Collapsible)
API: \`https://pokeapi.co/api/v2/pokemon/charizard\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group wrap="nowrap">
<Avatar src={data.sprites.front_default} size={80} radius="sm" />
<Stack gap={4} style={{flex: 1}}>
<Title order={3} tt="capitalize">{data.name}</Title>
<Text size="xs" c="dimmed">#{String(data.id).padStart(3, "0")}</Text>
<Group gap="xs">
{data.types.map(t => <TypeBadge type={t.type.name} />)}
</Group>
</Stack>
</Group>
<TabsContainer defaultTab="stats">
<TabPanel value="stats" label="Stats">
<Stack gap="xs" pt="xs">
{data.stats.map(s =>
<StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? "green" : "red"} />
)}
</Stack>
</TabPanel>
<TabPanel value="abilities" label="Abilities">
<Stack gap="xs" pt="xs">
{data.abilities.map(a =>
<Badge variant={a.is_hidden ? "outline" : "filled"} tt="capitalize">
{a.ability.name}{a.is_hidden ? " (hidden)" : ""}
</Badge>
)}
</Stack>
</TabPanel>
<TabPanel value="moves" label="Moves">
<Collapsible title="Move list">
<Group gap="xs" pt="xs">
{data.moves.slice(0, 20).map(m =>
<Badge size="xs" variant="light" tt="capitalize">{m.move.name}</Badge>
)}
</Group>
</Collapsible>
</TabPanel>
</TabsContainer>
</Stack>
\`\`\`
### Paginated List Example
API: \`https://pokeapi.co/api/v2/pokemon?limit=50\`
\`\`\`jsx
<Stack gap="sm" p="xs">
<Group justify="space-between">
<Title order={3}>Pokédex</Title>
<Badge size="lg" color="red">{data.count} Pokémon</Badge>
</Group>
<PaginatedList pageSize={10}>
{data.results.map((pokemon, i) =>
<Card withBorder p="xs" mb="xs">
<Group wrap="nowrap">
<Avatar
src={"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/" + String(i + 1) + ".png"}
size="lg"
radius="sm"
/>
<Stack gap={0} style={{ flex: 1 }}>
<Text fw={700} tt="capitalize">{pokemon.name}</Text>
<Text size="xs" c="dimmed">#{String(i + 1)}</Text>
</Stack>
</Group>
</Card>
)}
</PaginatedList>
</Stack>
\`\`\`
## API Response
`;
const PROMPT_NO_RESPONSE = `Paste the raw JSON response from your API endpoint below:
\`\`\`json
PASTE_YOUR_API_RESPONSE_HERE
\`\`\`
`;
const PROMPT_FOOTER = `
## Your Request
Describe what you want the widget to show:
`;
function buildAiPrompt(
jsonSchema: unknown,
rawResponse?: string | null,
currentConfig?: Record<string, unknown> | null,
) {
const schemaStr = JSON.stringify(jsonSchema, null, 2);
const responseSection = rawResponse
? `The API returned the following JSON:\n\n\`\`\`json\n${rawResponse}\n\`\`\`\n`
: PROMPT_NO_RESPONSE;
const configSection = currentConfig
? `\n## Current Widget Configuration\nThe widget is currently configured as follows. Use this as a starting point and modify based on the user's request:\n\n\`\`\`json\n${JSON.stringify(currentConfig, null, 2)}\n\`\`\`\n`
: "";
return PROMPT_HEADER + schemaStr + PROMPT_RULES + responseSection + configSection + PROMPT_FOOTER;
}
interface CopyAiPromptButtonProps {
rawResponse?: string | null;
currentConfig?: Record<string, unknown> | null;
}
export const CopyAiPromptButton = ({ rawResponse, currentConfig }: CopyAiPromptButtonProps) => {
const t = useScopedI18n("customWidget");
const [opened, { open, close }] = useDisclosure(false);
const { data: schema, isLoading } = clientApi.customWidget.schema.useQuery();
const handleCopy = async () => {
if (!schema) return;
const prompt = buildAiPrompt(schema, rawResponse, currentConfig);
try {
await navigator.clipboard.writeText(prompt);
close();
showSuccessNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopied") });
} catch {
showErrorNotification({ title: t("action.copyAiPrompt"), message: t("notification.aiPromptCopyError") });
}
};
return (
<Popover opened={opened} onClose={close} width={320} position="bottom" shadow="md" withinPortal>
<Popover.Target>
<Button
variant="light"
leftSection={<IconSparkles size={16} />}
onClick={open}
loading={isLoading}
disabled={!schema}
fullWidth
size="sm"
>
{t("action.copyAiPrompt")}
</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Text size="sm">{t("notification.aiPromptDescription")}</Text>
{!rawResponse && (
<Text size="xs" c="dimmed" fs="italic">
{t("notification.aiPromptNoResponse")}
</Text>
)}
<Button leftSection={<IconCopy size={16} />} onClick={() => void handleCopy()} fullWidth>
{t("notification.aiPromptCopy")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,213 @@
"use client";
import { useRef } from "react";
import { ActionIcon, Menu } from "@mantine/core";
import {
IconCopy,
IconDots,
IconDownload,
IconToggleLeft,
IconToggleRight,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
const iconProps = { size: 16, stroke: 1.5 };
interface WidgetRef {
id: string;
name: string;
enabled: boolean;
}
export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => {
const t = useScopedI18n("customWidget");
const { openConfirmModal } = useConfirmModal();
const deleteMutation = clientApi.customWidget.delete.useMutation();
const duplicateMutation = clientApi.customWidget.duplicate.useMutation();
const toggleEnabledMutation = clientApi.customWidget.toggleEnabled.useMutation();
const utils = clientApi.useUtils();
const handleToggleEnabled = () => {
toggleEnabledMutation.mutate(
{ id: widget.id, enabled: !widget.enabled },
{
onSuccess: () => {
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({
title: widget.enabled ? t("action.disable") : t("action.enable"),
message: t("notification.toggleError"),
});
},
},
);
};
const handleExport = async () => {
try {
const data = await utils.customWidget.export.fetch({ id: widget.id });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${widget.name}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
showErrorNotification({ title: t("action.export"), message: t("notification.exportError") });
}
};
const handleDuplicate = () => {
duplicateMutation.mutate(
{ id: widget.id },
{
onSuccess: (result) => {
showSuccessNotification({
title: t("action.duplicate"),
message: t("notification.duplicated", { name: result.name }),
});
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.duplicate"), message: t("notification.duplicateError") });
},
},
);
};
const handleDelete = () => {
openConfirmModal({
title: t("action.delete"),
children: t("action.deleteConfirm", { name: widget.name }),
onConfirm: () => {
deleteMutation.mutate(
{ id: widget.id },
{
onSuccess: () => {
showSuccessNotification({
title: t("action.delete"),
message: t("notification.deleted", { name: widget.name }),
});
void utils.customWidget.all.invalidate();
void utils.widget.customApi.getData.invalidate({ definitionId: widget.id });
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.delete"), message: t("notification.deleteError") });
},
},
);
},
});
};
return (
<Menu withinPortal position="bottom-end" shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label={t("action.menu")}>
<IconDots {...iconProps} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleToggleEnabled}
leftSection={widget.enabled ? <IconToggleLeft {...iconProps} /> : <IconToggleRight {...iconProps} />}
disabled={toggleEnabledMutation.isPending}
>
{widget.enabled ? t("action.disable") : t("action.enable")}
</Menu.Item>
<Menu.Divider />
<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.Divider />
<Menu.Item
color="red"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{t("action.delete")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export const ImportCustomWidgetButton = () => {
const t = useScopedI18n("customWidget");
const fileInputRef = useRef<HTMLInputElement>(null);
const utils = clientApi.useUtils();
const importMutation = clientApi.customWidget.import.useMutation({
onSuccess: () => {
showSuccessNotification({ title: t("action.import"), message: t("notification.imported") });
void utils.customWidget.all.invalidate();
void revalidatePathActionAsync("/manage/custom-widgets");
},
onError: () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
},
});
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result as string);
importMutation.mutate(json);
} catch {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
}
};
reader.onerror = () => {
showErrorNotification({ title: t("action.import"), message: t("notification.importError") });
};
reader.readAsText(file);
event.target.value = "";
};
return (
<>
<MobileAffixButton
variant="default"
leftSection={<IconUpload size={16} />}
onClick={() => fileInputRef.current?.click()}
loading={importMutation.isPending}
>
{t("action.import")}
</MobileAffixButton>
<input
ref={fileInputRef}
type="file"
accept=".json"
hidden
onChange={handleImport}
aria-label="Import custom widget"
/>
</>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,101 @@
"use client";
import { ActionIcon, Avatar, Badge, Group, Stack, Table, Text } from "@mantine/core";
import { IconApi, IconPencil } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { NoResults } from "~/components/no-results";
import { CustomWidgetRowActions } from "./_custom-widget-actions";
const displayTypeBadgeColors: Record<string, string> = {
singleValue: "blue",
keyValue: "green",
table: "orange",
statGrid: "violet",
progressBars: "teal",
statusIndicator: "cyan",
countGrid: "indigo",
raw: "gray",
actionButton: "red",
customJsx: "pink",
};
const iconProps = { size: 16, stroke: 1.5 };
interface CustomWidgetListProps {
definitions: RouterOutputs["customWidget"]["all"];
}
export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
const t = useScopedI18n("customWidget");
if (definitions.length === 0) {
return (
<NoResults
icon={IconApi}
title={t("page.list.noResults")}
action={{ href: "/manage/custom-widgets/new", label: t("action.create") }}
/>
);
}
return (
<Stack gap="md">
<Table striped highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("table.name")}</Table.Th>
<Table.Th>{t("table.url")}</Table.Th>
<Table.Th>{t("table.display")}</Table.Th>
<Table.Th w={90} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{definitions.map((def) => (
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
<Table.Td>
<Group gap="xs">
{def.iconUrl ? (
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
) : (
<IconApi size={16} />
)}
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
{def.name}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed" lineClamp={1}>
{def.url}
</Text>
</Table.Td>
<Table.Td>
<Badge color={displayTypeBadgeColors[def.displayType] ?? "gray"} size="sm">
{t(`displayType.${def.displayType}` as never)}
</Badge>
</Table.Td>
<Table.Td>
<Group gap={4} justify="flex-end" wrap="nowrap">
<ActionIcon
component={Link}
href={`/manage/custom-widgets/edit/${def.id}`}
variant="filled"
color="red"
aria-label={t("action.edit")}
>
<IconPencil {...iconProps} />
</ActionIcon>
<CustomWidgetRowActions widget={{ id: def.id, name: def.name, enabled: def.enabled }} />
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
};
@@ -0,0 +1,178 @@
"use client";
import { useMemo } from "react";
import { Alert, Badge, Button, Card, Center, Code, Group, Loader, ScrollArea, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { extractDisplayData } from "@homarr/widgets/custom-api/extract-display-data";
import { displayComponents } from "@homarr/widgets/custom-api/component";
interface PreviewInput {
url: string;
method: string;
authType: string;
headerName?: string;
requestBody?: string;
displayType: string;
displayConfig: Record<string, unknown>;
secrets: Array<{ kind: string; value: string }>;
definitionId?: string;
}
export interface PreviewFetchResult {
success: boolean;
error?: string;
responseInfo: { status: number; statusText: string } | null;
rawResponse: string | null;
}
interface CustomWidgetPreviewProps {
getFormValues: () => PreviewInput;
formValues: PreviewInput;
fetchResult: PreviewFetchResult | null;
cachedJson: unknown;
onTest: () => void;
isTesting: boolean;
testError?: string | null;
}
export function CustomWidgetPreview({
getFormValues,
formValues,
fetchResult,
cachedJson,
onTest,
isTesting,
testError,
}: CustomWidgetPreviewProps) {
const t = useScopedI18n("customWidget");
const displayData = useMemo((): Record<string, unknown> | null => {
const { displayType, displayConfig } = formValues;
if (displayType === "actionButton") {
return extractDisplayData(null, displayType, displayConfig) as Record<string, unknown>;
}
if (!fetchResult?.success || cachedJson == null) return null;
return extractDisplayData(cachedJson, displayType, displayConfig) as Record<string, unknown>;
}, [fetchResult?.success, cachedJson, formValues]);
const handleTest = () => {
const values = getFormValues();
if (!values.url) return;
onTest();
};
return (
<Card withBorder shadow="sm" p="md">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Title order={5}>{t("preview.title")}</Title>
<Button
size="xs"
variant="light"
leftSection={isTesting ? <Loader size={14} /> : <IconPlayerPlay size={14} />}
onClick={handleTest}
loading={isTesting}
>
{t("preview.test")}
</Button>
</Group>
{testError && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{testError}</Text>
</Alert>
)}
{fetchResult && !fetchResult.success && (
<Alert color="red" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs" fw={500}>
{fetchResult.error}
</Text>
{fetchResult.responseInfo && (
<Badge size="xs" color="red" variant="light" mt={4}>
{fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText}
</Badge>
)}
</Alert>
)}
{displayData && (formValues.displayType === "actionButton" || fetchResult?.success) && (
<Stack gap="xs">
{fetchResult?.success && (
<Group gap="xs">
<Badge size="xs" color="green" variant="light">
{fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText}
</Badge>
</Group>
)}
<Card withBorder p="xs" bg="var(--mantine-color-dark-7)" mih={120}>
<PreviewDisplay data={displayData} />
</Card>
{fetchResult?.success && fetchResult.rawResponse && (
<Button
size="xs"
variant="subtle"
leftSection={<IconExternalLink size={14} />}
onClick={() => {
const blob = new Blob([fetchResult.rawResponse ?? ""], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}}
>
{t("preview.rawResponse")}
</Button>
)}
</Stack>
)}
{!displayData && !testError && !isTesting && (
<Text size="xs" c="dimmed" ta="center">
{t("preview.hint")}
</Text>
)}
</Stack>
</Card>
);
}
function PreviewDisplay({ data }: { data: unknown }) {
const typed = data as Record<string, unknown>;
if (!typed || typeof typed !== "object") {
return (
<Text size="xs" c="dimmed">
No data
</Text>
);
}
const dataType = typed.type as string | undefined;
if (dataType === "actionButton") {
return (
<Center p="sm">
<Button size="sm" color={(typed.buttonColor as string) ?? "blue"} disabled>
{(typed.buttonLabel as string) ?? "Execute"}
</Button>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
return <Component data={typed} />;
}
return (
<ScrollArea h={100}>
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}
@@ -0,0 +1,190 @@
import { redirect } from "next/navigation";
import { Container, Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { catchTrpcNotFound } from "~/errors/trpc-catch-error";
import { CustomWidgetBetaBanner } from "../../_beta-banner";
import { CustomWidgetForm } from "../../_custom-widget-form";
const authTypeExpectedSecrets: Record<string, string[]> = {
bearer: ["apiKey"],
basic: ["username", "password"],
apiKeyHeader: ["apiKey"],
apiKeyQuery: ["apiKey"],
};
function buildInitialSecrets(authType: string, dbSecrets: Array<{ kind: string; hasValue?: boolean }>) {
const expected = authTypeExpectedSecrets[authType] ?? [];
const existingKinds = new Set(dbSecrets.map((s) => s.kind));
const result = dbSecrets.map((s) => ({ kind: s.kind, value: "", hasValue: s.hasValue ?? false }));
for (const kind of expected) {
if (!existingKinds.has(kind)) {
result.push({ kind, value: "", hasValue: false });
}
}
return result;
}
function buildDisplayInitialValues(displayType: string, config: Record<string, unknown>) {
const base = {
displayType,
jsonPath: "$",
label: "",
unit: "",
valueSize: "lg",
labelPosition: "below",
mappings: [{ label: "", jsonPath: "$", unit: "" }],
kvLayout: "list",
kvColumns: 2,
tablePath: "$",
columns: [{ header: "", jsonPath: "$" }],
striped: true,
compact: false,
statGridItems: [{ label: "", jsonPath: "$", unit: "", color: "blue" }],
statGridColumns: 2,
cardStyle: "filled",
progressBars: [{ label: "", valuePath: "$", maxPath: "", unit: "", color: "blue" }],
showPercentage: true,
barSize: "md",
statusItems: [{ label: "", jsonPath: "$", goodValues: "online,true" }],
statusLayout: "list",
dotSize: "md",
countGridItems: [{ label: "", jsonPath: "$", unit: "" }],
countGridColumns: 2,
countValueSize: "md",
rawJsonPath: "$",
rawMaxHeight: 300,
buttonLabel: "Execute",
buttonColor: "blue",
confirmText: "",
successMessage: "",
template: "",
};
const typeOverrides: Record<string, () => Record<string, unknown>> = {
singleValue: () => ({
jsonPath: (config.jsonPath as string) ?? "$",
label: (config.label as string) ?? "",
unit: (config.unit as string) ?? "",
valueSize: (config.valueSize as string) ?? "lg",
labelPosition: (config.labelPosition as string) ?? "below",
}),
keyValue: () => ({
mappings: (config.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.mappings,
kvLayout: (config.layout as string) ?? "list",
kvColumns: (config.columns as number) ?? 2,
}),
table: () => ({
tablePath: (config.tablePath as string) ?? "$",
columns: (config.columns as Array<{ header: string; jsonPath: string }>) ?? base.columns,
striped: (config.striped as boolean) ?? true,
compact: (config.compact as boolean) ?? false,
}),
statGrid: () => ({
statGridItems: (
(config.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []
).map((item) => ({
label: item.label,
jsonPath: item.jsonPath,
unit: item.unit,
color: item.color ?? "blue",
})),
statGridColumns: (config.columns as number) ?? 2,
cardStyle: (config.cardStyle as string) ?? "filled",
}),
progressBars: () => ({
progressBars: (
(config.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ??
[]
).map((bar) => ({
label: bar.label,
valuePath: bar.valuePath,
maxPath: bar.maxPath ?? "",
unit: bar.unit,
color: bar.color ?? "blue",
})),
showPercentage: (config.showPercentage as boolean) ?? true,
barSize: (config.barSize as string) ?? "md",
}),
statusIndicator: () => ({
statusItems: ((config.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map(
(item) => ({
label: item.label,
jsonPath: item.jsonPath,
goodValues: item.goodValues.join(", "),
}),
),
statusLayout: (config.layout as string) ?? "list",
dotSize: (config.dotSize as string) ?? "md",
}),
countGrid: () => ({
countGridItems: (config.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? base.countGridItems,
countGridColumns: (config.columns as number) ?? 2,
countValueSize: (config.valueSize as string) ?? "md",
}),
raw: () => ({
rawJsonPath: (config.jsonPath as string) ?? "$",
rawMaxHeight: (config.maxHeight as number) ?? 300,
}),
actionButton: () => ({
buttonLabel: (config.buttonLabel as string) ?? "Execute",
buttonColor: (config.buttonColor as string) ?? "blue",
confirmText: (config.confirmText as string) ?? "",
successMessage: (config.successMessage as string) ?? "",
}),
customJsx: () => ({
template: (config.template as string) ?? "",
}),
};
const overrides = typeOverrides[displayType]?.() ?? {};
return { ...base, ...overrides };
}
interface EditCustomWidgetPageProps {
params: Promise<{ id: string }>;
}
export default async function EditCustomWidgetPage(props: EditCustomWidgetPageProps) {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets");
}
const params = await props.params;
const definition = await api.customWidget.byId({ id: params.id }).catch(catchTrpcNotFound);
const displayConfig = definition.displayConfig as Record<string, unknown>;
const displayValues = buildDisplayInitialValues(definition.displayType, displayConfig);
return (
<>
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, definition.name]])} nonInteractable={["edit"]} />
<Container size="xl">
<Stack>
<Title>{definition.name}</Title>
<CustomWidgetBetaBanner />
<CustomWidgetForm
mode="edit"
definitionId={params.id}
initialValues={{
name: definition.name,
description: definition.description ?? "",
iconUrl: definition.iconUrl ?? "",
url: definition.url,
authType: definition.authType,
headerName: definition.headerName ?? "",
method: definition.method,
requestBody: definition.requestBody ?? "",
...displayValues,
secrets: buildInitialSecrets(definition.authType, definition.secrets),
}}
/>
</Stack>
</Container>
</>
);
}
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { Container, Stack, Text, Title } from "@mantine/core";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { CustomWidgetBetaBanner } from "../_beta-banner";
import { CustomWidgetForm } from "../_custom-widget-form";
export default async function NewCustomWidgetPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
redirect("/manage/custom-widgets");
}
const t = await getScopedI18n("customWidget");
return (
<>
<DynamicBreadcrumb />
<Container size="xl">
<Stack>
<div>
<Title>{t("page.create.title")}</Title>
<Text c="dimmed" size="sm" mt={4}>
{t("page.create.subtitle")}
</Text>
</div>
<CustomWidgetBetaBanner />
<CustomWidgetForm mode="create" />
</Stack>
</Container>
</>
);
}
@@ -0,0 +1,42 @@
import { redirect } from "next/navigation";
import { Group } from "@mantine/core";
import { IconPlus } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui";
import { ManagePageLayout } from "~/components/manage/manage-page-layout";
import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
import { CustomWidgetBetaBanner } from "./_beta-banner";
import { ImportCustomWidgetButton } from "./_custom-widget-actions";
import { CustomWidgetList } from "./_custom-widget-list";
export default async function CustomWidgetsPage() {
const session = await auth();
if (!session || !session.user.permissions.includes("admin")) {
redirect(session ? "/" : "/auth/login");
}
const definitions = await api.customWidget.all();
const t = await getScopedI18n("customWidget");
return (
<ManagePageLayout
title={t("page.list.title")}
primaryAction={
<Group gap="xs">
<ImportCustomWidgetButton />
<MobileAffixButton component={Link} href="/manage/custom-widgets/new" leftSection={<IconPlus size={16} />}>
{t("action.create")}
</MobileAffixButton>
</Group>
}
>
<CustomWidgetBetaBanner />
<CustomWidgetList definitions={definitions} />
</ManagePageLayout>
);
}
@@ -2,6 +2,7 @@ import type { PropsWithChildren } from "react";
import { AppShellMain } from "@mantine/core";
import {
IconAffiliateFilled,
IconApi,
IconBook2,
IconBox,
IconBrandDiscord,
@@ -73,6 +74,12 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
hidden: !session,
"data-onboarding-tour-id": "manage-integrations",
},
{
icon: IconApi,
href: "/manage/custom-widgets",
label: t("items.customWidgets"),
hidden: !session,
},
{
icon: IconSearch,
href: "/manage/search-engines",
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { Avatar, Box, Button, Card, Center, Group, Stack, Text, Tooltip } from "@mantine/core";
import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, Tooltip } from "@mantine/core";
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { createId, objectEntries } from "@homarr/common";
@@ -21,6 +22,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
const settings = useSettings();
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -28,6 +30,7 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
const items = useMemo(
() =>
objectEntries(widgetImports)
.filter(([kind]) => kind !== "customApi")
.map(([kind, value]) => ({
kind,
supportedIntegrations:
@@ -52,6 +55,20 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
);
}, [items, search]);
const filteredCustomWidgets = useMemo(
() =>
(customWidgetDefs ?? []).filter((def) => def.enabled && def.name.toLowerCase().includes(search.toLowerCase())),
[customWidgetDefs, search],
);
const handleAddCustomWidget = (definitionId: string) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
createItem({ id: itemId, kind: "customApi", integrationIds: [] });
updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
actions.closeModal();
};
const handleAdd = (kind: WidgetKind) => {
const definition = widgetImports[kind].definition;
const hasIntegrationSupport = "supportedIntegrations" in definition;
@@ -113,7 +130,61 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
/>
))}
{filteredItems.length === 0 && (
{filteredCustomWidgets.length > 0 && (
<>
<Divider
label={t("customWidget.page.list.title")}
labelPosition="center"
my="sm"
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
<Card
key={def.id}
h={selectGridCardHeight}
withBorder
pos="relative"
style={{ overflow: "hidden", "--_hover-opacity": "0" }}
onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
<Stack h="100%" gap="xs">
<Group gap="sm" wrap="nowrap" align="flex-start">
{def.iconUrl ? (
<Image src={def.iconUrl} w={22} h={22} fit="contain" style={{ flexShrink: 0, marginTop: 2 }} />
) : (
<IconApi size={22} style={{ flexShrink: 0, marginTop: 2 }} />
)}
<Text lh={1.2} style={{ whiteSpace: "normal" }} fw={500} size="sm" lineClamp={2}>
{def.name}
</Text>
</Group>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" c="dimmed" lineClamp={1}>
{def.description ?? def.url}
</Text>
</Stack>
<Box
pos="absolute"
bottom={0}
left={0}
right={0}
p="xs"
style={{
opacity: "var(--_hover-opacity)",
transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
<Button onClick={() => handleAddCustomWidget(def.id)} variant="light" size="xs" fullWidth>
{t("item.create.addToBoard")}
</Button>
</Box>
</Card>
))}
</>
)}
{filteredItems.length === 0 && filteredCustomWidgets.length === 0 && (
<Center p="xl">
<Text c="dimmed">{t("common.noResults")}</Text>
</Center>
+82
View File
@@ -0,0 +1,82 @@
import { chromium, expect } from "@playwright/test";
import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { startMockApiServerAsync } from "./shared/mock-api-server";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = {
username: "admin",
password: "Comp(exP4sswOrd",
};
const initialTemplate =
'<Stack gap="xs"><Title order={4}>{data.title}</Title><Badge color="blue">{data.status}</Badge></Stack>';
const updatedTemplate = '<Stack gap="xs"><Text>{data.title}</Text><Badge color="green">{data.value}</Badge></Stack>';
describe("Custom JSX custom widgets", () => {
test("creates, previews, and edits a customJsx widget", async () => {
const mockApi = await startMockApiServerAsync({
title: "E2E Widget",
status: "online",
value: 42,
});
const { db, localMountPath } = await createSqliteDbFileAsync();
await seedAdminUserAsync(db, adminCredentials);
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "credentials",
},
mounts: {
"/appdata": localMountPath,
},
}).start();
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
try {
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
await page.locator("css=button[type='submit']").click();
await page.waitForURL(baseUrl, { timeout: 15_000 });
await page.goto(`${baseUrl}/manage/custom-widgets/new`);
await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 });
await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX");
await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`);
await page.getByRole("combobox", { name: "Display Type" }).click();
await page.getByRole("option", { name: "Custom JSX" }).click();
await page.getByLabel("JSX Template").fill(initialTemplate);
await page.getByRole("button", { name: "Test" }).first().click();
const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" });
await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({
timeout: 15_000,
});
await page.getByRole("button", { name: "Create" }).first().click();
await expect(page.getByText('Widget "E2E Custom JSX" created successfully.')).toBeVisible({ timeout: 15_000 });
await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 });
await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate);
await page.getByLabel("JSX Template").fill(updatedTemplate);
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
await page.getByRole("button", { name: "Save" }).first().click();
await expect(page.getByText('Widget "E2E Custom JSX" updated successfully.')).toBeVisible({ timeout: 15_000 });
await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate);
} finally {
await browser.close();
await homarrContainer.stop();
await mockApi.close();
}
}, 120_000);
});
+35
View File
@@ -0,0 +1,35 @@
import { createServer } from "node:http";
import type { Server } from "node:http";
export interface MockApiServer {
url: string;
port: number;
close: () => Promise<void>;
}
export const startMockApiServerAsync = async (responseBody: unknown): Promise<MockApiServer> => {
const body = JSON.stringify(responseBody);
const server: Server = await new Promise((resolve) => {
const s = createServer((_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(body);
});
s.listen(0, "0.0.0.0", () => resolve(s));
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to start mock API server");
}
return {
port: address.port,
url: `http://host.docker.internal:${address.port}`,
close: () =>
new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
};
};
+59
View File
@@ -0,0 +1,59 @@
import { createId } from "@paralleldrive/cuid2";
import { hashPasswordAsync } from "../../packages/auth/security";
import { getMaxGroupPositionAsync } from "../../packages/db/queries";
import * as sqliteSchema from "../../packages/db/schema/sqlite";
import { credentialsAdminGroup } from "../../packages/definitions/src/group";
import type { SqliteDatabase } from "./e2e-db";
export interface SeedAdminUserInput {
username: string;
password: string;
}
export const seedAdminUserAsync = async (db: SqliteDatabase, input: SeedAdminUserInput) => {
const existingOnboarding = await db.query.onboarding.findFirst();
if (existingOnboarding) {
await db.update(sqliteSchema.onboarding).set({
step: "finish",
previousStep: "settings",
});
} else {
await db.insert(sqliteSchema.onboarding).values({
id: createId(),
step: "finish",
previousStep: "settings",
});
}
const userId = createId();
const groupId = createId();
const maxPosition = await getMaxGroupPositionAsync(db);
await db.insert(sqliteSchema.users).values({
id: userId,
name: input.username,
provider: "credentials",
password: await hashPasswordAsync(input.password),
});
await db.insert(sqliteSchema.groups).values({
id: groupId,
name: credentialsAdminGroup,
ownerId: userId,
position: maxPosition + 1,
});
await db.insert(sqliteSchema.groupPermissions).values({
groupId,
permission: "admin",
});
await db.insert(sqliteSchema.groupMembers).values({
groupId,
userId,
});
return { userId, groupId };
};
+3
View File
@@ -107,5 +107,8 @@
"core-js-pure",
"protobufjs"
]
},
"dependencies": {
"jsonpath-plus": "catalog:"
}
}
+2
View File
@@ -14,6 +14,7 @@
"./websocket": "./src/websocket.ts",
"./shared": "./src/shared.ts",
"./open-api": "./src/open-api.ts",
"./custom-widget/display-data": "./src/router/custom-widget/display-data.ts",
"./mcp": "./src/mcp.ts"
},
"scripts": {
@@ -47,6 +48,7 @@
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"jsonpath-plus": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"react": "catalog:",
+1
View File
@@ -29,6 +29,7 @@ export const appRouter = createTRPCRouter({
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
});
// export type definition of API
+5 -1
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { createId } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
@@ -1768,7 +1769,7 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL<unknown>, use
options: superjson.parse<Record<string, unknown>>(item.options),
}),
)
.filter((item) => item !== null),
.filter((item): item is NonNullable<typeof item> => item !== null),
};
};
@@ -1780,10 +1781,13 @@ const forKind = <T extends WidgetKind>(kind: T) =>
const outputItemSchema = zodUnionFromArray(widgetKinds.map((kind) => forKind(kind))).and(sharedItemSchema);
const boardLogger = createLogger({ module: "board" });
const parseItem = (item: unknown) => {
const result = outputItemSchema.safeParse(item);
if (!result.success) {
boardLogger.warn("Failed to parse board item, skipping", { error: result.error.message });
return null;
}
return result.data;
@@ -0,0 +1,41 @@
export const AUTH_HANDLERS: Record<string, (headers: Headers, url: URL, apiKey: string, headerName?: string) => void> =
{
bearer: (headers, _url, apiKey) => {
headers.set("Authorization", `Bearer ${apiKey}`);
},
apiKeyHeader: (headers, _url, apiKey, headerName) => {
headers.set(headerName ?? "X-API-Key", apiKey);
},
apiKeyQuery: (_headers, url, apiKey, headerName) => {
url.searchParams.set(headerName ?? "api_key", apiKey);
},
};
export function buildSecretMap(secrets: Array<{ kind: string; value: string }>): Record<string, string> {
const map: Record<string, string> = {};
for (const secret of secrets) {
map[secret.kind] = secret.value;
}
return map;
}
export function applyAuth(
headers: Headers,
url: URL,
authType: string,
secrets: Array<{ kind: string; value: string }>,
headerName?: string | null,
): void {
const secretMap = buildSecretMap(secrets);
if (authType === "basic" && secretMap.username && secretMap.password) {
const credentials = Buffer.from(`${secretMap.username}:${secretMap.password}`).toString("base64");
headers.set("Authorization", `Basic ${credentials}`);
return;
}
const handler = AUTH_HANDLERS[authType];
if (handler && secretMap.apiKey) {
handler(headers, url, secretMap.apiKey, headerName ?? undefined);
}
}
@@ -0,0 +1,456 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
customWidgetAuthTypes,
customWidgetCreateSchema,
customWidgetDisplayTypes,
customWidgetImportSchema,
customWidgetMethods,
customWidgetSecretKinds,
customWidgetUpdateSchema,
displayConfigSchema,
} from "@homarr/validation/custom-widget";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { applyAuth } from "./auth";
import { extractDisplayDataWithFallback } from "./display-data";
import { parseDisplayConfig } from "./parse-display-config";
const adminProcedure = permissionRequiredProcedure.requiresPermission("admin");
const logger = createLogger({ module: "custom-widget" });
const FETCH_TIMEOUT_MS = 10_000;
const validateUrl = (urlString: string): URL => new URL(urlString);
const updateFieldSerializers: Record<string, (value: unknown) => unknown> = {
displayConfig: (value) => superjson.stringify(value),
};
let _importJsonSchema: Record<string, unknown> | null = null;
function getImportJsonSchema() {
if (!_importJsonSchema) {
_importJsonSchema = {
...z.toJSONSchema(customWidgetImportSchema),
title: "Homarr Custom Widget",
description:
"Schema for importing/exporting custom widget definitions in Homarr. " +
"All jsonPath fields use JSONPath syntax (e.g. $.data.count, $.items[0].name). " +
"The displayConfig must match the chosen displayType. " +
"Secrets (API keys, passwords) are not included in exports and must be configured separately after import.",
};
}
return _importJsonSchema;
}
export const customWidgetRouter = createTRPCRouter({
schema: publicProcedure.query(() => getImportJsonSchema()),
all: protectedProcedure.query(async ({ ctx }) => {
const definitions = await ctx.db.query.customWidgetDefinitions.findMany({
orderBy: (table, { asc }) => asc(table.name),
});
return definitions.map((def) => ({
id: def.id,
name: def.name,
description: def.description,
iconUrl: def.iconUrl,
url: def.url,
method: def.method,
displayType: def.displayType,
authType: def.authType,
enabled: def.enabled,
}));
}),
byId: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
const displayConfig = parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig in custom widget",
);
return {
...definition,
enabled: definition.enabled,
displayConfig,
secrets: definition.secrets.map((s) => ({
kind: s.kind,
hasValue: true,
updatedAt: s.updatedAt,
})),
};
}),
create: adminProcedure.input(customWidgetCreateSchema).mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: superjson.stringify(input.displayConfig),
creatorId: ctx.session.user.id,
});
if (input.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
}
logger.info("Created custom widget definition", { id, name: input.name });
return { id };
}),
update: adminProcedure.input(customWidgetUpdateSchema).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const { id, secrets, ...updateFields } = input;
const updateValues: Record<string, unknown> = { updatedAt: new Date() };
for (const [key, value] of Object.entries(updateFields)) {
if (value === undefined) continue;
const serialize = updateFieldSerializers[key];
if (serialize) {
updateValues[key] = serialize(value);
} else {
updateValues[key] = value;
}
}
await ctx.db.update(customWidgetDefinitions).set(updateValues).where(eq(customWidgetDefinitions.id, id));
if (secrets !== undefined) {
const effectiveAuthType = (updateFields.authType as string | undefined) ?? existing.authType;
if (secrets.length > 0) {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
await ctx.db.insert(customWidgetSecrets).values(
secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
definitionId: id,
updatedAt: new Date(),
})),
);
} else if (effectiveAuthType === "none") {
await ctx.db.delete(customWidgetSecrets).where(eq(customWidgetSecrets.definitionId, id));
}
}
logger.info("Updated custom widget definition", { id });
}),
toggleEnabled: adminProcedure
.input(z.object({ id: z.string(), enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await ctx.db
.update(customWidgetDefinitions)
.set({ enabled: input.enabled, updatedAt: new Date() })
.where(eq(customWidgetDefinitions.id, input.id));
}),
delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!existing) {
throw new TRPCError({ code: "NOT_FOUND" });
}
await ctx.db.delete(customWidgetDefinitions).where(eq(customWidgetDefinitions.id, input.id));
logger.info("Deleted custom widget definition", { id: input.id });
}),
export: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return {
$schema: "homarr-custom-widget-v2" as const,
name: definition.name,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: parseDisplayConfig(
definition.displayConfig,
input.id,
logger,
"Corrupt displayConfig during export",
),
};
}),
import: adminProcedure.input(customWidgetImportSchema).mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
url: input.url,
authType: input.authType,
headerName: input.headerName,
method: input.method,
requestBody: input.requestBody,
displayType: input.displayType,
displayConfig: superjson.stringify(input.displayConfig),
creatorId: ctx.session.user.id,
});
logger.info("Imported custom widget definition", { id, name: input.name });
return { id };
}),
preview: adminProcedure
.input(
z.object({
url: z.string().url(),
method: z.enum(customWidgetMethods),
authType: z.enum(customWidgetAuthTypes),
headerName: z.string().optional(),
requestBody: z.string().optional(),
displayType: z.enum(customWidgetDisplayTypes),
displayConfig: displayConfigSchema,
secrets: z.array(z.object({ kind: z.enum(customWidgetSecretKinds), value: z.string() })),
definitionId: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const secrets = [...input.secrets];
if (input.definitionId) {
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (existing) {
for (const dbSecret of existing.secrets) {
if (!secrets.some((s) => s.kind === dbSecret.kind)) {
secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) });
}
}
}
}
const url = validateUrl(input.url);
const headers = new Headers({ Accept: "application/json" });
if (input.method !== "GET" && input.requestBody) {
headers.set("Content-Type", "application/json");
}
applyAuth(headers, url, input.authType, secrets, input.headerName);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: input.method,
headers,
body: input.method !== "GET" ? input.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
});
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
const body = await response.text().catch(() => "");
return {
success: false as const,
error: `HTTP ${response.status}: ${response.statusText}`,
responseInfo,
rawResponse: body,
};
}
const json: unknown = await response.json();
const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig);
return {
success: true as const,
responseInfo,
rawResponse: JSON.stringify(json, null, 2),
displayData,
};
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget preview failed", { error });
return {
success: false as const,
error: error instanceof Error ? error.message : "Failed to fetch data",
responseInfo: null,
rawResponse: null,
};
} finally {
clearTimeout(timeout);
}
}),
duplicate: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.id),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND" });
}
const newId = createId();
await ctx.db.insert(customWidgetDefinitions).values({
id: newId,
name: `${definition.name} (copy)`,
description: definition.description,
iconUrl: definition.iconUrl,
url: definition.url,
authType: definition.authType,
headerName: definition.headerName,
method: definition.method,
requestBody: definition.requestBody,
displayType: definition.displayType,
displayConfig: definition.displayConfig,
enabled: definition.enabled,
creatorId: ctx.session.user.id,
});
if (definition.secrets.length > 0) {
await ctx.db.insert(customWidgetSecrets).values(
definition.secrets.map((s) => ({
kind: s.kind,
value: s.value,
definitionId: newId,
updatedAt: new Date(),
})),
);
}
logger.info("Duplicated custom widget definition", { sourceId: input.id, newId });
return { id: newId, name: `${definition.name} (copy)` };
}),
execute: protectedProcedure.input(z.object({ definitionId: z.string() })).mutation(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
if (!definition.enabled) {
throw new TRPCError({ code: "FORBIDDEN", message: "Widget is disabled" });
}
if (definition.displayType !== "actionButton") {
throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can be executed" });
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
if (definition.method !== "GET" && definition.requestBody) {
headers.set("Content-Type", "application/json");
}
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
});
const responseInfo = { status: response.status, statusText: response.statusText };
if (!response.ok) {
return { success: false as const, error: `HTTP ${response.status}: ${response.statusText}`, responseInfo };
}
logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status });
return { success: true as const, responseInfo };
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Custom widget execute failed", { definitionId: input.definitionId, error });
return {
success: false as const,
error: error instanceof Error ? error.message : "Request failed",
responseInfo: null,
};
} finally {
clearTimeout(timeout);
}
}),
});
@@ -0,0 +1,145 @@
import { JSONPath } from "jsonpath-plus";
type Config = Record<string, unknown>;
type Extractor = (json: unknown, config: Config) => unknown;
const query = (json: unknown, path: string) => JSONPath({ path, json: json as object, wrap: false });
const queryRows = (json: unknown, path: string): unknown[] => {
const rows = JSONPath({ path, json: json as object, wrap: true }) as unknown[];
const first = rows[0];
if (Array.isArray(first)) return first as unknown[];
return rows;
};
const toNumber = (value: unknown, fallback: number): number => {
const num = Number(value);
if (num) return num;
return fallback;
};
const optionalMax = (max: unknown): number | undefined => {
if (max === undefined) return undefined;
return toNumber(max, 100);
};
const isGoodValue = (value: unknown, goodValues: string[]): boolean =>
goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
const extractors: Record<string, Extractor> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: query(json, (c.jsonPath as string) ?? "$"),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: query(json, m.jsonPath),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const flatRows = queryRows(json, (c.tablePath as string) ?? "$");
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) => columns.map((col) => query(row, col.jsonPath))),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: query(json, item.jsonPath),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = query(json, bar.valuePath);
const max = bar.maxPath ? query(json, bar.maxPath) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: toNumber(value, 0),
max: optionalMax(max),
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = query(json, item.jsonPath);
return { label: item.label, value: String(value ?? "unknown"), isGood: isGoodValue(value, item.goodValues) };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: query(json, item.jsonPath),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: query(json, (c.jsonPath as string) ?? "$"),
maxHeight: c.maxHeight ?? 300,
}),
actionButton: (_json, c) => ({
type: "actionButton",
buttonLabel: c.buttonLabel ?? "Execute",
buttonColor: c.buttonColor ?? "blue",
confirmText: c.confirmText ?? "",
successMessage: c.successMessage ?? "",
}),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
}),
};
export function extractDisplayData(json: unknown, displayType: string, displayConfig: Config): unknown {
const type = (displayConfig.type as string) ?? displayType;
const extractor = extractors[type];
if (!extractor) {
throw new Error(`Unknown display type: ${type}`);
}
return extractor(json, displayConfig);
}
export function extractDisplayDataWithFallback(json: unknown, displayType: string, displayConfig: Config): unknown {
const type = (displayConfig.type as string) ?? displayType;
const extractor = extractors[type] ?? extractors.singleValue;
return extractor?.(json, displayConfig);
}
export function extractActionButtonDisplay(displayConfig: Config): unknown {
return extractors.actionButton?.(null, displayConfig);
}
@@ -0,0 +1,15 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import type { createLogger } from "@homarr/core/infrastructure/logs";
type Logger = ReturnType<typeof createLogger>;
export function parseDisplayConfig(raw: string, id: string, logger: Logger, context: string): Record<string, unknown> {
try {
return superjson.parse(raw) as Record<string, unknown>;
} catch {
logger.error(context, { id });
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
}
}
@@ -0,0 +1,252 @@
import { describe, expect, it } from "vitest";
import { JSONPath } from "jsonpath-plus";
import {
sonarrResponse,
sonarrStatGridConfig,
proxmoxResponse,
proxmoxProgressBarsConfig,
diskUsageResponse,
diskTableConfig,
piholeResponse,
piholeStatusIndicatorConfig,
piholeCountGridConfig,
radarrResponse,
radarrStatGridConfig,
jellyfinItemCountsResponse,
jellyfinCountGridConfig,
} from "./custom-api-fixtures";
const extractors: Record<string, (json: unknown, config: Record<string, unknown>) => unknown> = {
singleValue: (json, c) => ({
type: "singleValue",
label: (c.label as string) ?? "",
unit: (c.unit as string) ?? "",
value: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
valueSize: c.valueSize ?? "lg",
labelPosition: c.labelPosition ?? "below",
}),
keyValue: (json, c) => ({
type: "keyValue",
entries: ((c.mappings as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((m) => ({
label: m.label,
unit: m.unit,
value: JSONPath({ path: m.jsonPath, json: json as object, wrap: false }),
})),
layout: c.layout ?? "list",
columns: c.columns ?? 2,
}),
table: (json, c) => {
const tablePath = (c.tablePath as string) ?? "$";
const columns = (c.columns as Array<{ header: string; jsonPath: string }>) ?? [];
const rows = JSONPath({ path: tablePath, json: json as object, wrap: true }) as unknown[];
const flatRows = Array.isArray(rows[0]) ? (rows[0] as unknown[]) : rows;
return {
type: "table",
columns: columns.map((col) => col.header),
rows: flatRows.map((row) =>
columns.map((col) => JSONPath({ path: col.jsonPath, json: row as object, wrap: false })),
),
striped: c.striped ?? true,
compact: c.compact ?? false,
};
},
statGrid: (json, c) => ({
type: "statGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string; color?: string }>) ?? []).map(
(item) => ({
label: item.label,
unit: item.unit,
color: item.color ?? "blue",
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
}),
),
columns: c.columns ?? 2,
cardStyle: c.cardStyle ?? "filled",
}),
progressBars: (json, c) => ({
type: "progressBars",
bars: (
(c.bars as Array<{ label: string; valuePath: string; maxPath?: string; unit: string; color?: string }>) ?? []
).map((bar) => {
const value = JSONPath({ path: bar.valuePath, json: json as object, wrap: false });
const max = bar.maxPath ? JSONPath({ path: bar.maxPath, json: json as object, wrap: false }) : undefined;
return {
label: bar.label,
unit: bar.unit,
color: bar.color ?? "blue",
value: Number(value) || 0,
max: max !== undefined ? Number(max) || 100 : undefined,
};
}),
showPercentage: c.showPercentage ?? true,
barSize: c.barSize ?? "md",
}),
statusIndicator: (json, c) => ({
type: "statusIndicator",
items: ((c.items as Array<{ label: string; jsonPath: string; goodValues: string[] }>) ?? []).map((item) => {
const value = JSONPath({ path: item.jsonPath, json: json as object, wrap: false });
const isGood = item.goodValues.some((gv) => String(value).toLowerCase() === gv.toLowerCase());
return { label: item.label, value: String(value ?? "unknown"), isGood };
}),
layout: c.layout ?? "list",
dotSize: c.dotSize ?? "md",
}),
countGrid: (json, c) => ({
type: "countGrid",
items: ((c.items as Array<{ label: string; jsonPath: string; unit: string }>) ?? []).map((item) => ({
label: item.label,
unit: item.unit,
value: JSONPath({ path: item.jsonPath, json: json as object, wrap: false }),
})),
columns: c.columns ?? 2,
valueSize: c.valueSize ?? "md",
}),
raw: (json, c) => ({
type: "raw",
data: JSONPath({ path: (c.jsonPath as string) ?? "$", json: json as object, wrap: false }),
maxHeight: c.maxHeight ?? 300,
}),
customJsx: (json, config) => ({
type: "customJsx" as const,
template: config.template as string,
data: json,
}),
};
function extract(type: string, json: unknown, config: Record<string, unknown>) {
const fn = extractors[type];
if (!fn) throw new Error(`No extractor for type: ${type}`);
return fn(json, config);
}
describe("custom-api extractors", () => {
it("sonarr statGrid extracts missing and queue counts", () => {
const result = extract("statGrid", sonarrResponse, sonarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; color: string }>;
};
expect(result.type).toBe("statGrid");
expect(result.items).toHaveLength(2);
expect(result.items[0]?.value).toBe(42);
expect(result.items[0]?.label).toBe("Missing");
expect(result.items[0]?.color).toBe("red");
expect(result.items[1]?.value).toBe(5);
});
it("proxmox progressBars extracts memory and disk usage", () => {
const result = extract("progressBars", proxmoxResponse, proxmoxProgressBarsConfig) as {
type: string;
bars: Array<{ label: string; value: number; max: number }>;
};
expect(result.type).toBe("progressBars");
expect(result.bars).toHaveLength(2);
expect(result.bars[0]?.value).toBe(12_884_901_888);
expect(result.bars[0]?.max).toBe(34_359_738_368);
expect(result.bars[1]?.label).toBe("Disk");
});
it("disk table extracts filesystem rows", () => {
const result = extract("table", diskUsageResponse, diskTableConfig) as {
type: string;
columns: string[];
rows: unknown[][];
};
expect(result.type).toBe("table");
expect(result.columns).toEqual(["Mount", "Size", "Used"]);
expect(result.rows).toHaveLength(3);
expect(result.rows[0]?.[0]).toBe("/");
expect(result.rows[2]?.[2]).toBe("95%");
});
it("pihole statusIndicator detects enabled state", () => {
const result = extract("statusIndicator", piholeResponse, piholeStatusIndicatorConfig) as {
type: string;
items: Array<{ label: string; value: string; isGood: boolean }>;
};
expect(result.type).toBe("statusIndicator");
expect(result.items).toHaveLength(1);
expect(result.items[0]?.isGood).toBe(true);
expect(result.items[0]?.value).toBe("enabled");
});
it("pihole countGrid extracts all 4 stats", () => {
const result = extract("countGrid", piholeResponse, piholeCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
};
expect(result.type).toBe("countGrid");
expect(result.items).toHaveLength(4);
expect(result.items[0]?.value).toBe(12_847);
expect(result.items[1]?.value).toBe(48_291);
expect(result.items[2]?.value).toBe(26.6);
expect(result.items[2]?.unit).toBe("%");
expect(result.items[3]?.value).toBe(14);
});
it("radarr statGrid with outline card style", () => {
const result = extract("statGrid", radarrResponse, radarrStatGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown }>;
cardStyle: string;
};
expect(result.type).toBe("statGrid");
expect(result.cardStyle).toBe("outline");
expect(result.items[0]?.value).toBe(15);
expect(result.items[1]?.value).toBe(3);
});
it("raw extractor returns raw data at path", () => {
const result = extract("raw", piholeResponse, { type: "raw", jsonPath: "$.status", maxHeight: 200 }) as {
type: string;
data: unknown;
maxHeight: number;
};
expect(result.type).toBe("raw");
expect(result.data).toBe("enabled");
expect(result.maxHeight).toBe(200);
});
it("customJsx passes full JSON and template through", () => {
const json = { title: "Status", items: [{ name: "CPU", value: 42 }] };
const config = {
type: "customJsx" as const,
template: "<Stack><Title>{data.title}</Title></Stack>",
};
const result = extract("customJsx", json, config) as {
type: string;
template: string;
data: typeof json;
};
expect(result.type).toBe("customJsx");
expect(result.template).toBe(config.template);
expect(result.data).toEqual(json);
});
it("jellyfin countGrid extracts movies/series/episodes/songs in 4-column grid", () => {
const result = extract("countGrid", jellyfinItemCountsResponse, jellyfinCountGridConfig) as {
type: string;
items: Array<{ label: string; value: unknown; unit: string }>;
columns: number;
valueSize: string;
};
expect(result.type).toBe("countGrid");
expect(result.columns).toBe(4);
expect(result.valueSize).toBe("lg");
expect(result.items).toHaveLength(4);
expect(result.items[0]).toEqual({ label: "Movies", value: 185, unit: "" });
expect(result.items[1]).toEqual({ label: "Series", value: 38, unit: "" });
expect(result.items[2]).toEqual({ label: "Episodes", value: 770, unit: "" });
expect(result.items[3]).toEqual({ label: "Songs", value: 0, unit: "" });
});
});
@@ -0,0 +1,142 @@
export const sonarrResponse = {
wanted: { totalRecords: 42 },
queue: { totalRecords: 5 },
series: [
{ title: "Breaking Bad", seasons: 5, episodeCount: 62, episodeFileCount: 60 },
{ title: "The Wire", seasons: 5, episodeCount: 60, episodeFileCount: 55 },
],
diskspace: [
{ path: "/tv", label: "TV Shows", freeSpace: 150_000_000_000, totalSpace: 500_000_000_000 },
{ path: "/movies", label: "Movies", freeSpace: 80_000_000_000, totalSpace: 250_000_000_000 },
],
};
export const sonarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "blue" },
],
columns: 2,
cardStyle: "filled" as const,
};
export const proxmoxResponse = {
data: {
cpu: 0.23,
memory: { used: 12_884_901_888, total: 34_359_738_368 },
rootfs: { used: 42_949_672_960, total: 107_374_182_400 },
uptime: 864_000,
status: "running",
maxcpu: 8,
maxmem: 34_359_738_368,
name: "proxmox-node1",
},
};
export const proxmoxProgressBarsConfig = {
type: "progressBars" as const,
bars: [
{ label: "Memory", valuePath: "$.data.memory.used", maxPath: "$.data.memory.total", unit: "B", color: "blue" },
{ label: "Disk", valuePath: "$.data.rootfs.used", maxPath: "$.data.rootfs.total", unit: "B", color: "orange" },
],
showPercentage: true,
barSize: "md" as const,
};
export const diskUsageResponse = {
filesystems: [
{ filesystem: "/dev/sda1", size: "500G", used: "350G", avail: "150G", usePct: "70%", mountedOn: "/" },
{ filesystem: "/dev/sdb1", size: "2T", used: "1.2T", avail: "800G", usePct: "60%", mountedOn: "/data" },
{ filesystem: "/dev/sdc1", size: "4T", used: "3.8T", avail: "200G", usePct: "95%", mountedOn: "/media" },
],
};
export const diskTableConfig = {
type: "table" as const,
tablePath: "$.filesystems",
columns: [
{ header: "Mount", jsonPath: "$.mountedOn" },
{ header: "Size", jsonPath: "$.size" },
{ header: "Used", jsonPath: "$.usePct" },
],
striped: true,
compact: false,
};
export const piholeResponse = {
domains_being_blocked: 125_432,
dns_queries_today: 48_291,
ads_blocked_today: 12_847,
ads_percentage_today: 26.6,
unique_clients: 14,
status: "enabled",
gravity_last_updated: { relative: { days: 2, hours: 3 } },
};
export const piholeStatusIndicatorConfig = {
type: "statusIndicator" as const,
items: [{ label: "Pi-Hole", jsonPath: "$.status", goodValues: ["enabled"] }],
layout: "list" as const,
dotSize: "md" as const,
};
export const piholeCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Blocked Today", jsonPath: "$.ads_blocked_today", unit: "" },
{ label: "DNS Queries", jsonPath: "$.dns_queries_today", unit: "" },
{ label: "Block Rate", jsonPath: "$.ads_percentage_today", unit: "%" },
{ label: "Clients", jsonPath: "$.unique_clients", unit: "" },
],
columns: 2,
valueSize: "md" as const,
};
export const radarrResponse = {
wanted: { totalRecords: 15 },
queue: { totalRecords: 3 },
movie: [
{ title: "Inception", hasFile: true, monitored: true, runtime: 148, year: 2010 },
{ title: "Interstellar", hasFile: false, monitored: true, runtime: 169, year: 2014 },
{ title: "The Dark Knight", hasFile: true, monitored: false, runtime: 152, year: 2008 },
],
rootfolder: [{ path: "/movies", freeSpace: 200_000_000_000, totalSpace: 1_000_000_000_000 }],
};
export const radarrStatGridConfig = {
type: "statGrid" as const,
items: [
{ label: "Missing", jsonPath: "$.wanted.totalRecords", unit: "", color: "red" },
{ label: "Queue", jsonPath: "$.queue.totalRecords", unit: "", color: "teal" },
],
columns: 2,
cardStyle: "outline" as const,
};
export const jellyfinItemCountsResponse = {
MovieCount: 185,
SeriesCount: 38,
EpisodeCount: 770,
ArtistCount: 0,
ProgramCount: 0,
TrailerCount: 0,
SongCount: 0,
AlbumCount: 0,
MusicVideoCount: 0,
BoxSetCount: 12,
BookCount: 0,
ItemCount: 0,
};
export const jellyfinCountGridConfig = {
type: "countGrid" as const,
items: [
{ label: "Movies", jsonPath: "$.MovieCount", unit: "" },
{ label: "Series", jsonPath: "$.SeriesCount", unit: "" },
{ label: "Episodes", jsonPath: "$.EpisodeCount", unit: "" },
{ label: "Songs", jsonPath: "$.SongCount", unit: "" },
],
columns: 4,
valueSize: "lg" as const,
};
@@ -0,0 +1,91 @@
import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
import { decryptSecret } from "@homarr/common/server";
import { customWidgetDefinitions } from "@homarr/db/schema";
import { eq } from "@homarr/db";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { applyAuth } from "../custom-widget/auth";
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
const logger = createLogger({ module: "widget:customApi" });
const FETCH_TIMEOUT_MS = 10_000;
const validateUrl = (urlString: string): URL => new URL(urlString);
export const customApiRouter = createTRPCRouter({
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
where: eq(customWidgetDefinitions.id, input.definitionId),
with: { secrets: true },
});
if (!definition) {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
}
if (!definition.enabled) {
return { type: "disabled" };
}
let displayConfig: Record<string, unknown>;
try {
displayConfig = superjson.parse(definition.displayConfig) as Record<string, unknown>;
} catch {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" });
}
if (definition.displayType === "actionButton") {
return extractActionButtonDisplay(displayConfig);
}
const decryptedSecrets = definition.secrets.map((s) => ({
kind: s.kind,
value: decryptSecret(s.value),
}));
const url = validateUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
if (definition.method !== "GET" && definition.requestBody) {
headers.set("Content-Type", "application/json");
}
applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), {
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
redirect: "follow",
signal: controller.signal,
});
if (!response.ok) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `API returned ${response.status}: ${response.statusText}`,
});
}
const json: unknown = await response.json();
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
} catch (error) {
if (error instanceof TRPCError) throw error;
logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error });
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : "Failed to fetch data from external API",
});
} finally {
clearTimeout(timeout);
}
}),
});
+1
View File
@@ -36,4 +36,5 @@ export const widgetRouter = createTRPCRouter({
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
});
@@ -0,0 +1,29 @@
CREATE TABLE `custom_widget_definition` (
`id` varchar(64) NOT NULL,
`name` varchar(256) NOT NULL,
`description` text,
`icon_url` text,
`base_url` text NOT NULL,
`auth_type` varchar(32) NOT NULL DEFAULT 'none',
`header_name` varchar(256),
`endpoint` text NOT NULL,
`method` varchar(16) NOT NULL DEFAULT 'GET',
`request_body` text,
`display_type` varchar(32) NOT NULL DEFAULT 'singleValue',
`display_config` text NOT NULL DEFAULT ('{"json": {}}'),
`created_at` timestamp NOT NULL DEFAULT (now()),
`updated_at` timestamp NOT NULL DEFAULT (now()),
`creator_id` varchar(64),
CONSTRAINT `custom_widget_definition_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `custom_widget_secret` (
`kind` varchar(64) NOT NULL,
`value` text NOT NULL,
`updated_at` timestamp NOT NULL,
`definition_id` varchar(64) NOT NULL,
CONSTRAINT `custom_widget_secret_definition_id_kind_pk` PRIMARY KEY(`definition_id`,`kind`)
);
--> statement-breakpoint
ALTER TABLE `custom_widget_definition` ADD CONSTRAINT `custom_widget_definition_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `custom_widget_secret` ADD CONSTRAINT `cw_secret_definition_id_cw_definition_id_fk` FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,6 @@
ALTER TABLE `custom_widget_definition` ADD `url` text;--> statement-breakpoint
UPDATE `custom_widget_definition` SET `url` = CONCAT(`base_url`, `endpoint`);--> statement-breakpoint
ALTER TABLE `custom_widget_definition` MODIFY `url` text NOT NULL;--> statement-breakpoint
ALTER TABLE `custom_widget_definition` DROP COLUMN `base_url`;--> statement-breakpoint
ALTER TABLE `custom_widget_definition` DROP COLUMN `endpoint`;--> statement-breakpoint
ALTER TABLE `custom_widget_definition` ADD `enabled` boolean NOT NULL DEFAULT true;
@@ -2,7 +2,7 @@
"version": "5",
"dialect": "mysql",
"id": "ddg-bangs-mysql-0038",
"prevId": "ddg-bangs-mysql-0038",
"prevId": "730ee1c1-a35e-426f-a828-7ecde882f920",
"tables": {
"account": {
"name": "account",
@@ -88,7 +88,9 @@
"indexes": {
"userId_idx": {
"name": "userId_idx",
"columns": ["user_id"],
"columns": [
"user_id"
],
"isUnique": false
}
},
@@ -97,8 +99,12 @@
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -106,7 +112,10 @@
"compositePrimaryKeys": {
"account_provider_provider_account_id_pk": {
"name": "account_provider_provider_account_id_pk",
"columns": ["provider", "provider_account_id"]
"columns": [
"provider",
"provider_account_id"
]
}
},
"uniqueConstraints": {},
@@ -143,8 +152,12 @@
"name": "apiKey_user_id_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -152,7 +165,9 @@
"compositePrimaryKeys": {
"apiKey_id": {
"name": "apiKey_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -209,7 +224,9 @@
"compositePrimaryKeys": {
"app_id": {
"name": "app_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -246,8 +263,12 @@
"name": "boardGroupPermission_board_id_board_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -255,8 +276,12 @@
"name": "boardGroupPermission_group_id_group_id_fk",
"tableFrom": "boardGroupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -264,7 +289,11 @@
"compositePrimaryKeys": {
"boardGroupPermission_board_id_group_id_permission_pk": {
"name": "boardGroupPermission_board_id_group_id_permission_pk",
"columns": ["board_id", "group_id", "permission"]
"columns": [
"board_id",
"group_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -301,8 +330,12 @@
"name": "boardUserPermission_board_id_board_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -310,8 +343,12 @@
"name": "boardUserPermission_user_id_user_id_fk",
"tableFrom": "boardUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -319,7 +356,11 @@
"compositePrimaryKeys": {
"boardUserPermission_board_id_user_id_permission_pk": {
"name": "boardUserPermission_board_id_user_id_permission_pk",
"columns": ["board_id", "user_id", "permission"]
"columns": [
"board_id",
"user_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -477,8 +518,12 @@
"name": "board_creator_id_user_id_fk",
"tableFrom": "board",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -486,13 +531,17 @@
"compositePrimaryKeys": {
"board_id": {
"name": "board_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"]
"columns": [
"name"
]
}
},
"checkConstraint": {}
@@ -528,7 +577,9 @@
"compositePrimaryKeys": {
"cron_job_configuration_name": {
"name": "cron_job_configuration_name",
"columns": ["name"]
"columns": [
"name"
]
}
},
"uniqueConstraints": {},
@@ -558,8 +609,12 @@
"name": "groupMember_group_id_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -567,8 +622,12 @@
"name": "groupMember_user_id_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -576,7 +635,10 @@
"compositePrimaryKeys": {
"groupMember_group_id_user_id_pk": {
"name": "groupMember_group_id_user_id_pk",
"columns": ["group_id", "user_id"]
"columns": [
"group_id",
"user_id"
]
}
},
"uniqueConstraints": {},
@@ -606,8 +668,12 @@
"name": "groupPermission_group_id_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -668,8 +734,12 @@
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"columnsFrom": [
"owner_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -677,8 +747,12 @@
"name": "group_home_board_id_board_id_fk",
"tableFrom": "group",
"tableTo": "board",
"columnsFrom": ["home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -686,8 +760,12 @@
"name": "group_mobile_home_board_id_board_id_fk",
"tableFrom": "group",
"tableTo": "board",
"columnsFrom": ["mobile_home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"mobile_home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -695,13 +773,17 @@
"compositePrimaryKeys": {
"group_id": {
"name": "group_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"group_name_unique": {
"name": "group_name_unique",
"columns": ["name"]
"columns": [
"name"
]
}
},
"checkConstraint": {}
@@ -729,7 +811,9 @@
"compositePrimaryKeys": {
"iconRepository_id": {
"name": "iconRepository_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -780,8 +864,12 @@
"name": "icon_icon_repository_id_iconRepository_id_fk",
"tableFrom": "icon",
"tableTo": "iconRepository",
"columnsFrom": ["icon_repository_id"],
"columnsTo": ["id"],
"columnsFrom": [
"icon_repository_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -789,7 +877,9 @@
"compositePrimaryKeys": {
"icon_id": {
"name": "icon_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -826,8 +916,12 @@
"name": "integrationGroupPermissions_integration_id_integration_id_fk",
"tableFrom": "integrationGroupPermissions",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -835,8 +929,12 @@
"name": "integrationGroupPermissions_group_id_group_id_fk",
"tableFrom": "integrationGroupPermissions",
"tableTo": "group",
"columnsFrom": ["group_id"],
"columnsTo": ["id"],
"columnsFrom": [
"group_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -844,7 +942,11 @@
"compositePrimaryKeys": {
"integration_group_permission__pk": {
"name": "integration_group_permission__pk",
"columns": ["integration_id", "group_id", "permission"]
"columns": [
"integration_id",
"group_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -874,8 +976,12 @@
"name": "integration_item_item_id_item_id_fk",
"tableFrom": "integration_item",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -883,8 +989,12 @@
"name": "integration_item_integration_id_integration_id_fk",
"tableFrom": "integration_item",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -892,7 +1002,10 @@
"compositePrimaryKeys": {
"integration_item_item_id_integration_id_pk": {
"name": "integration_item_item_id_integration_id_pk",
"columns": ["item_id", "integration_id"]
"columns": [
"item_id",
"integration_id"
]
}
},
"uniqueConstraints": {},
@@ -933,12 +1046,16 @@
"indexes": {
"integration_secret__kind_idx": {
"name": "integration_secret__kind_idx",
"columns": ["kind"],
"columns": [
"kind"
],
"isUnique": false
},
"integration_secret__updated_at_idx": {
"name": "integration_secret__updated_at_idx",
"columns": ["updated_at"],
"columns": [
"updated_at"
],
"isUnique": false
}
},
@@ -947,8 +1064,12 @@
"name": "integrationSecret_integration_id_integration_id_fk",
"tableFrom": "integrationSecret",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -956,7 +1077,10 @@
"compositePrimaryKeys": {
"integrationSecret_integration_id_kind_pk": {
"name": "integrationSecret_integration_id_kind_pk",
"columns": ["integration_id", "kind"]
"columns": [
"integration_id",
"kind"
]
}
},
"uniqueConstraints": {},
@@ -993,8 +1117,12 @@
"name": "integrationUserPermission_integration_id_integration_id_fk",
"tableFrom": "integrationUserPermission",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1002,8 +1130,12 @@
"name": "integrationUserPermission_user_id_user_id_fk",
"tableFrom": "integrationUserPermission",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1011,7 +1143,11 @@
"compositePrimaryKeys": {
"integrationUserPermission_integration_id_user_id_permission_pk": {
"name": "integrationUserPermission_integration_id_user_id_permission_pk",
"columns": ["integration_id", "user_id", "permission"]
"columns": [
"integration_id",
"user_id",
"permission"
]
}
},
"uniqueConstraints": {},
@@ -1059,7 +1195,9 @@
"indexes": {
"integration__kind_idx": {
"name": "integration__kind_idx",
"columns": ["kind"],
"columns": [
"kind"
],
"isUnique": false
}
},
@@ -1068,8 +1206,12 @@
"name": "integration_app_id_app_id_fk",
"tableFrom": "integration",
"tableTo": "app",
"columnsFrom": ["app_id"],
"columnsTo": ["id"],
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1077,7 +1219,9 @@
"compositePrimaryKeys": {
"integration_id": {
"name": "integration_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -1121,8 +1265,12 @@
"name": "invite_creator_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1130,13 +1278,17 @@
"compositePrimaryKeys": {
"invite_id": {
"name": "invite_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"invite_token_unique": {
"name": "invite_token_unique",
"columns": ["token"]
"columns": [
"token"
]
}
},
"checkConstraint": {}
@@ -1200,8 +1352,12 @@
"name": "item_layout_item_id_item_id_fk",
"tableFrom": "item_layout",
"tableTo": "item",
"columnsFrom": ["item_id"],
"columnsTo": ["id"],
"columnsFrom": [
"item_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1209,8 +1365,12 @@
"name": "item_layout_section_id_section_id_fk",
"tableFrom": "item_layout",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1218,8 +1378,12 @@
"name": "item_layout_layout_id_layout_id_fk",
"tableFrom": "item_layout",
"tableTo": "layout",
"columnsFrom": ["layout_id"],
"columnsTo": ["id"],
"columnsFrom": [
"layout_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1227,7 +1391,11 @@
"compositePrimaryKeys": {
"item_layout_item_id_section_id_layout_id_pk": {
"name": "item_layout_item_id_section_id_layout_id_pk",
"columns": ["item_id", "section_id", "layout_id"]
"columns": [
"item_id",
"section_id",
"layout_id"
]
}
},
"uniqueConstraints": {},
@@ -1280,8 +1448,12 @@
"name": "item_board_id_board_id_fk",
"tableFrom": "item",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1289,7 +1461,9 @@
"compositePrimaryKeys": {
"item_id": {
"name": "item_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -1341,8 +1515,12 @@
"name": "layout_board_id_board_id_fk",
"tableFrom": "layout",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1350,7 +1528,9 @@
"compositePrimaryKeys": {
"layout_id": {
"name": "layout_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -1416,8 +1596,12 @@
"name": "media_creator_id_user_id_fk",
"tableFrom": "media",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"columnsFrom": [
"creator_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -1425,7 +1609,9 @@
"compositePrimaryKeys": {
"media_id": {
"name": "media_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -1461,7 +1647,9 @@
"compositePrimaryKeys": {
"onboarding_id": {
"name": "onboarding_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -1534,8 +1722,12 @@
"name": "search_engine_integration_id_integration_id_fk",
"tableFrom": "search_engine",
"tableTo": "integration",
"columnsFrom": ["integration_id"],
"columnsTo": ["id"],
"columnsFrom": [
"integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1543,13 +1735,17 @@
"compositePrimaryKeys": {
"search_engine_id": {
"name": "search_engine_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {
"search_engine_short_unique": {
"name": "search_engine_short_unique",
"columns": ["short"]
"columns": [
"short"
]
}
},
"checkConstraint": {}
@@ -1586,8 +1782,12 @@
"name": "section_collapse_state_user_id_user_id_fk",
"tableFrom": "section_collapse_state",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1595,8 +1795,12 @@
"name": "section_collapse_state_section_id_section_id_fk",
"tableFrom": "section_collapse_state",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1604,7 +1808,10 @@
"compositePrimaryKeys": {
"section_collapse_state_user_id_section_id_pk": {
"name": "section_collapse_state_user_id_section_id_pk",
"columns": ["user_id", "section_id"]
"columns": [
"user_id",
"section_id"
]
}
},
"uniqueConstraints": {},
@@ -1669,8 +1876,12 @@
"name": "section_layout_section_id_section_id_fk",
"tableFrom": "section_layout",
"tableTo": "section",
"columnsFrom": ["section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1678,8 +1889,12 @@
"name": "section_layout_layout_id_layout_id_fk",
"tableFrom": "section_layout",
"tableTo": "layout",
"columnsFrom": ["layout_id"],
"columnsTo": ["id"],
"columnsFrom": [
"layout_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
@@ -1687,8 +1902,12 @@
"name": "section_layout_parent_section_id_section_id_fk",
"tableFrom": "section_layout",
"tableTo": "section",
"columnsFrom": ["parent_section_id"],
"columnsTo": ["id"],
"columnsFrom": [
"parent_section_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1696,7 +1915,10 @@
"compositePrimaryKeys": {
"section_layout_section_id_layout_id_pk": {
"name": "section_layout_section_id_layout_id_pk",
"columns": ["section_id", "layout_id"]
"columns": [
"section_id",
"layout_id"
]
}
},
"uniqueConstraints": {},
@@ -1762,8 +1984,12 @@
"name": "section_board_id_board_id_fk",
"tableFrom": "section",
"tableTo": "board",
"columnsFrom": ["board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"board_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1771,7 +1997,9 @@
"compositePrimaryKeys": {
"section_id": {
"name": "section_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -1801,13 +2029,17 @@
"compositePrimaryKeys": {
"serverSetting_setting_key": {
"name": "serverSetting_setting_key",
"columns": ["setting_key"]
"columns": [
"setting_key"
]
}
},
"uniqueConstraints": {
"serverSetting_settingKey_unique": {
"name": "serverSetting_settingKey_unique",
"columns": ["setting_key"]
"columns": [
"setting_key"
]
}
},
"checkConstraint": {}
@@ -1840,7 +2072,9 @@
"indexes": {
"user_id_idx": {
"name": "user_id_idx",
"columns": ["user_id"],
"columns": [
"user_id"
],
"isUnique": false
}
},
@@ -1849,8 +2083,12 @@
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -1858,7 +2096,9 @@
"compositePrimaryKeys": {
"session_session_token": {
"name": "session_session_token",
"columns": ["session_token"]
"columns": [
"session_token"
]
}
},
"uniqueConstraints": {},
@@ -1985,8 +2225,12 @@
"name": "user_home_board_id_board_id_fk",
"tableFrom": "user",
"tableTo": "board",
"columnsFrom": ["home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -1994,8 +2238,12 @@
"name": "user_mobile_home_board_id_board_id_fk",
"tableFrom": "user",
"tableTo": "board",
"columnsFrom": ["mobile_home_board_id"],
"columnsTo": ["id"],
"columnsFrom": [
"mobile_home_board_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
@@ -2003,8 +2251,12 @@
"name": "user_default_search_engine_id_search_engine_id_fk",
"tableFrom": "user",
"tableTo": "search_engine",
"columnsFrom": ["default_search_engine_id"],
"columnsTo": ["id"],
"columnsFrom": [
"default_search_engine_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
@@ -2012,7 +2264,9 @@
"compositePrimaryKeys": {
"user_id": {
"name": "user_id",
"columns": ["id"]
"columns": [
"id"
]
}
},
"uniqueConstraints": {},
@@ -2048,7 +2302,10 @@
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"name": "verificationToken_identifier_token_pk",
"columns": ["identifier", "token"]
"columns": [
"identifier",
"token"
]
}
},
"uniqueConstraints": {},
@@ -2084,7 +2341,10 @@
"compositePrimaryKeys": {
"trusted_certificate_hostname_hostname_thumbprint_pk": {
"name": "trusted_certificate_hostname_hostname_thumbprint_pk",
"columns": ["hostname", "thumbprint"]
"columns": [
"hostname",
"thumbprint"
]
}
},
"uniqueConstraints": {},
@@ -2101,4 +2361,4 @@
"tables": {},
"indexes": {}
}
}
}
File diff suppressed because it is too large. Load diff
@@ -281,6 +281,20 @@
"when": 1779199104477,
"tag": "0039_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 40,
"version": "5",
"when": 1780764453945,
"tag": "0040_wealthy_thunderbolts",
"breakpoints": true
},
{
"idx": 41,
"version": "5",
"when": 1780764500000,
"tag": "0041_merge_custom_widget_url",
"breakpoints": true
}
]
}
}
@@ -0,0 +1,28 @@
CREATE TABLE "custom_widget_definition" (
"id" varchar(64) PRIMARY KEY NOT NULL,
"name" varchar(256) NOT NULL,
"description" text,
"icon_url" text,
"base_url" text NOT NULL,
"auth_type" varchar(32) DEFAULT 'none' NOT NULL,
"header_name" varchar(256),
"endpoint" text NOT NULL,
"method" varchar(16) DEFAULT 'GET' NOT NULL,
"request_body" text,
"display_type" varchar(32) DEFAULT 'singleValue' NOT NULL,
"display_config" text DEFAULT '{"json": {}}' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"creator_id" varchar(64)
);
--> statement-breakpoint
CREATE TABLE "custom_widget_secret" (
"kind" varchar(64) NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp NOT NULL,
"definition_id" varchar(64) NOT NULL,
CONSTRAINT "custom_widget_secret_definition_id_kind_pk" PRIMARY KEY("definition_id","kind")
);
--> statement-breakpoint
ALTER TABLE "custom_widget_definition" ADD CONSTRAINT "custom_widget_definition_creator_id_user_id_fk" FOREIGN KEY ("creator_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "custom_widget_secret" ADD CONSTRAINT "custom_widget_secret_definition_id_custom_widget_definition_id_fk" FOREIGN KEY ("definition_id") REFERENCES "public"."custom_widget_definition"("id") ON DELETE cascade ON UPDATE no action;
@@ -0,0 +1,6 @@
ALTER TABLE "custom_widget_definition" ADD COLUMN "url" text;--> statement-breakpoint
UPDATE "custom_widget_definition" SET "url" = "base_url" || "endpoint";--> statement-breakpoint
ALTER TABLE "custom_widget_definition" ALTER COLUMN "url" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "custom_widget_definition" DROP COLUMN "base_url";--> statement-breakpoint
ALTER TABLE "custom_widget_definition" DROP COLUMN "endpoint";--> statement-breakpoint
ALTER TABLE "custom_widget_definition" ADD COLUMN "enabled" boolean DEFAULT true NOT NULL;
File diff suppressed because it is too large. Load diff
@@ -43,6 +43,20 @@
"when": 1779199097716,
"tag": "0005_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1780764416094,
"tag": "0006_last_callisto",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1780764500000,
"tag": "0007_merge_custom_widget_url",
"breakpoints": true
}
]
}
}
+67 -1
View File
@@ -1,6 +1,11 @@
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import SuperJSON from "superjson";
import { createId, objectKeys } from "@homarr/common";
import { customWidgetImportSchema } from "@homarr/validation/custom-widget";
import {
createDocumentationLink,
credentialsAdminGroup,
@@ -15,7 +20,7 @@ import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import type { Database } from "..";
import { eq } from "..";
import { eq, inArray } from "..";
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
getServerSettingByKeyAsync,
@@ -38,11 +43,20 @@ import {
searchEngines,
sections,
users,
customWidgetDefinitions,
} from "../schema";
import type { Integration } from "../schema";
const isTruthyEnv = (value: string | undefined) => ["1", "yes", "t", "true"].includes((value ?? "").toLowerCase());
const CUSTOM_WIDGET_SEED_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "seed", "custom-widgets");
const CUSTOM_WIDGET_SEED_IDS: Record<string, string> = {
"dog-facts.json": "seed-dog-facts",
"currency-exchange.json": "seed-currency-exchange",
"jellyfin.json": "seed-jellyfin",
};
export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.UNSAFE_ENABLE_MOCK_INTEGRATION)) {
console.warn(
@@ -57,6 +71,7 @@ export const seedDataAsync = async (db: Database) => {
await seedDefaultIntegrationsAsync(db);
await seedDefaultAppsAsync(db);
await seedDefaultBoardAsync(db);
await seedDefaultCustomWidgetsAsync(db);
await seedBoardWidgetsAsync(db);
if (isTruthyEnv(process.env.DEMO_MODE)) {
@@ -552,6 +567,57 @@ const seedDemoUserAsync = async (db: Database) => {
);
};
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
const seedIds = Object.values(CUSTOM_WIDGET_SEED_IDS);
const beforeCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const seedResults = await Promise.all(
Object.entries(CUSTOM_WIDGET_SEED_IDS).map(async ([filename, id]) => {
try {
const raw = await readFile(join(CUSTOM_WIDGET_SEED_DIR, filename), "utf-8");
const parsed = customWidgetImportSchema.parse(JSON.parse(raw));
return {
id,
name: parsed.name,
description: parsed.description ?? null,
iconUrl: parsed.iconUrl ?? null,
url: parsed.url,
authType: parsed.authType,
headerName: parsed.headerName ?? null,
method: parsed.method,
requestBody: parsed.requestBody ?? null,
displayType: parsed.displayType,
displayConfig: SuperJSON.stringify(parsed.displayConfig),
enabled: false,
creatorId: null,
};
} catch (error) {
console.error(`Failed to parse custom widget seed file: ${filename}`, error);
return null;
}
}),
);
const seedValues = seedResults.filter((v): v is NonNullable<typeof v> => v !== null);
if (seedValues.length === 0) return;
await db
.insert(customWidgetDefinitions)
.values(seedValues)
.onConflictDoNothing({ target: customWidgetDefinitions.id });
const afterCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const createdCount = afterCount - beforeCount;
if (createdCount === 0) {
console.log("Skipping seeding of default custom widgets as they already exist");
return;
}
console.log(`Created ${createdCount} default custom widgets through seeding process`);
};
const seedBoardWidgetsAsync = async (db: Database) => {
const existingItems = await db.$count(items);
if (existingItems > 0) {
@@ -0,0 +1,27 @@
CREATE TABLE `custom_widget_definition` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`icon_url` text,
`base_url` text NOT NULL,
`auth_type` text DEFAULT 'none' NOT NULL,
`header_name` text,
`endpoint` text NOT NULL,
`method` text DEFAULT 'GET' NOT NULL,
`request_body` text,
`display_type` text DEFAULT 'singleValue' NOT NULL,
`display_config` text DEFAULT '{"json": {}}' NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
`creator_id` text,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `custom_widget_secret` (
`kind` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer NOT NULL,
`definition_id` text NOT NULL,
PRIMARY KEY(`definition_id`, `kind`),
FOREIGN KEY (`definition_id`) REFERENCES `custom_widget_definition`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,23 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_custom_widget_definition` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`icon_url` text,
`url` text NOT NULL,
`auth_type` text DEFAULT 'none' NOT NULL,
`header_name` text,
`method` text DEFAULT 'GET' NOT NULL,
`request_body` text,
`display_type` text DEFAULT 'singleValue' NOT NULL,
`display_config` text DEFAULT '{"json": {}}' NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
`creator_id` text,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);--> statement-breakpoint
INSERT INTO `__new_custom_widget_definition`("id", "name", "description", "icon_url", "url", "auth_type", "header_name", "method", "request_body", "display_type", "display_config", "enabled", "created_at", "updated_at", "creator_id") SELECT "id", "name", "description", "icon_url", "base_url" || "endpoint", "auth_type", "header_name", "method", "request_body", "display_type", "display_config", 1, "created_at", "updated_at", "creator_id" FROM `custom_widget_definition`;--> statement-breakpoint
DROP TABLE `custom_widget_definition`;--> statement-breakpoint
ALTER TABLE `__new_custom_widget_definition` RENAME TO `custom_widget_definition`;--> statement-breakpoint
PRAGMA foreign_keys=ON;
File diff suppressed because it is too large. Load diff
@@ -267,6 +267,20 @@
"when": 1779199046805,
"tag": "0037_add_user_onboarding_tour_status",
"breakpoints": true
},
{
"idx": 38,
"version": "6",
"when": 1780764406810,
"tag": "0038_crazy_hobgoblin",
"breakpoints": true
},
{
"idx": 39,
"version": "6",
"when": 1780764500000,
"tag": "0039_merge_custom_widget_url",
"breakpoints": true
}
]
}
}
+1
View File
@@ -48,6 +48,7 @@
"@homarr/core": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@paralleldrive/cuid2": "catalog:",
"@testcontainers/mysql": "catalog:",
+4
View File
@@ -49,6 +49,8 @@ export const {
sectionLayouts,
trustedCertificateHostnames,
cronJobConfigurations,
customWidgetDefinitions,
customWidgetSecrets,
} = schema;
export type User = InferSelectModel<typeof schema.users>;
@@ -57,3 +59,5 @@ export type Session = InferSelectModel<typeof schema.sessions>;
export type VerificationToken = InferSelectModel<typeof schema.verificationTokens>;
export type Integration = InferSelectModel<typeof schema.integrations>;
export type IntegrationSecret = InferSelectModel<typeof schema.integrationSecrets>;
export type CustomWidgetDefinition = InferSelectModel<typeof schema.customWidgetDefinitions>;
export type CustomWidgetSecret = InferSelectModel<typeof schema.customWidgetSecrets>;
+58
View File
@@ -39,6 +39,12 @@ import type {
SupportedAuthProvider,
WidgetKind,
} from "@homarr/definitions";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
} from "@homarr/validation/custom-widget";
const customBlob = customType<{ data: Buffer }>({
dataType() {
@@ -498,6 +504,43 @@ export const onboarding = mysqlTable("onboarding", {
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
export const customWidgetDefinitions = mysqlTable("custom_widget_definition", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
description: text(),
iconUrl: text(),
url: text().notNull(),
authType: varchar({ length: 32 }).$type<CustomWidgetAuthType>().notNull().default("none"),
headerName: varchar({ length: 256 }),
method: varchar({ length: 16 }).$type<CustomWidgetMethod>().notNull().default("GET"),
requestBody: text(),
displayType: varchar({ length: 32 }).$type<CustomWidgetDisplayType>().notNull().default("singleValue"),
displayConfig: text().default(emptySuperJSON).notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
updatedAt: timestamp({ mode: "date" }).notNull().defaultNow(),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
});
export const customWidgetSecrets = mysqlTable(
"custom_widget_secret",
{
kind: varchar({ length: 64 }).$type<CustomWidgetSecretKind>().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
definitionId: varchar({ length: 64 })
.notNull()
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.definitionId, table.kind],
}),
}),
);
export const cronJobConfigurations = mysqlTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
@@ -768,3 +811,18 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const customWidgetDefinitionRelations = relations(customWidgetDefinitions, ({ many, one }) => ({
secrets: many(customWidgetSecrets),
creator: one(users, {
fields: [customWidgetDefinitions.creatorId],
references: [users.id],
}),
}));
export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one }) => ({
definition: one(customWidgetDefinitions, {
fields: [customWidgetSecrets.definitionId],
references: [customWidgetDefinitions.id],
}),
}));
+58
View File
@@ -38,6 +38,12 @@ import type {
SupportedAuthProvider,
WidgetKind,
} from "@homarr/definitions";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
} from "@homarr/validation/custom-widget";
const customBlob = customType<{ data: Buffer }>({
dataType() {
@@ -497,6 +503,43 @@ export const onboarding = pgTable("onboarding", {
previousStep: varchar({ length: 64 }).$type<OnboardingStep>(),
});
export const customWidgetDefinitions = pgTable("custom_widget_definition", {
id: varchar({ length: 64 }).notNull().primaryKey(),
name: varchar({ length: 256 }).notNull(),
description: text(),
iconUrl: text(),
url: text().notNull(),
authType: varchar({ length: 32 }).$type<CustomWidgetAuthType>().notNull().default("none"),
headerName: varchar({ length: 256 }),
method: varchar({ length: 16 }).$type<CustomWidgetMethod>().notNull().default("GET"),
requestBody: text(),
displayType: varchar({ length: 32 }).$type<CustomWidgetDisplayType>().notNull().default("singleValue"),
displayConfig: text().default(emptySuperJSON).notNull(),
enabled: boolean().notNull().default(true),
createdAt: timestamp({ mode: "date" }).notNull().defaultNow(),
updatedAt: timestamp({ mode: "date" }).notNull().defaultNow(),
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
});
export const customWidgetSecrets = pgTable(
"custom_widget_secret",
{
kind: varchar({ length: 64 }).$type<CustomWidgetSecretKind>().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: timestamp()
.$onUpdateFn(() => new Date())
.notNull(),
definitionId: varchar({ length: 64 })
.notNull()
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.definitionId, table.kind],
}),
}),
);
export const cronJobConfigurations = pgTable("cron_job_configuration", {
name: varchar({ length: 256 }).notNull().primaryKey(),
cronExpression: varchar({ length: 32 }).notNull(),
@@ -767,3 +810,18 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const customWidgetDefinitionRelations = relations(customWidgetDefinitions, ({ many, one }) => ({
secrets: many(customWidgetSecrets),
creator: one(users, {
fields: [customWidgetDefinitions.creatorId],
references: [users.id],
}),
}));
export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one }) => ({
definition: one(customWidgetDefinitions, {
fields: [customWidgetSecrets.definitionId],
references: [customWidgetDefinitions.id],
}),
}));
+62
View File
@@ -27,6 +27,12 @@ import type {
SupportedAuthProvider,
WidgetKind,
} from "@homarr/definitions";
import type {
CustomWidgetAuthType,
CustomWidgetDisplayType,
CustomWidgetMethod,
CustomWidgetSecretKind,
} from "@homarr/validation/custom-widget";
export * from "@homarr/core/infrastructure/certificates/hostnames/db/sqlite";
@@ -483,6 +489,47 @@ export const onboarding = sqliteTable("onboarding", {
previousStep: text().$type<OnboardingStep>(),
});
export const customWidgetDefinitions = sqliteTable("custom_widget_definition", {
id: text().notNull().primaryKey(),
name: text().notNull(),
description: text(),
iconUrl: text(),
url: text().notNull(),
authType: text().$type<CustomWidgetAuthType>().notNull().default("none"),
headerName: text(),
method: text().$type<CustomWidgetMethod>().notNull().default("GET"),
requestBody: text(),
displayType: text().$type<CustomWidgetDisplayType>().notNull().default("singleValue"),
displayConfig: text().default(emptySuperJSON).notNull(),
enabled: int({ mode: "boolean" }).notNull().default(true),
createdAt: int({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: int({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
creatorId: text().references(() => users.id, { onDelete: "set null" }),
});
export const customWidgetSecrets = sqliteTable(
"custom_widget_secret",
{
kind: text().$type<CustomWidgetSecretKind>().notNull(),
value: text().$type<`${string}.${string}`>().notNull(),
updatedAt: int({ mode: "timestamp" })
.$onUpdateFn(() => new Date())
.notNull(),
definitionId: text()
.notNull()
.references(() => customWidgetDefinitions.id, { onDelete: "cascade" }),
},
(table) => ({
compoundKey: primaryKey({
columns: [table.definitionId, table.kind],
}),
}),
);
export const cronJobConfigurations = sqliteTable("cron_job_configuration", {
name: text().notNull().primaryKey(),
cronExpression: text().notNull(),
@@ -753,3 +800,18 @@ export const layoutRelations = relations(layouts, ({ one, many }) => ({
references: [boards.id],
}),
}));
export const customWidgetDefinitionRelations = relations(customWidgetDefinitions, ({ many, one }) => ({
secrets: many(customWidgetSecrets),
creator: one(users, {
fields: [customWidgetDefinitions.creatorId],
references: [users.id],
}),
}));
export const customWidgetSecretRelations = relations(customWidgetSecrets, ({ one }) => ({
definition: one(customWidgetDefinitions, {
fields: [customWidgetSecrets.definitionId],
references: [customWidgetDefinitions.id],
}),
}));
@@ -0,0 +1,18 @@
{
"$schema": "homarr-custom-widget-v2",
"name": "Currency Exchange (JPY)",
"description": "Converts 50 Japanese Yen to EUR and USD using European Central Bank rates",
"url": "https://api.frankfurter.dev/v1/latest?from=JPY&to=EUR,USD&amount=50",
"authType": "none",
"method": "GET",
"displayType": "keyValue",
"displayConfig": {
"type": "keyValue",
"mappings": [
{ "label": "50 JPY → EUR", "jsonPath": "$.rates.EUR", "unit": "€" },
{ "label": "50 JPY → USD", "jsonPath": "$.rates.USD", "unit": "$" }
],
"layout": "list",
"columns": 2
}
}
@@ -0,0 +1,17 @@
{
"$schema": "homarr-custom-widget-v2",
"name": "Random Dog Fact",
"description": "Displays a random fun fact about dogs",
"url": "https://dogapi.dog/api/v2/facts",
"authType": "none",
"method": "GET",
"displayType": "singleValue",
"displayConfig": {
"type": "singleValue",
"jsonPath": "$.data[0].attributes.body",
"label": "Dog Fact",
"unit": "",
"valueSize": "sm",
"labelPosition": "above"
}
}
@@ -0,0 +1,23 @@
{
"$schema": "homarr-custom-widget-v2",
"name": "Jellyfin library",
"description": "Counts the number of movies, series, episodes and songs in the library",
"iconUrl": "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
"url": "https://jellyfin.homelab.com/Items/Counts",
"authType": "apiKeyHeader",
"headerName": "X-Emby-Token",
"method": "GET",
"requestBody": null,
"displayType": "countGrid",
"displayConfig": {
"type": "countGrid",
"items": [
{ "label": "Movies", "jsonPath": "$.MovieCount", "unit": "" },
{ "label": "Series", "jsonPath": "$.SeriesCount", "unit": "" },
{ "label": "Episodes", "jsonPath": "$.EpisodeCount", "unit": "" },
{ "label": "Songs", "jsonPath": "$.SongCount", "unit": "" }
],
"columns": 4,
"valueSize": "lg"
}
}
@@ -0,0 +1,14 @@
{
"$schema": "homarr-custom-widget-v2",
"name": "Pokédex — Charizard",
"description": "Detailed Pokémon card with types, stats, tabs, pagination, and collapsible info",
"iconUrl": "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/poke-ball.png",
"url": "https://pokeapi.co/api/v2/pokemon/charizard",
"authType": "none",
"method": "GET",
"displayType": "customJsx",
"displayConfig": {
"type": "customJsx",
"template": "<Stack gap=\"sm\" p=\"xs\">\n <Group wrap=\"nowrap\">\n <Avatar\n src={data.sprites.front_default}\n size={80}\n radius=\"sm\"\n />\n <Stack gap={4} style={{flex: 1}}>\n <Title order={3} tt=\"capitalize\">{data.name}</Title>\n <Text size=\"xs\" c=\"dimmed\">#{String(data.id).padStart(3, \"0\")} · {data.height / 10} m · {data.weight / 10} kg</Text>\n <Group gap=\"xs\">\n {data.types.map(t =>\n <TypeBadge type={t.type.name} />\n )}\n </Group>\n </Stack>\n </Group>\n <TabsContainer defaultTab=\"stats\">\n <TabPanel value=\"stats\" label=\"Stats\">\n <Stack gap=\"xs\" pt=\"xs\">\n {data.stats.map(s =>\n <StatBar label={s.stat.name} value={s.base_stat} max={255} color={s.base_stat > 100 ? \"green\" : s.base_stat > 60 ? \"yellow\" : \"red\"} />\n )}\n </Stack>\n </TabPanel>\n <TabPanel value=\"abilities\" label=\"Abilities\">\n <Collapsible title=\"Abilities\" defaultOpen={true}>\n <Group gap=\"xs\" pt=\"xs\">\n {data.abilities.map(a =>\n <Badge variant={a.is_hidden ? \"outline\" : \"filled\"} color={a.is_hidden ? \"gray\" : \"blue\"} tt=\"capitalize\">\n {a.ability.name}{a.is_hidden ? \" (hidden)\" : \"\"}\n </Badge>\n )}\n </Group>\n </Collapsible>\n </TabPanel>\n <TabPanel value=\"moves\" label=\"Moves\">\n <PaginatedList pageSize={12}>\n {data.moves.map(m =>\n <Badge size=\"xs\" variant=\"light\" tt=\"capitalize\" mb={4}>{m.move.name}</Badge>\n )}\n </PaginatedList>\n </TabPanel>\n </TabsContainer>\n</Stack>"
}
}
+1
View File
@@ -46,6 +46,7 @@ export const widgetKinds = [
"beszelSystemGrid",
"beszelAlerts",
"beszelSystemStats",
"customApi",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
+238
View File
@@ -684,6 +684,211 @@
"add": "Add an app"
}
},
"customWidget": {
"page": {
"list": {
"title": "Custom Widgets",
"noResults": "No custom widgets yet. Create your first one!",
"loading": "Loading..."
},
"create": {
"title": "Create Custom Widget",
"subtitle": "Define an API URL, authentication, and how to display the response data."
},
"edit": {
"title": "Edit Custom Widget"
}
},
"action": {
"create": "Create",
"import": "Import",
"save": "Save",
"delete": "Delete Custom Widget",
"deleteConfirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
"export": "Export",
"addMapping": "Add Mapping",
"addColumn": "Add Column",
"edit": "Edit",
"removeMapping": "Remove mapping",
"removeColumn": "Remove column",
"addItem": "Add Item",
"removeItem": "Remove item",
"addBar": "Add Bar",
"duplicate": "Duplicate",
"copyAiPrompt": "Copy AI prompt",
"enable": "Enable",
"disable": "Disable",
"menu": "Actions"
},
"field": {
"name": "Name",
"description": "Description",
"iconUrl": "Icon URL",
"url": "URL",
"authType": "Auth Type",
"headerName": "Header/Query Parameter Name",
"method": "Method",
"requestBody": "Request Body",
"displayType": "Display Type",
"jsonPath": "JSON Path",
"jsonPathHint": "Use JSONPath syntax to extract values, e.g. $.data.temperature or $.items[0].name",
"label": "Label",
"unit": "Unit",
"mappings": "Mappings",
"tablePath": "Array JSON Path",
"tablePathHint": "Path to the array in the response, e.g. $.data.items",
"columns": "Columns",
"header": "Header",
"items": "Items",
"gridColumns": "Grid Columns",
"cardStyle": "Card Style",
"bars": "Bars",
"valuePath": "Value JSON Path",
"maxPath": "Max Value JSON Path",
"showPercentage": "Show Percentage",
"barSize": "Bar Size",
"goodValues": "Good Values (comma-separated)",
"dotSize": "Dot Size",
"valueSize": "Value Size",
"labelPosition": "Label Position",
"kvLayout": "Layout",
"striped": "Striped Rows",
"compact": "Compact Mode",
"maxHeight": "Max Height (px)",
"color": "Color",
"buttonLabel": "Button Label",
"buttonColor": "Button Color",
"confirmText": "Confirmation Text",
"confirmTextHint": "If set, a confirmation dialog will appear before executing the request",
"successMessage": "Success Message",
"successMessageHint": "Message shown after successful execution",
"enabled": "Enabled",
"template": {
"label": "JSX Template",
"description": "Mantine JSX template. Access API response fields via data bindings (e.g. data.name). Use whitelisted components only. Forbidden: constructor, __proto__, eval, Function, window, document."
}
},
"placeholder": {
"exampleLabel": "CPU Temperature",
"exampleLabelShort": "CPU Temp",
"exampleHeader": "Name",
"exampleUnit": "°C",
"iconUrl": "https://...",
"url": "https://api.example.com/api/v1/status",
"headerName": "X-API-Key",
"jsonPath": "$.data.value",
"tablePath": "$.data.items",
"columnJsonPath": "$.name",
"valuePath": "$.data.used",
"maxPath": "$.data.total",
"goodValues": "online, true, up",
"color": "blue"
},
"cardStyleOption": {
"filled": "Filled",
"outline": "Outline",
"subtle": "Subtle"
},
"layoutOption": {
"list": "List",
"grid": "Grid"
},
"sizeOption": {
"sm": "Small",
"md": "Medium",
"lg": "Large",
"xl": "Extra Large"
},
"labelPositionOption": {
"above": "Above",
"below": "Below"
},
"fieldset": {
"general": "General",
"connection": "API Connection",
"display": "Display Configuration"
},
"displayTypeDescription": {
"singleValue": "Show one extracted value with a label and unit",
"keyValue": "Show multiple labeled values side by side",
"table": "Show rows of data in a table format",
"statGrid": "Colored stat cards in a grid — like Pi-Hole or Speedtest widgets",
"progressBars": "Progress bars showing usage or completion — for disk, memory, quotas",
"statusIndicator": "Colored status dots showing online/offline or health states",
"countGrid": "Compact number + label counters in a tight grid",
"raw": "Pretty-printed JSON output for debugging",
"actionButton": "A button that triggers a request when clicked",
"customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding"
},
"method": {
"GET": "GET",
"POST": "POST",
"PUT": "PUT",
"DELETE": "DELETE",
"PATCH": "PATCH"
},
"authType": {
"none": "None",
"bearer": "Bearer Token",
"basic": "Basic Auth",
"apiKeyHeader": "API Key (Header)",
"apiKeyQuery": "API Key (Query)"
},
"displayType": {
"singleValue": "Single Value",
"keyValue": "Key-Value",
"table": "Table",
"statGrid": "Stat Grid",
"progressBars": "Progress Bars",
"statusIndicator": "Status Indicator",
"countGrid": "Count Grid",
"raw": "Raw JSON",
"actionButton": "Action Button",
"customJsx": "Custom JSX"
},
"secret": {
"apiKey": "API Key / Token",
"username": "Username",
"password": "Password",
"savedPlaceholder": "••••••• (saved, leave empty to keep)"
},
"preview": {
"title": "Preview",
"test": "Test",
"hint": "Click Test to preview the API response with your current configuration.",
"rawResponse": "Raw JSON response"
},
"notification": {
"created": "Widget \"{name}\" created successfully.",
"updated": "Widget \"{name}\" updated successfully.",
"deleted": "Widget \"{name}\" deleted.",
"imported": "Widget imported successfully.",
"aiPromptCopied": "AI prompt copied to clipboard",
"aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.",
"aiPromptCopy": "Copy to clipboard",
"aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.",
"aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.",
"createError": "Failed to create widget. Check your API endpoint is reachable.",
"updateError": "Failed to update widget. Your changes were not saved.",
"importError": "Failed to import widget. Ensure the file is a valid custom widget JSON export.",
"exportError": "Failed to export widget.",
"duplicated": "Widget \"{name}\" duplicated.",
"duplicateError": "Failed to duplicate widget.",
"toggleError": "Failed to toggle widget status.",
"deleteError": "Failed to delete widget.",
"previewError": "Failed to fetch preview. Check the URL and try again.",
"executed": "Action executed successfully.",
"executeError": "Action failed: {error}"
},
"table": {
"name": "Name",
"url": "URL",
"display": "Display"
},
"widget": {
"disabled": "This widget is disabled"
}
},
"integration": {
"page": {
"list": {
@@ -3288,6 +3493,26 @@
"unhealthy": "Unhealthy"
}
},
"customApi": {
"name": "Custom API",
"description": "Display data from any external API with custom configuration.",
"option": {
"definitionId": {
"label": "Definition ID"
},
"refreshInterval": {
"label": "Refresh interval",
"description": "How often to refetch data from the API (in seconds)"
}
},
"noDefinition": "No definition selected",
"definitionNotFound": "Widget definition not found. It may have been deleted.",
"noDisplayOutput": "No display output",
"executeConfirm": "Are you sure you want to execute this action?",
"executing": "Executing...",
"executeSuccess": "Action executed successfully",
"executeFailed": "Action failed. Check the widget configuration."
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
"description": "Table view of all Beszel-monitored systems with sortable columns and status indicators",
@@ -3879,6 +4104,7 @@
"boards": "Boards",
"apps": "Apps",
"integrations": "Integrations",
"customWidgets": "Custom Widgets",
"searchEngies": "Search engines",
"medias": "Medias",
"users": {
@@ -5128,6 +5354,18 @@
"label": "Edit"
}
},
"custom-widgets": {
"label": "Custom widgets",
"new": {
"label": "New"
},
"edit": {
"label": "Edit",
"flow": {
"label": "Flow"
}
}
},
"users": {
"label": "Users",
"create": {
+471
View File
@@ -0,0 +1,471 @@
import { z } from "zod/v4";
export const customWidgetAuthTypes = ["none", "bearer", "basic", "apiKeyHeader", "apiKeyQuery"] as const;
export type CustomWidgetAuthType = (typeof customWidgetAuthTypes)[number];
export const customWidgetDisplayTypes = [
"singleValue",
"keyValue",
"table",
"statGrid",
"progressBars",
"statusIndicator",
"countGrid",
"raw",
"actionButton",
"customJsx",
] as const;
export type CustomWidgetDisplayType = (typeof customWidgetDisplayTypes)[number];
export const customWidgetMethods = ["GET", "POST", "PUT", "DELETE", "PATCH"] as const;
export type CustomWidgetMethod = (typeof customWidgetMethods)[number];
export const customWidgetSecretKinds = ["apiKey", "username", "password"] as const;
export type CustomWidgetSecretKind = (typeof customWidgetSecretKinds)[number];
const singleValueDisplayConfigSchema = z.object({
type: z
.literal("singleValue")
.describe("Display type discriminator — must be 'singleValue' when displayType is singleValue"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the value from the API response (e.g. $.data.count, $.items[0].name, $.status). Use $ for the root object.",
),
label: z
.string()
.describe("Human-readable label shown alongside the extracted value (e.g. 'CPU Temperature', 'Total Users')"),
unit: z
.string()
.describe("Unit suffix displayed after the value (e.g. '°C', '%', 'GB'). Leave empty string if no unit is needed."),
valueSize: z
.enum(["sm", "md", "lg", "xl"])
.optional()
.describe(
"Font size of the displayed value: 'sm' (small), 'md' (medium), 'lg' (large, default), 'xl' (extra large)",
),
labelPosition: z
.enum(["above", "below"])
.optional()
.describe("Position of the label relative to the value: 'above' or 'below' (default: below)"),
});
const keyValueMappingSchema = z.object({
label: z.string().min(1).describe("Label for this key-value pair (e.g. 'CPU', 'Memory', 'Uptime')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract this value from the API response (e.g. $.cpu.percent, $.memory.used). Use $ for the root object.",
),
unit: z
.string()
.describe("Unit suffix for this value (e.g. '%', 'GB', 'ms'). Leave empty string if no unit is needed."),
});
const keyValueDisplayConfigSchema = z.object({
type: z.literal("keyValue").describe("Display type discriminator — must be 'keyValue' when displayType is keyValue"),
mappings: z
.array(keyValueMappingSchema)
.min(1)
.describe("Array of label/jsonPath/unit mappings — each entry becomes one labeled value in the widget"),
layout: z
.enum(["list", "grid"])
.optional()
.describe("Layout style: 'list' (vertical stack, default) or 'grid' (multi-column grid)"),
columns: z
.number()
.int()
.min(1)
.max(3)
.optional()
.describe("Number of columns when layout is 'grid' (1–3, default: 2)"),
});
const tableColumnSchema = z.object({
header: z.string().min(1).describe("Column header text shown in the table (e.g. 'Name', 'Status', 'Size')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression relative to each row object to extract the cell value (e.g. $.name, $.status, $.size). Do not include the array path here — that goes in tablePath.",
),
});
const tableDisplayConfigSchema = z.object({
type: z.literal("table").describe("Display type discriminator — must be 'table' when displayType is table"),
tablePath: z
.string()
.min(1)
.describe(
"JSONPath expression to the array of row objects in the API response (e.g. $.data.items, $.results, $.users[*])",
),
columns: z
.array(tableColumnSchema)
.min(1)
.describe("Column definitions — each column has a header and a jsonPath relative to each row"),
striped: z
.boolean()
.optional()
.describe("Whether to alternate row background colors for readability (default: true)"),
compact: z.boolean().optional().describe("Whether to use compact row spacing (default: false)"),
});
const statGridItemSchema = z.object({
label: z.string().min(1).describe("Label for this stat card (e.g. 'Movies', 'Series', 'Episodes')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the stat value from the API response (e.g. $.library.movies, $.counts.series)",
),
unit: z.string().describe("Unit suffix for the stat (e.g. '', 'GB', '%'). Leave empty string if no unit is needed."),
color: z
.string()
.optional()
.describe("Mantine color name for the stat card (e.g. 'blue', 'green', 'red', 'orange', 'violet'). Default: blue"),
});
const statGridDisplayConfigSchema = z.object({
type: z.literal("statGrid").describe("Display type discriminator — must be 'statGrid' when displayType is statGrid"),
items: z
.array(statGridItemSchema)
.min(1)
.describe("Array of stat cards — each item shows a labeled value in a colored card"),
columns: z.number().int().min(1).max(4).optional().describe("Number of columns in the stat grid (1–4, default: 2)"),
cardStyle: z
.enum(["filled", "outline", "subtle"])
.optional()
.describe(
"Visual style of stat cards: 'filled' (solid background, default), 'outline' (border only), 'subtle' (light tint)",
),
});
const progressBarItemSchema = z.object({
label: z.string().min(1).describe("Label for this progress bar (e.g. 'Disk Usage', 'Memory', 'Quota')"),
valuePath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the current value (numerator) from the API response (e.g. $.disk.used, $.memory.used_bytes)",
),
maxPath: z
.string()
.optional()
.describe(
"JSONPath expression to extract the maximum value (denominator). If omitted, defaults to 100. Use for ratios like used/total (e.g. $.disk.total)",
),
unit: z
.string()
.describe("Unit suffix shown alongside values (e.g. 'GB', 'MB', '%'). Leave empty string if no unit is needed."),
color: z
.string()
.optional()
.describe("Mantine color name for the progress bar fill (e.g. 'blue', 'green', 'red'). Default: blue"),
});
const progressBarsDisplayConfigSchema = z.object({
type: z
.literal("progressBars")
.describe("Display type discriminator — must be 'progressBars' when displayType is progressBars"),
bars: z
.array(progressBarItemSchema)
.min(1)
.describe("Array of progress bar definitions — each bar shows value/max as a visual progress indicator"),
showPercentage: z.boolean().optional().describe("Whether to display the percentage next to each bar (default: true)"),
barSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Height of progress bars: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const statusIndicatorItemSchema = z.object({
label: z.string().min(1).describe("Label for this status indicator (e.g. 'Web Server', 'Database', 'API')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the status value from the API response (e.g. $.services.web.status, $.health.database)",
),
goodValues: z
.array(z.string())
.min(1)
.describe(
"Array of string values considered 'healthy/good' — case-insensitive match (e.g. ['online', 'up', 'true', 'healthy', 'ok'])",
),
});
const statusIndicatorDisplayConfigSchema = z.object({
type: z
.literal("statusIndicator")
.describe("Display type discriminator — must be 'statusIndicator' when displayType is statusIndicator"),
items: z
.array(statusIndicatorItemSchema)
.min(1)
.describe("Array of status indicators — each shows a green dot if the value matches goodValues, red otherwise"),
layout: z
.enum(["list", "grid"])
.optional()
.describe("Layout style: 'list' (vertical, default) or 'grid' (multi-column)"),
dotSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Size of status dots: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const countGridItemSchema = z.object({
label: z.string().min(1).describe("Label for this count (e.g. 'Movies', 'Shows', 'Albums')"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the count value from the API response (e.g. $.counts.movies, $.stats.total)",
),
unit: z.string().describe("Unit suffix for the count (e.g. '', 'items'). Leave empty string if no unit is needed."),
});
const countGridDisplayConfigSchema = z.object({
type: z
.literal("countGrid")
.describe("Display type discriminator — must be 'countGrid' when displayType is countGrid"),
items: z
.array(countGridItemSchema)
.min(1)
.describe("Array of count items — each shows a number with a label in a simple grid (no colors)"),
columns: z.number().int().min(2).max(4).optional().describe("Number of columns in the count grid (2–4, default: 2)"),
valueSize: z
.enum(["sm", "md", "lg"])
.optional()
.describe("Font size of count values: 'sm' (small), 'md' (medium, default), 'lg' (large)"),
});
const rawDisplayConfigSchema = z.object({
type: z.literal("raw").describe("Display type discriminator — must be 'raw' when displayType is raw"),
jsonPath: z
.string()
.min(1)
.describe(
"JSONPath expression to extract the data to display (e.g. $ for entire response, $.data, $.result). The extracted value is pretty-printed as JSON.",
),
maxHeight: z
.number()
.int()
.min(50)
.max(1000)
.optional()
.describe("Maximum height of the raw JSON display area in pixels (50–1000, default: 300)"),
});
const FORBIDDEN_TEMPLATE_PATTERN =
/\bconstructor\b|\b__proto__\b|\bprototype\b|\beval\b|\bFunction\b|\bimport\s*\(|\brequire\b|\bglobalThis\b|\bwindow\b|\bdocument\b|\bfetch\b|\bXMLHttpRequest\b/i;
const customJsxDisplayConfigSchema = z.object({
type: z
.literal("customJsx")
.describe("Display type discriminator — must be 'customJsx' when displayType is customJsx"),
template: z
.string()
.min(1)
.max(10000)
.refine((t) => !FORBIDDEN_TEMPLATE_PATTERN.test(t), {
message:
"Template contains forbidden keywords (constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch)",
})
.describe(
"JSX template string using whitelisted Mantine components. Access API data via {data.fieldName}. Forbidden keywords: constructor, __proto__, eval, Function, import, require, globalThis, window, document, fetch",
),
});
const actionButtonDisplayConfigSchema = z.object({
type: z
.literal("actionButton")
.describe("Display type discriminator — must be 'actionButton' when displayType is actionButton"),
buttonLabel: z
.string()
.min(1)
.describe("Text shown on the action button (e.g. 'Restart Service', 'Clear Cache', 'Run Backup')"),
buttonColor: z
.string()
.optional()
.describe("Mantine color name for the button (e.g. 'blue', 'red', 'green'). Default: blue"),
confirmText: z
.string()
.optional()
.describe(
"If set, shows a confirmation dialog with this message before executing the API request. Omit for immediate execution.",
),
successMessage: z
.string()
.optional()
.describe("Notification message shown after successful API execution. Omit for a generic success message."),
});
export const displayConfigSchema = z
.discriminatedUnion("type", [
singleValueDisplayConfigSchema,
keyValueDisplayConfigSchema,
tableDisplayConfigSchema,
statGridDisplayConfigSchema,
progressBarsDisplayConfigSchema,
statusIndicatorDisplayConfigSchema,
countGridDisplayConfigSchema,
rawDisplayConfigSchema,
actionButtonDisplayConfigSchema,
customJsxDisplayConfigSchema,
])
.describe(
"Configuration for how API response data is displayed. The 'type' field MUST match the top-level 'displayType' field exactly. Choose the variant that matches your displayType.",
);
export type DisplayConfig = z.infer<typeof displayConfigSchema>;
const displayTypesMatch = (displayType?: string, configType?: string): boolean => {
if (!displayType) return true;
if (!configType) return true;
return displayType === configType;
};
const displayTypeMatchRefinement = (d: { displayType: string; displayConfig: { type: string } }) =>
displayTypesMatch(d.displayType, d.displayConfig.type);
const displayTypeMatchMessage = {
message: "displayType must match displayConfig.type",
path: ["displayConfig", "type"],
};
const baseDefinitionSchema = z.object({
name: z
.string()
.min(1)
.max(128)
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
description: z
.string()
.max(512)
.optional()
.describe("Optional longer description of what this widget shows, shown in tooltips and management UI"),
iconUrl: z
.string()
.url()
.optional()
.describe(
"Optional URL to an icon image for the widget (e.g. https://example.com/icon.png). Shown in widget picker.",
),
url: z
.string()
.min(1)
.describe(
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
authType: z
.enum(customWidgetAuthTypes)
.describe(
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter. Secrets are configured separately in the UI.",
),
headerName: z
.string()
.max(256)
.optional()
.describe(
"Custom header or query parameter name for API key auth. For apiKeyHeader: the HTTP header name (default: X-API-Key). For apiKeyQuery: the query param name (default: api_key). Ignored for other auth types.",
),
method: z
.enum(customWidgetMethods)
.describe(
"HTTP method for the API request: 'GET' (fetch data, most common), 'POST'/'PUT'/'PATCH' (with optional requestBody), 'DELETE' (destructive actions)",
),
requestBody: z
.string()
.optional()
.describe(
'JSON string body sent with POST/PUT/PATCH requests. Must be valid JSON as a string (e.g. \'{"action":"restart"}\'). Omit for GET requests.',
),
displayType: z
.enum(customWidgetDisplayTypes)
.describe(
"How to render the API response: 'singleValue' (one big number/text), 'keyValue' (labeled pairs), 'table' (rows/columns), 'statGrid' (colored stat cards), 'progressBars' (usage bars), 'statusIndicator' (health dots), 'countGrid' (simple counts), 'raw' (JSON debug view), 'actionButton' (clickable action), 'customJsx' (custom JSX template with Mantine components)",
),
displayConfig: displayConfigSchema,
});
const secretsInputSchema = z.array(
z.object({
kind: z
.enum(customWidgetSecretKinds)
.describe(
"Secret type: 'apiKey' (token/key for bearer/apiKey auth), 'username' (for basic auth), 'password' (for basic auth)",
),
value: z
.string()
.min(1)
.describe("The secret value. Never include in exported JSON — configure in the UI after import."),
}),
);
export const customWidgetCreateSchema = baseDefinitionSchema
.extend({ secrets: secretsInputSchema })
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
export const customWidgetUpdateSchema = baseDefinitionSchema
.partial()
.extend({ id: z.string(), secrets: secretsInputSchema.optional() })
.refine((d) => displayTypesMatch(d.displayType, d.displayConfig?.type), displayTypeMatchMessage);
const customWidgetImportFieldsSchema = z.object({
name: z
.string()
.min(1)
.max(128)
.describe("Human-readable name for the widget, displayed in the dashboard and management UI"),
description: z
.string()
.max(512)
.nullish()
.describe("Optional longer description of what this widget shows. Use null or omit if not needed."),
iconUrl: z
.string()
.url()
.nullish()
.describe("Optional URL to an icon image for the widget. Use null or omit if not needed."),
authType: z
.enum(customWidgetAuthTypes)
.describe(
"Authentication method: 'none' for public APIs, 'bearer' for Bearer token, 'basic' for Basic auth, 'apiKeyHeader' for API key sent as a custom header, 'apiKeyQuery' for API key as query parameter",
),
headerName: z
.string()
.max(256)
.nullish()
.describe(
"Custom header or query parameter name for API key auth. For apiKeyHeader: HTTP header name (default: X-API-Key). For apiKeyQuery: query param name (default: api_key).",
),
method: z
.enum(customWidgetMethods)
.describe(
"HTTP method: 'GET' (fetch data), 'POST'/'PUT'/'PATCH' (with requestBody), 'DELETE' (destructive). Use GET unless the API requires otherwise.",
),
requestBody: z.string().nullish().describe("JSON string body for POST/PUT/PATCH. Use null or omit for GET requests."),
displayType: z
.enum(customWidgetDisplayTypes)
.describe(
"How to render the API response: 'singleValue', 'keyValue', 'table', 'statGrid', 'progressBars', 'statusIndicator', 'countGrid', 'raw', 'actionButton', or 'customJsx'",
),
displayConfig: displayConfigSchema,
});
export const customWidgetImportSchema = customWidgetImportFieldsSchema
.extend({
$schema: z
.literal("homarr-custom-widget-v2")
.optional()
.describe("Schema version identifier. Should be 'homarr-custom-widget-v2' for current format."),
url: z
.string()
.min(1)
.describe(
"Full URL to the API endpoint to fetch data from (e.g. https://myapp.local/api/stats). Must include protocol.",
),
})
.refine(displayTypeMatchRefinement, displayTypeMatchMessage);
export type CustomWidgetImport = z.infer<typeof customWidgetImportSchema>;
+5
View File
@@ -13,6 +13,9 @@
},
"exports": {
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./errors": "./src/errors/component.tsx",
"./modals": "./src/modals/index.ts",
"./prefetch": "./src/prefetch.ts"
@@ -70,12 +73,14 @@
"@tiptap/starter-kit": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"jsonpath-plus": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-jsx-parser": "catalog:",
"react-markdown": "catalog:",
"react-quill-new": "catalog:",
"recharts": "catalog:",
+2
View File
@@ -1,6 +1,7 @@
import type { WidgetOptionType } from "../options";
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
import { WidgetAppInput } from "./widget-app-input";
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
import { WidgetLocationInput } from "./widget-location-input";
import { WidgetMultiTextInput } from "./widget-multi-text-input";
@@ -33,6 +34,7 @@ const mapping = {
umamiEventName: WidgetUmamiEventInput,
umamiEventNames: WidgetUmamiEventNamesInput,
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
} satisfies Record<WidgetOptionType, unknown>;
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
@@ -0,0 +1,386 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Badge, Combobox, Group, InputBase, ScrollArea, Text, useCombobox } from "@mantine/core";
import {
IconBraces,
IconChevronDown,
IconCircleCheck,
IconHash,
IconLetterT,
IconList,
IconToggleLeft,
} from "@tabler/icons-react";
const VALUE_TRUNCATE_LENGTH = 80;
const LEVEL_OFFSET = 14;
const BASE_PADDING = 8;
const typeIcons: Record<string, typeof IconLetterT> = {
string: IconLetterT,
number: IconHash,
boolean: IconToggleLeft,
null: IconToggleLeft,
object: IconBraces,
array: IconList,
};
function getValueType(val: unknown): string {
if (val === null) return "null";
if (Array.isArray(val)) return "array";
return typeof val;
}
function truncateValue(value: unknown): string {
const str = value === null ? "null" : String(value);
if (str.length <= VALUE_TRUNCATE_LENGTH) return str;
return `${str.slice(0, VALUE_TRUNCATE_LENGTH)}…`;
}
function appendPath(prefix: string, segment: string): string {
if (segment.startsWith("[")) return `${prefix}${segment}`;
return prefix === "$" ? `$.${segment}` : `${prefix}.${segment}`;
}
function lastSegment(path: string): string {
if (path === "$") return "$";
const bracketMatch = path.match(/\[(\d+)\]$/);
if (bracketMatch) return `[${bracketMatch[1]}]`;
const dotIndex = path.lastIndexOf(".");
return dotIndex === -1 ? path : path.slice(dotIndex + 1);
}
function getParentPath(path: string): string | null {
if (path.length <= 1) return null;
const cutIdx = Math.max(path.lastIndexOf("."), path.lastIndexOf("["));
if (cutIdx <= 0) return null;
return path.slice(0, cutIdx);
}
function isPrimitive(value: unknown): boolean {
return value === null || (typeof value !== "object" && typeof value !== "function");
}
function formatObjectPreview(entries: [string, unknown][]): string {
if (entries.length === 0) return "{}";
const parts = entries.slice(0, 3).map(([k, v]) => `${k}: ${truncateValue(v)}`);
const suffix = entries.length > 3 ? ", …" : "";
return `{${parts.join(", ")}${suffix}}`;
}
interface FlatNode {
path: string;
label: string;
preview: string | null;
type: string;
depth: number;
hasChildren: boolean;
isExpanded: boolean;
}
function flattenJson(
json: unknown,
prefix: string,
depth: number,
expanded: Record<string, boolean>,
seen: WeakSet<object>,
expandAll = false,
): FlatNode[] {
if (isPrimitive(json)) {
return [
{
path: prefix,
label: lastSegment(prefix),
preview: truncateValue(json),
type: getValueType(json),
depth,
hasChildren: false,
isExpanded: false,
},
];
}
const obj = json as object;
if (seen.has(obj)) return [];
seen.add(obj);
const nodes: FlatNode[] = [];
if (Array.isArray(json)) {
const isExpanded = expandAll || (expanded[prefix] ?? depth === 0);
const preview = json.length === 0 ? "[]" : `${json.length} items`;
nodes.push({
path: prefix,
label: lastSegment(prefix),
preview,
type: "array",
depth,
hasChildren: json.length > 0,
isExpanded,
});
if (isExpanded) {
for (let i = 0; i < json.length; i++) {
const childPath = `${prefix}[${i}]`;
nodes.push(...flattenJson(json[i], childPath, depth + 1, expanded, seen, expandAll));
}
}
return nodes;
}
const entries = Object.entries(json as Record<string, unknown>);
const isExpanded = expandAll || (expanded[prefix] ?? depth <= 1);
if (depth > 0) {
nodes.push({
path: prefix,
label: lastSegment(prefix),
preview: formatObjectPreview(entries),
type: "object",
depth,
hasChildren: entries.length > 0,
isExpanded,
});
}
if (isExpanded || depth === 0) {
for (const [key, val] of entries) {
const childPath = appendPath(prefix, key);
if (isPrimitive(val)) {
nodes.push({
path: childPath,
label: key,
preview: truncateValue(val),
type: getValueType(val),
depth: depth + 1,
hasChildren: false,
isExpanded: false,
});
} else {
nodes.push(...flattenJson(val, childPath, depth + 1, expanded, seen, expandAll));
}
}
}
return nodes;
}
const SEARCH_FIELDS = ["path", "label", "preview"] as const;
function matchesSearch(node: FlatNode, query: string): boolean {
if (!query) return true;
const q = query.toLowerCase();
return SEARCH_FIELDS.some((key) => node[key]?.toLowerCase().includes(q));
}
interface JsonPathTreePickerProps {
value?: string;
onChange: (path: string) => void;
json: unknown | null;
label?: string;
description?: string;
required?: boolean;
error?: string;
placeholder?: string;
noDataHint?: string;
loadedHint?: string;
}
const isChildPath = (child: string, parent: string) =>
child !== parent && (child.startsWith(`${parent}.`) || child.startsWith(`${parent}[`));
export function JsonPathTreePicker({
value = "",
onChange,
json,
label,
description,
required,
error,
placeholder,
noDataHint = "Run a test to browse response fields",
loadedHint = "Browsing live response",
}: JsonPathTreePickerProps) {
const [search, setSearch] = useState(value);
const [isTyping, setIsTyping] = useState(false);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!isTyping) {
setSearch(value || "");
}
}, [value, isTyping]);
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setIsTyping(false);
},
});
const hasData = json != null;
const searchQuery = isTyping ? search : "";
const isSearching = !!searchQuery.trim();
const flatNodes = useMemo(
() => (hasData ? flattenJson(json, "$", 0, expanded, new WeakSet(), isSearching) : []),
[json, expanded, hasData, isSearching],
);
const nodesByPath = useMemo(() => new Map(flatNodes.map((n) => [n.path, n])), [flatNodes]);
const fieldCount = flatNodes.length;
const visibleNodes = useMemo(() => {
if (!isSearching) return flatNodes;
const matchingPaths = new Set<string>();
for (const n of flatNodes) {
if (matchesSearch(n, searchQuery)) {
matchingPaths.add(n.path);
for (let parent = getParentPath(n.path); parent; parent = getParentPath(parent)) {
matchingPaths.add(parent);
}
}
}
return flatNodes.filter((n) => matchingPaths.has(n.path));
}, [flatNodes, searchQuery, isSearching]);
const toggleExpand = (path: string) => {
const childPaths = flatNodes.filter((n) => n.hasChildren && isChildPath(n.path, path)).map((n) => n.path);
setExpanded((prev) => {
const isCurrentlyExpanded = prev[path] ?? false;
const next = { ...prev, [path]: !isCurrentlyExpanded };
for (const cp of childPaths) {
next[cp] = !isCurrentlyExpanded;
}
return next;
});
};
return (
<Combobox
store={combobox}
withinPortal
width={560}
onOptionSubmit={(path) => {
const node = nodesByPath.get(path);
if (node?.hasChildren) {
toggleExpand(path);
return;
}
onChange(path);
setSearch(path);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
rightSection={<Combobox.Chevron />}
rightSectionPointerEvents="none"
value={search}
onChange={(event) => {
const val = event.currentTarget.value;
setSearch(val);
setIsTyping(true);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => {
combobox.closeDropdown();
if (isTyping && search.trim()) {
onChange(search);
} else {
setSearch(value || "");
}
setIsTyping(false);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && isTyping) {
event.preventDefault();
onChange(search);
setIsTyping(false);
combobox.closeDropdown();
}
}}
label={label}
description={description}
error={error}
required={required}
placeholder={placeholder}
/>
</Combobox.Target>
<Combobox.Dropdown>
{hasData && (
<Combobox.Header>
<Group gap="xs" px="xs" pt="xs" pb={4}>
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
{loadedHint}
</Badge>
<Text size="xs" c="dimmed">
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
</Text>
</Group>
</Combobox.Header>
)}
<Combobox.Options>
<ScrollArea.Autosize mah={420} type="scroll">
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
{visibleNodes.map((node) => (
<TreeNodeOption key={node.path} node={node} selected={value === node.path} isSearching={isSearching} />
))}
</ScrollArea.Autosize>
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
}
function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selected: boolean; isSearching: boolean }) {
const TypeIcon = typeIcons[node.type] ?? IconLetterT;
const indent = isSearching ? 0 : (node.depth - 1) * LEVEL_OFFSET;
return (
<Combobox.Option
value={node.path}
active={selected}
style={{
paddingInlineStart: BASE_PADDING + Math.max(0, indent),
paddingBlock: 4,
}}
>
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{node.hasChildren && (
<IconChevronDown
size={14}
style={{
flexShrink: 0,
color: "var(--mantine-color-dimmed)",
transform: node.isExpanded ? "rotate(0deg)" : "rotate(-90deg)",
transition: "transform 150ms ease",
}}
/>
)}
<TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
<Text
size="sm"
fw={selected ? 600 : 400}
c={selected ? "var(--mantine-color-primary-filled)" : undefined}
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}
>
{node.label}
</Text>
{node.preview !== null && (
<Text
size="xs"
c="dimmed"
style={{ flexShrink: 0, maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{node.preview}
</Text>
)}
</Group>
</Combobox.Option>
);
}
@@ -0,0 +1,91 @@
"use client";
import { useMemo, useState } from "react";
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
import { useFormContext } from "./form";
export const WidgetCustomWidgetSelectInput = ({
property,
kind,
options,
}: CommonWidgetInputProps<"customWidgetSelect">) => {
const t = useWidgetInputTranslation(kind, property);
const form = useFormContext();
const { data, isLoading } = clientApi.customWidget.all.useQuery();
const [search, setSearch] = useState("");
const currentValue = form.values.options[property] as string;
const definitions = useMemo(
() =>
(data ?? [])
.filter((def) => def.enabled || def.id === currentValue)
.map((def) => ({ value: def.id, label: def.name })),
[data, currentValue],
);
const filteredOptions = useMemo(
() =>
definitions.filter(
(def) =>
def.label.toLowerCase().includes(search.toLowerCase()) ||
def.value.toLowerCase().includes(search.toLowerCase()),
),
[definitions, search],
);
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
const combobox = useCombobox({
onDropdownClose: () => {
combobox.resetSelectedOption();
setSearch("");
},
});
return (
<Combobox
store={combobox}
onOptionSubmit={(val) => {
form.setFieldValue(`options.${property}`, val);
combobox.closeDropdown();
}}
>
<Combobox.Target>
<InputBase
label={t("label")}
description={options.withDescription ? t("description") : undefined}
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
rightSectionPointerEvents="none"
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
onChange={(event) => {
setSearch(event.currentTarget.value);
form.setFieldValue(`options.${property}`, event.currentTarget.value);
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
}}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={t("label")}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{filteredOptions.map((def) => (
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
{def.label}
</Combobox.Option>
))}
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>No definitions found</Combobox.Empty>}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
@@ -457,7 +457,7 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
isNaN(precision) || precision <= 0
? undefined
: {
...(tempRepository.versionFilter ?? {}),
...tempRepository.versionFilter,
precision,
},
});
@@ -561,11 +561,11 @@ const ImportRepositorySelect = ({
{tRepository("versionFilter.label")}:
</Text>
<Code>{repository.versionFilter.prefix && repository.versionFilter.prefix}</Code>
<Code>{repository.versionFilter.prefix}</Code>
<Code color="var(--mantine-primary-color-light)" fw={700}>
{versionFilterPrecisionOptions[repository.versionFilter.precision]}
</Code>
<Code>{repository.versionFilter.suffix && repository.versionFilter.suffix}</Code>
<Code>{repository.versionFilter.suffix}</Code>
</Group>
)}
</Group>
@@ -0,0 +1,493 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Button,
Card,
Center,
Code,
Flex,
Group,
Loader,
Progress,
ScrollArea,
SimpleGrid,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
const CustomJsxDisplay = dynamic(() => import("./custom-jsx-display"), { ssr: false });
const valueSizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg", xl: "xl" };
function SingleValueDisplay({ data }: { data: Record<string, unknown> }) {
const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg";
const labelAbove = (data.labelPosition as string) === "above";
const label = data.label ? (
<Text c="dimmed" size="sm">
{String(data.label)}
</Text>
) : null;
return (
<Stack h="100%" align="center" justify="center" gap="xs">
{labelAbove && label}
<Title order={size === "xl" ? 1 : size === "lg" ? 2 : size === "md" ? 3 : 4}>
{String(data.value ?? "—")}
{data.unit ? ` ${data.unit}` : ""}
</Title>
{!labelAbove && label}
</Stack>
);
}
function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
const entries = (data.entries as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const layout = (data.layout as string) ?? "list";
const columns = (data.columns as number) ?? 2;
if (layout === "grid") {
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{entries.map((entry, i) => (
<Stack key={i} align="center" gap={2}>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
<Text size="xs" c="dimmed">
{entry.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{entries.map((entry, i) => (
<Group key={i} justify="space-between" wrap="nowrap">
<Text size="sm" c="dimmed">
{entry.label}
</Text>
<Text size="sm" fw={600}>
{String(entry.value ?? "—")}
{entry.unit ? ` ${entry.unit}` : ""}
</Text>
</Group>
))}
</Stack>
);
}
function TableDisplay({ data }: { data: Record<string, unknown> }) {
const columns = (data.columns as string[]) ?? [];
const rows = (data.rows as unknown[][]) ?? [];
const striped = (data.striped as boolean) ?? true;
const compact = (data.compact as boolean) ?? false;
return (
<ScrollArea>
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, i) => (
<Table.Tr key={i}>
{row.map((cell, j) => (
<Table.Td key={j} py={compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
))}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
);
}
function StatGridCard({
item,
cardStyle,
}: {
item: { label: string; unit: string; color: string; value: unknown };
cardStyle: string;
}) {
const { ref, height, width } = useElementSize();
const isWide = width > height + 20;
const hideLabel = height > 0 && height <= 38;
const bgMap: Record<string, string> = {
filled: `var(--mantine-color-${item.color}-light)`,
outline: "transparent",
subtle: `var(--mantine-color-${item.color}-light)`,
};
return (
<Card
ref={ref}
p="sm"
radius="md"
bg={bgMap[cardStyle] ?? bgMap.filled}
withBorder={cardStyle === "outline"}
h="100%"
style={{ flex: 1 }}
>
<Flex
h="100%"
w="100%"
align="center"
justify="center"
direction={isWide ? "row" : "column"}
gap={isWide ? 8 : 4}
>
<Flex direction="column" align={isWide ? "flex-start" : "center"} gap={0}>
<Text size="md" fw={700} ta="center" lh={1.1}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
{!hideLabel && (
<Text size="xs" c="dimmed" ta="center" tt="uppercase" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
)}
</Flex>
</Flex>
</Card>
);
}
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const cardStyle = (data.cardStyle as string) ?? "filled";
return (
<SimpleGrid
cols={columns}
spacing="xs"
p="xs"
h="100%"
style={{ gridTemplateRows: `repeat(${Math.ceil(items.length / columns)}, 1fr)` }}
>
{items.map((item, i) => (
<StatGridCard key={i} item={item} cardStyle={cardStyle} />
))}
</SimpleGrid>
);
}
function ProgressBarsDisplay({ data }: { data: Record<string, unknown> }) {
const bars = (data.bars as Array<{ label: string; unit: string; color: string; value: number; max?: number }>) ?? [];
const showPercentage = (data.showPercentage as boolean) ?? true;
const barSize = (data.barSize as string) ?? "md";
const sizeMap: Record<string, number> = { sm: 8, md: 14, lg: 22 };
return (
<Stack h="100%" justify="center" gap="sm" p="sm">
{bars.map((bar, i) => {
const max = bar.max ?? 100;
const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0;
return (
<Stack key={i} gap={4}>
<Group justify="space-between" wrap="nowrap">
<Text size="xs" fw={500}>
{bar.label}
</Text>
<Text size="xs" c="dimmed">
{showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`}
{bar.max !== undefined && showPercentage
? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})`
: ""}
</Text>
</Group>
<Progress value={pct} size={sizeMap[barSize] ?? 14} color={bar.color} radius="sm" />
</Stack>
);
})}
</Stack>
);
}
function StatusIndicatorDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; value: string; isGood: boolean }>) ?? [];
const layout = (data.layout as string) ?? "list";
const dotSize = (data.dotSize as string) ?? "md";
const dotSizeMap: Record<string, number> = { sm: 8, md: 10, lg: 14 };
const size = dotSizeMap[dotSize] ?? 10;
const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
<Group key={i} gap="xs" wrap="nowrap">
<div
style={{
width: size,
height: size,
borderRadius: "50%",
backgroundColor: item.isGood ? "var(--mantine-color-green-6)" : "var(--mantine-color-red-6)",
flexShrink: 0,
}}
/>
<Text size="sm" fw={500}>
{item.label}
</Text>
<Text size="xs" c="dimmed" ml="auto">
{item.value}
</Text>
</Group>
);
if (layout === "grid") {
return (
<SimpleGrid cols={2} spacing="xs" p="sm" h="100%">
{items.map(renderItem)}
</SimpleGrid>
);
}
return (
<Stack h="100%" justify="center" gap="xs" p="sm">
{items.map(renderItem)}
</Stack>
);
}
function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
const items = (data.items as Array<{ label: string; unit: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
const valueSize = (data.valueSize as string) ?? "md";
const sizeMap: Record<string, string> = { sm: "sm", md: "md", lg: "lg" };
return (
<SimpleGrid cols={columns} spacing="xs" p="sm" h="100%">
{items.map((item, i) => (
<Stack key={i} align="center" justify="center" gap={0}>
<Text size={sizeMap[valueSize] ?? "md"} fw={700} lh={1.2}>
{String(item.value ?? "—")}
{item.unit ? ` ${item.unit}` : ""}
</Text>
<Text size="xs" c="dimmed" tt="uppercase" ta="center" lh={1.3} style={{ letterSpacing: "0.03em" }}>
{item.label}
</Text>
</Stack>
))}
</SimpleGrid>
);
}
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data }: { data: Record<string, unknown> }) {
const maxHeight = (data.maxHeight as number) ?? 300;
const jsonString = JSON.stringify(data.data, null, 2);
return (
<Stack gap={4} p="xs">
<Group justify="flex-end">
<Tooltip label="Open in browser JSON viewer">
<ActionIcon variant="subtle" size="sm" onClick={() => openJsonInBrowser(data.data)}>
<IconExternalLink size={14} />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea mah={maxHeight}>
<Code block style={{ fontSize: 12 }}>
{jsonString}
</Code>
</ScrollArea>
</Stack>
);
}
function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
const t = useScopedI18n("widget.customApi");
const { openConfirmModal } = useConfirmModal();
const executeMutation = clientApi.customWidget.execute.useMutation();
const [lastSuccess, setLastSuccess] = useState(false);
const buttonLabel = (data.buttonLabel as string) ?? "Execute";
const buttonColor = (data.buttonColor as string) ?? "blue";
const confirmText = (data.confirmText as string) || "";
const successMessage = (data.successMessage as string) || t("executeSuccess");
const definitionId = data.widgetDefinitionId as string | undefined;
const handleExecute = async () => {
if (!definitionId) return;
setLastSuccess(false);
try {
const result = await executeMutation.mutateAsync({ definitionId });
if (result.success) {
setLastSuccess(true);
showSuccessNotification({ title: buttonLabel, message: successMessage });
setTimeout(() => setLastSuccess(false), 3000);
} else {
showErrorNotification({ title: buttonLabel, message: result.error ?? t("executeFailed") });
}
} catch {
showErrorNotification({ title: buttonLabel, message: t("executeFailed") });
}
};
const handleClick = () => {
if (confirmText) {
openConfirmModal({
title: buttonLabel,
children: confirmText,
onConfirm: () => void handleExecute(),
});
} else {
void handleExecute();
}
};
return (
<Center h="100%">
<Button
size="lg"
color={buttonColor}
onClick={handleClick}
loading={executeMutation.isPending}
leftSection={lastSuccess ? <IconCheck size={20} /> : <IconPlayerPlay size={20} />}
variant={lastSuccess ? "light" : "filled"}
>
{executeMutation.isPending ? t("executing") : buttonLabel}
</Button>
</Center>
);
}
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
};
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
if (!definitionId) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color="var(--mantine-color-yellow-6)" />
<Text c="dimmed" size="sm">
{t("noDefinition")}
</Text>
</Stack>
</Center>
);
}
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
}
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
{ definitionId },
{
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (result?.type === "actionButton" || result?.type === "disabled") return false;
return intervalMs;
},
retry: (failureCount, err) => {
if (err.data?.code === "NOT_FOUND") return false;
return failureCount < 3;
},
},
);
if (isLoading) {
return (
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (error) {
const isNotFound = error.data?.code === "NOT_FOUND";
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={32} color={`var(--mantine-color-${isNotFound ? "yellow" : "red"}-6)`} />
<Text c="dimmed" size="sm" ta="center">
{isNotFound ? t("definitionNotFound") : error.message}
</Text>
</Stack>
</Center>
);
}
if (!data) return null;
const widgetData = data as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;
if (dataType === "disabled") {
return (
<Center h="100%">
<Text c="dimmed" size="sm">
{tCustomWidget("widget.disabled")}
</Text>
</Center>
);
}
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
return <Component data={enrichedData} />;
}
return (
<ScrollArea h="100%" p="xs">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}
@@ -0,0 +1,178 @@
import { MantineProvider } from "@mantine/core";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import type { Root } from "react-dom/client";
import CustomJsxDisplay from "./custom-jsx-display";
import { SAFE_BINDINGS } from "./jsx-whitelist";
describe("CustomJsxDisplay", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(() => {
root?.unmount();
container?.remove();
});
const renderDisplay = async (data: Record<string, unknown>) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(createElement(MantineProvider, null, createElement(CustomJsxDisplay, { data })));
});
};
it("renders Mantine components with data bindings", async () => {
await renderDisplay({
template: "<Stack><Title order={3}>{data.name}</Title><Badge>{data.count}</Badge></Stack>",
data: { name: "Server A", count: "3" },
});
expect(container.textContent).toContain("Server A");
expect(container.textContent).toContain("3");
});
it("blocks script tags and event handlers", async () => {
await renderDisplay({
template: '<script>document.title = "hacked"</script><button onClick={() => {}}>Click</button><Text>Safe</Text>',
data: {},
});
expect(document.title).not.toBe("hacked");
expect(container.querySelector("script")).toBeNull();
expect(container.textContent).toContain("Safe");
});
describe("safe bindings security", () => {
it("exposes wrapper functions, not native constructors", () => {
const bindings = SAFE_BINDINGS({ value: 7 });
expect(typeof bindings.String).toBe("function");
expect(bindings.String(42)).toBe("42");
expect(typeof bindings.Number).toBe("function");
expect(bindings.Number("42")).toBe(42);
expect(typeof bindings.Boolean).toBe("function");
expect(bindings.Boolean(1)).toBe(true);
expect(bindings.String).not.toBe(String);
expect(bindings.Number).not.toBe(Number);
expect(bindings.Boolean).not.toBe(Boolean);
});
it("uses null-prototype objects for Math/JSON/Array/Object helpers", () => {
const bindings = SAFE_BINDINGS({});
expect(Object.getPrototypeOf(bindings.Math)).toBeNull();
expect(Object.getPrototypeOf(bindings.JSON)).toBeNull();
expect(Object.getPrototypeOf(bindings.Array)).toBeNull();
expect(Object.getPrototypeOf(bindings.Object)).toBeNull();
});
it("sanitizes data to strip constructor/__proto__/prototype keys", () => {
const maliciousData = {
name: "safe",
constructor: "should be stripped",
__proto__: "should be stripped",
prototype: "should be stripped",
nested: {
value: 1,
constructor: "also stripped",
},
list: [{ constructor: "stripped", val: 2 }],
};
const bindings = SAFE_BINDINGS(maliciousData);
const data = bindings.data as Record<string, unknown>;
expect(data.name).toBe("safe");
expect("constructor" in data).toBe(false);
expect("__proto__" in data).toBe(false);
expect("prototype" in data).toBe(false);
const nested = data.nested as Record<string, unknown>;
expect(nested.value).toBe(1);
expect("constructor" in nested).toBe(false);
const list = data.list as Array<Record<string, unknown>>;
const firstItem = list[0] as Record<string, unknown>;
expect(firstItem.val).toBe(2);
expect("constructor" in firstItem).toBe(false);
});
it("data object has no prototype chain to Function", () => {
const bindings = SAFE_BINDINGS({ value: 1 });
const data = bindings.data as Record<string, unknown>;
expect(Object.getPrototypeOf(data)).toBeNull();
expect("constructor" in data).toBe(false);
});
it("does not expose eval, Function, fetch, window, document", () => {
const bindings = SAFE_BINDINGS({});
expect("eval" in bindings).toBe(false);
expect("Function" in bindings).toBe(false);
expect("fetch" in bindings).toBe(false);
expect("window" in bindings).toBe(false);
expect("document" in bindings).toBe(false);
});
it("Math helpers work correctly", () => {
const bindings = SAFE_BINDINGS({});
const math = bindings.Math as {
round: (v: number) => number;
floor: (v: number) => number;
ceil: (v: number) => number;
abs: (v: number) => number;
};
expect(math.round(3.7)).toBe(4);
expect(math.floor(3.7)).toBe(3);
expect(math.ceil(3.2)).toBe(4);
expect(math.abs(-5)).toBe(5);
});
it("JSON.stringify works correctly", () => {
const bindings = SAFE_BINDINGS({});
const json = bindings.JSON as { stringify: (v: unknown) => string };
expect(json.stringify({ a: 1 })).toBe('{"a":1}');
});
it("Object/Array helpers work correctly", () => {
const bindings = SAFE_BINDINGS({});
const obj = bindings.Object as {
keys: (v: object) => string[];
values: (v: object) => unknown[];
entries: (v: object) => [string, unknown][];
};
const arr = bindings.Array as { isArray: (v: unknown) => boolean };
expect(obj.keys({ a: 1, b: 2 })).toEqual(["a", "b"]);
expect(obj.values({ a: 1 })).toEqual([1]);
expect(arr.isArray([1, 2])).toBe(true);
expect(arr.isArray("not array")).toBe(false);
});
});
});
@@ -0,0 +1,68 @@
"use client";
import { useCallback, useState } from "react";
import JsxParser from "react-jsx-parser";
import { Alert, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { WHITELISTED_COMPONENTS, SAFE_BINDINGS } from "./jsx-whitelist";
const MAX_PARSE_ERRORS = 5;
function appendParseError(prev: string[], message: string): string[] {
if (prev.length >= MAX_PARSE_ERRORS) return prev;
if (prev.includes(message)) return prev;
return [...prev, message];
}
export default function CustomJsxDisplay({ data }: { data: Record<string, unknown> }) {
const template = String(data.template ?? "");
const apiData = data.data;
const [parseErrors, setParseErrors] = useState<string[]>([]);
const handleError = useCallback((error: Error) => {
setParseErrors((prev) => appendParseError(prev, error.message));
}, []);
if (!template.trim()) {
return (
<Alert color="gray" variant="light" p="xs">
<Text size="xs" c="dimmed">
No JSX template configured
</Text>
</Alert>
);
}
return (
<Stack gap={0} h="100%">
<JsxParser
jsx={template}
components={WHITELISTED_COMPONENTS as never}
bindings={SAFE_BINDINGS(apiData)}
componentsOnly
allowUnknownElements={false}
blacklistedAttrs={[/^on.+/i, /^dangerously/i]}
blacklistedTags={["script", "iframe", "object", "embed", "form", "style", "link", "meta", "base"]}
onError={handleError}
renderError={({ error }) => (
<Alert color="red" variant="light" icon={<IconAlertTriangle size={16} />} p="xs">
<Text size="xs">{String(error)}</Text>
</Alert>
)}
/>
{parseErrors.length > 0 && (
<Alert color="yellow" variant="light" p="xs" mt="xs">
<Text size="xs" c="dimmed">
{parseErrors.length} template warning(s):
</Text>
{parseErrors.map((msg) => (
<Text key={msg} size="xs" c="dimmed" style={{ fontFamily: "monospace" }}>
{msg}
</Text>
))}
</Alert>
)}
</Stack>
);
}
@@ -0,0 +1 @@
export { extractDisplayData } from "@homarr/api/custom-widget/display-data";
+20
View File
@@ -0,0 +1,20 @@
import { IconApi } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
refreshInterval: factory.slider({
defaultValue: 30,
validate: z.number().min(1).max(3600),
step: 1,
withDescription: true,
}),
}));
},
}).withDynamicImport(() => import("./component"));
@@ -0,0 +1,224 @@
"use client";
import type { ReactNode } from "react";
import { Children, isValidElement, useEffect, useRef, useState } from "react";
import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core";
import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react";
interface PaginatedListProps {
children: ReactNode;
pageSize?: number;
}
export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
const items = Children.toArray(children);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const [page, setPage] = useState(0);
const prevCountRef = useRef(items.length);
useEffect(() => {
if (items.length !== prevCountRef.current) {
setPage(0);
prevCountRef.current = items.length;
}
}, [items.length]);
const clampedPage = Math.min(page, totalPages - 1);
const start = clampedPage * pageSize;
const visible = items.slice(start, start + pageSize);
return (
<Stack gap="xs">
<div>{visible}</div>
{totalPages > 1 && (
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
disabled={clampedPage >= totalPages - 1}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
</ActionIcon>
</Group>
)}
</Stack>
);
}
interface TabsContainerProps {
children: ReactNode;
defaultTab?: string;
}
function isTabPanel(child: ReactNode): child is React.ReactElement<TabPanelProps> {
return isValidElement<TabPanelProps>(child) && child.type === TabPanel && typeof child.props.value === "string";
}
export function TabsContainer({ children, defaultTab }: TabsContainerProps) {
const panels = Children.toArray(children).filter(isTabPanel);
const firstPanel = panels[0];
return (
<Tabs defaultValue={defaultTab ?? firstPanel?.props.value}>
<Tabs.List>
{panels.map((panel) => (
<Tabs.Tab key={panel.props.value} value={panel.props.value}>
{panel.props.label ?? panel.props.value}
</Tabs.Tab>
))}
</Tabs.List>
{panels.map((panel) => (
<Tabs.Panel key={panel.props.value} value={panel.props.value} pt="sm">
{panel.props.children}
</Tabs.Panel>
))}
</Tabs>
);
}
interface TabPanelProps {
children: ReactNode;
value: string;
label?: string;
}
export function TabPanel({ children }: TabPanelProps) {
return <>{children}</>;
}
interface CollapsibleProps {
children: ReactNode;
title: string;
defaultOpen?: boolean;
}
const COLLAPSE_CHEVRONS = {
closed: IconChevronDown,
open: IconChevronUp,
} as const;
const COLLAPSE_STATE: Record<0 | 1, keyof typeof COLLAPSE_CHEVRONS> = {
0: "closed",
1: "open",
};
export function Collapsible({ children, title, defaultOpen = false }: CollapsibleProps) {
const [opened, setOpened] = useState(defaultOpen);
const Chevron = COLLAPSE_CHEVRONS[COLLAPSE_STATE[Number(opened) as 0 | 1]];
return (
<Stack gap={0}>
<UnstyledButton onClick={() => setOpened((o) => !o)} py={4}>
<Group gap="xs" wrap="nowrap">
<Chevron size={14} />
<Text size="sm" fw={600}>
{title}
</Text>
</Group>
</UnstyledButton>
<Collapse expanded={opened}>{children}</Collapse>
</Stack>
);
}
interface StatBarProps {
value: number;
max?: number;
label?: string;
color?: string;
}
function calcPct(value: number, max: number): number {
if (max <= 0) return 0;
return Math.min((value / max) * 100, 100);
}
export function StatBar({ value, max = 100, label, color = "blue" }: StatBarProps) {
const pct = calcPct(value, max);
return (
<Group gap="xs" wrap="nowrap">
{label && (
<Text size="xs" w={80} ta="right" c="dimmed">
{label}
</Text>
)}
<div
style={{
flex: 1,
height: 8,
borderRadius: 4,
background: "var(--mantine-color-default-border)",
overflow: "hidden",
}}
>
<div
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 4,
background: `var(--mantine-color-${color}-6)`,
transition: "width 0.3s ease",
}}
/>
</div>
<Text size="xs" w={30} c="dimmed">
{value}
</Text>
</Group>
);
}
interface TypeBadgeProps {
type: string;
size?: "xs" | "sm" | "md" | "lg" | "xl";
}
const TYPE_COLORS: Record<string, string> = {
normal: "gray",
fire: "red",
water: "blue",
electric: "yellow",
grass: "green",
ice: "cyan",
fighting: "orange",
poison: "grape",
ground: "yellow",
flying: "indigo",
psychic: "pink",
bug: "lime",
rock: "orange",
ghost: "violet",
dragon: "indigo",
dark: "dark",
steel: "gray",
fairy: "pink",
};
function normalizeType(type: unknown): string {
if (typeof type === "string") return type;
if (type == null) return "";
return String(type);
}
export function TypeBadge({ type, size = "sm" }: TypeBadgeProps) {
const typeStr = normalizeType(type);
const color = TYPE_COLORS[typeStr.toLowerCase()] ?? "gray";
return (
<Badge color={color} size={size} variant="filled" tt="capitalize">
{typeStr}
</Badge>
);
}
@@ -0,0 +1,192 @@
import type { ComponentType } from "react";
import { createElement } from "react";
import {
Stack,
Group,
Flex,
Grid,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor,
NumberFormatter,
Badge,
Card,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
List,
Timeline,
Accordion,
Indicator,
Pill,
Spoiler,
Progress,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
} from "@mantine/core";
import {
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
} from "@mantine/charts";
import { PaginatedList, TabsContainer, TabPanel, Collapsible, StatBar, TypeBadge } from "./jsx-interactive-components";
const SAFE_URL_PATTERN = /^https?:\/\//i;
function isSafeUrl(url: unknown): boolean {
if (typeof url !== "string") return false;
return SAFE_URL_PATTERN.test(url) || url.startsWith("/") || url.startsWith("#");
}
function SafeAnchor(props: Record<string, unknown>) {
const href = props.href;
const safeHref = href && isSafeUrl(href) ? href : undefined;
return createElement(Anchor as never, { ...props, href: safeHref });
}
export const WHITELISTED_COMPONENTS: Record<string, ComponentType<never>> = {
Stack,
Group,
Flex,
Grid,
"Grid.Col": Grid.Col,
SimpleGrid,
Center,
Space,
Container,
AspectRatio,
Text,
Title,
Code,
Highlight,
Mark,
Kbd,
Blockquote,
Anchor: SafeAnchor,
NumberFormatter,
Badge,
Card,
"Card.Section": Card.Section,
Paper,
Alert,
ThemeIcon,
ColorSwatch,
Table,
"Table.Thead": Table.Thead,
"Table.Tbody": Table.Tbody,
"Table.Tr": Table.Tr,
"Table.Th": Table.Th,
"Table.Td": Table.Td,
List,
"List.Item": List.Item,
Timeline,
"Timeline.Item": Timeline.Item,
Accordion,
"Accordion.Item": Accordion.Item,
"Accordion.Control": Accordion.Control,
"Accordion.Panel": Accordion.Panel,
Indicator,
Pill,
Spoiler,
Progress,
"Progress.Section": Progress.Section,
RingProgress,
Skeleton,
Loader,
Image,
Avatar,
"Avatar.Group": Avatar.Group,
BackgroundImage,
Tooltip,
Divider,
ScrollArea,
AreaChart,
BarChart,
LineChart,
DonutChart,
PieChart,
RadarChart,
RadialBarChart,
Sparkline,
PaginatedList,
TabsContainer,
TabPanel,
Collapsible,
StatBar,
TypeBadge,
};
function sanitizeData(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj;
if (typeof obj !== "object") return obj;
if (Array.isArray(obj)) return obj.map(sanitizeData);
const safe: Record<string, unknown> = Object.create(null);
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (key === "constructor" || key === "__proto__" || key === "prototype") continue;
safe[key] = sanitizeData(value);
}
return safe;
}
const safeMath: Record<string, unknown> = Object.create(null);
safeMath.round = (v: number) => Math.round(v);
safeMath.floor = (v: number) => Math.floor(v);
safeMath.ceil = (v: number) => Math.ceil(v);
safeMath.abs = (v: number) => Math.abs(v);
safeMath.min = (...args: number[]) => Math.min(...args);
safeMath.max = (...args: number[]) => Math.max(...args);
safeMath.pow = (b: number, e: number) => Math.pow(b, e);
safeMath.sqrt = (v: number) => Math.sqrt(v);
safeMath.PI = Math.PI;
Object.freeze(safeMath);
const safeJSON: Record<string, unknown> = Object.create(null);
safeJSON.stringify = (v: unknown) => JSON.stringify(v);
Object.freeze(safeJSON);
const safeArray: Record<string, unknown> = Object.create(null);
safeArray.isArray = (v: unknown) => Array.isArray(v);
Object.freeze(safeArray);
const safeObject: Record<string, unknown> = Object.create(null);
safeObject.keys = (v: object) => Object.keys(v);
safeObject.values = (v: object) => Object.values(v);
safeObject.entries = (v: object) => Object.entries(v);
Object.freeze(safeObject);
export const SAFE_BINDINGS = (apiData: unknown) => ({
data: sanitizeData(apiData),
String: (v: unknown) => String(v),
Number: (v: unknown) => Number(v),
Boolean: (v: unknown) => Boolean(v),
Math: safeMath,
JSON: safeJSON,
Array: safeArray,
Object: safeObject,
});
@@ -1,4 +1,15 @@
import { Group, Indicator, Popover, Table, TableTbody, TableTh, TableThead, TableTr, Text } from "@mantine/core";
import {
Group,
Indicator,
Popover,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
} from "@mantine/core";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
@@ -39,7 +50,7 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
</TableThead>
<TableTbody>
{data
.sort((itemA, itemB) => {
.toSorted((itemA, itemB) => {
const nodeResult = itemA.node.localeCompare(itemB.node);
if (nodeResult !== 0) return nodeResult;
return itemA.name.localeCompare(itemB.name);
@@ -49,14 +60,16 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
<ResourcePopover key={item.id} item={item}>
<Popover.Target>
<TableTr fz={isTiny ? "8px" : "xs"}>
<td>
<TableTd>
<Group wrap="nowrap" gap={isTiny ? 8 : "xs"}>
<Indicator size={isTiny ? 4 : 8} children={null} color={item.isRunning ? "green" : "yellow"} />
<Indicator size={isTiny ? 4 : 8} color={item.isRunning ? "green" : "yellow"}>
{null}
</Indicator>
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
{item.name}
</Text>
</Group>
</td>
</TableTd>
{item.type === "storage" ? (
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
) : (
+2
View File
@@ -57,6 +57,7 @@ import * as beszelAlerts from "./beszel-alerts";
import * as beszelSystemStats from "./beszel-system-stats";
import * as video from "./video";
import * as weather from "./weather";
import * as customApi from "./custom-api";
export type { WidgetDefinition, WidgetOptionsSettings } from "./definition";
export type { WidgetComponentProps };
@@ -110,6 +111,7 @@ export const widgetImports = {
beszelSystemGrid,
beszelAlerts,
beszelSystemStats,
customApi,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;
+5
View File
@@ -175,6 +175,11 @@ const optionsFactory = {
defaultValue: "",
withDescription: true,
}),
customWidgetSelect: (input?: CommonInput<string>) => ({
type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "",
withDescription: input?.withDescription ?? false,
}),
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
input: SortableItemListInput<TItem, TOptionValue>,
) => ({
+77 -4
View File
@@ -390,6 +390,9 @@ catalogs:
json5:
specifier: ^2.2.3
version: 2.2.3
jsonpath-plus:
specifier: ^10.4.0
version: 10.4.0
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -459,6 +462,9 @@ catalogs:
react-error-boundary:
specifier: ^6.1.2
version: 6.1.2
react-jsx-parser:
specifier: ^2.4.1
version: 2.4.1
react-markdown:
specifier: ^10.1.0
version: 10.1.0
@@ -582,6 +588,10 @@ patchedDependencies:
importers:
.:
dependencies:
jsonpath-plus:
specifier: 'catalog:'
version: 10.4.0
devDependencies:
'@playwright/test':
specifier: 'catalog:'
@@ -1199,6 +1209,9 @@ importers:
'@trpc/tanstack-react-query':
specifier: 'catalog:'
version: 11.17.0(@tanstack/react-query@5.101.0(react@19.2.7))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.7)(typescript@5.9.3)
jsonpath-plus:
specifier: 'catalog:'
version: 10.4.0
lodash.clonedeep:
specifier: 'catalog:'
version: 4.5.0
@@ -1547,6 +1560,9 @@ importers:
'@homarr/server-settings':
specifier: workspace:^0.1.0
version: link:../server-settings
'@homarr/validation':
specifier: workspace:^0.1.0
version: link:../validation
'@mantine/core':
specifier: 'catalog:'
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -2523,6 +2539,9 @@ importers:
dayjs:
specifier: 'catalog:'
version: 1.11.21
jsonpath-plus:
specifier: 'catalog:'
version: 10.4.0
lucide-react:
specifier: 'catalog:'
version: 1.17.0(react@19.2.7)
@@ -2541,6 +2560,9 @@ importers:
react-dom:
specifier: 'catalog:'
version: 19.2.7(react@19.2.7)
react-jsx-parser:
specifier: 'catalog:'
version: 2.4.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-markdown:
specifier: 'catalog:'
version: 10.1.0(@types/react@19.2.16)(react@19.2.7)
@@ -5721,17 +5743,28 @@ packages:
'@types/prismjs@1.26.6':
resolution: {integrity: sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qs@6.9.16':
resolution: {integrity: sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==}
'@types/range-parser@1.2.7':
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
'@types/react': ^18.0.0
'@types/react-dom@19.2.3':
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react@18.3.31':
resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==}
'@types/react@19.2.16':
resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==}
@@ -6051,6 +6084,11 @@ packages:
peerDependencies:
acorn: ^8.14.0
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'}
@@ -7986,8 +8024,8 @@ packages:
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonpath-plus@10.3.0:
resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==}
jsonpath-plus@10.4.0:
resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -9217,6 +9255,13 @@ packages:
react-is@19.2.7:
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
react-jsx-parser@2.4.1:
resolution: {integrity: sha512-gE25DiHseuKmSbb5O1K1IkyQ/bwth3zl4z3EdW8XMhwrb2agUpuh8GvqHFvGwh/wdZjDDuadGrSR2lEgjcOvqQ==}
engines: {bun: ^1.1.27}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -11908,7 +11953,7 @@ snapshots:
hpagent: 1.2.0
isomorphic-ws: 5.0.0(ws@8.20.1)
js-yaml: 4.1.1
jsonpath-plus: 10.3.0
jsonpath-plus: 10.4.0
node-fetch: 2.7.0
openid-client: 6.3.3
rfc4648: 1.5.4
@@ -13721,14 +13766,28 @@ snapshots:
'@types/prismjs@1.26.6': {}
'@types/prop-types@15.7.15':
optional: true
'@types/qs@6.9.16': {}
'@types/range-parser@1.2.7': {}
'@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
optional: true
'@types/react-dom@19.2.3(@types/react@19.2.16)':
dependencies:
'@types/react': 19.2.16
'@types/react@18.3.31':
dependencies:
'@types/prop-types': 15.7.15
csstype: 3.2.3
optional: true
'@types/react@19.2.16':
dependencies:
csstype: 3.2.3
@@ -14115,6 +14174,10 @@ snapshots:
dependencies:
acorn: 8.16.0
acorn-jsx@5.3.2(acorn@8.16.0):
dependencies:
acorn: 8.16.0
acorn@8.16.0: {}
adm-zip@0.5.17: {}
@@ -16066,7 +16129,7 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonpath-plus@10.3.0:
jsonpath-plus@10.4.0:
dependencies:
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
@@ -17495,6 +17558,16 @@ snapshots:
react-is@19.2.7: {}
react-jsx-parser@2.4.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
acorn: 8.16.0
acorn-jsx: 5.3.2(acorn@8.16.0)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 18.3.31
'@types/react-dom': 18.3.7(@types/react@18.3.31)
react-markdown@10.1.0(@types/react@19.2.16)(react@19.2.7):
dependencies:
'@types/hast': 3.0.4
+3
View File
@@ -134,9 +134,11 @@ catalog:
jotai: ^2.20.0
jsdom: ^29.1.1
json5: ^2.2.3
jsonpath-plus: ^10.4.0
ldapts: 8.1.8
lodash.clonedeep: ^4.5.0
lucide-react: ^1.17.0
"mantine-contextmenu": ^7.15.1
mantine-datatable: ^9.2.2
mantine-react-table: npm:mantine-react-table-open@9.0.3
mcp-handler: ^1.1.0
@@ -156,6 +158,7 @@ catalog:
react: 19.2.7
react-dom: 19.2.7
react-error-boundary: ^6.1.2
react-jsx-parser: ^2.4.1
react-markdown: ^10.1.0
react-quill-new: ^3.8.3
react-simple-code-editor: ^0.14.1