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:
2026-03-23 16:46:43 +05:30
parent ba63c1ba1a
commit 201d40c481

View File

@@ -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 { interface CpuChartProps {
history: Array<{ cpuPercent: number; timestamp: string }> history: Array<{ cpuPercent: number; timestamp: string }>
loadAvg: [number, number, number] loadAvg: [number, number, number]
} }
const CHART_WIDTH = 600 const CHART_HEIGHT = 200
const CHART_HEIGHT = 220 const PADDING = { top: 12, right: 16, bottom: 28, left: 44 }
const PADDING = { top: 10, right: 10, bottom: 30, left: 45 }
const INNER_W = CHART_WIDTH - PADDING.left - PADDING.right
const INNER_H = CHART_HEIGHT - PADDING.top - PADDING.bottom 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) { 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 currentCpu = history.length > 0 ? history[history.length - 1].cpuPercent : 0
const color = cpuColor(currentCpu)
// Build points // Trigger CSS transition whenever a new point arrives
const points = history.map((point, i) => { useEffect(() => {
const x = PADDING.left + (history.length > 1 ? (i / (history.length - 1)) * INNER_W : INNER_W / 2) if (history.length !== prevLenRef.current) {
const y = PADDING.top + INNER_H - (point.cpuPercent / 100) * INNER_H prevLenRef.current = history.length
return { x, y, time: point.timestamp, cpu: point.cpuPercent } setTick(t => t + 1)
}) }
}, [history.length])
// Build line path // Assign fixed x positions: latest point always at right edge, older ones to the left
const linePath = points.length > 1 const pts = history.map((p, i) => ({
? points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ") 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 = visible.length > 1
const areaPath = points.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`
? `${linePath} L ${points[points.length - 1].x} ${PADDING.top + INNER_H} L ${points[0].x} ${PADDING.top + INNER_H} Z`
: "" : ""
// Y-axis ticks
const yTicks = [0, 25, 50, 75, 100] const yTicks = [0, 25, 50, 75, 100]
// X-axis labels (show ~5 evenly spaced) // X-axis time labels: show 5 evenly spaced from visible points
const xLabelCount = Math.min(5, points.length) const xLabelIndexes = visible.length > 1
const xLabels = points.length > 1 ? [0, Math.floor(visible.length * 0.25), Math.floor(visible.length * 0.5), Math.floor(visible.length * 0.75), visible.length - 1]
? Array.from({ length: xLabelCount }, (_, i) => { : visible.length === 1 ? [0] : []
const idx = Math.round((i / (xLabelCount - 1)) * (points.length - 1)) const xLabels = [...new Set(xLabelIndexes)].map(i => {
const p = points[idx] const p = visible[i]
const time = new Date(p.time).toLocaleTimeString("en-US", { if (!p) return null
hour: "2-digit", return {
minute: "2-digit", x: p.x,
second: "2-digit", label: new Date(p.time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }),
hour12: false, }
}) }).filter(Boolean) as { x: number; label: string }[]
return { x: p.x, label: time }
}) // Gradient colors based on CPU status
: points.length === 1 const gradientId = `cpuGrad-${color.stroke.replace("#", "")}`
? [{ x: points[0].x, label: new Date(points[0].time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }) }]
: []
return ( return (
<Card className="h-full"> <Card className="h-full flex flex-col">
<CardHeader> <CardHeader className="pb-2">
<CardTitle>CPU & Load Average</CardTitle> <div className="flex items-center justify-between">
<CardDescription> <CardTitle className="text-base font-semibold">CPU & Load Average</CardTitle>
Current: {currentCpu.toFixed(1)}% | Load: {loadAvg.map((l) => l.toFixed(2)).join(", ")} {/* LIVE badge */}
</CardDescription> <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> </CardHeader>
<CardContent>
<CardContent className="flex-1 pb-3 pt-0">
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<svg <svg
viewBox={`0 0 ${CHART_WIDTH} ${CHART_HEIGHT}`} viewBox={`0 0 ${PADDING.left + INNER_W + PADDING.right} ${CHART_HEIGHT}`}
className="w-full h-auto" className="w-full h-auto"
preserveAspectRatio="xMidYMid meet" preserveAspectRatio="xMidYMid meet"
> >
<defs> <defs>
<linearGradient id="cpuAreaGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} /> <stop offset="0%" stopColor={color.fill} stopOpacity={0.25} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.02} /> <stop offset="100%" stopColor={color.fill} stopOpacity={0.02} />
</linearGradient> </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> </defs>
{/* Grid lines */} {/* Grid lines */}
{yTicks.map((tick) => { {yTicks.map((tick) => {
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H const y = toY(tick)
return ( return (
<line <g key={tick}>
key={tick} <line
x1={PADDING.left} x1={PADDING.left}
y1={y} y1={y}
x2={PADDING.left + INNER_W} x2={PADDING.left + INNER_W}
y2={y} y2={y}
stroke="#e5e7eb" stroke={tick === 0 ? "#d1d5db" : "#e5e7eb"}
strokeWidth={1} strokeWidth={1}
strokeDasharray={tick === 0 ? undefined : "4 4"} 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 */} {/* Only show last dot (the live "now" point) */}
{yTicks.map((tick) => { {visible.length > 0 && (() => {
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H const last = visible[visible.length - 1]
return ( return (
<text <>
key={tick} <circle cx={last.x} cy={last.y} r={5} fill={color.stroke} opacity={0.25}
x={PADDING.left - 8} style={{ transition: "cx 0.4s cubic-bezier(0.4,0,0.2,1), cy 0.4s cubic-bezier(0.4,0,0.2,1)" }}
y={y + 4} />
textAnchor="end" <circle cx={last.x} cy={last.y} r={3} fill={color.stroke} stroke="white" strokeWidth={1.5}
fontSize={11} 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" }}
fill="#6b7280" />
> </>
{tick}% )
</text> })()}
) </g>
})}
{/* Area fill */} {/* NOW line at right edge */}
{areaPath && ( <line
<path x1={PADDING.left + INNER_W}
d={areaPath} y1={PADDING.top}
fill="url(#cpuAreaGrad)" x2={PADDING.left + INNER_W}
style={{ transition: "d 0.3s ease" }} y2={PADDING.top + INNER_H}
/> stroke={color.stroke}
)} strokeWidth={1}
strokeDasharray="3 3"
{/* Line */} opacity={0.4}
{linePath && ( />
<path <text
d={linePath} x={PADDING.left + INNER_W - 2}
fill="none" y={PADDING.top + 9}
stroke="#3b82f6" textAnchor="end"
strokeWidth={2} fontSize={9}
strokeLinejoin="round" fill={color.stroke}
strokeLinecap="round" opacity={0.7}
style={{ transition: "d 0.3s ease" }} fontWeight="600"
/> >
)} NOW
</text>
{/* 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}
/>
))}
{/* X-axis labels */} {/* X-axis labels */}
{xLabels.map((label, i) => ( {xLabels.map((label, i) => (
<text <text
key={i} key={i}
x={label.x} x={label.x}
y={CHART_HEIGHT - 5} y={CHART_HEIGHT - 4}
textAnchor="middle" textAnchor="middle"
fontSize={10} fontSize={9}
fill="#6b7280" fill="#9ca3af"
> >
{label.label} {label.label}
</text> </text>