"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 = { 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}` : ""} ))} ); } interface CustomDisplayProps { data: Record; 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 ( {visibleColumns.map((col, i) => ( {col} ))} {rows.slice(0, visibleRowCount).map((row, i) => ( {row.slice(0, visibleColumnCount).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, 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 ( {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, "_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 ( 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 (
); } const CustomJsxAdapter = ({ data }: CustomDisplayProps) => ; export const displayComponents: Record> = { 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 (
{t("noDefinition")}
); } return ( ); } 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 | 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; 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 (
); } if (error) { const isNotFound = error.data?.code === "NOT_FOUND"; return (
{isNotFound ? ( {t("definitionNotFound")} ) : ( {t("fetchFailed")} )}
); } if (!data) return null; if (dataType === "disabled") { return (
{tCustomWidget("widget.disabled")}
); } 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 = ; if (displayMode === "compact") return content; return ( {displayTypeKey ? t(displayTypeKey) : t("displayType.unknown")} {dataType !== "actionButton" && ( { if (pollingPaused) void refetch(); setPollingPaused((value) => !value); }} > {pollingPaused ? : } )} {content} ); } return ( {JSON.stringify(data, null, 2)} ); }