add more cpu metrics (#1356)

- adds monitoring for cpu state time and per-core usage

Co-authored-by: Sven van Ginkel <svenvanginkel@icloud.com>
This commit is contained in:
henrygd
2025-11-01 12:57:48 -04:00
parent 3dbc48727e
commit 6a562ce03b
14 changed files with 407 additions and 46 deletions

View File

@@ -17,6 +17,7 @@ export type DataPoint = {
dataKey: (data: SystemStatsRecord) => number | undefined
color: number | string
opacity: number
stackId?: string | number
}
export default function AreaChartDefault({
@@ -29,19 +30,23 @@ export default function AreaChartDefault({
domain,
legend,
itemSorter,
reverseStackOrder = false,
hideYAxis = 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?: [number, number]
legend?: boolean
itemSorter?: (a: any, b: any) => number
// logRender?: boolean
}) {
{
chartData: ChartData
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: [number, number]
legend?: boolean
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
// logRender?: boolean
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
@@ -56,12 +61,13 @@ export default function AreaChartDefault({
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<AreaChart reverseStackOrder={reverseStackOrder} accessibilityLayer data={chartData.systemStats} margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
{!hideYAxis && <YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
@@ -70,7 +76,7 @@ export default function AreaChartDefault({
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
/>}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
@@ -99,6 +105,7 @@ export default function AreaChartDefault({
fillOpacity={dataPoint.opacity}
stroke={color}
isAnimationActive={false}
stackId={dataPoint.stackId}
/>
)
})}

View File

@@ -69,7 +69,7 @@ export function useContainerChartConfigs(containerData: ChartData["containerData
const hue = ((i * 360) / count) % 360
chartConfig[containerName] = {
label: containerName,
color: `hsl(${hue}, 60%, 55%)`,
color: `hsl(${hue}, var(--chart-saturation), var(--chart-lightness))`,
}
}

View File

@@ -1,5 +1,5 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { timeTicks } from "d3-time"
@@ -73,6 +73,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { Separator } from "../ui/separator"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
import NetworkSheet from "./system/network-sheet"
import CpuCoresSheet from "./system/cpu-sheet"
import LineChartDefault from "../charts/line-chart"
@@ -97,8 +98,8 @@ function getTimeData(chartTime: ChartTimes, lastCreated: number) {
}
}
const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now() + buffer)
// 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 = {
@@ -585,7 +586,12 @@ export default memo(function SystemDetail({ id }: { id: string }) {
grid={grid}
title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
>
<AreaChartDefault
chartData={chartData}
@@ -1119,7 +1125,7 @@ export function ChartCard({
<CardDescription>{description}</CardDescription>
{cornerEl && <div className="py-1 grid sm:justify-end sm:absolute sm:top-3.5 sm:end-3.5">{cornerEl}</div>}
</CardHeader>
<div className={cn("ps-0 w-[calc(100%-1.5em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
<div className={cn("ps-0 w-[calc(100%-1.3em)] relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
{
<Spinner
msg={empty ? t`Waiting for enough records to display` : undefined}

View File

@@ -0,0 +1,195 @@
import { t } from "@lingui/core/macro"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useMemo, useRef, useState } from "react"
import AreaChartDefault, { 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"
const minAgentVersion = parseSemVer("0.15.3")
export default memo(function CpuCoresSheet({
chartData,
dataEmpty,
grid,
maxValues,
}: {
chartData: ChartData
dataEmpty: boolean
grid: boolean
maxValues: boolean
}) {
const [cpuCoresOpen, setCpuCoresOpen] = useState(false)
const hasOpened = useRef(false)
const supportsBreakdown = useMemo(() => compareSemVer(chartData.agentVersion, minAgentVersion) >= 0, [chartData.agentVersion])
if (!supportsBreakdown) {
return null
}
if (cpuCoresOpen && !hasOpened.current) {
hasOpened.current = true
}
// Latest stats snapshot
const latest = chartData.systemStats.at(-1)?.stats
const cpus = latest?.cpus ?? []
const numCores = cpus.length
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
const breakdownDataPoints = [
{
label: t`Other`,
dataKey: ({ stats }: SystemStatsRecord) => {
const total = stats?.cpub?.reduce((acc, curr) => acc + curr, 0) ?? 0
return total > 0 ? 100 - total : null
},
color: `hsl(80, 65%, 52%)`,
opacity: 0.4,
stackId: "a"
},
{
label: "Steal",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
color: 5,
opacity: 0.4,
stackId: "a"
},
{
label: "Idle",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
color: 2,
opacity: 0.4,
stackId: "a"
},
{
label: "IOWait",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
color: 4,
opacity: 0.4,
stackId: "a"
},
{
label: "User",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
color: 1,
opacity: 0.4,
stackId: "a"
},
{
label: "System",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
color: 3,
opacity: 0.4,
stackId: "a"
},
] as DataPoint[]
return (
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
<SheetTrigger asChild>
<Button
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
>
<MoreHorizontalIcon />
</Button>
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
{hasBreakdown && (
<ChartCard
key="cpu-breakdown"
empty={dataEmpty}
grid={grid}
title={t`CPU Time Breakdown`}
description={t`Percentage of time spent in each state`}
legend={true}
className="min-h-auto"
>
<AreaChartDefault
reverseStackOrder={true}
chartData={chartData}
maxToggled={maxValues}
legend={true}
dataPoints={breakdownDataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
itemSorter={() => 1}
domain={[0, 100]}
/>
</ChartCard>
)}
{numCores > 0 && (
<ChartCard
key="cpu-cores-all"
empty={dataEmpty}
grid={grid}
title={t`CPU Cores`}
legend={numCores < 10}
description={t`Per-core average utilization`}
className="min-h-auto"
>
<AreaChartDefault
hideYAxis={true}
chartData={chartData}
maxToggled={maxValues}
legend={numCores < 10}
dataPoints={Array.from({ length: numCores }).map((_, i) => ({
label: `CPU ${i}`,
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"
}))}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
reverseStackOrder={true}
itemSorter={() => 1}
/>
</ChartCard>
)}
{Array.from({ length: numCores }).map((_, i) => (
<ChartCard
key={`cpu-core-${i}`}
empty={dataEmpty}
grid={grid}
title={`CPU ${i}`}
description={t`Per-core average utilization`}
legend={false}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={false}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i],
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, 65%, 52%)`,
opacity: 0.35,
},
]}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>
))}
</SheetContent>
)}
</Sheet>
)
})

View File

@@ -53,7 +53,7 @@ export default memo(function NetworkSheet({
</SheetTrigger>
{hasOpened.current && (
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
<ChartTimeSelect className="w-[calc(100%-2em)]" agentVersion={chartData.agentVersion} />
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
<ChartCard
empty={dataEmpty}
grid={grid}