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 {
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user