diff --git a/beszel/Makefile b/beszel/Makefile index b995d85..46e3b1a 100644 --- a/beszel/Makefile +++ b/beszel/Makefile @@ -56,7 +56,7 @@ dev-hub: export ENV=dev dev-hub: mkdir -p ./site/dist && touch ./site/dist/index.html @if command -v entr >/dev/null 2>&1; then \ - find ./cmd/hub ./internal/{alerts,hub,records,users} -name "*.go" | entr -r -s "cd ./cmd/hub && go run . serve"; \ + find ./cmd/hub/*.go ./internal/{alerts,hub,records,users}/*.go | entr -r -s "cd ./cmd/hub && go run . serve --http 0.0.0.0:8090"; \ else \ cd ./cmd/hub && go run . serve --http 0.0.0.0:8090; \ fi diff --git a/beszel/internal/agent/system.go b/beszel/internal/agent/system.go index 48dac40..b3ec974 100644 --- a/beszel/internal/agent/system.go +++ b/beszel/internal/agent/system.go @@ -251,6 +251,7 @@ func (a *Agent) getSystemStats() system.Stats { // update base system info a.systemInfo.Cpu = systemStats.Cpu + a.systemInfo.LoadAvg1 = systemStats.LoadAvg1 a.systemInfo.LoadAvg5 = systemStats.LoadAvg5 a.systemInfo.LoadAvg15 = systemStats.LoadAvg15 a.systemInfo.MemPct = systemStats.MemPct diff --git a/beszel/internal/alerts/alerts.go b/beszel/internal/alerts/alerts.go index 7ec6f41..26cfe5c 100644 --- a/beszel/internal/alerts/alerts.go +++ b/beszel/internal/alerts/alerts.go @@ -47,6 +47,7 @@ type SystemAlertStats struct { NetSent float64 `json:"ns"` NetRecv float64 `json:"nr"` Temperatures map[string]float32 `json:"t"` + LoadAvg1 float64 `json:"l1"` LoadAvg5 float64 `json:"l5"` LoadAvg15 float64 `json:"l15"` } diff --git a/beszel/internal/alerts/alerts_system.go b/beszel/internal/alerts/alerts_system.go index 17d032c..d336192 100644 --- a/beszel/internal/alerts/alerts_system.go +++ b/beszel/internal/alerts/alerts_system.go @@ -54,6 +54,9 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst } val = data.Info.DashboardTemp unit = "°C" + case "LoadAvg1": + val = data.Info.LoadAvg1 + unit = "" case "LoadAvg5": val = data.Info.LoadAvg5 unit = "" @@ -196,6 +199,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst } alert.mapSums[key] += temp } + case "LoadAvg1": + alert.val += stats.LoadAvg1 case "LoadAvg5": alert.val += stats.LoadAvg5 case "LoadAvg15": diff --git a/beszel/internal/entities/system/system.go b/beszel/internal/entities/system/system.go index 78678e1..15bc1c1 100644 --- a/beszel/internal/entities/system/system.go +++ b/beszel/internal/entities/system/system.go @@ -92,8 +92,9 @@ type Info struct { GpuPct float64 `json:"g,omitempty" cbor:"12,keyasint,omitempty"` DashboardTemp float64 `json:"dt,omitempty" cbor:"13,keyasint,omitempty"` Os Os `json:"os" cbor:"14,keyasint"` - LoadAvg5 float64 `json:"l5,omitempty" cbor:"15,keyasint,omitempty,omitzero"` - LoadAvg15 float64 `json:"l15,omitempty" cbor:"16,keyasint,omitempty,omitzero"` + LoadAvg1 float64 `json:"l1,omitempty" cbor:"15,keyasint,omitempty"` + LoadAvg5 float64 `json:"l5,omitempty" cbor:"16,keyasint,omitempty"` + LoadAvg15 float64 `json:"l15,omitempty" cbor:"17,keyasint,omitempty"` } // Final data structure to return to the hub diff --git a/beszel/migrations/0_collections_snapshot_0_12_0.go b/beszel/migrations/1_collections_snapshot_0_12_0.go similarity index 99% rename from beszel/migrations/0_collections_snapshot_0_12_0.go rename to beszel/migrations/1_collections_snapshot_0_12_0.go index ce97ec9..9041296 100644 --- a/beszel/migrations/0_collections_snapshot_0_12_0.go +++ b/beszel/migrations/1_collections_snapshot_0_12_0.go @@ -76,6 +76,7 @@ func init() { "Disk", "Temperature", "Bandwidth", + "LoadAvg1", "LoadAvg5", "LoadAvg15" ] diff --git a/beszel/site/src/components/alerts/alert-button.tsx b/beszel/site/src/components/alerts/alert-button.tsx index b06eef5..4ec27d0 100644 --- a/beszel/site/src/components/alerts/alert-button.tsx +++ b/beszel/site/src/components/alerts/alert-button.tsx @@ -11,13 +11,14 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" -import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react" +import { BellIcon, GlobeIcon, ServerIcon, HourglassIcon } from "lucide-react" import { alertInfo, cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { AlertRecord, SystemRecord } from "@/types" import { $router, Link } from "../router" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Checkbox } from "../ui/checkbox" +import { Collapsible } from "../ui/collapsible" import { SystemAlert, SystemAlertGlobal } from "./alerts-system" import { getPagePath } from "@nanostores/router" diff --git a/beszel/site/src/components/charts/load-average-chart.tsx b/beszel/site/src/components/charts/load-average-chart.tsx new file mode 100644 index 0000000..0607bcd --- /dev/null +++ b/beszel/site/src/components/charts/load-average-chart.tsx @@ -0,0 +1,123 @@ +import { CartesianGrid, Line, LineChart, YAxis } from "recharts" + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + xAxis, +} from "@/components/ui/chart" +import { + useYAxisWidth, + cn, + formatShortDate, + toFixedWithoutTrailingZeros, + decimalString, + chartMargin, +} from "@/lib/utils" +import { ChartData } from "@/types" +import { memo, useMemo } from "react" +import { t } from "@lingui/core/macro" + +export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) { + const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() + + if (chartData.systemStats.length === 0) { + return null + } + + /** Format load average data for chart */ + const newChartData = useMemo(() => { + const newChartData = { data: [], colors: {} } as { + data: Record[] + colors: Record + } + + // Define colors for the three load average lines + const colors = { + "1m": "hsl(25, 95%, 53%)", // Orange for 1-minute + "5m": "hsl(217, 91%, 60%)", // Blue for 5-minute + "15m": "hsl(271, 81%, 56%)", // Purple for 15-minute + } + + for (let data of chartData.systemStats) { + let newData = { created: data.created } as Record + + // Add load average values if they exist and stats is not null + if (data.stats && data.stats.l1 !== undefined) { + newData["1m"] = data.stats.l1 + } + if (data.stats && data.stats.l5 !== undefined) { + newData["5m"] = data.stats.l5 + } + if (data.stats && data.stats.l15 !== undefined) { + newData["15m"] = data.stats.l15 + } + + newChartData.data.push(newData) + } + + newChartData.colors = colors + return newChartData + }, [chartData]) + + const loadKeys = ["1m", "5m", "15m"].filter(key => + newChartData.data.some(data => data[key] !== undefined) + ) + + // console.log('rendered at', new Date()) + + return ( +
+ + + + { + const val = toFixedWithoutTrailingZeros(value, 2) + return updateYAxisWidth(val) + }} + tickLine={false} + axisLine={false} + /> + {xAxis(chartData)} + b.value - a.value} + content={ + formatShortDate(data[0].payload.created)} + contentFormatter={(item) => decimalString(item.value)} + /> + } + /> + {loadKeys.map((key) => ( + + ))} + } /> + + +
+ ) +}) \ No newline at end of file diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index 376cce1..f4c8b35 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -48,6 +48,7 @@ const DiskChart = lazy(() => import("../charts/disk-chart")) const SwapChart = lazy(() => import("../charts/swap-chart")) const TemperatureChart = lazy(() => import("../charts/temperature-chart")) const GpuPowerChart = lazy(() => import("../charts/gpu-power-chart")) +const LoadAverageChart = lazy(() => import("../charts/load-average-chart")) const cache = new Map() @@ -483,6 +484,18 @@ export default function SystemDetail({ name }: { name: string }) { /> + {/* Load Average chart */} + {(systemStats.at(-1)?.stats.l1 !== undefined || systemStats.at(-1)?.stats.l5 !== undefined || systemStats.at(-1)?.stats.l15 !== undefined) && ( + + + + )} + {containerFilterBar && ( originalRow.info.l5, - id: "l5", - name: () => t({ message: "L5", comment: "Load average 5 minutes" }), + id: "loadAverage", + name: () => t`Load Average`, size: 0, hideSort: true, Icon: HourglassIcon, header: sortableHeader, - cell(info) { - const val = info.getValue() as number - if (!val) { - return null + cell(info: CellContext) { + const system = info.row.original; + const l1 = system.info?.l1; + const l5 = system.info?.l5; + const l15 = system.info?.l15; + const cores = system.info?.c || 1; + + // If no load average data, return null + if (!l1 && !l5 && !l15) return null; + + const loadAverages = [ + { name: "1m", value: l1 }, + { name: "5m", value: l5 }, + { name: "15m", value: l15 } + ].filter(la => la.value !== undefined); + + if (!loadAverages.length) return null; + + function getDotColor(value: number) { + const normalized = value / cores; + if (normalized < 0.7) return "bg-green-500"; + if (normalized < 1.0) return "bg-orange-500"; + return "bg-red-600"; } + return ( - - {decimalString(val)} - - ) - }, - }, - { - accessorFn: (originalRow) => originalRow.info.l15, - id: "l15", - name: () => t({ message: "L15", comment: "Load average 15 minutes" }), - size: 0, - hideSort: true, - Icon: HourglassIcon, - header: sortableHeader, - cell(info) { - const val = info.getValue() as number - if (!val) { - return null - } - return ( - - {decimalString(val)} - - ) +
+ {loadAverages.map((la, idx) => ( + + + + + + + {decimalString(la.value || 0, 2)} + + {idx < loadAverages.length - 1 && /} + + + +
+
{t`${la.name}`}
+
+
+
+
+ ))} +
+ ); }, }, { diff --git a/beszel/site/src/components/ui/collapsible.tsx b/beszel/site/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..f30abcb --- /dev/null +++ b/beszel/site/src/components/ui/collapsible.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { ChevronDownIcon, HourglassIcon } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "./button" + +interface CollapsibleProps { + title: string + children: React.ReactNode + description?: React.ReactNode + defaultOpen?: boolean + className?: string + icon?: React.ReactNode +} + +export function Collapsible({ title, children, description, defaultOpen = false, className, icon }: CollapsibleProps) { + const [isOpen, setIsOpen] = React.useState(defaultOpen) + + return ( +
+ + {description && ( +
+ {description} +
+ )} + {isOpen && ( +
+
+ {children} +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/beszel/site/src/lib/utils.ts b/beszel/site/src/lib/utils.ts index 6f61358..bb8cd2f 100644 --- a/beszel/site/src/lib/utils.ts +++ b/beszel/site/src/lib/utils.ts @@ -407,6 +407,16 @@ export const alertInfo: Record = { icon: ThermometerIcon, desc: () => t`Triggers when any sensor exceeds a threshold`, }, + LoadAvg1: { + name: () => t`Load Average 1m`, + unit: "", + icon: HourglassIcon, + max: 100, + min: 0.1, + start: 10, + step: 0.1, + desc: () => t`Triggers when 1 minute load average exceeds a threshold`, + }, LoadAvg5: { name: () => t`Load Average 5m`, unit: "", @@ -445,3 +455,27 @@ export const getHubURL = () => BESZEL?.HUB_URL || window.location.origin /** Map of system IDs to their corresponding tokens (used to avoid fetching in add-system dialog) */ export const tokenMap = new Map() + +/** + * Calculate load average percentage relative to CPU cores + * @param loadAverage - The load average value (1m, 5m, or 15m) + * @param cores - Number of CPU cores + * @returns Percentage (0-100) representing CPU utilization + */ +export const calculateLoadAveragePercent = (loadAverage: number, cores: number): number => { + if (!loadAverage || !cores) return 0 + return Math.min((loadAverage / cores) * 100, 100) +} + +/** + * Get load average opacity based on utilization relative to cores + * @param loadAverage - The load average value + * @param cores - Number of CPU cores + * @returns Opacity value (0.6, 0.8, or 1.0) + */ +export const getLoadAverageOpacity = (loadAverage: number, cores: number): number => { + if (!loadAverage || !cores) return 0.6 + if (loadAverage < cores * 0.5) return 0.6 + if (loadAverage < cores) return 0.8 + return 1.0 +} \ No newline at end of file diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts index 85ea620..6558546 100644 --- a/beszel/site/src/types.d.ts +++ b/beszel/site/src/types.d.ts @@ -44,6 +44,8 @@ export interface SystemInfo { c: number /** cpu model */ m: string + /** load average 1 minute */ + l1?: number /** load average 5 minutes */ l5?: number /** load average 15 minutes */