feat: custom widgets (#5889)
This commit is contained in:
1 parent
48e1844b5b
commit
aecbca2b81
69 files changed
+14620
-142
No files matched your search
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
Reference in new issue
Block a user