Dashboard tab: CPU/mem/load, hwmon temps+fans, net, disks, filesystems, UPS with 10-min sparklines
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
24d64245ec
commit
3c6f4d4639
@@ -0,0 +1,64 @@
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
export interface SparkSeries { name: string; values: number[]; color: string }
|
||||
|
||||
interface Props {
|
||||
series: SparkSeries[]
|
||||
max?: number // fixed ceiling (e.g. 100 for percent); otherwise auto from data
|
||||
format: (v: number) => string
|
||||
height?: number
|
||||
}
|
||||
|
||||
const W = 300
|
||||
|
||||
// Thin-line sparkline with a crosshair tooltip. Stroke stays 2px at any width via
|
||||
// non-scaling-stroke; the box is stretched with preserveAspectRatio="none".
|
||||
export default function Sparkline({ series, max, format, height = 44 }: Props) {
|
||||
const box = useRef<HTMLDivElement>(null)
|
||||
const [hover, setHover] = useState<number | null>(null)
|
||||
const n = Math.max(...series.map((s) => s.values.length), 0)
|
||||
if (n < 2) return <div className="spark" style={{ height }} />
|
||||
|
||||
let top = max ?? 0
|
||||
if (max === undefined) for (const s of series) for (const v of s.values) if (v > top) top = v
|
||||
if (top <= 0) top = 1
|
||||
const H = height
|
||||
const x = (i: number) => (i / (n - 1)) * W
|
||||
const y = (v: number) => H - 2 - (Math.min(v, top) / top) * (H - 4)
|
||||
const path = (vals: number[]) => {
|
||||
const off = n - vals.length
|
||||
return vals.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i + off).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
|
||||
}
|
||||
|
||||
const move = (clientX: number) => {
|
||||
const r = box.current?.getBoundingClientRect()
|
||||
if (!r || r.width === 0) return
|
||||
const f = Math.min(1, Math.max(0, (clientX - r.left) / r.width))
|
||||
setHover(Math.round(f * (n - 1)))
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={box} className="spark" style={{ height }}
|
||||
onMouseMove={(e) => move(e.clientX)} onMouseLeave={() => setHover(null)}
|
||||
onTouchStart={(e) => move(e.touches[0].clientX)} onTouchMove={(e) => move(e.touches[0].clientX)} onTouchEnd={() => setHover(null)}>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" aria-hidden="true">
|
||||
<line x1={0} x2={W} y1={H - 2} y2={H - 2} stroke="var(--border)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
{series.map((s) => (
|
||||
<path key={s.name} d={path(s.values)} fill="none" stroke={s.color} strokeWidth={2}
|
||||
strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
))}
|
||||
{hover !== null && (
|
||||
<line x1={x(hover)} x2={x(hover)} y1={0} y2={H} stroke="var(--muted)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
</svg>
|
||||
{hover !== null && (
|
||||
<div className="tip" style={{ left: `${(hover / (n - 1)) * 100}%` }}>
|
||||
{series.map((s) => {
|
||||
const v = s.values[hover - (n - s.values.length)]
|
||||
return v === undefined ? null : <span key={s.name}><span className="sw" style={{ background: s.color }} />{s.name} {format(v)} </span>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user