Files
homarr/packages/widgets/src/custom-api/component.tsx
T

604 lines
19 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Badge,
Box,
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, IconPlayerPause, 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>
);
}
interface CustomDisplayProps {
data: Record<string, unknown>;
displayMode: "compact" | "advanced";
width: number;
height: number;
}
function TableDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
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;
const visibleColumnCount = displayMode === "advanced" ? columns.length : Math.max(1, Math.floor(width / 120));
const visibleRowCount = displayMode === "advanced" ? rows.length : Math.max(1, Math.floor(height / 32) - 1);
const visibleColumns = columns.slice(0, visibleColumnCount);
return (
<ScrollArea h="100%">
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{visibleColumns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.slice(0, visibleRowCount).map((row, i) => (
<Table.Tr key={i}>
{row.slice(0, visibleColumnCount).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, displayMode, width }: CustomDisplayProps) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const configuredColumns = (data.columns as number) ?? 2;
const columns =
displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 120)));
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, "_blank", "noopener,noreferrer");
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data, displayMode, height }: CustomDisplayProps) {
const maxHeight = displayMode === "advanced" ? Math.max(160, height - 80) : ((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>
);
}
const CustomJsxAdapter = ({ data }: CustomDisplayProps) => <CustomJsxDisplay data={data} />;
export const displayComponents: Record<string, ComponentType<CustomDisplayProps>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
statGrid: StatGridDisplay,
progressBars: ProgressBarsDisplay,
statusIndicator: StatusIndicatorDisplay,
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxAdapter,
};
const displayTypeTranslationKeys = {
singleValue: "displayType.singleValue",
keyValue: "displayType.keyValue",
table: "displayType.table",
statGrid: "displayType.statGrid",
progressBars: "displayType.progressBars",
statusIndicator: "displayType.statusIndicator",
countGrid: "displayType.countGrid",
raw: "displayType.raw",
actionButton: "displayType.actionButton",
customJsx: "displayType.customJsx",
} as const;
export default function CustomApiWidget({
options,
width,
height,
displayMode = "compact",
widgetStateRef,
}: 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}
width={width}
height={height}
displayMode={displayMode}
widgetStateRef={widgetStateRef}
/>
);
}
function CustomApiWidgetInner({
definitionId,
refreshInterval,
width,
height,
displayMode,
widgetStateRef,
}: {
definitionId: string;
refreshInterval: number;
width: number;
height: number;
displayMode: "compact" | "advanced";
widgetStateRef?: WidgetComponentProps<"customApi">["widgetStateRef"];
}) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
const [pollingPaused, setPollingPaused] = useState(false);
const { data, isLoading, error, refetch, isFetching } = clientApi.widget.customApi.getData.useQuery(
{ definitionId },
{
refetchInterval: (query) => {
const result = query.state.data as Record<string, unknown> | undefined;
if (pollingPaused || result?.type === "actionButton" || result?.type === "disabled") return false;
return intervalMs;
},
retry: (failureCount, err) => {
if (err.data?.code === "NOT_FOUND") return false;
return failureCount < 3;
},
},
);
const widgetData = (data ?? {}) as Record<string, unknown>;
const dataType = widgetData.type as string | undefined;
const canTogglePolling = Boolean(data) && dataType !== "actionButton" && dataType !== "disabled";
const togglePolling = useCallback(() => setPollingPaused((value) => !value), []);
useEffect(() => {
if (!widgetStateRef) return;
if (canTogglePolling) {
widgetStateRef.current = { ...widgetStateRef.current, togglePolling };
} else if (widgetStateRef.current) {
delete widgetStateRef.current.togglePolling;
}
return () => {
if (widgetStateRef.current?.togglePolling === togglePolling) {
delete widgetStateRef.current.togglePolling;
}
};
}, [canTogglePolling, togglePolling, widgetStateRef]);
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)`} />
{isNotFound ? (
<Text c="dimmed" size="sm" ta="center">
{t("definitionNotFound")}
</Text>
) : (
<Tooltip label={error.message} multiline>
<Text c="dimmed" size="sm" ta="center" tabIndex={0} aria-label={`${t("fetchFailed")}. ${error.message}`}>
{t("fetchFailed")}
</Text>
</Tooltip>
)}
</Stack>
</Center>
);
}
if (!data) return null;
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 displayTypeKey = displayTypeTranslationKeys[dataType as keyof typeof displayTypeTranslationKeys];
const pollingLabel = pollingPaused ? t("actions.resumePolling") : t("actions.pausePolling");
const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
const content = <Component data={enrichedData} displayMode={displayMode} width={width} height={height} />;
if (displayMode === "compact") return content;
return (
<Stack h="100%" gap="xs" p="xs">
<Group justify="space-between" wrap="nowrap">
<Badge variant="light">{displayTypeKey ? t(displayTypeKey) : t("displayType.unknown")}</Badge>
{dataType !== "actionButton" && (
<Tooltip label={pollingLabel}>
<ActionIcon
aria-label={pollingLabel}
variant="subtle"
loading={isFetching}
onClick={() => {
if (pollingPaused) void refetch();
setPollingPaused((value) => !value);
}}
>
{pollingPaused ? <IconPlayerPlay size={16} /> : <IconPlayerPause size={16} />}
</ActionIcon>
</Tooltip>
)}
</Group>
<Box style={{ flex: 1, minHeight: 0 }}>{content}</Box>
</Stack>
);
}
return (
<ScrollArea h="100%" p="xs">
<Code block style={{ fontSize: 11 }}>
{JSON.stringify(data, null, 2)}
</Code>
</ScrollArea>
);
}