diff --git a/beszel/site/src/components/charts/area-chart.tsx b/beszel/site/src/components/charts/area-chart.tsx index 52c97bc..12b5114 100644 --- a/beszel/site/src/components/charts/area-chart.tsx +++ b/beszel/site/src/components/charts/area-chart.tsx @@ -1,6 +1,7 @@ import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { useYAxisWidth, cn, formatShortDate, chartMargin } from "@/lib/utils" +import { cn, formatShortDate, chartMargin } from "@/lib/utils" +import { useYAxisWidth } from "./hooks" import { ChartData, SystemStatsRecord } from "@/types" import { useMemo } from "react" diff --git a/beszel/site/src/components/charts/container-chart.tsx b/beszel/site/src/components/charts/container-chart.tsx index fbd8497..c40dd4f 100644 --- a/beszel/site/src/components/charts/container-chart.tsx +++ b/beszel/site/src/components/charts/container-chart.tsx @@ -1,23 +1,26 @@ import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { memo, useMemo } from "react" -import { useYAxisWidth, cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils" +import { cn, formatShortDate, chartMargin, toFixedFloat, formatBytes, decimalString } from "@/lib/utils" // import Spinner from '../spinner' import { useStore } from "@nanostores/react" import { $containerFilter, $userSettings } from "@/lib/stores" import { ChartData } from "@/types" import { Separator } from "../ui/separator" import { ChartType, Unit } from "@/lib/enums" +import { useYAxisWidth } from "./hooks" export default memo(function ContainerChart({ dataKey, chartData, chartType, + chartConfig, unit = "%", }: { dataKey: string chartData: ChartData chartType: ChartType + chartConfig: ChartConfig unit?: string }) { const filter = useStore($containerFilter) @@ -28,40 +31,6 @@ export default memo(function ContainerChart({ const isNetChart = chartType === ChartType.Network - const chartConfig = useMemo(() => { - const config = {} as Record - const totalUsage = new Map() - - // calculate total usage of each container - for (const stats of containerData) { - for (const key in stats) { - if (!key || key === "created") continue - - const currentTotal = totalUsage.get(key) ?? 0 - const increment = isNetChart - ? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0) - : // @ts-ignore - stats[key]?.[dataKey] ?? 0 - - totalUsage.set(key, currentTotal + increment) - } - } - - // Sort keys and generate colors based on usage - const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a) - - const length = sortedEntries.length - sortedEntries.forEach(([key], i) => { - const hue = ((i * 360) / length) % 360 - config[key] = { - label: key, - color: `hsl(${hue}, 60%, 55%)`, - } - }) - - return config satisfies ChartConfig - }, [chartData]) - const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => { const obj = {} as { toolTipFormatter: (item: any, key: string) => React.ReactNode | string @@ -119,7 +88,14 @@ export default memo(function ContainerChart({ return obj }, []) - const filterLower = filter?.toLowerCase() + // Filter with set lookup + const filteredKeys = useMemo(() => { + if (!filter) { + return new Set() + } + const filterLower = filter.toLowerCase() + return new Set(Object.keys(chartConfig).filter((key) => !key.toLowerCase().includes(filterLower))) + }, [chartConfig, filter]) // console.log('rendered at', new Date()) @@ -162,9 +138,9 @@ export default memo(function ContainerChart({ content={} /> {Object.keys(chartConfig).map((key) => { - const filtered = filterLower && !key.toLowerCase().includes(filterLower) - let fillOpacity = filtered ? 0.05 : 0.4 - let strokeOpacity = filtered ? 0.1 : 1 + const filtered = filteredKeys.has(key) + const fillOpacity = filtered ? 0.05 : 0.4 + const strokeOpacity = filtered ? 0.1 : 1 return ( { + const configs = { + cpu: {} as ChartConfig, + memory: {} as ChartConfig, + network: {} as ChartConfig, + } + + // Aggregate usage metrics for each container + const totalUsage = { + cpu: new Map(), + memory: new Map(), + network: new Map(), + } + + // Process each data point to calculate totals + for (let i = 0; i < containerData.length; i++) { + const stats = containerData[i] + const containerNames = Object.keys(stats) + + for (let j = 0; j < containerNames.length; j++) { + const containerName = containerNames[j] + // Skip metadata field + if (containerName === "created") { + continue + } + + const containerStats = stats[containerName] + if (!containerStats) { + continue + } + + // Accumulate metrics for CPU, memory, and network + const currentCpu = totalUsage.cpu.get(containerName) ?? 0 + const currentMemory = totalUsage.memory.get(containerName) ?? 0 + const currentNetwork = totalUsage.network.get(containerName) ?? 0 + + totalUsage.cpu.set(containerName, currentCpu + (containerStats.c ?? 0)) + totalUsage.memory.set(containerName, currentMemory + (containerStats.m ?? 0)) + totalUsage.network.set(containerName, currentNetwork + (containerStats.nr ?? 0) + (containerStats.ns ?? 0)) + } + } + + // Generate chart configurations for each metric type + Object.entries(totalUsage).forEach(([chartType, usageMap]) => { + const sortedContainers = Array.from(usageMap.entries()).sort(([, a], [, b]) => b - a) + const chartConfig = {} as Record + const count = sortedContainers.length + + // Generate colors for each container + for (let i = 0; i < count; i++) { + const [containerName] = sortedContainers[i] + const hue = ((i * 360) / count) % 360 + chartConfig[containerName] = { + label: containerName, + color: `hsl(${hue}, 60%, 55%)`, + } + } + + configs[chartType as keyof typeof configs] = chartConfig + }) + + return configs + }, [containerData]) +} + +/** Sets the correct width of the y axis in recharts based on the longest label */ +export function useYAxisWidth() { + const [yAxisWidth, setYAxisWidth] = useState(0) + let maxChars = 0 + let timeout: ReturnType + function updateYAxisWidth(str: string) { + if (str.length > maxChars) { + maxChars = str.length + const div = document.createElement("div") + div.className = "text-xs tabular-nums tracking-tighter table sr-only" + div.innerHTML = str + clearTimeout(timeout) + timeout = setTimeout(() => { + document.body.appendChild(div) + const width = div.offsetWidth + 24 + if (width > yAxisWidth) { + setYAxisWidth(div.offsetWidth + 24) + } + document.body.removeChild(div) + }) + } + return str + } + return { yAxisWidth, updateYAxisWidth } +} diff --git a/beszel/site/src/components/charts/load-average-chart.tsx b/beszel/site/src/components/charts/load-average-chart.tsx index f5d4c5b..5c3cecd 100644 --- a/beszel/site/src/components/charts/load-average-chart.tsx +++ b/beszel/site/src/components/charts/load-average-chart.tsx @@ -8,10 +8,11 @@ import { ChartTooltipContent, xAxis, } from "@/components/ui/chart" -import { useYAxisWidth, cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils" +import { cn, formatShortDate, toFixedFloat, decimalString, chartMargin } from "@/lib/utils" import { ChartData, SystemStats } from "@/types" import { memo } from "react" import { t } from "@lingui/core/macro" +import { useYAxisWidth } from "./hooks" export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() diff --git a/beszel/site/src/components/charts/mem-chart.tsx b/beszel/site/src/components/charts/mem-chart.tsx index 2819618..af78746 100644 --- a/beszel/site/src/components/charts/mem-chart.tsx +++ b/beszel/site/src/components/charts/mem-chart.tsx @@ -1,10 +1,11 @@ import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { useYAxisWidth, cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" +import { cn, decimalString, formatShortDate, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" import { memo } from "react" import { ChartData } from "@/types" import { useLingui } from "@lingui/react/macro" import { Unit } from "@/lib/enums" +import { useYAxisWidth } from "./hooks" export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() diff --git a/beszel/site/src/components/charts/swap-chart.tsx b/beszel/site/src/components/charts/swap-chart.tsx index bd166a6..f2cd243 100644 --- a/beszel/site/src/components/charts/swap-chart.tsx +++ b/beszel/site/src/components/charts/swap-chart.tsx @@ -2,11 +2,12 @@ import { t } from "@lingui/core/macro" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { useYAxisWidth, cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" +import { cn, formatShortDate, decimalString, chartMargin, formatBytes, toFixedFloat } from "@/lib/utils" import { ChartData } from "@/types" import { memo } from "react" import { $userSettings } from "@/lib/stores" import { useStore } from "@nanostores/react" +import { useYAxisWidth } from "./hooks" export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() diff --git a/beszel/site/src/components/charts/temperature-chart.tsx b/beszel/site/src/components/charts/temperature-chart.tsx index 0754b73..61248a4 100644 --- a/beszel/site/src/components/charts/temperature-chart.tsx +++ b/beszel/site/src/components/charts/temperature-chart.tsx @@ -8,19 +8,12 @@ import { ChartTooltipContent, xAxis, } from "@/components/ui/chart" -import { - useYAxisWidth, - cn, - formatShortDate, - toFixedFloat, - chartMargin, - formatTemperature, - decimalString, -} from "@/lib/utils" +import { cn, formatShortDate, toFixedFloat, chartMargin, formatTemperature, decimalString } from "@/lib/utils" import { ChartData } from "@/types" import { memo, useMemo } from "react" import { $temperatureFilter, $userSettings } from "@/lib/stores" import { useStore } from "@nanostores/react" +import { useYAxisWidth } from "./hooks" export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { const filter = useStore($temperatureFilter) diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index 6d632fe..496f65f 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -11,6 +11,7 @@ import { $allSystemsByName, } from "@/lib/stores" import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" +import { useContainerChartConfigs } from "@/components/charts/hooks" import { ChartType, Unit, Os, SystemStatus } from "@/lib/enums" import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type JSX } from "react" import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card" @@ -174,6 +175,9 @@ export default memo(function SystemDetail({ name }: { name: string }) { } }, [systemStats, containerData, direction]) + // Share chart config computation for all container charts + const containerChartConfigs = useContainerChartConfigs(containerData) + // get stats useEffect(() => { if (!system.id || !chartTime) { @@ -482,7 +486,12 @@ export default memo(function SystemDetail({ name }: { name: string }) { description={t`Average CPU utilization of containers`} cornerEl={containerFilterBar} > - + )} @@ -504,7 +513,12 @@ export default memo(function SystemDetail({ name }: { name: string }) { description={dockerOrPodman(t`Memory usage of docker containers`, system)} cornerEl={containerFilterBar} > - + )} @@ -606,8 +620,12 @@ export default memo(function SystemDetail({ name }: { name: string }) { description={dockerOrPodman(t`Network traffic of docker containers`, system)} cornerEl={containerFilterBar} > - {/* @ts-ignore */} - + )} diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts index f69c8ea..134a57c 100644 --- a/beszel/site/src/lib/utils.ts +++ b/beszel/site/src/lib/utils.ts @@ -108,32 +108,6 @@ export const chartTimeData: ChartTimeData = { }, } -/** Sets the correct width of the y axis in recharts based on the longest label */ -export function useYAxisWidth() { - const [yAxisWidth, setYAxisWidth] = useState(0) - let maxChars = 0 - let timeout: Timer - function updateYAxisWidth(str: string) { - if (str.length > maxChars) { - maxChars = str.length - const div = document.createElement("div") - div.className = "text-xs tabular-nums tracking-tighter table sr-only" - div.innerHTML = str - clearTimeout(timeout) - timeout = setTimeout(() => { - document.body.appendChild(div) - const width = div.offsetWidth + 24 - if (width > yAxisWidth) { - setYAxisWidth(div.offsetWidth + 24) - } - document.body.removeChild(div) - }) - } - return str - } - return { yAxisWidth, updateYAxisWidth } -} - /** Format number to x decimal places, without trailing zeros */ export function toFixedFloat(num: number, digits: number) { return parseFloat((digits === 0 ? Math.ceil(num) : num).toFixed(digits))