Redesign CPU chart with live flow and real-time status
- Scrolling chart: latest point always anchors to right edge, older points flow left - LIVE pulsing red badge - Color-coded CPU%: green/amber/red based on load level - Load average chips (1m/5m/15m) with color-coded borders - NOW marker line at right edge - Smooth cubic-bezier transitions on path, dot, and area - Gradient fill color adapts to CPU status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,157 +1,261 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CpuChartProps {
|
||||
history: Array<{ cpuPercent: number; timestamp: string }>
|
||||
loadAvg: [number, number, number]
|
||||
}
|
||||
|
||||
const CHART_WIDTH = 600
|
||||
const CHART_HEIGHT = 220
|
||||
const PADDING = { top: 10, right: 10, bottom: 30, left: 45 }
|
||||
const INNER_W = CHART_WIDTH - PADDING.left - PADDING.right
|
||||
const CHART_HEIGHT = 200
|
||||
const PADDING = { top: 12, right: 16, bottom: 28, left: 44 }
|
||||
const INNER_H = CHART_HEIGHT - PADDING.top - PADDING.bottom
|
||||
const STEP = 18 // px between data points
|
||||
const VISIBLE = 30 // number of points visible at once
|
||||
const INNER_W = VISIBLE * STEP
|
||||
|
||||
// Map a CPU % value to a y coordinate
|
||||
function toY(cpu: number) {
|
||||
return PADDING.top + INNER_H - (Math.min(cpu, 100) / 100) * INNER_H
|
||||
}
|
||||
|
||||
function cpuColor(cpu: number) {
|
||||
if (cpu < 50) return { stroke: "#22c55e", fill: "#22c55e", label: "Normal" }
|
||||
if (cpu < 80) return { stroke: "#f59e0b", fill: "#f59e0b", label: "Elevated" }
|
||||
return { stroke: "#ef4444", fill: "#ef4444", label: "Critical" }
|
||||
}
|
||||
|
||||
function loadColor(load: number) {
|
||||
if (load < 1) return "text-green-600 bg-green-50 border-green-200"
|
||||
if (load < 2) return "text-amber-600 bg-amber-50 border-amber-200"
|
||||
return "text-red-600 bg-red-50 border-red-200"
|
||||
}
|
||||
|
||||
export function CpuChart({ history, loadAvg }: CpuChartProps) {
|
||||
const [tick, setTick] = useState(0)
|
||||
const prevLenRef = useRef(0)
|
||||
const currentCpu = history.length > 0 ? history[history.length - 1].cpuPercent : 0
|
||||
const color = cpuColor(currentCpu)
|
||||
|
||||
// Build points
|
||||
const points = history.map((point, i) => {
|
||||
const x = PADDING.left + (history.length > 1 ? (i / (history.length - 1)) * INNER_W : INNER_W / 2)
|
||||
const y = PADDING.top + INNER_H - (point.cpuPercent / 100) * INNER_H
|
||||
return { x, y, time: point.timestamp, cpu: point.cpuPercent }
|
||||
})
|
||||
// Trigger CSS transition whenever a new point arrives
|
||||
useEffect(() => {
|
||||
if (history.length !== prevLenRef.current) {
|
||||
prevLenRef.current = history.length
|
||||
setTick(t => t + 1)
|
||||
}
|
||||
}, [history.length])
|
||||
|
||||
// Build line path
|
||||
const linePath = points.length > 1
|
||||
? points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ")
|
||||
// Assign fixed x positions: latest point always at right edge, older ones to the left
|
||||
const pts = history.map((p, i) => ({
|
||||
x: PADDING.left + INNER_W - (history.length - 1 - i) * STEP,
|
||||
y: toY(p.cpuPercent),
|
||||
cpu: p.cpuPercent,
|
||||
time: p.timestamp,
|
||||
}))
|
||||
|
||||
// Only keep points that are within or just left of the chart area
|
||||
const visible = pts.filter(p => p.x >= PADDING.left - STEP)
|
||||
|
||||
const linePath = visible.length > 1
|
||||
? visible.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x.toFixed(1)} ${p.y.toFixed(1)}`).join(" ")
|
||||
: ""
|
||||
|
||||
// Build area path (line + close to bottom)
|
||||
const areaPath = points.length > 1
|
||||
? `${linePath} L ${points[points.length - 1].x} ${PADDING.top + INNER_H} L ${points[0].x} ${PADDING.top + INNER_H} Z`
|
||||
const areaPath = visible.length > 1
|
||||
? `${linePath} L ${visible[visible.length - 1].x.toFixed(1)} ${(PADDING.top + INNER_H).toFixed(1)} L ${visible[0].x.toFixed(1)} ${(PADDING.top + INNER_H).toFixed(1)} Z`
|
||||
: ""
|
||||
|
||||
// Y-axis ticks
|
||||
const yTicks = [0, 25, 50, 75, 100]
|
||||
|
||||
// X-axis labels (show ~5 evenly spaced)
|
||||
const xLabelCount = Math.min(5, points.length)
|
||||
const xLabels = points.length > 1
|
||||
? Array.from({ length: xLabelCount }, (_, i) => {
|
||||
const idx = Math.round((i / (xLabelCount - 1)) * (points.length - 1))
|
||||
const p = points[idx]
|
||||
const time = new Date(p.time).toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
})
|
||||
return { x: p.x, label: time }
|
||||
})
|
||||
: points.length === 1
|
||||
? [{ x: points[0].x, label: new Date(points[0].time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) }]
|
||||
: []
|
||||
// X-axis time labels: show 5 evenly spaced from visible points
|
||||
const xLabelIndexes = visible.length > 1
|
||||
? [0, Math.floor(visible.length * 0.25), Math.floor(visible.length * 0.5), Math.floor(visible.length * 0.75), visible.length - 1]
|
||||
: visible.length === 1 ? [0] : []
|
||||
const xLabels = [...new Set(xLabelIndexes)].map(i => {
|
||||
const p = visible[i]
|
||||
if (!p) return null
|
||||
return {
|
||||
x: p.x,
|
||||
label: new Date(p.time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }),
|
||||
}
|
||||
}).filter(Boolean) as { x: number; label: string }[]
|
||||
|
||||
// Gradient colors based on CPU status
|
||||
const gradientId = `cpuGrad-${color.stroke.replace("#", "")}`
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>CPU & Load Average</CardTitle>
|
||||
<CardDescription>
|
||||
Current: {currentCpu.toFixed(1)}% | Load: {loadAvg.map((l) => l.toFixed(2)).join(", ")}
|
||||
</CardDescription>
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">CPU & Load Average</CardTitle>
|
||||
{/* LIVE badge */}
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-red-600 bg-red-50 border border-red-200 rounded-full px-2.5 py-0.5">
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
LIVE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CPU % + status + load avg chips */}
|
||||
<div className="flex items-center gap-3 mt-2 flex-wrap">
|
||||
{/* Big current CPU */}
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span
|
||||
className={cn("text-2xl font-bold tabular-nums transition-colors duration-500",
|
||||
currentCpu < 50 ? "text-green-600" : currentCpu < 80 ? "text-amber-500" : "text-red-500"
|
||||
)}
|
||||
>
|
||||
{currentCpu.toFixed(1)}%
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium px-1.5 py-0.5 rounded-full border",
|
||||
currentCpu < 50
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: currentCpu < 80
|
||||
? "text-amber-600 bg-amber-50 border-amber-200"
|
||||
: "text-red-600 bg-red-50 border-red-200"
|
||||
)}>
|
||||
{color.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{/* Load avg chips */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground font-medium">Load:</span>
|
||||
{([["1m", loadAvg[0]], ["5m", loadAvg[1]], ["15m", loadAvg[2]]] as [string, number][]).map(([label, val]) => (
|
||||
<span
|
||||
key={label}
|
||||
className={cn("px-2 py-0.5 rounded border font-mono font-semibold", loadColor(val))}
|
||||
>
|
||||
{label} {val.toFixed(2)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
<CardContent className="flex-1 pb-3 pt-0">
|
||||
<div className="w-full overflow-hidden">
|
||||
<svg
|
||||
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`}
|
||||
viewBox={`0 0 ${PADDING.left + INNER_W + PADDING.right} ${CHART_HEIGHT}`}
|
||||
className="w-full h-auto"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="cpuAreaGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.02} />
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color.fill} stopOpacity={0.25} />
|
||||
<stop offset="100%" stopColor={color.fill} stopOpacity={0.02} />
|
||||
</linearGradient>
|
||||
{/* Clip to chart area */}
|
||||
<clipPath id="chartClip">
|
||||
<rect
|
||||
x={PADDING.left}
|
||||
y={PADDING.top - 4}
|
||||
width={INNER_W}
|
||||
height={INNER_H + 8}
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Grid lines */}
|
||||
{yTicks.map((tick) => {
|
||||
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H
|
||||
const y = toY(tick)
|
||||
return (
|
||||
<line
|
||||
key={tick}
|
||||
x1={PADDING.left}
|
||||
y1={y}
|
||||
x2={PADDING.left + INNER_W}
|
||||
y2={y}
|
||||
stroke="#e5e7eb"
|
||||
strokeWidth={1}
|
||||
strokeDasharray={tick === 0 ? undefined : "4 4"}
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={PADDING.left}
|
||||
y1={y}
|
||||
x2={PADDING.left + INNER_W}
|
||||
y2={y}
|
||||
stroke={tick === 0 ? "#d1d5db" : "#e5e7eb"}
|
||||
strokeWidth={1}
|
||||
strokeDasharray={tick === 0 ? undefined : "3 4"}
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left - 6}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={10}
|
||||
fill="#9ca3af"
|
||||
>
|
||||
{tick}%
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Chart paths clipped */}
|
||||
<g clipPath="url(#chartClip)">
|
||||
{areaPath && (
|
||||
<path
|
||||
d={areaPath}
|
||||
fill={`url(#${gradientId})`}
|
||||
style={{ transition: "d 0.4s cubic-bezier(0.4,0,0.2,1)" }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
)}
|
||||
{linePath && (
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke={color.stroke}
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
style={{ transition: "d 0.4s cubic-bezier(0.4,0,0.2,1), stroke 0.5s ease" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yTicks.map((tick) => {
|
||||
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H
|
||||
return (
|
||||
<text
|
||||
key={tick}
|
||||
x={PADDING.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
fontSize={11}
|
||||
fill="#6b7280"
|
||||
>
|
||||
{tick}%
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
{/* Only show last dot (the live "now" point) */}
|
||||
{visible.length > 0 && (() => {
|
||||
const last = visible[visible.length - 1]
|
||||
return (
|
||||
<>
|
||||
<circle cx={last.x} cy={last.y} r={5} fill={color.stroke} opacity={0.25}
|
||||
style={{ transition: "cx 0.4s cubic-bezier(0.4,0,0.2,1), cy 0.4s cubic-bezier(0.4,0,0.2,1)" }}
|
||||
/>
|
||||
<circle cx={last.x} cy={last.y} r={3} fill={color.stroke} stroke="white" strokeWidth={1.5}
|
||||
style={{ transition: "cx 0.4s cubic-bezier(0.4,0,0.2,1), cy 0.4s cubic-bezier(0.4,0,0.2,1), fill 0.5s ease" }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</g>
|
||||
|
||||
{/* Area fill */}
|
||||
{areaPath && (
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="url(#cpuAreaGrad)"
|
||||
style={{ transition: "d 0.3s ease" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Line */}
|
||||
{linePath && (
|
||||
<path
|
||||
d={linePath}
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
style={{ transition: "d 0.3s ease" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Dots */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r={points.length <= 5 ? 4 : 2.5}
|
||||
fill="#3b82f6"
|
||||
stroke="white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
))}
|
||||
{/* NOW line at right edge */}
|
||||
<line
|
||||
x1={PADDING.left + INNER_W}
|
||||
y1={PADDING.top}
|
||||
x2={PADDING.left + INNER_W}
|
||||
y2={PADDING.top + INNER_H}
|
||||
stroke={color.stroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="3 3"
|
||||
opacity={0.4}
|
||||
/>
|
||||
<text
|
||||
x={PADDING.left + INNER_W - 2}
|
||||
y={PADDING.top + 9}
|
||||
textAnchor="end"
|
||||
fontSize={9}
|
||||
fill={color.stroke}
|
||||
opacity={0.7}
|
||||
fontWeight="600"
|
||||
>
|
||||
NOW
|
||||
</text>
|
||||
|
||||
{/* X-axis labels */}
|
||||
{xLabels.map((label, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={label.x}
|
||||
y={CHART_HEIGHT - 5}
|
||||
y={CHART_HEIGHT - 4}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="#6b7280"
|
||||
fontSize={9}
|
||||
fill="#9ca3af"
|
||||
>
|
||||
{label.label}
|
||||
</text>
|
||||
|
||||
Reference in New Issue
Block a user