"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 = { sm: "sm", md: "md", lg: "lg", xl: "xl" }; function SingleValueDisplay({ data }: { data: Record }) { const size = valueSizeMap[(data.valueSize as string) ?? "lg"] ?? "lg"; const labelAbove = (data.labelPosition as string) === "above"; const label = data.label ? ( {String(data.label)} ) : null; return ( {labelAbove && label} {String(data.value ?? "—")} {data.unit ? ` ${data.unit}` : ""} {!labelAbove && label} ); } function KeyValueDisplay({ data }: { data: Record }) { 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 ( {entries.map((entry, i) => ( {String(entry.value ?? "—")} {entry.unit ? ` ${entry.unit}` : ""} {entry.label} ))} ); } return ( {entries.map((entry, i) => ( {entry.label} {String(entry.value ?? "—")} {entry.unit ? ` ${entry.unit}` : ""} ))} ); } function TableDisplay({ data }: { data: Record }) { 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 ( {columns.map((col, i) => ( {col} ))} {rows.map((row, i) => ( {row.map((cell, j) => ( {String(cell ?? "—")} ))} ))}
); } 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 = { filled: `var(--mantine-color-${item.color}-light)`, outline: "transparent", subtle: `var(--mantine-color-${item.color}-light)`, }; return ( {String(item.value ?? "—")} {item.unit ? ` ${item.unit}` : ""} {!hideLabel && ( {item.label} )} ); } function StatGridDisplay({ data }: { data: Record }) { 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 ( {items.map((item, i) => ( ))} ); } function ProgressBarsDisplay({ data }: { data: Record }) { 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 = { sm: 8, md: 14, lg: 22 }; return ( {bars.map((bar, i) => { const max = bar.max ?? 100; const pct = max > 0 ? Math.min((bar.value / max) * 100, 100) : 0; return ( {bar.label} {showPercentage ? `${pct.toFixed(0)}%` : `${bar.value}${bar.unit ? ` ${bar.unit}` : ""}`} {bar.max !== undefined && showPercentage ? ` (${bar.value}/${max}${bar.unit ? ` ${bar.unit}` : ""})` : ""} ); })} ); } function StatusIndicatorDisplay({ data }: { data: Record }) { 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 = { sm: 8, md: 10, lg: 14 }; const size = dotSizeMap[dotSize] ?? 10; const renderItem = (item: { label: string; value: string; isGood: boolean }, i: number) => (
{item.label} {item.value} ); if (layout === "grid") { return ( {items.map(renderItem)} ); } return ( {items.map(renderItem)} ); } function CountGridDisplay({ data }: { data: Record }) { 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 = { sm: "sm", md: "md", lg: "lg" }; return ( {items.map((item, i) => ( {String(item.value ?? "—")} {item.unit ? ` ${item.unit}` : ""} {item.label} ))} ); } 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 }) { const maxHeight = (data.maxHeight as number) ?? 300; const jsonString = JSON.stringify(data.data, null, 2); return ( openJsonInBrowser(data.data)}> {jsonString} ); } function ActionButtonDisplay({ data }: { data: Record }) { 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 (
); } export const displayComponents: Record }>> = { 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 (
{t("noDefinition")}
); } return ; } 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 | 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 (
); } if (error) { const isNotFound = error.data?.code === "NOT_FOUND"; return (
{isNotFound ? t("definitionNotFound") : error.message}
); } if (!data) return null; const widgetData = data as Record; const dataType = widgetData.type as string | undefined; if (dataType === "disabled") { return (
{tCustomWidget("widget.disabled")}
); } const Component = dataType ? displayComponents[dataType] : undefined; if (Component) { const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData; return ; } return ( {JSON.stringify(data, null, 2)} ); }