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 {
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 (
<g key={tick}>
<line
key={tick}
x1={PADDING.left}
y1={y}
x2={PADDING.left + INNER_W}
y2={y}
stroke="#e5e7eb"
stroke={tick === 0 ? "#d1d5db" : "#e5e7eb"}
strokeWidth={1}
strokeDasharray={tick === 0 ? undefined : "4 4"}
strokeDasharray={tick === 0 ? undefined : "3 4"}
/>
)
})}
{/* Y-axis labels */}
{yTicks.map((tick) => {
const y = PADDING.top + INNER_H - (tick / 100) * INNER_H
return (
<text
key={tick}
x={PADDING.left - 8}
x={PADDING.left - 6}
y={y + 4}
textAnchor="end"
fontSize={11}
fill="#6b7280"
fontSize={10}
fill="#9ca3af"
>
{tick}%
</text>
</g>
)
})}
{/* Area fill */}
{/* Chart paths clipped */}
<g clipPath="url(#chartClip)">
{areaPath && (
<path
d={areaPath}
fill="url(#cpuAreaGrad)"
style={{ transition: "d 0.3s ease" }}
fill={`url(#${gradientId})`}
style={{ transition: "d 0.4s cubic-bezier(0.4,0,0.2,1)" }}
/>
)}
{/* Line */}
{linePath && (
<path
d={linePath}
fill="none"
stroke="#3b82f6"
stroke={color.stroke}
strokeWidth={2}
strokeLinejoin="round"
strokeLinecap="round"
style={{ transition: "d 0.3s ease" }}
style={{ transition: "d 0.4s cubic-bezier(0.4,0,0.2,1), stroke 0.5s 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}
{/* 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>
{/* 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>