"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic"; import { SystemResourceCPUChart } from "./chart/cpu-chart"; import { SystemResourceGPUChart } from "./chart/gpu-chart"; import { SystemResourceMemoryChart } from "./chart/memory-chart"; import { NetworkTrafficChart } from "./chart/network-traffic"; const COMPACT_HISTORY_SIZE = 15; const ADVANCED_HISTORY_SIZE = 60; 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, gpu) => acc + gpu.processorUtilization, 0) / healthInfo.gpu.length : 0, network: healthInfo.network, }); 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 default function SystemResources({ integrationIds, options, width, height, displayMode, }: WidgetComponentProps<"systemResources">) { const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); const data = getUsableWidgetQueryData(healthQuery) ?? []; const { dataUpdatedAt, isPending } = healthQuery; const isAdvanced = displayMode === "advanced"; const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]); const [historyByIntegration, setHistoryByIntegration] = useState>({}); const previousIntegrationKey = useRef(integrationKey); useEffect(() => { if (previousIntegrationKey.current === integrationKey) return; previousIntegrationKey.current = integrationKey; setHistoryByIntegration({}); }, [integrationKey]); const lastUpdatedAt = useRef(dataUpdatedAt); useEffect(() => { if (dataUpdatedAt === lastUpdatedAt.current) return; lastUpdatedAt.current = dataUpdatedAt; setHistoryByIntegration((previous) => Object.fromEntries( data.map((entry) => [ entry.integrationId, appendBoundedHistory( previous[entry.integrationId] ?? [], toChartItem(entry.healthInfo), ADVANCED_HISTORY_SIZE, ), ]), ), ); }, [dataUpdatedAt, data]); const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1; const panelWidth = width / panelColumns; 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); return ( 1} /> ); }; if (isPending) { return (
); } if (data.length === 0) return ; if (!isAdvanced && data.length === 1 && data[0]) { return ( {renderCharts(data[0], width, Math.max(0, height - 20))} ); } const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2))); return ( {data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))} ); } interface SystemChartsProps { integrationName: string; items: ChartItem[]; memoryCapacityInBytes: number; options: WidgetComponentProps<"systemResources">["options"]; width: number; height: number; isAdvanced: boolean; showTitle: boolean; } const SystemCharts = ({ integrationName, items, memoryCapacityInBytes, options, width, height, isAdvanced, showTitle, }: SystemChartsProps) => { const networkItems = getNetworkHistory(items); const availableCharts = options.visibleCharts.filter((chart) => chart !== "network" || networkItems.length > 0); const visibleCharts = isAdvanced ? availableCharts : availableCharts.slice(0, getCompactChartBudget(height)); const showNetwork = visibleCharts.includes("network"); const chartCount = visibleCharts.length; const chartColumns = isAdvanced && width >= 560 ? 2 : 1; const chartHeight = isAdvanced ? 180 : `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`; return ( {showTitle && ( {integrationName} )} {chartCount === 0 && (
—
)} {visibleCharts.includes("cpu") && ( item.cpu)} hasShadow={options.hasShadow} labelDisplayMode={options.labelDisplayMode} /> )} {visibleCharts.includes("memory") && ( item.memory)} totalCapacityInBytes={memoryCapacityInBytes} hasShadow={options.hasShadow} labelDisplayMode={options.labelDisplayMode} /> )} {visibleCharts.includes("gpu") && ( item.gpu)} hasShadow={options.hasShadow} labelDisplayMode={options.labelDisplayMode} /> )} {showNetwork && (width >= 300 ? ( network.down)} isUp={false} hasShadow={options.hasShadow} labelDisplayMode={options.labelDisplayMode} /> network.up)} isUp hasShadow={options.hasShadow} labelDisplayMode={options.labelDisplayMode} /> ) : ( ))}
); };