feat: refine widget interactions
This commit is contained in:
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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user