feat: refine widget interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent a8a7545f40
commit 842e86012d
107 files changed
+3144 -1233

No files matched your search

@@ -83,6 +83,13 @@ export const CommonChart = ({
)}
</Group>
)}
{data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && (
<Center pos="absolute" w="100%" h="100%" style={{ zIndex: 2, pointerEvents: "none" }}>
<Text size="xs" fw={600}>
{lastValue}
</Text>
</Center>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"} gap={4}>
@@ -92,6 +99,11 @@ export const CommonChart = ({
{title}
</Text>
)}
{lastValue && (
<Text size={height > 100 ? "md" : "xs"} fw={600} ta="center">
{lastValue}
</Text>
)}
</Stack>
</Center>
) : (
@@ -22,7 +22,7 @@ export const SystemResourceMemoryChart = ({
const t = useScopedI18n("widget.systemResources.card");
const percentageUsed =
memoryUsageOverTime.length > 0
memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
: undefined;
@@ -45,7 +45,8 @@ export const SystemResourceMemoryChart = ({
return (
<Paper px={3} py={2} shadow="md">
<Text c="dimmed" size="xs">
{memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%)
{memory.used} / {memory.available} (
{totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%)
</Text>
</Paper>
);
@@ -23,6 +23,7 @@ export const NetworkTrafficChart = ({
const max = Math.max(...usageOverTime);
const upperBound = max + max * 0.2;
const latest = usageOverTime.at(-1) ?? 0;
return (
<CommonChart
@@ -32,7 +33,7 @@ export const NetworkTrafficChart = ({
title={isUp ? t("up") : t("down")}
icon={isUp ? IconArrowUp : IconArrowDown}
yAxisProps={{ domain: [0, upperBound] }}
lastValue={formatByteRate(Math.round(max))}
lastValue={formatByteRate(Math.round(latest))}
chartType={hasShadow ? "area" : "line"}
labelDisplayMode={labelDisplayMode}
tooltipProps={{
@@ -1,11 +1,16 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
import { SystemResourceCPUChart } from "./chart/cpu-chart";
import { SystemResourceGPUChart } from "./chart/gpu-chart";
@@ -14,6 +19,9 @@ import { NetworkTrafficChart } from "./chart/network-traffic";
const COMPACT_HISTORY_SIZE = 15;
const ADVANCED_HISTORY_SIZE = 60;
const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const;
type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number];
type ChartItem = {
cpu: number;
@@ -40,19 +48,59 @@ export const toChartItem = (healthInfo: {
export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
[...history, item].slice(-limit);
export const getNetworkHistory = (items: readonly ChartItem[]) =>
items.flatMap((item) => (item.network === null ? [] : [item.network]));
export const getCompactChartBudget = (height: number): number => {
if (height < 140) return 1;
if (height < 260) return 2;
return 4;
};
export const getVisibleSystemCharts = ({
configuredCharts,
hasGpu,
hasNetwork,
height,
isAdvanced,
}: {
configuredCharts: readonly SystemChart[];
hasGpu: boolean;
hasNetwork: boolean;
height: number;
isAdvanced: boolean;
}): SystemChart[] => {
const selectedCharts = isAdvanced ? ALL_SYSTEM_CHARTS : configuredCharts;
const availableCharts = selectedCharts.filter(
(chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork),
);
return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height));
};
export default function SystemResources({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemResources">) {
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const t = useScopedI18n("widget.systemResources");
const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(healthQuery) ?? [];
const data = results.filter(
(entry): entry is typeof entry & { healthInfo: NonNullable<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const { dataUpdatedAt, isPending } = healthQuery;
const isAdvanced = displayMode === "advanced";
const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>({});
const previousIntegrationKey = useRef(integrationKey);
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
useEffect(() => {
if (previousIntegrationKey.current === integrationKey) return;
@@ -80,7 +128,7 @@ export default function SystemResources({
const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1;
const panelWidth = width / panelColumns;
const renderCharts = (entry: (typeof data)[number], availableWidth: number) => {
const renderCharts = (entry: (typeof data)[number], availableWidth: number, availableHeight: number) => {
const currentItem = toChartItem(entry.healthInfo);
const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem];
const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE);
@@ -90,38 +138,71 @@ export default function SystemResources({
key={entry.integrationId}
integrationName={entry.integrationName}
items={history}
hasGpu={entry.healthInfo.gpu.length > 0}
memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes}
options={options}
width={availableWidth}
height={availableHeight}
isAdvanced={isAdvanced}
showTitle={isAdvanced || data.length > 1}
/>
);
};
if (!isAdvanced && data.length === 1 && data[0]) {
if (isPending) {
return (
<Box h="100%" p="xs">
{renderCharts(data[0], width)}
<Center h="100%">
<Loader size="sm" />
</Center>
);
}
if (data.length === 0) {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
}
if (!isAdvanced && data.length === 1 && data[0]) {
return (
<Box h="100%" p="xs" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
{renderCharts(data[0], width, Math.max(0, height - 20))}
</Box>
);
}
const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2)));
return (
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth))}
</SimpleGrid>
</ScrollArea>
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
</SimpleGrid>
</ScrollArea>
</Box>
);
}
interface SystemChartsProps {
integrationName: string;
items: ChartItem[];
hasGpu: boolean;
memoryCapacityInBytes: number;
options: WidgetComponentProps<"systemResources">["options"];
width: number;
height: number;
isAdvanced: boolean;
showTitle: boolean;
}
@@ -129,53 +210,70 @@ interface SystemChartsProps {
const SystemCharts = ({
integrationName,
items,
hasGpu,
memoryCapacityInBytes,
options,
width,
height,
isAdvanced,
showTitle,
}: SystemChartsProps) => {
const showNetwork =
items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network");
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
const networkItems = getNetworkHistory(items);
const visibleCharts = getVisibleSystemCharts({
configuredCharts: options.visibleCharts,
hasGpu,
hasNetwork: networkItems.length > 0,
height,
isAdvanced,
});
const showNetwork = visibleCharts.includes("network");
const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode;
const chartCount = visibleCharts.length;
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
const chartHeight = isAdvanced
? 180
: `calc((100% - ${Math.max(0, chartCount - 1) * 8}px) / ${Math.max(1, chartCount)})`;
: `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`;
return (
<Stack gap="xs" h={isAdvanced ? "auto" : "100%"} miw={0}>
<Stack gap="xs" h={isAdvanced ? "auto" : height} miw={0}>
{showTitle && (
<Text size="sm" fw={600} truncate="end">
{integrationName}
</Text>
)}
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1 }}>
{options.visibleCharts.includes("cpu") && (
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1, minHeight: 0 }}>
{chartCount === 0 && (
<Center h="100%">
<Text size="sm" c="dimmed">
—
</Text>
</Center>
)}
{visibleCharts.includes("cpu") && (
<Box h={chartHeight}>
<SystemResourceCPUChart
cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
)}
{options.visibleCharts.includes("memory") && (
{visibleCharts.includes("memory") && (
<Box h={chartHeight}>
<SystemResourceMemoryChart
memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
)}
{options.visibleCharts.includes("gpu") && (
{visibleCharts.includes("gpu") && (
<Box h={chartHeight}>
<SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
)}
@@ -183,24 +281,24 @@ const SystemCharts = ({
(width >= 300 ? (
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
<NetworkTrafficChart
usageOverTime={items.map((item) => item.network?.down ?? 0)}
usageOverTime={networkItems.map((network) => network.down)}
isUp={false}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
<NetworkTrafficChart
usageOverTime={items.map((item) => item.network?.up ?? 0)}
usageOverTime={networkItems.map((network) => network.up)}
isUp
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Group>
) : (
<Box h={chartHeight}>
<CombinedNetworkTrafficChart
usageOverTime={items.map((item) => item.network ?? { up: 0, down: 0 })}
usageOverTime={networkItems}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
labelDisplayMode={labelDisplayMode}
/>
</Box>
))}
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
import { appendBoundedHistory, toChartItem } from "./component";
import {
appendBoundedHistory,
getCompactChartBudget,
getNetworkHistory,
getVisibleSystemCharts,
toChartItem,
} from "./component";
describe("system resource history", () => {
test("averages all GPU values", () => {
@@ -18,4 +24,46 @@ describe("system resource history", () => {
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
});
test("omits unavailable network samples instead of inventing zero traffic", () => {
const missing = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
const available = toChartItem({
cpuUtilization: 1,
memUsedInBytes: 2,
gpu: [],
network: { up: 12, down: 34 },
});
expect(getNetworkHistory([missing, available, missing])).toEqual([{ up: 12, down: 34 }]);
});
test("limits compact charts to the available height", () => {
expect(getCompactChartBudget(100)).toBe(1);
expect(getCompactChartBudget(200)).toBe(2);
expect(getCompactChartBudget(300)).toBe(4);
});
test("advanced mode exposes every available chart regardless of compact configuration", () => {
expect(
getVisibleSystemCharts({
configuredCharts: ["cpu"],
hasGpu: true,
hasNetwork: true,
height: 100,
isAdvanced: true,
}),
).toEqual(["cpu", "memory", "gpu", "network"]);
});
test("advanced mode omits charts whose data is unavailable", () => {
expect(
getVisibleSystemCharts({
configuredCharts: ["gpu", "network"],
hasGpu: false,
hasNetwork: false,
height: 300,
isAdvanced: true,
}),
).toEqual(["cpu", "memory"]);
});
});