feat: modernize dashboard widgets
This commit is contained in:
1 parent
1a5e083fd0
commit
6f2cd7fc2b
270 files changed
+10189
-3544
No files matched your search
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Box, Group, Stack } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
@@ -13,115 +12,226 @@ import { SystemResourceGPUChart } from "./chart/gpu-chart";
|
||||
import { SystemResourceMemoryChart } from "./chart/memory-chart";
|
||||
import { NetworkTrafficChart } from "./chart/network-traffic";
|
||||
|
||||
const MAX_QUEUE_SIZE = 15;
|
||||
const COMPACT_HISTORY_SIZE = 15;
|
||||
const ADVANCED_HISTORY_SIZE = 60;
|
||||
|
||||
const toChartItem = (healthInfo: {
|
||||
type ChartItem = {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
gpu: number;
|
||||
network: { up: number; down: number } | null;
|
||||
};
|
||||
|
||||
export const toChartItem = (healthInfo: {
|
||||
cpuUtilization: number;
|
||||
memUsedInBytes: number;
|
||||
gpu: { processorUtilization: number }[];
|
||||
network: { up: number; down: number } | null;
|
||||
}) => ({
|
||||
}): ChartItem => ({
|
||||
cpu: healthInfo.cpuUtilization,
|
||||
memory: healthInfo.memUsedInBytes,
|
||||
gpu:
|
||||
healthInfo.gpu.length > 0
|
||||
? healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / healthInfo.gpu.length
|
||||
? healthInfo.gpu.reduce((acc, gpu) => acc + gpu.processorUtilization, 0) / healthInfo.gpu.length
|
||||
: 0,
|
||||
network: healthInfo.network,
|
||||
});
|
||||
|
||||
export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) {
|
||||
const { ref, width } = useElementSize();
|
||||
export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
|
||||
[...history, item].slice(-limit);
|
||||
|
||||
export const boundHistoryByIntegration = (
|
||||
historyByIntegration: Record<string, ChartItem[]>,
|
||||
limit: number,
|
||||
): Record<string, ChartItem[]> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(historyByIntegration).map(([integrationId, history]) => [integrationId, history.slice(-limit)]),
|
||||
);
|
||||
|
||||
export default function SystemResources({
|
||||
integrationIds,
|
||||
options,
|
||||
width,
|
||||
displayMode,
|
||||
widgetStateRef,
|
||||
}: WidgetComponentProps<"systemResources">) {
|
||||
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
|
||||
integrationIds,
|
||||
});
|
||||
const memoryCapacityInBytes =
|
||||
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
|
||||
const isAdvanced = displayMode === "advanced";
|
||||
const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
|
||||
const persistedState = widgetStateRef?.current?.systemResources as
|
||||
| { integrationKey?: string; historyByIntegration?: Record<string, ChartItem[]> }
|
||||
| undefined;
|
||||
const [historyByIntegration, setHistoryByIntegration] = useState<Record<string, ChartItem[]>>(() =>
|
||||
persistedState?.integrationKey === integrationKey && persistedState.historyByIntegration
|
||||
? boundHistoryByIntegration(persistedState.historyByIntegration, ADVANCED_HISTORY_SIZE)
|
||||
: {},
|
||||
);
|
||||
const previousIntegrationKey = useRef(integrationKey);
|
||||
|
||||
const [items, setItems] = useState<
|
||||
{ cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[]
|
||||
>(() => (data[0] ? [toChartItem(data[0].healthInfo)] : []));
|
||||
|
||||
const prevIntegrationIds = useRef(integrationIds);
|
||||
useEffect(() => {
|
||||
if (prevIntegrationIds.current === integrationIds) return;
|
||||
prevIntegrationIds.current = integrationIds;
|
||||
setItems([]);
|
||||
}, [integrationIds]);
|
||||
if (previousIntegrationKey.current === integrationKey) return;
|
||||
previousIntegrationKey.current = integrationKey;
|
||||
setHistoryByIntegration({});
|
||||
}, [integrationKey]);
|
||||
|
||||
const lastUpdatedAt = useRef(dataUpdatedAt);
|
||||
useEffect(() => {
|
||||
if (dataUpdatedAt === lastUpdatedAt.current) return;
|
||||
lastUpdatedAt.current = dataUpdatedAt;
|
||||
const firstItem = data[0];
|
||||
if (!firstItem) return;
|
||||
setItems((prev) => [...prev, toChartItem(firstItem.healthInfo)].slice(-MAX_QUEUE_SIZE));
|
||||
setHistoryByIntegration((previous) =>
|
||||
Object.fromEntries(
|
||||
data.map((entry) => [
|
||||
entry.integrationId,
|
||||
appendBoundedHistory(
|
||||
previous[entry.integrationId] ?? [],
|
||||
toChartItem(entry.healthInfo),
|
||||
ADVANCED_HISTORY_SIZE,
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}, [dataUpdatedAt, data]);
|
||||
|
||||
const showNetwork =
|
||||
items.length === 0 || (items.every((item) => item.network !== null) && options.visibleCharts.includes("network"));
|
||||
const rowHeight = `calc((100% - ${(options.visibleCharts.length - 1) * 8}px) / ${options.visibleCharts.length})`;
|
||||
useEffect(() => {
|
||||
if (!widgetStateRef) return;
|
||||
widgetStateRef.current = {
|
||||
...widgetStateRef.current,
|
||||
systemResources: {
|
||||
integrationKey,
|
||||
historyByIntegration: boundHistoryByIntegration(historyByIntegration, ADVANCED_HISTORY_SIZE),
|
||||
},
|
||||
};
|
||||
}, [historyByIntegration, integrationKey, widgetStateRef]);
|
||||
|
||||
const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1;
|
||||
const panelWidth = width / panelColumns;
|
||||
const renderCharts = (entry: (typeof data)[number], availableWidth: number) => {
|
||||
const currentItem = toChartItem(entry.healthInfo);
|
||||
const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem];
|
||||
const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE);
|
||||
|
||||
return (
|
||||
<SystemCharts
|
||||
key={entry.integrationId}
|
||||
integrationName={entry.integrationName}
|
||||
items={history}
|
||||
memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes}
|
||||
options={options}
|
||||
width={availableWidth}
|
||||
isAdvanced={isAdvanced}
|
||||
showTitle={isAdvanced || data.length > 1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isAdvanced && data.length === 1 && data[0]) {
|
||||
return (
|
||||
<Box h="100%" p="xs">
|
||||
{renderCharts(data[0], width)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" p="xs" ref={ref} h="100%">
|
||||
{options.visibleCharts.includes("cpu") && (
|
||||
<Box h={rowHeight}>
|
||||
<SystemResourceCPUChart
|
||||
cpuUsageOverTime={items.map((item) => item.cpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{options.visibleCharts.includes("memory") && (
|
||||
<Box h={rowHeight}>
|
||||
<SystemResourceMemoryChart
|
||||
memoryUsageOverTime={items.map((item) => item.memory)}
|
||||
totalCapacityInBytes={memoryCapacityInBytes}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{options.visibleCharts.includes("gpu") && (
|
||||
<Box h={rowHeight}>
|
||||
<SystemResourceGPUChart
|
||||
gpuUsageOverTime={items.map((item) => item.gpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{showNetwork &&
|
||||
(width > 256 ? (
|
||||
<Group h={rowHeight} gap="xs" grow>
|
||||
<NetworkTrafficChart
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
usageOverTime={items.map((item) => item.network!.down)}
|
||||
isUp={false}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
<ScrollArea h="100%">
|
||||
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
|
||||
{data.map((entry) => renderCharts(entry, panelWidth))}
|
||||
</SimpleGrid>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
<NetworkTrafficChart
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
usageOverTime={items.map((item) => item.network!.up)}
|
||||
isUp
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Box h={rowHeight}>
|
||||
<CombinedNetworkTrafficChart
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
usageOverTime={items.map((item) => item.network!)}
|
||||
interface SystemChartsProps {
|
||||
integrationName: string;
|
||||
items: ChartItem[];
|
||||
memoryCapacityInBytes: number;
|
||||
options: WidgetComponentProps<"systemResources">["options"];
|
||||
width: number;
|
||||
isAdvanced: boolean;
|
||||
showTitle: boolean;
|
||||
}
|
||||
|
||||
const SystemCharts = ({
|
||||
integrationName,
|
||||
items,
|
||||
memoryCapacityInBytes,
|
||||
options,
|
||||
width,
|
||||
isAdvanced,
|
||||
showTitle,
|
||||
}: SystemChartsProps) => {
|
||||
const showNetwork =
|
||||
items.length > 0 && items.every((item) => item.network !== null) && options.visibleCharts.includes("network");
|
||||
const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
|
||||
const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
|
||||
const chartHeight = isAdvanced
|
||||
? 180
|
||||
: `calc((100% - ${Math.max(0, chartCount - 1) * 8}px) / ${Math.max(1, chartCount)})`;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" h={isAdvanced ? "auto" : "100%"} miw={0}>
|
||||
{showTitle && (
|
||||
<Text size="sm" fw={600} truncate="end">
|
||||
{integrationName}
|
||||
</Text>
|
||||
)}
|
||||
<SimpleGrid cols={chartColumns} spacing="xs" style={{ flex: 1 }}>
|
||||
{options.visibleCharts.includes("cpu") && (
|
||||
<Box h={chartHeight}>
|
||||
<SystemResourceCPUChart
|
||||
cpuUsageOverTime={items.map((item) => item.cpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
)}
|
||||
{options.visibleCharts.includes("memory") && (
|
||||
<Box h={chartHeight}>
|
||||
<SystemResourceMemoryChart
|
||||
memoryUsageOverTime={items.map((item) => item.memory)}
|
||||
totalCapacityInBytes={memoryCapacityInBytes}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{options.visibleCharts.includes("gpu") && (
|
||||
<Box h={chartHeight}>
|
||||
<SystemResourceGPUChart
|
||||
gpuUsageOverTime={items.map((item) => item.gpu)}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{showNetwork &&
|
||||
(width >= 300 ? (
|
||||
<Group h={chartHeight} gap="xs" grow wrap="nowrap">
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network?.down ?? 0)}
|
||||
isUp={false}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
<NetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network?.up ?? 0)}
|
||||
isUp
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Box h={chartHeight}>
|
||||
<CombinedNetworkTrafficChart
|
||||
usageOverTime={items.map((item) => item.network ?? { up: 0, down: 0 })}
|
||||
hasShadow={options.hasShadow}
|
||||
labelDisplayMode={options.labelDisplayMode}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { appendBoundedHistory, boundHistoryByIntegration, toChartItem } from "./component";
|
||||
|
||||
describe("system resource history", () => {
|
||||
test("averages all GPU values", () => {
|
||||
expect(
|
||||
toChartItem({
|
||||
cpuUtilization: 10,
|
||||
memUsedInBytes: 20,
|
||||
gpu: [{ processorUtilization: 20 }, { processorUtilization: 60 }],
|
||||
network: null,
|
||||
}).gpu,
|
||||
).toBe(40);
|
||||
});
|
||||
|
||||
test("keeps a bounded rolling history", () => {
|
||||
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
|
||||
expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("bounds restored history for every integration", () => {
|
||||
const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
|
||||
expect(boundHistoryByIntegration({ first: [item, item, item], second: [item, item] }, 2)).toEqual({
|
||||
first: [item, item],
|
||||
second: [item, item],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user