feat: system resources widget (#3538)

* feat: add system resources widget

* Update packages/widgets/src/system-resources/index.ts

Co-authored-by: Andre Silva <32734153+Aandree5@users.noreply.github.com>

* fix: system resources not updating

* refactor: improve logic in component

* fix: tooltip overflow

* feat: add label with last value

* feat: hide label when hovering

* fix: formatting

* fix: lint

* fix: formatting

* fix: wrong redis channel used for opnsense

---------

Co-authored-by: Andre Silva <32734153+Aandree5@users.noreply.github.com>
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
Manuel
2025-08-04 20:12:28 +00:00
committed by GitHub
parent 1b5ccb5293
commit 3ee408bf53
24 changed files with 512 additions and 26 deletions

View File

@@ -3,7 +3,7 @@ import { IconBrain } from "@tabler/icons-react";
import { progressColor } from "../system-health";
export const MemoryRing = ({ available, used, isTiny }: { available: string; used: string; isTiny: boolean }) => {
export const MemoryRing = ({ available, used, isTiny }: { available: number; used: number; isTiny: boolean }) => {
const memoryUsage = formatMemoryUsage(available, used);
return (
@@ -31,14 +31,12 @@ export const MemoryRing = ({ available, used, isTiny }: { available: string; use
);
};
export const formatMemoryUsage = (memFree: string, memUsed: string) => {
const memFreeBytes = Number(memFree);
const memUsedBytes = Number(memUsed);
const totalMemory = memFreeBytes + memUsedBytes;
const memFreeGB = (memFreeBytes / 1024 ** 3).toFixed(2);
const memUsedGB = (memUsedBytes / 1024 ** 3).toFixed(2);
const memFreePercent = Math.round((memFreeBytes / totalMemory) * 100);
const memUsedPercent = Math.round((memUsedBytes / totalMemory) * 100);
export const formatMemoryUsage = (memFree: number, memUsed: number) => {
const totalMemory = memFree + memUsed;
const memFreeGB = (memFree / 1024 ** 3).toFixed(2);
const memUsedGB = (memUsed / 1024 ** 3).toFixed(2);
const memFreePercent = Math.round((memFree / totalMemory) * 100);
const memUsedPercent = Math.round((memUsed / totalMemory) * 100);
const memTotalGB = (totalMemory / 1024 ** 3).toFixed(2);
return {

View File

@@ -89,7 +89,7 @@ export const SystemHealthMonitoring = ({
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailable, healthInfo.memUsed);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
<Stack
gap="sm"
@@ -176,7 +176,11 @@ export const SystemHealthMonitoring = ({
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{options.memory && (
<MemoryRing available={healthInfo.memAvailable} used={healthInfo.memUsed} isTiny={isTiny} />
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
</Flex>
{

View File

@@ -37,6 +37,7 @@ import * as rssFeed from "./rssFeed";
import * as smartHomeEntityState from "./smart-home/entity-state";
import * as smartHomeExecuteAutomation from "./smart-home/execute-automation";
import * as stockPrice from "./stocks";
import * as systemResources from "./system-resources";
import * as video from "./video";
import * as weather from "./weather";
@@ -73,6 +74,7 @@ export const widgetImports = {
firewall,
notifications,
mediaReleases,
systemResources,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -0,0 +1,56 @@
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonChart } from "./common-chart";
export const CombinedNetworkTrafficChart = ({
usageOverTime,
}: {
usageOverTime: {
up: number;
down: number;
}[];
}) => {
const chartData = usageOverTime.map((usage, index) => ({ index, up: usage.up, down: usage.down }));
const t = useScopedI18n("widget.systemResources.card");
return (
<CommonChart
data={chartData}
dataKey={"index"}
series={[
{ name: "up", color: "orange.5" },
{ name: "down", color: "yellow.5" },
]}
title={t("network")}
yAxisProps={{ domain: [0, "dataMax"] }}
tooltipProps={{
content: ({ payload }) => {
if (!payload) {
return null;
}
return (
<Paper px={3} py={2} withBorder shadow="md" radius="md">
<Stack gap={0}>
{payload.map((payloadData) => (
<Group key={payloadData.key} gap={4}>
<Box bg={payloadData.color} w={10} h={10} style={{ borderRadius: 99 }}></Box>
<Text c="dimmed" size="xs">
{payloadData.value === undefined ? (
<>N/A</>
) : (
<>{humanFileSize(Math.round(payloadData.value))}/s</>
)}
</Text>
</Group>
))}
</Stack>
</Paper>
);
},
}}
/>
);
};

View File

@@ -0,0 +1,97 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { LineChartSeries } from "@mantine/charts";
import { LineChart } from "@mantine/charts";
import { Card, Center, Group, Loader, Stack, Text, useMantineColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
import { useRequiredBoard } from "@homarr/boards/context";
export const CommonChart = ({
data,
dataKey,
series,
title,
tooltipProps,
yAxisProps,
lastValue,
}: {
data: Record<string, any>[];
dataKey: string;
series: LineChartSeries[];
title: string;
tooltipProps?: TooltipProps<number, any>;
yAxisProps?: Omit<YAxisProps, "ref">;
lastValue?: string;
}) => {
const { ref: elementSizeRef, height } = useElementSize();
const theme = useMantineTheme();
const scheme = useMantineColorScheme();
const board = useRequiredBoard();
const { hovered, ref: hoverRef } = useHover();
const ref = useMergedRef(elementSizeRef, hoverRef);
const opacity = board.opacity / 100;
const backgroundColor =
scheme.colorScheme === "dark" ? `rgba(57, 57, 57, ${opacity})` : `rgba(246, 247, 248, ${opacity})`;
return (
<Card
ref={ref}
h={"100%"}
pos={"relative"}
style={{ overflow: "visible" }}
p={0}
bg={data.length <= 1 ? backgroundColor : undefined}
radius={board.itemRadius}
>
{data.length > 1 && height > 40 && !hovered && (
<Group
pos={"absolute"}
top={0}
left={0}
p={8}
pt={6}
gap={5}
wrap={"nowrap"}
style={{ zIndex: 2, pointerEvents: "none" }}
>
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"}>
{title}
</Text>
{lastValue && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} lineClamp={1}>
{lastValue}
</Text>
)}
</Group>
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"}>
<Loader type="bars" size={height > 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} />
</Stack>
</Center>
) : (
<LineChart
data={data}
dataKey={dataKey}
h={"100%"}
series={series}
curveType="monotone"
tickLine="none"
gridAxis="none"
withXAxis={false}
withYAxis={false}
withDots={false}
bg={backgroundColor}
styles={{ root: { padding: 5, borderRadius: theme.radius[board.itemRadius] } }}
tooltipAnimationDuration={200}
tooltipProps={tooltipProps}
withTooltip={height >= 64}
yAxisProps={yAxisProps}
/>
)}
</Card>
);
};

View File

@@ -0,0 +1,39 @@
import { Paper, Text } from "@mantine/core";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonChart } from "./common-chart";
export const SystemResourceCPUChart = ({ cpuUsageOverTime }: { cpuUsageOverTime: number[] }) => {
const chartData = cpuUsageOverTime.map((usage, index) => ({ index, usage }));
const t = useScopedI18n("widget.systemResources.card");
return (
<CommonChart
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "blue.5" }]}
title={t("cpu")}
lastValue={
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
cpuUsageOverTime.length > 0 ? `${Math.round(cpuUsageOverTime[cpuUsageOverTime.length - 1]!)}%` : undefined
}
yAxisProps={{ domain: [0, 100] }}
tooltipProps={{
content: ({ payload }) => {
if (!payload) {
return null;
}
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} withBorder shadow="md" radius="md">
<Text c="dimmed" size="xs">
{value.toFixed(0)}%
</Text>
</Paper>
);
},
}}
/>
);
};

View File

@@ -0,0 +1,50 @@
import { Paper, Text } from "@mantine/core";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonChart } from "./common-chart";
export const SystemResourceMemoryChart = ({
memoryUsageOverTime,
totalCapacityInBytes,
}: {
memoryUsageOverTime: number[];
totalCapacityInBytes: number;
}) => {
const chartData = memoryUsageOverTime.map((usage, index) => ({ index, usage }));
const t = useScopedI18n("widget.systemResources.card");
const percentageUsed =
memoryUsageOverTime.length > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes
: undefined;
return (
<CommonChart
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "red.6" }]}
title={t("memory")}
yAxisProps={{ domain: [0, totalCapacityInBytes] }}
lastValue={percentageUsed !== undefined ? `${Math.round(percentageUsed * 100)}%` : undefined}
tooltipProps={{
content: ({ payload }) => {
if (!payload) {
return null;
}
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} withBorder shadow="md" radius="md">
<Text c="dimmed" size="xs">
{humanFileSize(value)} / {humanFileSize(totalCapacityInBytes)} (
{Math.round((value / totalCapacityInBytes) * 100)}%)
</Text>
</Paper>
);
},
}}
/>
);
};

