From 62587919f41226a638e43fdf698826ed6d5c9a9e Mon Sep 17 00:00:00 2001 From: henrygd Date: Thu, 26 Mar 2026 15:21:39 -0400 Subject: [PATCH] hub(ui): tabs display for system + major frontend/charts refactoring - System page tabs display option - Remove very specific chart components (disk usage, container cpu, etc) and refactor to use more flexible area and line chart components - Optimizations around chart handling to decrease mem usage. Charts are only redrawn now if in view. - Resolve most of the react dev warnings Co-authored-by: sveng93 --- .../site/src/components/charts/area-chart.tsx | 206 +-- .../src/components/charts/container-chart.tsx | 215 --- .../site/src/components/charts/disk-chart.tsx | 83 -- .../src/components/charts/gpu-power-chart.tsx | 117 -- internal/site/src/components/charts/hooks.ts | 43 +- .../site/src/components/charts/line-chart.tsx | 160 ++- .../components/charts/load-average-chart.tsx | 83 -- .../site/src/components/charts/mem-chart.tsx | 108 -- .../site/src/components/charts/swap-chart.tsx | 70 - .../components/charts/temperature-chart.tsx | 117 -- .../containers-table/containers-table.tsx | 1 - internal/site/src/components/lang-toggle.tsx | 50 +- internal/site/src/components/mode-toggle.tsx | 2 +- .../site/src/components/routes/system.tsx | 1264 +++-------------- .../components/routes/system/chart-card.tsx | 129 ++ .../components/routes/system/chart-data.ts | 116 ++ .../routes/system/charts/cpu-charts.tsx | 99 ++ .../routes/system/charts/disk-charts.tsx | 106 ++ .../routes/system/charts/extra-fs-charts.tsx | 120 ++ .../routes/system/charts/gpu-charts.tsx | 232 +++ .../system/charts/load-average-chart.tsx | 55 + .../routes/system/charts/memory-charts.tsx | 170 +++ .../routes/system/charts/network-charts.tsx | 183 +++ .../routes/system/charts/sensor-charts.tsx | 160 +++ .../components/routes/system/cpu-sheet.tsx | 34 +- .../src/components/routes/system/info-bar.tsx | 71 +- .../components/routes/system/lazy-tables.tsx | 36 + .../routes/system/network-sheet.tsx | 2 +- .../components/routes/system/smart-table.tsx | 1 - .../routes/system/use-system-data.ts | 344 +++++ .../systemd-table/systemd-table.tsx | 1 - .../systems-table/systems-table.tsx | 1 - internal/site/src/components/ui/tabs.tsx | 2 +- internal/site/src/index.css | 6 + 34 files changed, 2368 insertions(+), 2019 deletions(-) delete mode 100644 internal/site/src/components/charts/container-chart.tsx delete mode 100644 internal/site/src/components/charts/disk-chart.tsx delete mode 100644 internal/site/src/components/charts/gpu-power-chart.tsx delete mode 100644 internal/site/src/components/charts/load-average-chart.tsx delete mode 100644 internal/site/src/components/charts/mem-chart.tsx delete mode 100644 internal/site/src/components/charts/swap-chart.tsx delete mode 100644 internal/site/src/components/charts/temperature-chart.tsx create mode 100644 internal/site/src/components/routes/system/chart-card.tsx create mode 100644 internal/site/src/components/routes/system/chart-data.ts create mode 100644 internal/site/src/components/routes/system/charts/cpu-charts.tsx create mode 100644 internal/site/src/components/routes/system/charts/disk-charts.tsx create mode 100644 internal/site/src/components/routes/system/charts/extra-fs-charts.tsx create mode 100644 internal/site/src/components/routes/system/charts/gpu-charts.tsx create mode 100644 internal/site/src/components/routes/system/charts/load-average-chart.tsx create mode 100644 internal/site/src/components/routes/system/charts/memory-charts.tsx create mode 100644 internal/site/src/components/routes/system/charts/network-charts.tsx create mode 100644 internal/site/src/components/routes/system/charts/sensor-charts.tsx create mode 100644 internal/site/src/components/routes/system/lazy-tables.tsx create mode 100644 internal/site/src/components/routes/system/use-system-data.ts diff --git a/internal/site/src/components/charts/area-chart.tsx b/internal/site/src/components/charts/area-chart.tsx index 4838402b..f65501fd 100644 --- a/internal/site/src/components/charts/area-chart.tsx +++ b/internal/site/src/components/charts/area-chart.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react" +import { type ReactNode, useEffect, useMemo, useState } from "react" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, @@ -11,18 +11,23 @@ import { import { chartMargin, cn, formatShortDate } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" import { useYAxisWidth } from "./hooks" -import { AxisDomain } from "recharts/types/util/types" +import type { AxisDomain } from "recharts/types/util/types" +import { useIntersectionObserver } from "@/lib/use-intersection-observer" -export type DataPoint = { +export type DataPoint = { label: string - dataKey: (data: SystemStatsRecord) => number | undefined + dataKey: (data: T) => number | null | undefined color: number | string opacity: number stackId?: string | number + order?: number + strokeOpacity?: number + activeDot?: boolean } export default function AreaChartDefault({ chartData, + customData, max, maxToggled, tickFormatter, @@ -34,96 +39,129 @@ export default function AreaChartDefault({ showTotal = false, reverseStackOrder = false, hideYAxis = false, + filter, + truncate = false, }: // logRender = false, - { - chartData: ChartData - max?: number - maxToggled?: boolean - tickFormatter: (value: number, index: number) => string - contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string - dataPoints?: DataPoint[] - domain?: AxisDomain - legend?: boolean - showTotal?: boolean - itemSorter?: (a: any, b: any) => number - reverseStackOrder?: boolean - hideYAxis?: boolean - // logRender?: boolean - }) { +{ + chartData: ChartData + // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) + customData?: any[] + max?: number + maxToggled?: boolean + tickFormatter: (value: number, index: number) => string + // biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop + contentFormatter: (item: any, key: string) => ReactNode + // biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types + dataPoints?: DataPoint[] + domain?: AxisDomain + legend?: boolean + showTotal?: boolean + // biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop + itemSorter?: (a: any, b: any) => number + reverseStackOrder?: boolean + hideYAxis?: boolean + filter?: string + truncate?: boolean + // logRender?: boolean +}) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() + const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) + const sourceData = customData ?? chartData.systemStats + // Only update the rendered data while the chart is visible + const [displayData, setDisplayData] = useState(sourceData) + + // Reduce chart redraws by only updating while visible or when chart time changes + useEffect(() => { + const shouldPrimeData = sourceData.length && !displayData.length + const sourceChanged = sourceData !== displayData + const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting) + if (shouldUpdate) { + setDisplayData(sourceData) + } + }, [displayData, isIntersecting, sourceData]) + + // Use a stable key derived from data point identities and visual properties + const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0") + + const Areas = useMemo(() => { + return dataPoints?.map((dataPoint, i) => { + let { color } = dataPoint + if (typeof color === "number") { + color = `var(--chart-${color})` + } + return ( + + ) + }) + }, [areasKey, maxToggled]) - // biome-ignore lint/correctness/useExhaustiveDependencies: ignore return useMemo(() => { - if (chartData.systemStats.length === 0) { + if (displayData.length === 0) { return null } // if (logRender) { - // console.log("Rendered at", new Date()) + console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label) // } return ( -
- + - - - {!hideYAxis && ( - updateYAxisWidth(tickFormatter(value, index))} - tickLine={false} - axisLine={false} - /> - )} - {xAxis(chartData)} - formatShortDate(data[0].payload.created)} - contentFormatter={contentFormatter} - showTotal={showTotal} - /> - } + + {!hideYAxis && ( + updateYAxisWidth(tickFormatter(value, index))} + tickLine={false} + axisLine={false} /> - {dataPoints?.map((dataPoint) => { - let { color } = dataPoint - if (typeof color === "number") { - color = `var(--chart-${color})` - } - return ( - - ) - })} - {legend && } />} - - -
+ )} + {xAxis(chartData)} + formatShortDate(data[0].payload.created)} + contentFormatter={contentFormatter} + showTotal={showTotal} + filter={filter} + truncate={truncate} + /> + } + /> + {Areas} + {legend && } />} + + ) - }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal]) + }, [displayData, yAxisWidth, showTotal, filter]) } diff --git a/internal/site/src/components/charts/container-chart.tsx b/internal/site/src/components/charts/container-chart.tsx deleted file mode 100644 index f9274e1f..00000000 --- a/internal/site/src/components/charts/container-chart.tsx +++ /dev/null @@ -1,215 +0,0 @@ -// import Spinner from '../spinner' -import { useStore } from "@nanostores/react" -import { memo, useMemo } from "react" -import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" -import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, - pinnedAxisDomain, - xAxis, -} from "@/components/ui/chart" -import { ChartType, Unit } from "@/lib/enums" -import { $containerFilter, $userSettings } from "@/lib/stores" -import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData } from "@/types" -import { Separator } from "../ui/separator" -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) - const userSettings = useStore($userSettings) - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - - const { containerData } = chartData - - const isNetChart = chartType === ChartType.Network - - // Filter with set lookup - const filteredKeys = useMemo(() => { - if (!filter) { - return new Set() - } - const filterTerms = filter - .toLowerCase() - .split(" ") - .filter((term) => term.length > 0) - return new Set( - Object.keys(chartConfig).filter((key) => { - const keyLower = key.toLowerCase() - return !filterTerms.some((term) => keyLower.includes(term)) - }) - ) - }, [chartConfig, filter]) - - // biome-ignore lint/correctness/useExhaustiveDependencies: not necessary - const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => { - const obj = {} as { - toolTipFormatter: (item: any, key: string) => React.ReactNode | string - dataFunction: (key: string, data: any) => number | null - tickFormatter: (value: any) => string - } - // tick formatter - if (chartType === ChartType.CPU) { - obj.tickFormatter = (value) => { - const val = `${toFixedFloat(value, 2)}%` - return updateYAxisWidth(val) - } - } else { - const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes - obj.tickFormatter = (val) => { - const { value, unit } = formatBytes(val, isNetChart, chartUnit, !isNetChart) - return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`) - } - } - // tooltip formatter - if (isNetChart) { - const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => { - if (record?.b?.length && record.b.length >= 2) { - return [Number(record.b[0]) || 0, Number(record.b[1]) || 0] - } - return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024] - } - const formatRxTx = (recv: number, sent: number) => { - const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false) - const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false) - return ( - - {decimalString(receivedValue)} {receivedUnit} - rx - - {decimalString(sentValue)} {sentUnit} - tx - - ) - } - obj.toolTipFormatter = (item: any, key: string) => { - try { - if (key === "__total__") { - let totalSent = 0 - let totalRecv = 0 - const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {} - for (const [containerKey, value] of Object.entries(payloadData)) { - if (!value || typeof value !== "object") { - continue - } - // Skip filtered out containers - if (filteredKeys.has(containerKey)) { - continue - } - const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number }) - totalSent += sent - totalRecv += recv - } - return formatRxTx(totalRecv, totalSent) - } - const [sent, recv] = getRxTxBytes(item?.payload?.[key]) - return formatRxTx(recv, sent) - } catch (e) { - return null - } - } - } else if (chartType === ChartType.Memory) { - obj.toolTipFormatter = (item: any) => { - const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true) - return `${decimalString(value)} ${unit}` - } - } else { - obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}` - } - // data function - if (isNetChart) { - obj.dataFunction = (key: string, data: any) => { - const payload = data[key] - if (!payload) { - return null - } - const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024 - const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024 - return sent + recv - } - } else { - obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null - } - return obj - }, [filteredKeys]) - - // console.log('rendered at', new Date()) - - if (containerData.length === 0) { - return null - } - - return ( -
- - - - - {xAxis(chartData)} - formatShortDate(data[0].payload.created)} - // @ts-expect-error - itemSorter={(a, b) => b.value - a.value} - content={} - /> - {Object.keys(chartConfig).map((key) => { - const filtered = filteredKeys.has(key) - const fillOpacity = filtered ? 0.05 : 0.4 - const strokeOpacity = filtered ? 0.1 : 1 - return ( - - ) - })} - - -
- ) -}) diff --git a/internal/site/src/components/charts/disk-chart.tsx b/internal/site/src/components/charts/disk-chart.tsx deleted file mode 100644 index 4be78f93..00000000 --- a/internal/site/src/components/charts/disk-chart.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useLingui } from "@lingui/react/macro" -import { memo } from "react" -import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" -import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { Unit } from "@/lib/enums" -import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData, SystemStatsRecord } from "@/types" -import { useYAxisWidth } from "./hooks" - -export default memo(function DiskChart({ - dataKey, - diskSize, - chartData, -}: { - dataKey: string | ((data: SystemStatsRecord) => number | undefined) - diskSize: number - chartData: ChartData -}) { - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - const { t } = useLingui() - - // round to nearest GB - if (diskSize >= 100) { - diskSize = Math.round(diskSize) - } - - if (chartData.systemStats.length === 0) { - return null - } - - return ( -
- - - - { - const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true) - return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit) - }} - /> - {xAxis(chartData)} - formatShortDate(data[0].payload.created)} - contentFormatter={({ value }) => { - const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) - return decimalString(convertedValue) + " " + unit - }} - /> - } - /> - - - -
- ) -}) diff --git a/internal/site/src/components/charts/gpu-power-chart.tsx b/internal/site/src/components/charts/gpu-power-chart.tsx deleted file mode 100644 index c09287e2..00000000 --- a/internal/site/src/components/charts/gpu-power-chart.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { memo, useMemo } from "react" -import { CartesianGrid, Line, LineChart, YAxis } from "recharts" -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - xAxis, -} from "@/components/ui/chart" -import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData, GPUData } from "@/types" -import { useYAxisWidth } from "./hooks" -import type { DataPoint } from "./line-chart" - -export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) { - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - const packageKey = " package" - - const { gpuData, dataPoints } = useMemo(() => { - const dataPoints = [] as DataPoint[] - const gpuData = [] as Record[] - const addedKeys = new Map() - - const addKey = (key: string, value: number) => { - addedKeys.set(key, (addedKeys.get(key) ?? 0) + value) - } - - for (const stats of chartData.systemStats) { - const gpus = stats.stats?.g ?? {} - const data = { created: stats.created } as Record - for (const id in gpus) { - const gpu = gpus[id] as GPUData - data[gpu.n] = gpu - addKey(gpu.n, gpu.p ?? 0) - if (gpu.pp) { - data[`${gpu.n}${packageKey}`] = gpu - addKey(`${gpu.n}${packageKey}`, gpu.pp ?? 0) - } - } - gpuData.push(data) - } - const sortedKeys = Array.from(addedKeys.entries()) - .sort(([, a], [, b]) => b - a) - .map(([key]) => key) - - for (let i = 0; i < sortedKeys.length; i++) { - const id = sortedKeys[i] - dataPoints.push({ - label: id, - dataKey: (gpuData: Record) => { - return id.endsWith(packageKey) ? (gpuData[id]?.pp ?? 0) : (gpuData[id]?.p ?? 0) - }, - color: `hsl(${226 + (((i * 360) / addedKeys.size) % 360)}, 65%, 52%)`, - }) - } - return { gpuData, dataPoints } - }, [chartData]) - - if (chartData.systemStats.length === 0) { - return null - } - - return ( -
- - - - { - const val = toFixedFloat(value, 2) - return updateYAxisWidth(`${val}W`) - }} - tickLine={false} - axisLine={false} - /> - {xAxis(chartData)} - b.value - a.value} - content={ - formatShortDate(data[0].payload.created)} - contentFormatter={(item) => `${decimalString(item.value)}W`} - // indicator="line" - /> - } - /> - {dataPoints.map((dataPoint) => ( - - ))} - {dataPoints.length > 1 && } />} - - -
- ) -}) diff --git a/internal/site/src/components/charts/hooks.ts b/internal/site/src/components/charts/hooks.ts index 33c41d5b..ae4f6d91 100644 --- a/internal/site/src/components/charts/hooks.ts +++ b/internal/site/src/components/charts/hooks.ts @@ -1,6 +1,9 @@ import { useMemo, useState } from "react" +import { useStore } from "@nanostores/react" import type { ChartConfig } from "@/components/ui/chart" import type { ChartData, SystemStats, SystemStatsRecord } from "@/types" +import type { DataPoint } from "./area-chart" +import { $containerFilter } from "@/lib/stores" /** Chart configurations for CPU, memory, and network usage charts */ export interface ContainerChartConfigs { @@ -108,6 +111,44 @@ export function useYAxisWidth() { return { yAxisWidth, updateYAxisWidth } } +/** Subscribes to the container filter store and returns filtered DataPoints for container charts */ +export function useContainerDataPoints( + chartConfig: ChartConfig, + // biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys + dataFn: (key: string, data: Record) => number | null +) { + const filter = useStore($containerFilter) + const { dataPoints, filteredKeys } = useMemo(() => { + const filterTerms = filter + ? filter + .toLowerCase() + .split(" ") + .filter((term) => term.length > 0) + : [] + const filtered = new Set() + const points = Object.keys(chartConfig).map((key) => { + const isFiltered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term)) + if (isFiltered) filtered.add(key) + return { + label: key, + // biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys + dataKey: (data: Record) => dataFn(key, data), + color: chartConfig[key].color ?? "", + opacity: isFiltered ? 0.05 : 0.4, + strokeOpacity: isFiltered ? 0.1 : 1, + activeDot: !isFiltered, + stackId: "a", + } + }) + return { + // biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys + dataPoints: points as DataPoint>[], + filteredKeys: filtered, + } + }, [chartConfig, filter]) + return { filter, dataPoints, filteredKeys } +} + // Assures consistent colors for network interfaces export function useNetworkInterfaces(interfaces: SystemStats["ni"]) { const keys = Object.keys(interfaces ?? {}) @@ -124,4 +165,4 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) { })) }, } -} \ No newline at end of file +} diff --git a/internal/site/src/components/charts/line-chart.tsx b/internal/site/src/components/charts/line-chart.tsx index 45fcc996..fb0db907 100644 --- a/internal/site/src/components/charts/line-chart.tsx +++ b/internal/site/src/components/charts/line-chart.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react" +import { type ReactNode, useEffect, useMemo, useState } from "react" import { CartesianGrid, Line, LineChart, YAxis } from "recharts" import { ChartContainer, @@ -11,15 +11,22 @@ import { import { chartMargin, cn, formatShortDate } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" import { useYAxisWidth } from "./hooks" +import type { AxisDomain } from "recharts/types/util/types" +import { useIntersectionObserver } from "@/lib/use-intersection-observer" -export type DataPoint = { +export type DataPoint = { label: string - dataKey: (data: SystemStatsRecord) => number | undefined + dataKey: (data: T) => number | null | undefined color: number | string + stackId?: string | number + order?: number + strokeOpacity?: number + activeDot?: boolean } export default function LineChartDefault({ chartData, + customData, max, maxToggled, tickFormatter, @@ -28,38 +35,101 @@ export default function LineChartDefault({ domain, legend, itemSorter, + showTotal = false, + reverseStackOrder = false, + hideYAxis = false, + filter, + truncate = false, }: // logRender = false, { chartData: ChartData + // biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData) + customData?: any[] max?: number maxToggled?: boolean tickFormatter: (value: number, index: number) => string - contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string - dataPoints?: DataPoint[] - domain?: [number, number] + // biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop + contentFormatter: (item: any, key: string) => ReactNode + // biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types + dataPoints?: DataPoint[] + domain?: AxisDomain legend?: boolean + showTotal?: boolean + // biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop itemSorter?: (a: any, b: any) => number + reverseStackOrder?: boolean + hideYAxis?: boolean + filter?: string + truncate?: boolean // logRender?: boolean }) { const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() + const { isIntersecting, ref } = useIntersectionObserver({ freeze: false }) + const sourceData = customData ?? chartData.systemStats + // Only update the rendered data while the chart is visible + const [displayData, setDisplayData] = useState(sourceData) + + // Reduce chart redraws by only updating while visible or when chart time changes + useEffect(() => { + const shouldPrimeData = sourceData.length && !displayData.length + const sourceChanged = sourceData !== displayData + const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting) + if (shouldUpdate) { + setDisplayData(sourceData) + } + }, [displayData, isIntersecting, sourceData]) + + // Use a stable key derived from data point identities and visual properties + const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0") + + const Lines = useMemo(() => { + return dataPoints?.map((dataPoint, i) => { + let { color } = dataPoint + if (typeof color === "number") { + color = `var(--chart-${color})` + } + return ( + + ) + }) + }, [linesKey, maxToggled]) - // biome-ignore lint/correctness/useExhaustiveDependencies: ignore return useMemo(() => { - if (chartData.systemStats.length === 0) { + if (displayData.length === 0) { return null } // if (logRender) { - // console.log("Rendered at", new Date()) + // console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label) // } return ( -
- + - - + + {!hideYAxis && ( - {xAxis(chartData)} - formatShortDate(data[0].payload.created)} - contentFormatter={contentFormatter} - /> - } - /> - {dataPoints?.map((dataPoint) => { - let { color } = dataPoint - if (typeof color === "number") { - color = `var(--chart-${color})` - } - return ( - - ) - })} - {legend && } />} - - -
+ )} + {xAxis(chartData)} + formatShortDate(data[0].payload.created)} + contentFormatter={contentFormatter} + showTotal={showTotal} + filter={filter} + truncate={truncate} + /> + } + /> + {Lines} + {legend && } />} + + ) - }, [chartData.systemStats.at(-1), yAxisWidth, maxToggled]) + }, [displayData, yAxisWidth, showTotal, filter, chartData.chartTime]) } diff --git a/internal/site/src/components/charts/load-average-chart.tsx b/internal/site/src/components/charts/load-average-chart.tsx deleted file mode 100644 index f79c013b..00000000 --- a/internal/site/src/components/charts/load-average-chart.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { t } from "@lingui/core/macro" -import { memo } from "react" -import { CartesianGrid, Line, LineChart, YAxis } from "recharts" -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - xAxis, -} from "@/components/ui/chart" -import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData, SystemStats } from "@/types" -import { useYAxisWidth } from "./hooks" - -export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) { - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - - const keys: { color: string; label: string }[] = [ - { - color: "hsl(271, 81%, 60%)", // Purple - label: t({ message: `1 min`, comment: "Load average" }), - }, - { - color: "hsl(217, 91%, 60%)", // Blue - label: t({ message: `5 min`, comment: "Load average" }), - }, - { - color: "hsl(25, 95%, 53%)", // Orange - label: t({ message: `15 min`, comment: "Load average" }), - }, - ] - - return ( -
- - - - { - return updateYAxisWidth(String(toFixedFloat(value, 2))) - }} - tickLine={false} - axisLine={false} - /> - {xAxis(chartData)} - formatShortDate(data[0].payload.created)} - contentFormatter={(item) => decimalString(item.value)} - /> - } - /> - {keys.map(({ color, label }, i) => ( - value.stats?.la?.[i]} - name={label} - type="monotoneX" - dot={false} - strokeWidth={1.5} - stroke={color} - isAnimationActive={false} - /> - ))} - } /> - - -
- ) -}) diff --git a/internal/site/src/components/charts/mem-chart.tsx b/internal/site/src/components/charts/mem-chart.tsx deleted file mode 100644 index 98dd05a3..00000000 --- a/internal/site/src/components/charts/mem-chart.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { useLingui } from "@lingui/react/macro" -import { memo } from "react" -import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" -import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { Unit } from "@/lib/enums" -import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData } from "@/types" -import { useYAxisWidth } from "./hooks" - -export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) { - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - const { t } = useLingui() - - const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) - - // console.log('rendered at', new Date()) - - if (chartData.systemStats.length === 0) { - return null - } - - return ( -
- {/* {!yAxisSet && } */} - - - - {totalMem && ( - { - const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) - return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit) - }} - /> - )} - {xAxis(chartData)} - a.order - b.order} - labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} - contentFormatter={({ value }) => { - // mem values are supplied as GB - const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) - return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit - }} - showTotal={true} - /> - } - /> - (showMax ? stats?.mm : stats?.mu)} - type="monotoneX" - fill="var(--chart-2)" - fillOpacity={0.4} - stroke="var(--chart-2)" - stackId="1" - isAnimationActive={false} - /> - {/* {chartData.systemStats.at(-1)?.stats.mz && ( */} - (showMax ? null : stats?.mz)} - type="monotoneX" - fill="hsla(175 60% 45% / 0.8)" - fillOpacity={0.5} - stroke="hsla(175 60% 45% / 0.8)" - stackId="1" - isAnimationActive={false} - /> - {/* )} */} - (showMax ? null : stats?.mb)} - type="monotoneX" - fill="hsla(160 60% 45% / 0.5)" - fillOpacity={0.4} - stroke="hsla(160 60% 45% / 0.5)" - stackId="1" - isAnimationActive={false} - /> - {/* } /> */} - - -
- ) -}) diff --git a/internal/site/src/components/charts/swap-chart.tsx b/internal/site/src/components/charts/swap-chart.tsx deleted file mode 100644 index a45c1e9e..00000000 --- a/internal/site/src/components/charts/swap-chart.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { t } from "@lingui/core/macro" -import { useStore } from "@nanostores/react" -import { memo } from "react" -import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" -import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" -import { $userSettings } from "@/lib/stores" -import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData } from "@/types" -import { useYAxisWidth } from "./hooks" - -export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - const userSettings = useStore($userSettings) - - if (chartData.systemStats.length === 0) { - return null - } - - return ( -
- - - - toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]} - width={yAxisWidth} - tickLine={false} - axisLine={false} - tickFormatter={(value) => { - const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true) - return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit) - }} - /> - {xAxis(chartData)} - formatShortDate(data[0].payload.created)} - contentFormatter={({ value }) => { - // mem values are supplied as GB - const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true) - return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit - }} - // indicator="line" - /> - } - /> - - - -
- ) -}) diff --git a/internal/site/src/components/charts/temperature-chart.tsx b/internal/site/src/components/charts/temperature-chart.tsx deleted file mode 100644 index 60267d97..00000000 --- a/internal/site/src/components/charts/temperature-chart.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useStore } from "@nanostores/react" -import { memo, useMemo } from "react" -import { CartesianGrid, Line, LineChart, YAxis } from "recharts" -import { - ChartContainer, - ChartLegend, - ChartLegendContent, - ChartTooltip, - ChartTooltipContent, - xAxis, -} from "@/components/ui/chart" -import { $temperatureFilter, $userSettings } from "@/lib/stores" -import { chartMargin, cn, decimalString, formatShortDate, formatTemperature, toFixedFloat } from "@/lib/utils" -import type { ChartData } from "@/types" -import { useYAxisWidth } from "./hooks" - -export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) { - const filter = useStore($temperatureFilter) - const userSettings = useStore($userSettings) - const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() - - if (chartData.systemStats.length === 0) { - return null - } - - /** Format temperature data for chart and assign colors */ - const newChartData = useMemo(() => { - const newChartData = { data: [], colors: {} } as { - data: Record[] - colors: Record - } - const tempSums = {} as Record - for (const data of chartData.systemStats) { - const newData = { created: data.created } as Record - const keys = Object.keys(data.stats?.t ?? {}) - for (let i = 0; i < keys.length; i++) { - const key = keys[i] - newData[key] = data.stats.t![key] - tempSums[key] = (tempSums[key] ?? 0) + newData[key] - } - newChartData.data.push(newData) - } - const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a]) - for (const key of keys) { - newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)` - } - return newChartData - }, [chartData]) - - const colors = Object.keys(newChartData.colors) - - // console.log('rendered at', new Date()) - - return ( -
- - - - { - const { value, unit } = formatTemperature(val, userSettings.unitTemp) - return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit) - }} - tickLine={false} - axisLine={false} - /> - {xAxis(chartData)} - b.value - a.value} - content={ - formatShortDate(data[0].payload.created)} - contentFormatter={(item) => { - const { value, unit } = formatTemperature(item.value, userSettings.unitTemp) - return decimalString(value) + " " + unit - }} - filter={filter} - /> - } - /> - {colors.map((key) => { - const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : [] - const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term)) - const strokeOpacity = filtered ? 0.1 : 1 - return ( - - ) - })} - {colors.length < 12 && } />} - - -
- ) -}) \ No newline at end of file diff --git a/internal/site/src/components/containers-table/containers-table.tsx b/internal/site/src/components/containers-table/containers-table.tsx index 13225a74..d3f642f0 100644 --- a/internal/site/src/components/containers-table/containers-table.tsx +++ b/internal/site/src/components/containers-table/containers-table.tsx @@ -462,7 +462,6 @@ function ContainerSheet({ function ContainersTableHead({ table }: { table: TableType }) { return ( -
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { diff --git a/internal/site/src/components/lang-toggle.tsx b/internal/site/src/components/lang-toggle.tsx index c0b096e3..ff1773f6 100644 --- a/internal/site/src/components/lang-toggle.tsx +++ b/internal/site/src/components/lang-toggle.tsx @@ -1,6 +1,6 @@ import { Trans, useLingui } from "@lingui/react/macro" import { LanguagesIcon } from "lucide-react" -import { Button } from "@/components/ui/button" +import { buttonVariants } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { dynamicActivate } from "@/lib/i18n" import languages from "@/lib/languages" @@ -14,31 +14,29 @@ export function LangToggle() { return ( - - - - - - {LangTrans} - - - - {languages.map(([lang, label, e]) => ( - dynamicActivate(lang)} - > - - {e || {lang}} - {" "} - {label} - - ))} - + + + + + {LangTrans} + {LangTrans} + + + + {languages.map(([lang, label, e]) => ( + dynamicActivate(lang)} + > + + {e || {lang}} + {" "} + {label} + + ))} + + ) } diff --git a/internal/site/src/components/mode-toggle.tsx b/internal/site/src/components/mode-toggle.tsx index 69a64081..ab5c11e7 100644 --- a/internal/site/src/components/mode-toggle.tsx +++ b/internal/site/src/components/mode-toggle.tsx @@ -10,7 +10,7 @@ export function ModeToggle() { return ( - + - )} - - ) -} -const SelectAvgMax = memo(({ max }: { max: boolean }) => { - const Icon = max ? ChartMax : ChartAverage - return ( - + {displayMode === "tabs" ? tabbedLayout() : defaultLayout()} + ) }) - -export function ChartCard({ - title, - description, - children, - grid, - empty, - cornerEl, - legend, - className, -}: { - title: string - description: string - children: React.ReactNode - grid?: boolean - empty?: boolean - cornerEl?: JSX.Element | null - legend?: boolean - className?: string -}) { - const { isIntersecting, ref } = useIntersectionObserver() - - return ( - - - {title} - {description} - {cornerEl &&
{cornerEl}
} -
-
- { - - } - {isIntersecting && children} -
-
- ) -} - -const ContainersTable = lazy(() => import("../containers-table/containers-table")) - -function LazyContainersTable({ systemId }: { systemId: string }) { - const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) - return ( -
- {isIntersecting && } -
- ) -} - -const SmartTable = lazy(() => import("./system/smart-table")) - -function LazySmartTable({ systemId }: { systemId: string }) { - const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) - return ( -
- {isIntersecting && } -
- ) -} - -const SystemdTable = lazy(() => import("../systemd-table/systemd-table")) - -function LazySystemdTable({ systemId }: { systemId: string }) { - const { isIntersecting, ref } = useIntersectionObserver() - return ( -
- {isIntersecting && } -
- ) -} diff --git a/internal/site/src/components/routes/system/chart-card.tsx b/internal/site/src/components/routes/system/chart-card.tsx new file mode 100644 index 00000000..5ff8bb2a --- /dev/null +++ b/internal/site/src/components/routes/system/chart-card.tsx @@ -0,0 +1,129 @@ +import { t } from "@lingui/core/macro" +import { Trans, useLingui } from "@lingui/react/macro" +import { useStore } from "@nanostores/react" +import { XIcon } from "lucide-react" +import React, { type JSX, memo, useCallback, useEffect, useState } from "react" +import { $containerFilter, $maxValues } from "@/lib/stores" +import { useIntersectionObserver } from "@/lib/use-intersection-observer" +import { cn } from "@/lib/utils" +import Spinner from "../../spinner" +import { Button } from "../../ui/button" +import { Card, CardDescription, CardHeader, CardTitle } from "../../ui/card" +import { ChartAverage, ChartMax } from "../../ui/icons" +import { Input } from "../../ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select" + +export function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) { + const storeValue = useStore(store) + const [inputValue, setInputValue] = useState(storeValue) + const { t } = useLingui() + + useEffect(() => { + setInputValue(storeValue) + }, [storeValue]) + + useEffect(() => { + if (inputValue === storeValue) { + return + } + const handle = window.setTimeout(() => store.set(inputValue), 80) + return () => clearTimeout(handle) + }, [inputValue, storeValue, store]) + + const handleChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value + setInputValue(value) + }, []) + + const handleClear = useCallback(() => { + setInputValue("") + store.set("") + }, [store]) + + return ( + <> + + {inputValue && ( + + )} + + ) +} + +export const SelectAvgMax = memo(({ max }: { max: boolean }) => { + const Icon = max ? ChartMax : ChartAverage + return ( + + ) +}) + +export function ChartCard({ + title, + description, + children, + grid, + empty, + cornerEl, + legend, + className, +}: { + title: string + description: string + children: React.ReactNode + grid?: boolean + empty?: boolean + cornerEl?: JSX.Element | null + legend?: boolean + className?: string +}) { + const { isIntersecting, ref } = useIntersectionObserver() + + return ( + + + {title} + {description} + {cornerEl &&
{cornerEl}
} +
+
+ { + + } + {isIntersecting && children} +
+
+ ) +} diff --git a/internal/site/src/components/routes/system/chart-data.ts b/internal/site/src/components/routes/system/chart-data.ts new file mode 100644 index 00000000..8769caaf --- /dev/null +++ b/internal/site/src/components/routes/system/chart-data.ts @@ -0,0 +1,116 @@ +import { timeTicks } from "d3-time" +import { getPbTimestamp, pb } from "@/lib/api" +import { chartTimeData } from "@/lib/utils" +import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types" + +type ChartTimeData = { + time: number + data: { + ticks: number[] + domain: number[] + } + chartTime: ChartTimes +} + +export const cache = new Map< + string, + ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"] +>() + +// create ticks and domain for charts +export function getTimeData(chartTime: ChartTimes, lastCreated: number) { + const cached = cache.get("td") as ChartTimeData | undefined + if (cached && cached.chartTime === chartTime) { + if (!lastCreated || cached.time >= lastCreated) { + return cached.data + } + } + + // const buffer = chartTime === "1m" ? 400 : 20_000 + const now = new Date(Date.now()) + const startTime = chartTimeData[chartTime].getOffset(now) + const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime()) + const data = { + ticks, + domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()], + } + cache.set("td", { time: now.getTime(), data, chartTime }) + return data +} + +/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place. + * Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */ +export function appendData( + prev: T[], + newRecords: T[], + expectedInterval: number, + maxLen?: number +): T[] { + if (!newRecords.length) return prev + // Pre-trim prev so the single slice() below is the only copy we make + const trimmed = maxLen && prev.length >= maxLen ? prev.slice(-(maxLen - newRecords.length)) : prev + const result = trimmed.slice() + let prevTime = (trimmed.at(-1)?.created as number) ?? 0 + for (const record of newRecords) { + if (record.created !== null) { + if (typeof record.created === "string") { + record.created = new Date(record.created).getTime() + } + if (prevTime && (record.created as number) - prevTime > expectedInterval * 1.5) { + result.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T) + } + prevTime = record.created as number + } + result.push(record) + } + return result +} + +export async function getStats( + collection: string, + systemId: string, + chartTime: ChartTimes +): Promise { + const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined + const lastCached = cachedStats?.at(-1)?.created as number + return await pb.collection(collection).getFullList({ + filter: pb.filter("system={:id} && created > {:created} && type={:type}", { + id: systemId, + created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined), + type: chartTimeData[chartTime].type, + }), + fields: "created,stats", + sort: "created", + }) +} + +export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] { + const result = [] as ChartData["containerData"] + for (const { created, stats } of containers) { + if (!created) { + result.push({ created: null } as ChartData["containerData"][0]) + continue + } + result.push(makeContainerPoint(new Date(created).getTime(), stats)) + } + return result +} + +/** Transform a single realtime container stats message into a ChartDataContainer point. */ +export function makeContainerPoint( + created: number, + stats: ContainerStatsRecord["stats"] +): ChartData["containerData"][0] { + const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0] + for (const container of stats) { + ;(point as Record)[container.n] = container + } + return point +} + +export function dockerOrPodman(str: string, isPodman: boolean): string { + if (isPodman) { + return str.replace("docker", "podman").replace("Docker", "Podman") + } + return str +} diff --git a/internal/site/src/components/routes/system/charts/cpu-charts.tsx b/internal/site/src/components/routes/system/charts/cpu-charts.tsx new file mode 100644 index 00000000..5045c153 --- /dev/null +++ b/internal/site/src/components/routes/system/charts/cpu-charts.tsx @@ -0,0 +1,99 @@ +import { t } from "@lingui/core/macro" +import AreaChartDefault from "@/components/charts/area-chart" +import { useContainerDataPoints } from "@/components/charts/hooks" +import { decimalString, toFixedFloat } from "@/lib/utils" +import type { ChartConfig } from "@/components/ui/chart" +import type { ChartData } from "@/types" +import { pinnedAxisDomain } from "@/components/ui/chart" +import CpuCoresSheet from "../cpu-sheet" +import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card" +import { dockerOrPodman } from "../chart-data" + +export function CpuChart({ + chartData, + grid, + dataEmpty, + showMax, + isLongerChart, + maxValues, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + showMax: boolean + isLongerChart: boolean + maxValues: boolean +}) { + const maxValSelect = isLongerChart ? : null + + return ( + + {maxValSelect} + + + } + > + (showMax ? stats?.cpum : stats?.cpu), + color: 1, + opacity: 0.4, + }, + ]} + tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} + contentFormatter={({ value }) => `${decimalString(value)}%`} + domain={pinnedAxisDomain()} + /> + + ) +} + +export function ContainerCpuChart({ + chartData, + grid, + dataEmpty, + isPodman, + cpuConfig, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + isPodman: boolean + cpuConfig: ChartConfig +}) { + const { filter, dataPoints } = useContainerDataPoints(cpuConfig, (key, data) => data[key]?.c ?? null) + + return ( + } + > + `${toFixedFloat(val, 2)}%`} + contentFormatter={({ value }) => `${decimalString(value)}%`} + domain={pinnedAxisDomain()} + showTotal={true} + reverseStackOrder={true} + filter={filter} + truncate={true} + itemSorter={(a, b) => b.value - a.value} + /> + + ) +} diff --git a/internal/site/src/components/routes/system/charts/disk-charts.tsx b/internal/site/src/components/routes/system/charts/disk-charts.tsx new file mode 100644 index 00000000..1b4afa0f --- /dev/null +++ b/internal/site/src/components/routes/system/charts/disk-charts.tsx @@ -0,0 +1,106 @@ +import { t } from "@lingui/core/macro" +import AreaChartDefault from "@/components/charts/area-chart" +import { $userSettings } from "@/lib/stores" +import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" +import type { ChartData, SystemStatsRecord } from "@/types" +import { ChartCard, SelectAvgMax } from "../chart-card" +import { Unit } from "@/lib/enums" + +export function DiskCharts({ + chartData, + grid, + dataEmpty, + showMax, + isLongerChart, + maxValues, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + showMax: boolean + isLongerChart: boolean + maxValues: boolean + systemStats: SystemStatsRecord[] +}) { + const maxValSelect = isLongerChart ? : null + const userSettings = $userSettings.get() + + let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN + // round to nearest GB + if (diskSize >= 100) { + diskSize = Math.round(diskSize) + } + + return ( + <> + + { + const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={({ value }) => { + const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) + return `${decimalString(convertedValue)} ${unit}` + }} + dataPoints={[ + { + label: t`Disk Usage`, + color: 4, + opacity: 0.4, + dataKey: ({ stats }) => stats?.du, + }, + ]} + > + + + + { + if (showMax) { + return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024 + } + return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024 + }, + color: 3, + opacity: 0.3, + }, + { + label: t({ message: "Read", comment: "Disk read" }), + dataKey: ({ stats }: SystemStatsRecord) => { + if (showMax) { + return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024 + } + return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024 + }, + color: 1, + opacity: 0.3, + }, + ]} + tickFormatter={(val) => { + const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={({ value }) => { + const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false) + return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` + }} + showTotal={true} + /> + + + ) +} diff --git a/internal/site/src/components/routes/system/charts/extra-fs-charts.tsx b/internal/site/src/components/routes/system/charts/extra-fs-charts.tsx new file mode 100644 index 00000000..4baf41ca --- /dev/null +++ b/internal/site/src/components/routes/system/charts/extra-fs-charts.tsx @@ -0,0 +1,120 @@ +import { t } from "@lingui/core/macro" +import AreaChartDefault from "@/components/charts/area-chart" +import { $userSettings } from "@/lib/stores" +import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" +import type { ChartData, SystemStatsRecord } from "@/types" +import { ChartCard, SelectAvgMax } from "../chart-card" +import { Unit } from "@/lib/enums" + +export function ExtraFsCharts({ + chartData, + grid, + dataEmpty, + showMax, + isLongerChart, + maxValues, + systemStats, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + showMax: boolean + isLongerChart: boolean + maxValues: boolean + systemStats: SystemStatsRecord[] +}) { + const maxValSelect = isLongerChart ? : null + const userSettings = $userSettings.get() + const extraFs = systemStats.at(-1)?.stats.efs + if (!extraFs || Object.keys(extraFs).length === 0) { + return null + } + + return ( +
+ {Object.keys(extraFs).map((extraFsName) => { + let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN + // round to nearest GB + if (diskSize >= 100) { + diskSize = Math.round(diskSize) + } + return ( +
+ + { + const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={({ value }) => { + const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) + return `${decimalString(convertedValue)} ${unit}` + }} + dataPoints={[ + { + label: t`Disk Usage`, + color: 4, + opacity: 0.4, + dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.du, + }, + ]} + > + + + { + if (showMax) { + return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024 + } + return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024 + }, + color: 3, + opacity: 0.3, + }, + { + label: t`Read`, + dataKey: ({ stats }) => { + if (showMax) { + return stats?.efs?.[extraFsName]?.rbm ?? (stats?.efs?.[extraFsName]?.rm ?? 0) * 1024 * 1024 + } + return stats?.efs?.[extraFsName]?.rb ?? (stats?.efs?.[extraFsName]?.r ?? 0) * 1024 * 1024 + }, + color: 1, + opacity: 0.3, + }, + ]} + maxToggled={showMax} + tickFormatter={(val) => { + const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={({ value }) => { + const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false) + return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` + }} + /> + +
+ ) + })} +
+ ) +} diff --git a/internal/site/src/components/routes/system/charts/gpu-charts.tsx b/internal/site/src/components/routes/system/charts/gpu-charts.tsx new file mode 100644 index 00000000..31d9daf6 --- /dev/null +++ b/internal/site/src/components/routes/system/charts/gpu-charts.tsx @@ -0,0 +1,232 @@ +import { t } from "@lingui/core/macro" +import { useRef, useMemo } from "react" +import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart" +import LineChartDefault from "@/components/charts/line-chart" +import { Unit } from "@/lib/enums" +import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils" +import type { ChartData, GPUData, SystemStatsRecord } from "@/types" +import { ChartCard } from "../chart-card" + +/** GPU power draw chart for the main grid */ +export function GpuPowerChart({ + chartData, + grid, + dataEmpty, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean +}) { + const packageKey = " package" + const statsRef = useRef(chartData.systemStats) + statsRef.current = chartData.systemStats + + // Derive GPU power config key (cheap per render) + let gpuPowerKey = "" + for (let i = chartData.systemStats.length - 1; i >= 0; i--) { + const gpus = chartData.systemStats[i].stats?.g + if (gpus) { + const parts: string[] = [] + for (const id in gpus) { + const gpu = gpus[id] as GPUData + if (gpu.p !== undefined) parts.push(`${id}:${gpu.n}`) + if (gpu.pp !== undefined) parts.push(`${id}:${gpu.n}${packageKey}`) + } + gpuPowerKey = parts.sort().join("\0") + break + } + } + + const dataPoints = useMemo((): DataPoint[] => { + if (!gpuPowerKey) return [] + const totals = new Map() + for (const record of statsRef.current) { + const gpus = record.stats?.g + if (!gpus) continue + for (const id in gpus) { + const gpu = gpus[id] as GPUData + const key = gpu.n + const existing = totals.get(key) + if (existing) { + existing.total += gpu.p ?? 0 + } else { + totals.set(key, { label: gpu.n, gpuId: id, isPackage: false, total: gpu.p ?? 0 }) + } + if (gpu.pp !== undefined) { + const pkgKey = `${gpu.n}${packageKey}` + const existingPkg = totals.get(pkgKey) + if (existingPkg) { + existingPkg.total += gpu.pp + } else { + totals.set(pkgKey, { label: pkgKey, gpuId: id, isPackage: true, total: gpu.pp }) + } + } + } + } + const sorted = Array.from(totals.values()).sort((a, b) => b.total - a.total) + return sorted.map( + (entry, i): DataPoint => ({ + label: entry.label, + dataKey: (data: SystemStatsRecord) => { + const gpu = data.stats?.g?.[entry.gpuId] + return entry.isPackage ? (gpu?.pp ?? 0) : (gpu?.p ?? 0) + }, + color: `hsl(${226 + (((i * 360) / sorted.length) % 360)}, 65%, 52%)`, + opacity: 1, + }) + ) + }, [gpuPowerKey]) + + return ( + + 1} + chartData={chartData} + dataPoints={dataPoints} + itemSorter={(a: { value: number }, b: { value: number }) => b.value - a.value} + tickFormatter={(val) => `${toFixedFloat(val, 2)}W`} + contentFormatter={({ value }) => `${decimalString(value)}W`} + /> + + ) +} + +/** GPU detail grid (engines + per-GPU usage/VRAM) — rendered outside the main 2-col grid */ +export function GpuDetailCharts({ + chartData, + grid, + dataEmpty, + lastGpus, + hasGpuEnginesData, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + lastGpus: Record + hasGpuEnginesData: boolean +}) { + return ( +
+ {hasGpuEnginesData && ( + + + + )} + {Object.keys(lastGpus).map((id) => { + const gpu = lastGpus[id] as GPUData + return ( +
+ + stats?.g?.[id]?.u ?? 0, + color: 1, + opacity: 0.35, + }, + ]} + tickFormatter={(val) => `${toFixedFloat(val, 2)}%`} + contentFormatter={({ value }) => `${decimalString(value)}%`} + /> + + + {(gpu.mt ?? 0) > 0 && ( + + stats?.g?.[id]?.mu ?? 0, + color: 2, + opacity: 0.25, + }, + ]} + max={gpu.mt} + tickFormatter={(val) => { + const { value, unit } = formatBytes(val, false, Unit.Bytes, true) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={({ value }) => { + const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true) + return `${decimalString(convertedValue)} ${unit}` + }} + /> + + )} +
+ ) + })} +
+ ) +} + +function GpuEnginesChart({ chartData }: { chartData: ChartData }) { + // Derive stable engine config key (cheap per render) + let enginesKey = "" + for (let i = chartData.systemStats.length - 1; i >= 0; i--) { + const gpus = chartData.systemStats[i].stats?.g + if (!gpus) continue + for (const id in gpus) { + if (gpus[id].e) { + enginesKey = id + "\0" + Object.keys(gpus[id].e).sort().join("\0") + break + } + } + if (enginesKey) break + } + + const { gpuId, dataPoints } = useMemo((): { gpuId: string | null; dataPoints: DataPoint[] } => { + if (!enginesKey) return { gpuId: null, dataPoints: [] } + const parts = enginesKey.split("\0") + const gId = parts[0] + const engineNames = parts.slice(1) + return { + gpuId: gId, + dataPoints: engineNames.map((engine, i) => ({ + label: engine, + dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gId]?.e?.[engine] ?? 0, + color: `hsl(${140 + (((i * 360) / engineNames.length) % 360)}, 65%, 52%)`, + opacity: 0.35, + })), + } + }, [enginesKey]) + + if (!gpuId) { + return null + } + + return ( + `${toFixedFloat(val, 2)}%`} + contentFormatter={({ value }) => `${decimalString(value)}%`} + /> + ) +} diff --git a/internal/site/src/components/routes/system/charts/load-average-chart.tsx b/internal/site/src/components/routes/system/charts/load-average-chart.tsx new file mode 100644 index 00000000..d69bbcbd --- /dev/null +++ b/internal/site/src/components/routes/system/charts/load-average-chart.tsx @@ -0,0 +1,55 @@ +import { t } from "@lingui/core/macro" +import type { ChartData } from "@/types" +import { ChartCard } from "../chart-card" +import LineChartDefault from "@/components/charts/line-chart" +import { decimalString, toFixedFloat } from "@/lib/utils" + +export function LoadAverageChart({ + chartData, + grid, + dataEmpty, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean +}) { + const { major, minor } = chartData.agentVersion + if (major === 0 && minor <= 12) { + return null + } + return ( + + decimalString(item.value)} + tickFormatter={(value) => { + return String(toFixedFloat(value, 2)) + }} + legend={true} + dataPoints={[ + { + label: t({ message: `1 min`, comment: "Load average" }), + color: "hsl(271, 81%, 60%)", // Purple + dataKey: ({ stats }) => stats?.la?.[0], + }, + { + label: t({ message: `5 min`, comment: "Load average" }), + color: "hsl(217, 91%, 60%)", // Blue + dataKey: ({ stats }) => stats?.la?.[1], + }, + { + label: t({ message: `15 min`, comment: "Load average" }), + color: "hsl(25, 95%, 53%)", // Orange + dataKey: ({ stats }) => stats?.la?.[2], + }, + ]} + > + + ) +} diff --git a/internal/site/src/components/routes/system/charts/memory-charts.tsx b/internal/site/src/components/routes/system/charts/memory-charts.tsx new file mode 100644 index 00000000..ee6f21bc --- /dev/null +++ b/internal/site/src/components/routes/system/charts/memory-charts.tsx @@ -0,0 +1,170 @@ +import { t } from "@lingui/core/macro" +import AreaChartDefault from "@/components/charts/area-chart" +import { useContainerDataPoints } from "@/components/charts/hooks" +import { Unit } from "@/lib/enums" +import type { ChartConfig } from "@/components/ui/chart" +import type { ChartData, SystemStatsRecord } from "@/types" +import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card" +import { dockerOrPodman } from "../chart-data" +import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" +import { pinnedAxisDomain } from "@/components/ui/chart" + +export function MemoryChart({ + chartData, + grid, + dataEmpty, + showMax, + isLongerChart, + maxValues, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + showMax: boolean + isLongerChart: boolean + maxValues: boolean +}) { + const maxValSelect = isLongerChart ? : null + const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) + + return ( + + a.order - b.order} + maxToggled={showMax} + showTotal={true} + tickFormatter={(value) => { + const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) + return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={({ value }) => { + const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) + return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` + }} + dataPoints={[ + { + label: t`Used`, + dataKey: ({ stats }) => (showMax ? stats?.mm : stats?.mu), + color: 2, + opacity: 0.4, + stackId: "1", + order: 3, + }, + { + label: "ZFS ARC", + dataKey: ({ stats }) => (showMax ? null : stats?.mz), + color: "hsla(175 60% 45% / 0.8)", + opacity: 0.5, + order: 2, + }, + { + label: t`Cache / Buffers`, + dataKey: ({ stats }) => (showMax ? null : stats?.mb), + color: "hsla(160 60% 45% / 0.5)", + opacity: 0.4, + stackId: "1", + order: 1, + }, + ]} + /> + + ) +} + +export function ContainerMemoryChart({ + chartData, + grid, + dataEmpty, + isPodman, + memoryConfig, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + isPodman: boolean + memoryConfig: ChartConfig +}) { + const { filter, dataPoints } = useContainerDataPoints(memoryConfig, (key, data) => data[key]?.m ?? null) + + return ( + } + > + { + const { value, unit } = formatBytes(val, false, Unit.Bytes, true) + return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={(item) => { + const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true) + return `${decimalString(value)} ${unit}` + }} + domain={pinnedAxisDomain()} + showTotal={true} + reverseStackOrder={true} + filter={filter} + truncate={true} + itemSorter={(a, b) => b.value - a.value} + /> + + ) +} + +export function SwapChart({ + chartData, + grid, + dataEmpty, + systemStats, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + systemStats: SystemStatsRecord[] +}) { + // const userSettings = useStore($userSettings) + + const hasSwapData = (systemStats.at(-1)?.stats.su ?? 0) > 0 + if (!hasSwapData) { + return null + } + return ( + + toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]} + contentFormatter={({ value }) => { + // mem values are supplied as GB + const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) + return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}` + }} + tickFormatter={(value) => { + const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true) + return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}` + }} + dataPoints={[ + { + label: t`Used`, + dataKey: ({ stats }) => stats?.su, + color: 2, + opacity: 0.4, + }, + ]} + > + + ) +} diff --git a/internal/site/src/components/routes/system/charts/network-charts.tsx b/internal/site/src/components/routes/system/charts/network-charts.tsx new file mode 100644 index 00000000..13c93df0 --- /dev/null +++ b/internal/site/src/components/routes/system/charts/network-charts.tsx @@ -0,0 +1,183 @@ +import { useMemo } from "react" +import { t } from "@lingui/core/macro" +import AreaChartDefault from "@/components/charts/area-chart" +import { useContainerDataPoints } from "@/components/charts/hooks" +import { $userSettings } from "@/lib/stores" +import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" +import type { ChartConfig } from "@/components/ui/chart" +import { pinnedAxisDomain } from "@/components/ui/chart" +import type { ChartData, SystemStatsRecord } from "@/types" +import { Separator } from "@/components/ui/separator" +import NetworkSheet from "../network-sheet" +import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card" +import { dockerOrPodman } from "../chart-data" + +export function BandwidthChart({ + chartData, + grid, + dataEmpty, + showMax, + isLongerChart, + maxValues, + systemStats, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + showMax: boolean + isLongerChart: boolean + maxValues: boolean + systemStats: SystemStatsRecord[] +}) { + const maxValSelect = isLongerChart ? : null + const userSettings = $userSettings.get() + + return ( + + {maxValSelect} + + + } + description={t`Network traffic of public interfaces`} + > + (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))} + tickFormatter={(val) => { + const { value, unit } = formatBytes(val, true, userSettings.unitNet, false) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={(data) => { + const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false) + return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}` + }} + showTotal={true} + /> + + ) +} + +export function ContainerNetworkChart({ + chartData, + grid, + dataEmpty, + isPodman, + networkConfig, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + isPodman: boolean + networkConfig: ChartConfig +}) { + const userSettings = $userSettings.get() + const { filter, dataPoints, filteredKeys } = useContainerDataPoints(networkConfig, (key, data) => { + const payload = data[key] + if (!payload) return null + const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024 + const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024 + return sent + recv + }) + + const contentFormatter = useMemo(() => { + const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => { + if (record?.b?.length && record.b.length >= 2) { + return [Number(record.b[0]) || 0, Number(record.b[1]) || 0] + } + return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024] + } + const formatRxTx = (recv: number, sent: number) => { + const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false) + const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false) + return ( + + {decimalString(receivedValue)} {receivedUnit} + rx + + {decimalString(sentValue)} {sentUnit} + tx + + ) + } + // biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item + return (item: any, key: string) => { + try { + if (key === "__total__") { + let totalSent = 0 + let totalRecv = 0 + const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {} + for (const [containerKey, value] of Object.entries(payloadData)) { + if (!value || typeof value !== "object") continue + if (filteredKeys.has(containerKey)) continue + const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number }) + totalSent += sent + totalRecv += recv + } + return formatRxTx(totalRecv, totalSent) + } + const [sent, recv] = getRxTxBytes(item?.payload?.[key]) + return formatRxTx(recv, sent) + } catch { + return null + } + } + }, [filteredKeys, userSettings.unitNet]) + + return ( + } + > + { + const { value, unit } = formatBytes(val, true, userSettings.unitNet, false) + return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}` + }} + contentFormatter={contentFormatter} + domain={pinnedAxisDomain()} + showTotal={true} + reverseStackOrder={true} + filter={filter} + truncate={true} + itemSorter={(a, b) => b.value - a.value} + /> + + ) +} diff --git a/internal/site/src/components/routes/system/charts/sensor-charts.tsx b/internal/site/src/components/routes/system/charts/sensor-charts.tsx new file mode 100644 index 00000000..2e7df894 --- /dev/null +++ b/internal/site/src/components/routes/system/charts/sensor-charts.tsx @@ -0,0 +1,160 @@ +import { t } from "@lingui/core/macro" +import AreaChartDefault from "@/components/charts/area-chart" +import { batteryStateTranslations } from "@/lib/i18n" +import { $temperatureFilter, $userSettings } from "@/lib/stores" +import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils" +import type { ChartData, SystemStatsRecord } from "@/types" +import { ChartCard, FilterBar } from "../chart-card" +import LineChartDefault from "@/components/charts/line-chart" +import { useStore } from "@nanostores/react" +import { useRef, useMemo } from "react" + +export function BatteryChart({ + chartData, + grid, + dataEmpty, + maxValues, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean + maxValues: boolean +}) { + const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat + + if (!showBatteryChart) { + return null + } + + return ( + + stats?.bat?.[0], + color: 1, + opacity: 0.35, + }, + ]} + domain={[0, 100]} + tickFormatter={(val) => `${val}%`} + contentFormatter={({ value }) => `${value}%`} + /> + + ) +} + +export function TemperatureChart({ + chartData, + grid, + dataEmpty, +}: { + chartData: ChartData + grid: boolean + dataEmpty: boolean +}) { + const showTempChart = chartData.systemStats.at(-1)?.stats.t + + const filter = useStore($temperatureFilter) + const userSettings = useStore($userSettings) + + const statsRef = useRef(chartData.systemStats) + statsRef.current = chartData.systemStats + + // Derive sensor names key from latest data point + let sensorNamesKey = "" + for (let i = chartData.systemStats.length - 1; i >= 0; i--) { + const t = chartData.systemStats[i].stats?.t + if (t) { + sensorNamesKey = Object.keys(t).sort().join("\0") + break + } + } + + // Only recompute colors and dataKey functions when sensor names change + const { colorMap, dataKeys, sortedKeys } = useMemo(() => { + const stats = statsRef.current + const tempSums = {} as Record + for (const data of stats) { + const t = data.stats?.t + if (!t) continue + for (const key of Object.keys(t)) { + tempSums[key] = (tempSums[key] ?? 0) + t[key] + } + } + const sorted = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a]) + const colorMap = {} as Record + const dataKeys = {} as Record number | undefined> + for (let i = 0; i < sorted.length; i++) { + const key = sorted[i] + colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)` + dataKeys[key] = (d: SystemStatsRecord) => d.stats?.t?.[key] + } + return { colorMap, dataKeys, sortedKeys: sorted } + }, [sensorNamesKey]) + + const dataPoints = useMemo(() => { + return sortedKeys.map((key) => { + const filterTerms = filter + ? filter + .toLowerCase() + .split(" ") + .filter((term) => term.length > 0) + : [] + const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term)) + const strokeOpacity = filtered ? 0.1 : 1 + return { + label: key, + dataKey: dataKeys[key], + color: colorMap[key], + opacity: strokeOpacity, + } + }) + }, [sortedKeys, filter, dataKeys, colorMap]) + + if (!showTempChart) { + return null + } + + const legend = Object.keys(chartData.systemStats.at(-1)?.stats.t ?? {}).length < 12 + + return ( +
+ } + legend={legend} + > + b.value - a.value} + domain={["auto", "auto"]} + legend={legend} + tickFormatter={(val) => { + const { value, unit } = formatTemperature(val, userSettings.unitTemp) + return `${toFixedFloat(value, 2)} ${unit}` + }} + contentFormatter={(item) => { + const { value, unit } = formatTemperature(item.value, userSettings.unitTemp) + return `${decimalString(value)} ${unit}` + }} + dataPoints={dataPoints} + > + +
+ ) +} diff --git a/internal/site/src/components/routes/system/cpu-sheet.tsx b/internal/site/src/components/routes/system/cpu-sheet.tsx index 936d96b7..583267ce 100644 --- a/internal/site/src/components/routes/system/cpu-sheet.tsx +++ b/internal/site/src/components/routes/system/cpu-sheet.tsx @@ -1,14 +1,14 @@ import { t } from "@lingui/core/macro" import { MoreHorizontalIcon } from "lucide-react" import { memo, useRef, useState } from "react" -import AreaChartDefault, { DataPoint } from "@/components/charts/area-chart" +import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart" import ChartTimeSelect from "@/components/charts/chart-time-select" import { Button } from "@/components/ui/button" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { DialogTitle } from "@/components/ui/dialog" import { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils" import type { ChartData, SystemStatsRecord } from "@/types" -import { ChartCard } from "../system" +import { ChartCard } from "./chart-card" const minAgentVersion = parseSemVer("0.15.3") @@ -42,41 +42,54 @@ export default memo(function CpuCoresSheet({ const numCores = cpus.length const hasBreakdown = (latest?.cpub?.length ?? 0) > 0 + // make sure all individual core data points have the same y axis domain to make relative comparison easier + let highestCpuCorePct = 1 + if (hasOpened.current) { + for (let i = 0; i < numCores; i++) { + for (let j = 0; j < chartData.systemStats.length; j++) { + const pct = chartData.systemStats[j].stats?.cpus?.[i] ?? 0 + if (pct > highestCpuCorePct) { + highestCpuCorePct = pct + } + } + } + } + const breakdownDataPoints = [ { label: "System", dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1], color: 3, opacity: 0.35, - stackId: "a" + stackId: "a", }, { label: "User", dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0], color: 1, opacity: 0.35, - stackId: "a" + stackId: "a", }, { label: "IOWait", dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2], color: 4, opacity: 0.35, - stackId: "a" + stackId: "a", }, { label: "Steal", dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3], color: 5, opacity: 0.35, - stackId: "a" + stackId: "a", }, { label: "Idle", dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4], color: 2, opacity: 0.35, - stackId: "a" + stackId: "a", }, { label: t`Other`, @@ -86,11 +99,10 @@ export default memo(function CpuCoresSheet({ }, color: `hsl(80, 65%, 52%)`, opacity: 0.35, - stackId: "a" + stackId: "a", }, ] as DataPoint[] - return ( {t`CPU Usage`} @@ -151,7 +163,7 @@ export default memo(function CpuCoresSheet({ dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1), color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`, opacity: 0.35, - stackId: "a" + stackId: "a", }))} tickFormatter={(val) => `${val}%`} contentFormatter={({ value }) => `${value}%`} @@ -174,7 +186,7 @@ export default memo(function CpuCoresSheet({ void + displayMode: "default" | "tabs" + setDisplayMode: (mode: "default" | "tabs") => void details: SystemDetailsRecord | null }) { const { t } = useLingui() @@ -190,24 +202,53 @@ export default function InfoBar({
- - + + - - {t`Toggle grid`} - + + + + Display + + + setDisplayMode(v as "default" | "tabs")} + > + e.preventDefault()}> + Default + + e.preventDefault()}> + Tabs + + + + + Chart width + + + setGrid(v === "grid")} + > + e.preventDefault()}> + Grid + + e.preventDefault()}> + Full + + + +
diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx new file mode 100644 index 00000000..f487369c --- /dev/null +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -0,0 +1,36 @@ +import { lazy } from "react" +import { useIntersectionObserver } from "@/lib/use-intersection-observer" +import { cn } from "@/lib/utils" + +const ContainersTable = lazy(() => import("../../containers-table/containers-table")) + +export function LazyContainersTable({ systemId }: { systemId: string }) { + const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) + return ( +
+ {isIntersecting && } +
+ ) +} + +const SmartTable = lazy(() => import("./smart-table")) + +export function LazySmartTable({ systemId }: { systemId: string }) { + const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" }) + return ( +
+ {isIntersecting && } +
+ ) +} + +const SystemdTable = lazy(() => import("../../systemd-table/systemd-table")) + +export function LazySystemdTable({ systemId }: { systemId: string }) { + const { isIntersecting, ref } = useIntersectionObserver() + return ( +
+ {isIntersecting && } +
+ ) +} diff --git a/internal/site/src/components/routes/system/network-sheet.tsx b/internal/site/src/components/routes/system/network-sheet.tsx index 9e86f348..0681da1f 100644 --- a/internal/site/src/components/routes/system/network-sheet.tsx +++ b/internal/site/src/components/routes/system/network-sheet.tsx @@ -11,7 +11,7 @@ import { DialogTitle } from "@/components/ui/dialog" import { $userSettings } from "@/lib/stores" import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils" import type { ChartData } from "@/types" -import { ChartCard } from "../system" +import { ChartCard } from "./chart-card" export default memo(function NetworkSheet({ chartData, diff --git a/internal/site/src/components/routes/system/smart-table.tsx b/internal/site/src/components/routes/system/smart-table.tsx index 5126cccb..7247f1ed 100644 --- a/internal/site/src/components/routes/system/smart-table.tsx +++ b/internal/site/src/components/routes/system/smart-table.tsx @@ -636,7 +636,6 @@ const SmartDevicesTable = memo(function SmartDevicesTable({ function SmartTableHead({ table }: { table: TableType }) { return ( -
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( diff --git a/internal/site/src/components/routes/system/use-system-data.ts b/internal/site/src/components/routes/system/use-system-data.ts new file mode 100644 index 00000000..935ac2df --- /dev/null +++ b/internal/site/src/components/routes/system/use-system-data.ts @@ -0,0 +1,344 @@ +import { useStore } from "@nanostores/react" +import { getPagePath } from "@nanostores/router" +import { subscribeKeys } from "nanostores" +import { useEffect, useMemo, useRef, useState } from "react" +import { useContainerChartConfigs } from "@/components/charts/hooks" +import { pb } from "@/lib/api" +import { SystemStatus } from "@/lib/enums" +import { + $allSystemsById, + $allSystemsByName, + $chartTime, + $containerFilter, + $direction, + $maxValues, + $systems, + $userSettings, +} from "@/lib/stores" +import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils" +import type { + ChartData, + ContainerStatsRecord, + SystemDetailsRecord, + SystemInfo, + SystemRecord, + SystemStats, + SystemStatsRecord, +} from "@/types" +import { $router, navigate } from "../../router" +import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data" + +export function useSystemData(id: string) { + const direction = useStore($direction) + const systems = useStore($systems) + const chartTime = useStore($chartTime) + const maxValues = useStore($maxValues) + const [grid, setGrid] = useBrowserStorage("grid", true) + const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default") + const [activeTab, setActiveTabRaw] = useState("core") + const [mountedTabs, setMountedTabs] = useState(() => new Set(["core"])) + const tabsRef = useRef(["core", "disk"]) + + function setActiveTab(tab: string) { + setActiveTabRaw(tab) + setMountedTabs((prev) => (prev.has(tab) ? prev : new Set([...prev, tab]))) + } + const [system, setSystem] = useState({} as SystemRecord) + const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) + const [containerData, setContainerData] = useState([] as ChartData["containerData"]) + const persistChartTime = useRef(false) + const statsRequestId = useRef(0) + const [chartLoading, setChartLoading] = useState(true) + const [details, setDetails] = useState({} as SystemDetailsRecord) + + useEffect(() => { + return () => { + if (!persistChartTime.current) { + $chartTime.set($userSettings.get().chartTime) + } + persistChartTime.current = false + setSystemStats([]) + setContainerData([]) + setDetails({} as SystemDetailsRecord) + $containerFilter.set("") + } + }, [id]) + + // find matching system and update when it changes + useEffect(() => { + if (!systems.length) { + return + } + // allow old system-name slug to work + const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName + return subscribeKeys(store, [id], (newSystems) => { + const sys = newSystems[id] + if (sys) { + setSystem(sys) + document.title = `${sys?.name} / Beszel` + } + }) + }, [id, systems.length]) + + // hide 1m chart time if system agent version is less than 0.13.0 + useEffect(() => { + if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) { + $chartTime.set("1h") + } + }, [system?.info?.v]) + + // fetch system details + useEffect(() => { + // if system.info.m exists, agent is old version without system details + if (!system.id || system.info?.m) { + return + } + pb.collection("system_details") + .getOne(system.id, { + fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman", + headers: { + "Cache-Control": "public, max-age=60", + }, + }) + .then(setDetails) + }, [system.id]) + + // subscribe to realtime metrics if chart time is 1m + useEffect(() => { + let unsub = () => {} + if (!system.id || chartTime !== "1m") { + return + } + if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) { + $chartTime.set("1h") + return + } + let isFirst = true + pb.realtime + .subscribe( + `rt_metrics`, + (data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => { + const now = Date.now() + const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord + const containerPoint = + data.container?.length > 0 + ? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"]) + : null + // on first message, make sure we clear out data from other time periods + if (isFirst) { + isFirst = false + setSystemStats([statsPoint]) + setContainerData(containerPoint ? [containerPoint] : []) + return + } + setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60)) + if (containerPoint) { + setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60)) + } + }, + { query: { system: system.id } } + ) + .then((us) => { + unsub = us + }) + return () => { + unsub?.() + } + }, [chartTime, system.id]) + + const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v]) + + const chartData: ChartData = useMemo(() => { + const lastCreated = Math.max( + (systemStats.at(-1)?.created as number) ?? 0, + (containerData.at(-1)?.created as number) ?? 0 + ) + return { + systemStats, + containerData, + chartTime, + orientation: direction === "rtl" ? "right" : "left", + ...getTimeData(chartTime, lastCreated), + agentVersion, + } + }, [systemStats, containerData, direction]) + + // Share chart config computation for all container charts + const containerChartConfigs = useContainerChartConfigs(containerData) + + // get stats when system "changes." (Not just system to system, + // also when new info comes in via systemManager realtime connection, indicating an update) + useEffect(() => { + if (!system.id || !chartTime || chartTime === "1m") { + return + } + + const systemId = system.id + const { expectedInterval } = chartTimeData[chartTime] + const ss_cache_key = `${systemId}_${chartTime}_system_stats` + const cs_cache_key = `${systemId}_${chartTime}_container_stats` + const requestId = ++statsRequestId.current + + const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined + const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined + + // Render from cache immediately if available + if (cachedSystemStats?.length) { + setSystemStats(cachedSystemStats) + setContainerData(cachedContainerData || []) + setChartLoading(false) + + // Skip the fetch if the latest cached point is recent enough that no new point is expected yet + const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined + if (lastCreated && Date.now() - lastCreated < expectedInterval) { + return + } + } else { + setChartLoading(true) + } + + Promise.allSettled([ + getStats("system_stats", systemId, chartTime), + getStats("container_stats", systemId, chartTime), + ]).then(([systemStats, containerStats]) => { + // If another request has been made since this one, ignore the results + if (requestId !== statsRequestId.current) { + return + } + + setChartLoading(false) + + // make new system stats + let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[] + if (systemStats.status === "fulfilled" && systemStats.value.length) { + systemData = appendData(systemData, systemStats.value, expectedInterval, 100) + cache.set(ss_cache_key, systemData) + } + setSystemStats(systemData) + // make new container stats + let containerData = (cache.get(cs_cache_key) || []) as ChartData["containerData"] + if (containerStats.status === "fulfilled" && containerStats.value.length) { + containerData = appendData(containerData, makeContainerData(containerStats.value), expectedInterval, 100) + cache.set(cs_cache_key, containerData) + } + setContainerData(containerData) + }) + }, [system, chartTime]) + + // keyboard navigation between systems + // in tabs mode: arrow keys switch tabs, shift+arrow switches systems + // in default mode: arrow keys switch systems + useEffect(() => { + if (!systems.length) { + return + } + const handleKeyUp = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.ctrlKey || + e.metaKey || + e.altKey + ) { + return + } + + const isLeft = e.key === "ArrowLeft" || e.key === "h" + const isRight = e.key === "ArrowRight" || e.key === "l" + if (!isLeft && !isRight) { + return + } + + // in tabs mode, plain arrows switch tabs, shift+arrows switch systems + if (displayMode === "tabs") { + if (!e.shiftKey) { + // skip if focused in tablist (Radix handles it natively) + if (e.target instanceof HTMLElement && e.target.closest('[role="tablist"]')) { + return + } + const tabs = tabsRef.current + const currentIdx = tabs.indexOf(activeTab) + const nextIdx = isLeft ? (currentIdx - 1 + tabs.length) % tabs.length : (currentIdx + 1) % tabs.length + setActiveTab(tabs[nextIdx]) + return + } + } else if (e.shiftKey) { + return + } + + const currentIndex = systems.findIndex((s) => s.id === id) + if (currentIndex === -1 || systems.length <= 1) { + return + } + if (isLeft) { + const prevIndex = (currentIndex - 1 + systems.length) % systems.length + persistChartTime.current = true + setActiveTabRaw("core") + setMountedTabs(new Set(["core"])) + return navigate(getPagePath($router, "system", { id: systems[prevIndex].id })) + } + if (isRight) { + const nextIndex = (currentIndex + 1) % systems.length + persistChartTime.current = true + setActiveTabRaw("core") + setMountedTabs(new Set(["core"])) + return navigate(getPagePath($router, "system", { id: systems[nextIndex].id })) + } + } + return listen(document, "keyup", handleKeyUp) + }, [id, systems, displayMode, activeTab]) + + // derived values + const isLongerChart = !["1m", "1h"].includes(chartTime) + const showMax = maxValues && isLongerChart + const dataEmpty = !chartLoading && chartData.systemStats.length === 0 + const lastGpus = systemStats.at(-1)?.stats?.g + const isPodman = details?.podman ?? system.info?.p ?? false + + let hasGpuData = false + let hasGpuEnginesData = false + let hasGpuPowerData = false + + if (lastGpus) { + hasGpuData = Object.keys(lastGpus).length > 0 + for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) { + const gpus = systemStats[i].stats?.g + if (!gpus) continue + for (const id in gpus) { + if (!hasGpuEnginesData && gpus[id].e !== undefined) { + hasGpuEnginesData = true + } + if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) { + hasGpuPowerData = true + } + if (hasGpuEnginesData && hasGpuPowerData) break + } + } + } + + return { + system, + systemStats, + containerData, + chartData, + containerChartConfigs, + details, + grid, + setGrid, + displayMode, + setDisplayMode, + activeTab, + setActiveTab, + mountedTabs, + tabsRef, + maxValues, + isLongerChart, + showMax, + dataEmpty, + isPodman, + lastGpus, + hasGpuData, + hasGpuEnginesData, + hasGpuPowerData, + } +} diff --git a/internal/site/src/components/systemd-table/systemd-table.tsx b/internal/site/src/components/systemd-table/systemd-table.tsx index 91225f39..edc6c66a 100644 --- a/internal/site/src/components/systemd-table/systemd-table.tsx +++ b/internal/site/src/components/systemd-table/systemd-table.tsx @@ -614,7 +614,6 @@ function SystemdSheet({ function SystemdTableHead({ table }: { table: TableType }) { return ( -
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { diff --git a/internal/site/src/components/systems-table/systems-table.tsx b/internal/site/src/components/systems-table/systems-table.tsx index 64516ade..78438c5a 100644 --- a/internal/site/src/components/systems-table/systems-table.tsx +++ b/internal/site/src/components/systems-table/systems-table.tsx @@ -391,7 +391,6 @@ function SystemsTableHead({ table }: { table: TableType }) { const { t } = useLingui() return ( -
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { diff --git a/internal/site/src/components/ui/tabs.tsx b/internal/site/src/components/ui/tabs.tsx index 5b48294d..998ecbc9 100644 --- a/internal/site/src/components/ui/tabs.tsx +++ b/internal/site/src/components/ui/tabs.tsx @@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<