diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index cb57fd463..35896ec5d 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -21,6 +21,12 @@ interface BoardItemContentProps { item: SectionItem; } +const getOverflowFromKind = (kind: SectionItem["kind"]) => { + if (kind === "iframe") return "hidden"; + if (kind === "systemResources") return "visible"; + return undefined; +}; + export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); const board = useRequiredBoard(); @@ -41,7 +47,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { root: { "--opacity": board.opacity / 100, containerType: "size", - overflow: item.kind === "iframe" ? "hidden" : undefined, + overflow: getOverflowFromKind(item.kind), "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, }, }} diff --git a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts index c615aec07..e52db0d4e 100644 --- a/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts +++ b/packages/cron-jobs/src/jobs/integrations/health-monitoring.ts @@ -15,9 +15,10 @@ export const healthMonitoringJob = createCronJob("healthMonitoring", EVERY_5_SEC return clusterInfoRequestHandler.handler({ ...integration, kind }, itemOptions); }, { - widgetKinds: ["healthMonitoring"], + widgetKinds: ["healthMonitoring", "systemResources"], getInput: { healthMonitoring: () => ({}), + systemResources: () => ({}), }, }, ), diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 8bafbab57..ec0d2ff66 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -28,5 +28,6 @@ export const widgetKinds = [ "dockerContainers", "firewall", "notifications", + "systemResources", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/src/dashdot/dashdot-integration.ts b/packages/integrations/src/dashdot/dashdot-integration.ts index c5e22fadc..e4898bf02 100644 --- a/packages/integrations/src/dashdot/dashdot-integration.ts +++ b/packages/integrations/src/dashdot/dashdot-integration.ts @@ -7,7 +7,7 @@ import { z } from "zod"; import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; -import { createChannelEventHistory } from "../../../redis/src/lib/channel"; +import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import { TestConnectionError } from "../base/test-connection/test-connection-error"; @@ -32,14 +32,16 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni const cpuLoad = await this.getCurrentCpuLoadAsync(); const memoryLoad = await this.getCurrentMemoryLoadAsync(); const storageLoad = await this.getCurrentStorageLoadAsync(); + const networkLoad = await this.getCurrentNetworkLoadAsync(); const channel = this.getChannel(); const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate()); return { cpuUtilization: cpuLoad.sumLoad, - memUsed: `${memoryLoad.loadInBytes}`, - memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`, + memUsedInBytes: memoryLoad.loadInBytes, + memAvailableInBytes: info.maxAvailableMemoryBytes - memoryLoad.loadInBytes, + network: networkLoad, fileSystem: info.storage .filter((_, index) => storageLoad[index] !== -1) // filter out undermoutned drives, they display as -1 in the load API .map((storage, index) => ({ @@ -113,8 +115,13 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni }; } + private async getCurrentNetworkLoadAsync() { + const response = await fetchWithTrustedCertificatesAsync(this.url("/load/network")); + return await networkLoadApi.parseAsync(await response.json()); + } + private getChannel() { - return createChannelEventHistory>( + return createChannelEventHistoryOld>( `integration:${this.integration.id}:history:cpu`, 100, ); @@ -130,6 +137,11 @@ const memoryLoadApi = z.object({ load: z.number().min(0), }); +const networkLoadApi = z.object({ + up: z.number().min(0), + down: z.number().min(0), +}); + const internalServerInfoApi = z.object({ os: z.object({ distro: z.string(), diff --git a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts index 5935ba9e4..b427baab9 100644 --- a/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts +++ b/packages/integrations/src/interfaces/health-monitoring/health-monitoring-types.ts @@ -4,9 +4,13 @@ export interface SystemHealthMonitoring { version: string; cpuModelName: string; cpuUtilization: number; - memUsed: string; - memAvailable: string; + memUsedInBytes: number; + memAvailableInBytes: number; uptime: number; + network: { + up: number; + down: number; + } | null; loadAverage: { "1min": number; "5min": number; diff --git a/packages/integrations/src/mock/data/system-health-monitoring.ts b/packages/integrations/src/mock/data/system-health-monitoring.ts index 6ff1bb23c..ebc2ca010 100644 --- a/packages/integrations/src/mock/data/system-health-monitoring.ts +++ b/packages/integrations/src/mock/data/system-health-monitoring.ts @@ -7,9 +7,13 @@ export class SystemHealthMonitoringMockService implements ISystemHealthMonitorin version: "1.0.0", cpuModelName: "Mock CPU", cpuUtilization: Math.random(), - memUsed: (4 * 1024 * 1024 * 1024).toString(), // 4 GB in bytes - memAvailable: (8 * 1024 * 1024 * 1024).toString(), // 8 GB in bytes + memUsedInBytes: 4 * 1024 * 1024 * 1024, // 4 GB in bytes + memAvailableInBytes: 8 * 1024 * 1024 * 1024, // 8 GB in bytes availablePkgUpdates: 0, + network: { + up: 1024 * 16, + down: 1024 * 16 * 6, + }, rebootRequired: false, cpuTemp: Math.floor(Math.random() * 100), // Random temperature between 0 and 99 uptime: Math.floor(Math.random() * 1000000), // Random uptime in seconds diff --git a/packages/integrations/src/openmediavault/openmediavault-integration.ts b/packages/integrations/src/openmediavault/openmediavault-integration.ts index c3b1c6ada..0e5ad1171 100644 --- a/packages/integrations/src/openmediavault/openmediavault-integration.ts +++ b/packages/integrations/src/openmediavault/openmediavault-integration.ts @@ -69,9 +69,11 @@ export class OpenMediaVaultIntegration extends Integration implements ISystemHea version: systemResult.data.response.version, cpuModelName: systemResult.data.response.cpuModelName ?? "Unknown CPU", cpuUtilization: systemResult.data.response.cpuUtilization, - memUsed: systemResult.data.response.memUsed, - memAvailable: systemResult.data.response.memAvailable, + memUsedInBytes: Number(systemResult.data.response.memUsed), + memAvailableInBytes: Number(systemResult.data.response.memAvailable), uptime: systemResult.data.response.uptime, + /* real-time traffic monitoring is not available over the RPC API from OMV */ + network: null, loadAverage: { "1min": systemResult.data.response.loadAverage["1min"], "5min": systemResult.data.response.loadAverage["5min"], diff --git a/packages/integrations/src/opnsense/opnsense-integration.ts b/packages/integrations/src/opnsense/opnsense-integration.ts index d1085141e..5c86054b1 100644 --- a/packages/integrations/src/opnsense/opnsense-integration.ts +++ b/packages/integrations/src/opnsense/opnsense-integration.ts @@ -1,7 +1,7 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server"; import { ParseError, ResponseError } from "@homarr/common/server"; -import { createChannelEventHistory } from "@homarr/redis"; +import { createChannelEventHistoryOld } from "../../../redis/src/lib/channel"; import { HandleIntegrationErrors } from "../base/errors/decorator"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -64,7 +64,7 @@ export class OPNsenseIntegration extends Integration implements FirewallSummaryI } private getInterfacesChannel() { - return createChannelEventHistory(`integration:${this.integration.id}:interfaces`, 15); + return createChannelEventHistoryOld(`integration:${this.integration.id}:interfaces`, 15); } public async getFirewallInterfacesAsync(): Promise { diff --git a/packages/redis/src/index.ts b/packages/redis/src/index.ts index 05d4389f9..bd44350c2 100644 --- a/packages/redis/src/index.ts +++ b/packages/redis/src/index.ts @@ -15,6 +15,8 @@ export { createGetSetChannel, } from "./lib/channel"; +export { createIntegrationHistoryChannel } from "./lib/channels/history-channel"; + export const exampleChannel = createSubPubChannel<{ message: string }>("example"); export const pingChannel = createSubPubChannel< { url: string; statusCode: number; durationMs: number } | { url: string; error: string } diff --git a/packages/redis/src/lib/channel.ts b/packages/redis/src/lib/channel.ts index 5a184e55a..074f66414 100644 --- a/packages/redis/src/lib/channel.ts +++ b/packages/redis/src/lib/channel.ts @@ -226,7 +226,48 @@ export const createItemChannel = (itemId: string) => { return createChannelWithLatestAndEvents(`item:${itemId}`); }; -export const createChannelEventHistory = (channelName: string, maxElements = 15) => { +export const createChannelEventHistory = (channelName: string, maxElements = 32) => { + return { + subscribe: (callback: (data: TData) => void) => { + return ChannelSubscriptionTracker.subscribe(channelName, (message) => { + callback(superjson.parse(message)); + }); + }, + pushAsync: async (data: TData, options = { publish: false }) => { + if (options.publish) await publisher.publish(channelName, superjson.stringify(data)); + await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() })); + await getSetClient.ltrim(channelName, 0, maxElements); + }, + clearAsync: async () => { + await getSetClient.del(channelName); + }, + /** + * Returns a slice of the available data in the channel. + * If any of the indexes are out of range (or -range), returned data will be clamped. + * @param startIndex Start index of the slice, negative values are counted from the end, defaults at beginning of range. + * @param endIndex End index of the slice, negative values are counted from the end, defaults at end of range. + */ + getSliceAsync: async (startIndex = 0, endIndex = -1) => { + const range = await getSetClient.lrange(channelName, startIndex, endIndex); + return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)); + }, + getSliceUntilTimeAsync: async (time: Date) => { + const itemsInCollection = await getSetClient.lrange(channelName, 0, -1); + return itemsInCollection + .map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item)) + .filter((item) => item.timestamp < time); + }, + getLengthAsync: async () => { + return await getSetClient.llen(channelName); + }, + name: channelName, + }; +}; + +/** + * @deprecated This function should no longer be used, see history-channel functions. + */ +export const createChannelEventHistoryOld = (channelName: string, maxElements = 15) => { const popElementsOverMaxAsync = async () => { const length = await getSetClient.llen(channelName); if (length <= maxElements) { diff --git a/packages/redis/src/lib/channels/history-channel.ts b/packages/redis/src/lib/channels/history-channel.ts new file mode 100644 index 000000000..541830464 --- /dev/null +++ b/packages/redis/src/lib/channels/history-channel.ts @@ -0,0 +1,6 @@ +import { createChannelEventHistory } from "../channel"; + +export const createIntegrationHistoryChannel = (integrationId: string, queryKey: string, maxElements = 32) => { + const channelName = `integration:${integrationId}:history:${queryKey}`; + return createChannelEventHistory(channelName, maxElements); +}; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index af6198cd0..0a472c3da 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2447,6 +2447,18 @@ "description": "Display notification history from an integration", "noItems": "No notifications to display.", "option": {} + }, + "systemResources": { + "name": "System resources", + "description": "CPU, Memory, Disk and other hardware usage of your system", + "option": {}, + "card": { + "cpu": "CPU", + "memory": "MEM", + "network": "NET", + "up": "UP", + "down": "DOWN" + } } }, "widgetPreview": { diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 2e6fce46e..e4072e1d0 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -1,3 +1,4 @@ @import "@mantine/core/styles.css"; +@import "@mantine/charts/styles.css"; @import "@mantine/dates/styles.css"; @import "mantine-react-table/styles.css"; diff --git a/packages/widgets/src/health-monitoring/rings/memory-ring.tsx b/packages/widgets/src/health-monitoring/rings/memory-ring.tsx index e19ad0fe6..edfdef005 100644 --- a/packages/widgets/src/health-monitoring/rings/memory-ring.tsx +++ b/packages/widgets/src/health-monitoring/rings/memory-ring.tsx @@ -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 { diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 6e4aa2ed4..2c4b5fe0b 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -89,7 +89,7 @@ export const SystemHealthMonitoring = ({ {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 ( )} {options.memory && ( - + )} { diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index c4ab6bc67..a4e741649 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx new file mode 100644 index 000000000..d2db770da --- /dev/null +++ b/packages/widgets/src/system-resources/chart/combined-network-traffic.tsx @@ -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 ( + { + if (!payload) { + return null; + } + return ( + + + {payload.map((payloadData) => ( + + + + {payloadData.value === undefined ? ( + <>N/A + ) : ( + <>{humanFileSize(Math.round(payloadData.value))}/s + )} + + + ))} + + + ); + }, + }} + /> + ); +}; diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx new file mode 100644 index 000000000..3a5496eb3 --- /dev/null +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -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[]; + dataKey: string; + series: LineChartSeries[]; + title: string; + tooltipProps?: TooltipProps; + yAxisProps?: Omit; + 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 ( + + {data.length > 1 && height > 40 && !hovered && ( + + 100 ? "md" : "xs"} fw={"bold"}> + {title} + + {lastValue && ( + 100 ? "md" : "xs"} lineClamp={1}> + {lastValue} + + )} + + )} + {data.length <= 1 ? ( +
+ + 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} /> + +
+ ) : ( + = 64} + yAxisProps={yAxisProps} + /> + )} +
+ ); +}; diff --git a/packages/widgets/src/system-resources/chart/cpu-chart.tsx b/packages/widgets/src/system-resources/chart/cpu-chart.tsx new file mode 100644 index 000000000..0e5f81e81 --- /dev/null +++ b/packages/widgets/src/system-resources/chart/cpu-chart.tsx @@ -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 ( + 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 ( + + + {value.toFixed(0)}% + + + ); + }, + }} + /> + ); +}; diff --git a/packages/widgets/src/system-resources/chart/memory-chart.tsx b/packages/widgets/src/system-resources/chart/memory-chart.tsx new file mode 100644 index 000000000..026128268 --- /dev/null +++ b/packages/widgets/src/system-resources/chart/memory-chart.tsx @@ -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 ( + { + if (!payload) { + return null; + } + const value = payload[0] ? Number(payload[0].value) : 0; + return ( + + + {humanFileSize(value)} / {humanFileSize(totalCapacityInBytes)} ( + {Math.round((value / totalCapacityInBytes) * 100)}%) + + + ); + }, + }} + /> + ); +}; diff --git a/packages/widgets/src/system-resources/chart/network-traffic.tsx b/packages/widgets/src/system-resources/chart/network-traffic.tsx new file mode 100644 index 000000000..91e9a201d --- /dev/null +++ b/packages/widgets/src/system-resources/chart/network-traffic.tsx @@ -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 ( + { + if (!payload) { + return null; + } + const value = payload[0] ? Number(payload[0].value) : 0; + return ( + + + {humanFileSize(Math.round(value))}/s + + + ); + }, + }} + /> + ); +}; diff --git a/packages/widgets/src/system-resources/component.module.css b/packages/widgets/src/system-resources/component.module.css new file mode 100644 index 000000000..da1a998ed --- /dev/null +++ b/packages/widgets/src/system-resources/component.module.css @@ -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; +} diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx new file mode 100644 index 000000000..501eddc65 --- /dev/null +++ b/packages/widgets/src/system-resources/component.tsx @@ -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 ( +
+
+ item.cpu)} /> +
+
+ item.memory)} + totalCapacityInBytes={memoryCapacityInBytes} + /> +
+ {showNetwork && + (width > 200 ? ( + <> + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + item.network!.down)} isUp={false} /> + + {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + item.network!.up)} isUp /> + + ) : ( +
+ {/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */} + item.network!)} /> +
+ ))} +
+ ); +} diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts new file mode 100644 index 000000000..664bc0247 --- /dev/null +++ b/packages/widgets/src/system-resources/index.ts @@ -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"));