feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong committed 2026-08-01 21:14:33 +02:00
1 parent fcc5130068
commit d421046d15
286 files changed
+9664 -3282

No files matched your search

+145 -26
View File
@@ -1,10 +1,12 @@
"use client";
import dynamic from "next/dynamic";
import { useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { ComponentType } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Center,
@@ -22,7 +24,7 @@ import {
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPlay } from "@tabler/icons-react";
import { IconAlertTriangle, IconCheck, IconExternalLink, IconPlayerPause, IconPlayerPlay } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
@@ -96,18 +98,29 @@ function KeyValueDisplay({ data }: { data: Record<string, unknown> }) {
);
}
function TableDisplay({ data }: { data: Record<string, unknown> }) {
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>
<ScrollArea h="100%">
<Table striped={striped} highlightOnHover withTableBorder>
<Table.Thead>
<Table.Tr>
{columns.map((col, i) => (
{visibleColumns.map((col, i) => (
<Table.Th key={i} py={compact ? 4 : undefined}>
{col}
</Table.Th>
@@ -115,9 +128,9 @@ function TableDisplay({ data }: { data: Record<string, unknown> }) {
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row, i) => (
{rows.slice(0, visibleRowCount).map((row, i) => (
<Table.Tr key={i}>
{row.map((cell, j) => (
{row.slice(0, visibleColumnCount).map((cell, j) => (
<Table.Td key={j} py={compact ? 2 : undefined}>
{String(cell ?? "—")}
</Table.Td>
@@ -181,9 +194,11 @@ function StatGridCard({
);
}
function StatGridDisplay({ data }: { data: Record<string, unknown> }) {
function StatGridDisplay({ data, displayMode, width }: CustomDisplayProps) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
const columns = (data.columns as number) ?? 2;
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 (
@@ -304,12 +319,12 @@ function CountGridDisplay({ data }: { data: Record<string, unknown> }) {
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
window.open(blobUrl);
window.open(blobUrl, "_blank", "noopener,noreferrer");
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
function RawDisplay({ data }: { data: Record<string, unknown> }) {
const maxHeight = (data.maxHeight as number) ?? 300;
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 (
@@ -387,7 +402,9 @@ function ActionButtonDisplay({ data }: { data: Record<string, unknown> }) {
);
}
export const displayComponents: Record<string, ComponentType<{ data: Record<string, unknown> }>> = {
const CustomJsxAdapter = ({ data }: CustomDisplayProps) => <CustomJsxDisplay data={data} />;
export const displayComponents: Record<string, ComponentType<CustomDisplayProps>> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
@@ -397,10 +414,29 @@ export const displayComponents: Record<string, ComponentType<{ data: Record<stri
countGrid: CountGridDisplay,
raw: RawDisplay,
actionButton: ActionButtonDisplay,
customJsx: CustomJsxDisplay,
customJsx: CustomJsxAdapter,
};
export default function CustomApiWidget({ options }: WidgetComponentProps<"customApi">) {
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;
@@ -417,20 +453,45 @@ export default function CustomApiWidget({ options }: WidgetComponentProps<"custo
);
}
return <CustomApiWidgetInner definitionId={definitionId} refreshInterval={refreshInterval as number} />;
return (
<CustomApiWidgetInner
definitionId={definitionId}
refreshInterval={refreshInterval as number}
width={width}
height={height}
displayMode={displayMode}
widgetStateRef={widgetStateRef}
/>
);
}
function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
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 { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
const persistedState = widgetStateRef?.current?.customApi as { pollingPaused?: boolean } | undefined;
const [pollingPaused, setPollingPaused] = useState(persistedState?.pollingPaused === true);
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 (result?.type === "actionButton" || result?.type === "disabled") return false;
if (pollingPaused || result?.type === "actionButton" || result?.type === "disabled") return false;
return intervalMs;
},
retry: (failureCount, err) => {
@@ -440,6 +501,33 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
},
);
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;
widgetStateRef.current = {
...widgetStateRef.current,
customApi: { pollingPaused },
};
}, [pollingPaused, widgetStateRef]);
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%">
@@ -454,9 +542,17 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
<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>
{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>
);
@@ -464,9 +560,6 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
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%">
@@ -479,8 +572,34 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
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;
return <Component data={enrichedData} />;
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 (
+17 -1
View File
@@ -1,4 +1,4 @@
import { IconApi } from "@tabler/icons-react";
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
@@ -6,6 +6,22 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi"]],
contextActions({ options, widgetStateRef }) {
return [
{
key: "togglePolling",
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef.current?.togglePolling;
if (typeof action === "function") action();
},
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),