View File

@@ -0,0 +1,40 @@
import { Paper, Text } from "@mantine/core";
import { humanFileSize } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonChart } from "./common-chart";
export const NetworkTrafficChart = ({ usageOverTime, isUp }: { usageOverTime: number[]; isUp: boolean }) => {
const chartData = usageOverTime.map((usage, index) => ({ index, usage }));
const t = useScopedI18n("widget.systemResources.card");
const max = Math.max(...usageOverTime);
const upperBound = max + max * 0.2;
return (
<CommonChart
data={chartData}
dataKey={"index"}
series={[{ name: "usage", color: "yellow.5" }]}
title={isUp ? t("up") : t("down")}
yAxisProps={{ domain: [0, upperBound] }}
lastValue={`${humanFileSize(Math.round(max))}/s`}
tooltipProps={{
content: ({ payload }) => {
if (!payload) {
return null;
}
const value = payload[0] ? Number(payload[0].value) : 0;
return (
<Paper px={3} py={2} withBorder shadow="md" radius="md">
<Text c="dimmed" size="xs">
{humanFileSize(Math.round(value))}/s
</Text>
</Paper>
);
},
}}
/>
);
};

View File

@@ -0,0 +1,14 @@
.grid {
display: grid;
grid-template-rows: repeat(3, 1fr);
grid-template-columns: repeat(2, 1fr);
gap: 8px;
padding: 8px;
height: 100%;
}
.colSpanWide {
grid-column-start: 1;
grid-column-end: 3;
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useElementSize } from "@mantine/hooks";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic";
import { SystemResourceCPUChart } from "./chart/cpu-chart";
import { SystemResourceMemoryChart } from "./chart/memory-chart";
import { NetworkTrafficChart } from "./chart/network-traffic";
import classes from "./component.module.css";
const MAX_QUEUE_SIZE = 15;
export default function SystemResources({ integrationIds }: WidgetComponentProps<"systemResources">) {
const { ref, width } = useElementSize();
const [data] = clientApi.widget.healthMonitoring.getSystemHealthStatus.useSuspenseQuery({
integrationIds,
});
const memoryCapacityInBytes =
(data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
const [items, setItems] = useState<{ cpu: number; memory: number; network: { up: number; down: number } | null }[]>(
data.map((item) => ({
cpu: item.healthInfo.cpuUtilization,
memory: item.healthInfo.memUsedInBytes,
network: item.healthInfo.network,
})),
);
clientApi.widget.healthMonitoring.subscribeSystemHealthStatus.useSubscription(
{
integrationIds,
},
{
onData(data) {
setItems((previousItems) => {
const next = {
cpu: data.healthInfo.cpuUtilization,
memory: data.healthInfo.memUsedInBytes,
network: data.healthInfo.network,
};
return [...previousItems, next].slice(-MAX_QUEUE_SIZE);
});
},
},
);
const showNetwork = items.length === 0 || items.every((item) => item.network !== null);
return (
<div ref={ref} className={classes.grid}>
<div className={classes.colSpanWide}>
<SystemResourceCPUChart cpuUsageOverTime={items.map((item) => item.cpu)} />
</div>
<div className={classes.colSpanWide}>
<SystemResourceMemoryChart
memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes}
/>
</div>
{showNetwork &&
(width > 200 ? (
<>
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
<NetworkTrafficChart usageOverTime={items.map((item) => item.network!.down)} isUp={false} />
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
<NetworkTrafficChart usageOverTime={items.map((item) => item.network!.up)} isUp />
</>
) : (
<div className={classes.colSpanWide}>
{/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */}
<CombinedNetworkTrafficChart usageOverTime={items.map((item) => item.network!)} />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { IconGraphFilled } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled,
supportedIntegrations: ["dashDot", "openmediavault"],
createOptions() {
return optionsBuilder.from(() => ({}));
},
}).withDynamicImport(() => import("./component"));