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

+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>,
) => ({