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:
Mitchell-Johnson
2026-09-17 13:37:39 -07:00
co-authored by Claude Fable 5.1
parent 24d64245ec
commit 3c6f4d4639
10 changed files with 982 additions and 34 deletions
+7 -4
View File
@@ -128,10 +128,13 @@ something long in one, switch away and back, refresh — it's all still there.
- **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list, - **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list,
scrub status, and the `freeing` counter. `grid` is the target, which implies scrub status, and the `freeing` counter. `grid` is the target, which implies
multi-host. multi-host.
- **Dashboard** — CPU/mem/load/net, disks and SMART (`smartctl -j`), temps and fans - **Dashboard** — **first cut shipped 2026-09-17** (`internal/metrics`, `Dashboard.tsx`):
(`sensors -j`), UPS from `pwrstat`. Ham Lab collects on a timer into a SQLite CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight
ring (e.g. 10 s resolution, 7 days) and draws sparklines. This is where the from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy
"collects its own data" requirement lands; no Prometheus dependency. from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled
every 2 s into an in-memory ring of 10 min; the UI seeds from
`GET /api/metrics/history` then polls `GET /api/metrics`. Still to do: persistent
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
- **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini / - **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini /
`wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a `wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a
host level at the top, exactly like Proxmox's datacenter → node tree. Design the host level at the top, exactly like Proxmox's datacenter → node tree. Design the
+5 -1
View File
@@ -20,6 +20,7 @@ import (
"hamlab/internal/api" "hamlab/internal/api"
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/metrics"
"hamlab/internal/store" "hamlab/internal/store"
hterm "hamlab/internal/term" hterm "hamlab/internal/term"
"hamlab/internal/web" "hamlab/internal/web"
@@ -136,7 +137,10 @@ func serve(cfg *config.Config) error {
} }
} }
srv := api.New(cfg, sessions, mgr, web.FS(), version) col := metrics.NewCollector()
go col.Run(context.Background())
srv := api.New(cfg, sessions, mgr, col, web.FS(), version)
hs := &http.Server{ hs := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: srv.Handler(), Handler: srv.Handler(),
+21 -2
View File
@@ -17,6 +17,7 @@ import (
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/metrics"
"hamlab/internal/term" "hamlab/internal/term"
) )
@@ -27,6 +28,7 @@ type Server struct {
sessions *auth.Sessions sessions *auth.Sessions
limiter auth.Limiter limiter auth.Limiter
mgr *term.Manager mgr *term.Manager
metrics *metrics.Collector
version string version string
started time.Time started time.Time
user string user string
@@ -35,9 +37,9 @@ type Server struct {
mux *http.ServeMux mux *http.ServeMux
} }
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.FS, version string) *Server { func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, ui fs.FS, version string) *Server {
host, _ := os.Hostname() host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, ui: ui, version: version, s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")} started: time.Now(), host: host, user: os.Getenv("USER")}
if s.user == "" { if s.user == "" {
s.user = "core" s.user = "core"
@@ -52,6 +54,8 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.F
m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal)) m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal)) m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach)) m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
m.HandleFunc("GET /api/metrics", s.auth(s.metricsLatest))
m.HandleFunc("GET /api/metrics/history", s.auth(s.metricsHistory))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") }) m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
m.HandleFunc("/", s.static) m.HandleFunc("/", s.static)
s.mux = m s.mux = m
@@ -162,6 +166,21 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
}) })
} }
// ---- metrics (Dashboard) ----
func (s *Server) metricsLatest(w http.ResponseWriter, _ *http.Request) {
smp, ok := s.metrics.Latest()
if !ok {
jsonErr(w, 503, "no sample yet")
return
}
jsonOK(w, 200, smp)
}
func (s *Server) metricsHistory(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.metrics.History())
}
// ---- terminals ---- // ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) { func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
+579
View File
@@ -0,0 +1,579 @@
// Package metrics samples host telemetry (CPU, memory, temps, fans, network, disks,
// filesystems, UPS) on a timer into an in-memory ring, for the Dashboard tab.
// Everything is read from /proc and /sys directly; the only external command is
// pwrstat (CyberPower PowerPanel), which is optional.
package metrics
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
const (
Interval = 2 * time.Second
History = 300 // samples kept (10 min at 2 s)
fsEvery = 15 // sample fs usage every N ticks
upsEvery = 5 // sample UPS every N ticks
sectorSize = 512
)
type Mem struct {
Total uint64 `json:"total"`
Used uint64 `json:"used"`
Avail uint64 `json:"avail"`
}
type Temp struct {
Chip string `json:"chip"`
Label string `json:"label"`
C float64 `json:"c"`
Max float64 `json:"max,omitempty"`
Crit float64 `json:"crit,omitempty"`
}
type Fan struct {
Chip string `json:"chip"`
Label string `json:"label"`
RPM int `json:"rpm"`
}
type Net struct {
Iface string `json:"iface"`
Up bool `json:"up"`
RxBps float64 `json:"rx_bps"`
TxBps float64 `json:"tx_bps"`
RxTotal uint64 `json:"rx_total"`
TxTotal uint64 `json:"tx_total"`
}
type Disk struct {
Name string `json:"name"`
Model string `json:"model,omitempty"`
ReadBps float64 `json:"read_bps"`
WriteBps float64 `json:"write_bps"`
UtilPct float64 `json:"util_pct"`
Temp float64 `json:"temp,omitempty"`
}
type FS struct {
Mount string `json:"mount"`
Type string `json:"type"`
Size uint64 `json:"size"`
Used uint64 `json:"used"`
}
type UPS struct {
Model string `json:"model"`
State string `json:"state"`
Source string `json:"source"`
InV float64 `json:"in_v"`
OutV float64 `json:"out_v"`
BatteryPct float64 `json:"battery_pct"`
RuntimeMin float64 `json:"runtime_min"`
LoadW float64 `json:"load_w"`
LoadPct float64 `json:"load_pct"`
LastEvent string `json:"last_event,omitempty"`
}
type Sample struct {
T int64 `json:"t"` // unix ms
CPU float64 `json:"cpu"`
Cores []float64 `json:"cores"`
Load [3]float64 `json:"load"`
Mem Mem `json:"mem"`
Swap Mem `json:"swap"`
Temps []Temp `json:"temps"`
Fans []Fan `json:"fans"`
Net []Net `json:"net"`
Disks []Disk `json:"disks"`
FS []FS `json:"fs"`
UPS *UPS `json:"ups"`
Uptime float64 `json:"uptime"`
}
// Series is the compact history the UI seeds its sparklines from.
type Series struct {
T []int64 `json:"t"`
CPU []float64 `json:"cpu"`
Mem []uint64 `json:"mem"`
Net map[string]map[string][]float64 `json:"net"` // iface -> {rx,tx}
Disk map[string]map[string][]float64 `json:"disk"` // name -> {r,w,u}
Temp map[string][]float64 `json:"temp"` // "chip/label" -> °C
}
type Collector struct {
mu sync.RWMutex
ring []Sample
tick int
prevCPU []cpuTimes
prevNet map[string][2]uint64
prevDisk map[string][3]uint64 // sectors read, sectors written, io ticks ms
prevT time.Time
lastFS []FS
lastUPS *UPS
upsOK bool
}
func NewCollector() *Collector {
c := &Collector{prevNet: map[string][2]uint64{}, prevDisk: map[string][3]uint64{}}
c.upsOK = lookPath("pwrstat") && lookPath("sudo")
return c
}
// Run samples until ctx is cancelled.
func (c *Collector) Run(ctx context.Context) {
c.sample() // prime deltas
t := time.NewTicker(Interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.sample()
}
}
}
func (c *Collector) Latest() (Sample, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.ring) == 0 {
return Sample{}, false
}
return c.ring[len(c.ring)-1], true
}
func (c *Collector) History() Series {
c.mu.RLock()
defer c.mu.RUnlock()
n := len(c.ring)
s := Series{T: make([]int64, n), CPU: make([]float64, n), Mem: make([]uint64, n),
Net: map[string]map[string][]float64{}, Disk: map[string]map[string][]float64{}, Temp: map[string][]float64{}}
series := func(m map[string]map[string][]float64, key string, keys ...string) map[string][]float64 {
v, ok := m[key]
if !ok {
v = map[string][]float64{}
for _, k := range keys {
v[k] = make([]float64, n)
}
m[key] = v
}
return v
}
for i, smp := range c.ring {
s.T[i], s.CPU[i], s.Mem[i] = smp.T, smp.CPU, smp.Mem.Used
for _, x := range smp.Net {
v := series(s.Net, x.Iface, "rx", "tx")
v["rx"][i], v["tx"][i] = x.RxBps, x.TxBps
}
for _, d := range smp.Disks {
v := series(s.Disk, d.Name, "r", "w", "u")
v["r"][i], v["w"][i], v["u"][i] = d.ReadBps, d.WriteBps, d.UtilPct
}
for _, t := range smp.Temps {
k := t.Chip + "/" + t.Label
if s.Temp[k] == nil {
s.Temp[k] = make([]float64, n)
}
s.Temp[k][i] = t.C
}
}
return s
}
// ---- sampling ----
func (c *Collector) sample() {
now := time.Now()
dt := now.Sub(c.prevT).Seconds()
first := c.prevT.IsZero()
c.prevT = now
s := Sample{T: now.UnixMilli(), Temps: []Temp{}, Fans: []Fan{}, Net: []Net{}, Disks: []Disk{}}
s.CPU, s.Cores = c.cpu()
s.Load = loadavg()
s.Mem, s.Swap = meminfo()
s.Uptime = uptime()
s.Temps, s.Fans = hwmon()
s.Net = c.net(dt)
s.Disks = c.disks(dt, s.Temps)
if c.tick%fsEvery == 0 || c.lastFS == nil {
c.lastFS = filesystems()
}
s.FS = c.lastFS
if c.upsOK && (c.tick%upsEvery == 0 || c.lastUPS == nil) {
c.lastUPS = ups()
}
s.UPS = c.lastUPS
c.tick++
if first {
return // deltas are meaningless on the priming pass
}
c.mu.Lock()
c.ring = append(c.ring, s)
if len(c.ring) > History {
c.ring = c.ring[len(c.ring)-History:]
}
c.mu.Unlock()
}
type cpuTimes struct{ busy, total uint64 }
func (c *Collector) cpu() (float64, []float64) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, nil
}
defer f.Close()
var cur []cpuTimes
sc := bufio.NewScanner(f)
for sc.Scan() {
fs := strings.Fields(sc.Text())
if len(fs) < 8 || !strings.HasPrefix(fs[0], "cpu") {
continue
}
var vals [10]uint64
for i := 1; i < len(fs) && i <= 10; i++ {
vals[i-1], _ = strconv.ParseUint(fs[i], 10, 64)
}
idle := vals[3] + vals[4]
var total uint64
for _, v := range vals {
total += v
}
cur = append(cur, cpuTimes{busy: total - idle, total: total})
}
prev := c.prevCPU
c.prevCPU = cur
if len(prev) != len(cur) || len(cur) == 0 {
return 0, nil
}
pct := func(i int) float64 {
dt := float64(cur[i].total - prev[i].total)
if dt <= 0 {
return 0
}
return 100 * float64(cur[i].busy-prev[i].busy) / dt
}
cores := make([]float64, 0, len(cur)-1)
for i := 1; i < len(cur); i++ {
cores = append(cores, round1(pct(i)))
}
return round1(pct(0)), cores
}
func loadavg() (l [3]float64) {
b, err := os.ReadFile("/proc/loadavg")
if err != nil {
return
}
f := strings.Fields(string(b))
for i := 0; i < 3 && i < len(f); i++ {
l[i], _ = strconv.ParseFloat(f[i], 64)
}
return
}
func meminfo() (Mem, Mem) {
m := map[string]uint64{}
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return Mem{}, Mem{}
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(v)
if len(f) == 0 {
continue
}
n, _ := strconv.ParseUint(f[0], 10, 64)
m[k] = n * 1024
}
mem := Mem{Total: m["MemTotal"], Avail: m["MemAvailable"]}
mem.Used = mem.Total - mem.Avail
swap := Mem{Total: m["SwapTotal"], Avail: m["SwapFree"]}
swap.Used = swap.Total - swap.Avail
return mem, swap
}
func uptime() float64 {
b, err := os.ReadFile("/proc/uptime")
if err != nil {
return 0
}
if f := strings.Fields(string(b)); len(f) > 0 {
v, _ := strconv.ParseFloat(f[0], 64)
return v
}
return 0
}
var sensorRe = regexp.MustCompile(`^(temp|fan)(\d+)_input$`)
func hwmon() ([]Temp, []Fan) {
temps, fans := []Temp{}, []Fan{}
dirs, _ := filepath.Glob("/sys/class/hwmon/hwmon*")
for _, d := range dirs {
chip := strings.TrimSpace(readStr(filepath.Join(d, "name")))
ents, err := os.ReadDir(d)
if err != nil {
continue
}
for _, e := range ents {
m := sensorRe.FindStringSubmatch(e.Name())
if m == nil {
continue
}
base := m[1] + m[2]
raw, ok := readInt(filepath.Join(d, e.Name()))
if !ok {
continue
}
label := strings.TrimSpace(readStr(filepath.Join(d, base+"_label")))
if label == "" {
label = base
}
if m[1] == "temp" {
t := Temp{Chip: chip, Label: label, C: float64(raw) / 1000}
// Limits outside 0-200 °C are unset sentinels (NVMe reports ~65000 for absent ones).
if v, ok := readInt(filepath.Join(d, base+"_max")); ok && v > 0 && v < 200000 {
t.Max = float64(v) / 1000
}
if v, ok := readInt(filepath.Join(d, base+"_crit")); ok && v > 0 && v < 200000 {
t.Crit = float64(v) / 1000
}
temps = append(temps, t)
} else {
fans = append(fans, Fan{Chip: chip, Label: label, RPM: int(raw)})
}
}
}
sort.SliceStable(temps, func(i, j int) bool {
if temps[i].Chip != temps[j].Chip {
return temps[i].Chip < temps[j].Chip
}
return temps[i].Label < temps[j].Label
})
return temps, fans
}
func skipIface(n string) bool {
return n == "lo" || strings.HasPrefix(n, "veth") || strings.HasPrefix(n, "br-") ||
strings.HasPrefix(n, "docker") || strings.HasPrefix(n, "virbr")
}
func (c *Collector) net(dt float64) []Net {
out := []Net{}
f, err := os.Open("/proc/net/dev")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
name, rest, ok := strings.Cut(sc.Text(), ":")
if !ok {
continue
}
name = strings.TrimSpace(name)
if skipIface(name) {
continue
}
fs := strings.Fields(rest)
if len(fs) < 16 {
continue
}
rx, _ := strconv.ParseUint(fs[0], 10, 64)
tx, _ := strconv.ParseUint(fs[8], 10, 64)
n := Net{Iface: name, RxTotal: rx, TxTotal: tx}
st := strings.TrimSpace(readStr("/sys/class/net/" + name + "/operstate"))
n.Up = st == "up" || st == "unknown" // wireguard/tun report "unknown" while carrying traffic
if p, ok := c.prevNet[name]; ok && dt > 0 {
n.RxBps = round1(float64(rx-p[0]) / dt)
n.TxBps = round1(float64(tx-p[1]) / dt)
}
c.prevNet[name] = [2]uint64{rx, tx}
out = append(out, n)
}
sort.Slice(out, func(i, j int) bool { return out[i].Iface < out[j].Iface })
return out
}
var wholeDisk = regexp.MustCompile(`^(sd[a-z]+|vd[a-z]+|nvme\d+n\d+|mmcblk\d+)$`)
func (c *Collector) disks(dt float64, temps []Temp) []Disk {
out := []Disk{}
f, err := os.Open("/proc/diskstats")
if err != nil {
return out
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fs := strings.Fields(sc.Text())
if len(fs) < 14 || !wholeDisk.MatchString(fs[2]) {
continue
}
name := fs[2]
rd, _ := strconv.ParseUint(fs[5], 10, 64)
wr, _ := strconv.ParseUint(fs[9], 10, 64)
ticks, _ := strconv.ParseUint(fs[12], 10, 64)
d := Disk{Name: name, Model: strings.TrimSpace(readStr("/sys/block/" + name + "/device/model"))}
if p, ok := c.prevDisk[name]; ok && dt > 0 {
d.ReadBps = round1(float64(rd-p[0]) * sectorSize / dt)
d.WriteBps = round1(float64(wr-p[1]) * sectorSize / dt)
d.UtilPct = round1(float64(ticks-p[2]) / (dt * 1000) * 100)
if d.UtilPct > 100 {
d.UtilPct = 100
}
}
c.prevDisk[name] = [3]uint64{rd, wr, ticks}
// NVMe exposes a "Composite" temp on an hwmon chip named "nvme"; attach it when there is exactly one such disk.
if strings.HasPrefix(name, "nvme") {
for _, t := range temps {
if t.Chip == "nvme" && t.Label == "Composite" {
d.Temp = t.C
}
}
}
out = append(out, d)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
var realFS = map[string]bool{"ext4": true, "ext3": true, "ext2": true, "xfs": true, "btrfs": true, "zfs": true,
"f2fs": true, "vfat": true, "exfat": true, "ntfs": true, "ntfs3": true, "cifs": true, "nfs": true, "nfs4": true, "fuse.sshfs": true}
func filesystems() []FS {
out := []FS{}
b, err := os.ReadFile("/proc/mounts")
if err != nil {
return out
}
seen := map[string]bool{}
for _, line := range strings.Split(string(b), "\n") {
fs := strings.Fields(line)
if len(fs) < 3 || !realFS[fs[2]] || seen[fs[1]] {
continue
}
seen[fs[1]] = true
mount := strings.ReplaceAll(fs[1], `\040`, " ")
// statfs on a dead network mount can block; bound it.
done := make(chan FS, 1)
go func() {
var st syscall.Statfs_t
if err := syscall.Statfs(mount, &st); err != nil {
done <- FS{}
return
}
bs := uint64(st.Bsize)
done <- FS{Mount: mount, Type: fs[2], Size: st.Blocks * bs, Used: (st.Blocks - st.Bfree) * bs}
}()
select {
case r := <-done:
if r.Size > 0 {
out = append(out, r)
}
case <-time.After(3 * time.Second):
out = append(out, FS{Mount: mount, Type: fs[2]}) // unreachable: size 0 signals it
}
}
return out
}
func ups() *UPS {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
b, err := exec.CommandContext(ctx, "sudo", "-n", "pwrstat", "-status").Output()
if err != nil {
return nil
}
u := &UPS{}
got := false
num := func(s string) float64 {
s = strings.TrimSpace(s)
end := 0
for end < len(s) && (s[end] >= '0' && s[end] <= '9' || s[end] == '.') {
end++
}
v, _ := strconv.ParseFloat(s[:end], 64)
return v
}
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, "....")
if !ok {
continue
}
k = strings.TrimSpace(k)
v = strings.TrimSpace(strings.TrimLeft(v, "."))
got = true
switch k {
case "Model Name":
u.Model = v
case "State":
u.State = v
case "Power Supply by":
u.Source = v
case "Utility Voltage":
u.InV = num(v)
case "Output Voltage":
u.OutV = num(v)
case "Battery Capacity":
u.BatteryPct = num(v)
case "Remaining Runtime":
u.RuntimeMin = num(v)
case "Load":
u.LoadW = num(v)
if i := strings.Index(v, "("); i >= 0 {
u.LoadPct = num(v[i+1:])
}
case "Last Power Event":
u.LastEvent = v
}
}
if !got {
return nil
}
return u
}
// ---- small helpers ----
func readStr(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return string(b)
}
func readInt(p string) (int64, bool) {
s := strings.TrimSpace(readStr(p))
if s == "" {
return 0, false
}
v, err := strconv.ParseInt(s, 10, 64)
return v, err == nil
}
func round1(v float64) float64 { return float64(int64(v*10+0.5)) / 10 }
func lookPath(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
+215
View File
@@ -0,0 +1,215 @@
import { ReactNode, useEffect, useRef, useState } from 'react'
import { api, Sample, Series } from './api'
import Sparkline from './Sparkline'
const MAX = 300
const S1 = 'var(--s1)', S2 = 'var(--s2)'
function push(arr: number[], v: number) { arr.push(v); if (arr.length > MAX) arr.shift() }
function pad(arr: number[], n: number) { while (arr.length < n) arr.unshift(0) }
export function fmtBytes(b: number): string {
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let i = 0
while (b >= 1024 && i < u.length - 1) { b /= 1024; i++ }
return `${b < 10 && i > 0 ? b.toFixed(1) : Math.round(b)} ${u[i]}`
}
export function fmtRate(bps: number): string {
const u = ['B/s', 'kB/s', 'MB/s', 'GB/s']
let i = 0
while (bps >= 1000 && i < u.length - 1) { bps /= 1000; i++ }
return `${bps < 10 && i > 0 ? bps.toFixed(1) : Math.round(bps)} ${u[i]}`
}
function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
const pct = (v: number) => `${Math.round(v)}%`
function level(frac: number, warn = 0.85, bad = 0.95): '' | 'warn' | 'bad' {
return frac >= bad ? 'bad' : frac >= warn ? 'warn' : ''
}
function Meter({ frac, cls }: { frac: number; cls?: string }) {
return <div className="meter"><div className={'fill ' + (cls ?? level(frac))} style={{ width: `${Math.min(100, Math.max(0, frac * 100))}%` }} /></div>
}
function Tile({ label, value, sub, children }: { label: string; value: string; sub?: string; children?: ReactNode }) {
return (
<div className="tile">
<div className="label">{label}</div>
<div className="value">{value}</div>
{sub && <div className="sub">{sub}</div>}
{children}
</div>
)
}
function Card({ title, children }: { title: string; children: ReactNode }) {
return <section className="card"><h2>{title}</h2>{children}</section>
}
function Legend({ items }: { items: { name: string; color: string }[] }) {
return <div className="legend">{items.map((i) => <span key={i.name}><span className="sw" style={{ background: i.color }} />{i.name}</span>)}</div>
}
export default function Dashboard() {
const [latest, setLatest] = useState<Sample | null>(null)
const [err, setErr] = useState('')
const hist = useRef<Series | null>(null)
useEffect(() => {
let stop = false
let timer: number | undefined
const seed = async () => {
try { hist.current = await api.metricsHistory(); setLatest(await api.metrics()); setErr('') }
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
const tick = async () => {
if (stop) return
if (document.visibilityState === 'visible') {
try {
const s = await api.metrics()
const h = hist.current
if (h) {
push(h.t, s.t); push(h.cpu, s.cpu); push(h.mem, s.mem.used)
const n = h.t.length
for (const x of s.net) {
const v = (h.net[x.iface] ??= { rx: [], tx: [] })
pad(v.rx, n - 1); pad(v.tx, n - 1); push(v.rx, x.rx_bps); push(v.tx, x.tx_bps)
}
for (const d of s.disks) {
const v = (h.disk[d.name] ??= { r: [], w: [], u: [] })
pad(v.r, n - 1); pad(v.w, n - 1); pad(v.u, n - 1); push(v.r, d.read_bps); push(v.w, d.write_bps); push(v.u, d.util_pct)
}
for (const t of s.temps) {
const v = (h.temp[`${t.chip}/${t.label}`] ??= [])
pad(v, n - 1); push(v, t.c)
}
}
setLatest(s); setErr('')
} catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
timer = window.setTimeout(tick, 2000)
}
seed().then(() => { timer = window.setTimeout(tick, 2000) })
const onVis = () => { if (document.visibilityState === 'visible' && !hist.current) seed() }
document.addEventListener('visibilitychange', onVis)
return () => { stop = true; clearTimeout(timer); document.removeEventListener('visibilitychange', onVis) }
}, [])
if (!latest) return <div className="dash"><div className="center muted">{err || '…'}</div></div>
const h = hist.current
const s = latest
const memFrac = s.mem.total ? s.mem.used / s.mem.total : 0
return (
<div className="dash">
{err && <div className="error">{err}</div>}
<div className="kpis">
<Tile label="CPU" value={pct(s.cpu)} sub={s.cores.length ? `cores ${s.cores.map((c) => Math.round(c)).join(' · ')}` : undefined}>
{h && <Sparkline series={[{ name: 'cpu', values: h.cpu, color: S1 }]} max={100} format={pct} height={36} />}
</Tile>
<Tile label="Memory" value={fmtBytes(s.mem.used)} sub={`of ${fmtBytes(s.mem.total)} · ${pct(memFrac * 100)}${s.swap.total ? ` · swap ${fmtBytes(s.swap.used)}` : ''}`}>
{h && <Sparkline series={[{ name: 'used', values: h.mem, color: S1 }]} max={s.mem.total} format={fmtBytes} height={36} />}
</Tile>
<Tile label="Load" value={s.load.map((l) => l.toFixed(2)).join(' ')} sub={`1 · 5 · 15 min, ${s.cores.length || '?'} cores`} />
<Tile label="Uptime" value={fmtUptime(s.uptime)} sub={new Date(s.t).toLocaleTimeString()} />
</div>
<div className="cards">
<Card title="Temperatures">
{s.temps.length === 0 && <div className="empty">No temperature sensors reported by this host.</div>}
{s.temps.map((t) => {
const limit = t.crit || t.max || 100
const cls = t.c >= limit - 5 ? 'bad' : t.max && t.c >= t.max - 15 ? 'warn' : ''
return (
<div className="row" key={`${t.chip}/${t.label}`}>
<span className="k">{t.label}<small>{t.chip}</small></span>
<Meter frac={t.c / limit} cls={cls} />
<span className="num">{t.c.toFixed(0)}°C{t.max ? ` / ${t.max.toFixed(0)}` : ''}</span>
</div>
)
})}
</Card>
<Card title="Fans">
{s.fans.length === 0 && <div className="empty">No fan sensors reported by this host.</div>}
{s.fans.map((f) => (
<div className="row" key={`${f.chip}/${f.label}`}>
<span className="k">{f.label}<small>{f.chip}</small></span>
<span />
<span className="num">{f.rpm} rpm</span>
</div>
))}
</Card>
<Card title="Network">
{s.net.map((x) => (
<div className="sub-card" key={x.iface}>
<div className="sub-head">
<span className={'dot ' + (x.up ? 'ok' : '')} />
<span>{x.iface}</span>
<span className="spacer" />
<span className="num">↓ {fmtRate(x.rx_bps)} ↑ {fmtRate(x.tx_bps)}</span>
</div>
{h?.net[x.iface] && (
<Sparkline series={[{ name: 'rx', values: h.net[x.iface].rx, color: S1 }, { name: 'tx', values: h.net[x.iface].tx, color: S2 }]} format={fmtRate} />
)}
<div className="legend">
<span><span className="sw" style={{ background: S1 }} />rx · {fmtBytes(x.rx_total)} total</span>
<span><span className="sw" style={{ background: S2 }} />tx · {fmtBytes(x.tx_total)} total</span>
</div>
</div>
))}
</Card>
<Card title="Disks">
{s.disks.map((d) => (
<div className="sub-card" key={d.name}>
<div className="sub-head">
<span>{d.name}</span>
<span className="model">{d.model}</span>
<span className="spacer" />
<span className="num">{d.temp ? `${d.temp.toFixed(0)}°C · ` : ''}{pct(d.util_pct)} busy</span>
</div>
<div className="sub-head num">read {fmtRate(d.read_bps)}<span className="spacer" />write {fmtRate(d.write_bps)}</div>
{h?.disk[d.name] && (
<Sparkline series={[{ name: 'read', values: h.disk[d.name].r, color: S1 }, { name: 'write', values: h.disk[d.name].w, color: S2 }]} format={fmtRate} />
)}
<Legend items={[{ name: 'read', color: S1 }, { name: 'write', color: S2 }]} />
</div>
))}
</Card>
<Card title="Filesystems">
{s.fs.map((f) => (
<div className="row" key={f.mount}>
<span className="k">{f.mount}<small>{f.type}</small></span>
{f.size ? <Meter frac={f.used / f.size} /> : <span className="muted">unreachable</span>}
<span className="num">{f.size ? `${fmtBytes(f.used)} / ${fmtBytes(f.size)}` : ''}</span>
</div>
))}
</Card>
<Card title="UPS">
{!s.ups && <div className="empty">No UPS data (pwrstat not available on this host).</div>}
{s.ups && (
<>
<div className="sub-head">
<span className={'dot ' + (s.ups.source === 'Utility Power' ? 'ok' : 'bad')} />
<span>{s.ups.state} · {s.ups.source}</span>
<span className="spacer" />
<span className="model">{s.ups.model}</span>
</div>
<div className="row"><span className="k">Battery</span><Meter frac={s.ups.battery_pct / 100} cls={s.ups.battery_pct < 20 ? 'bad' : s.ups.battery_pct < 50 ? 'warn' : ''} /><span className="num">{pct(s.ups.battery_pct)} · {s.ups.runtime_min} min</span></div>
<div className="row"><span className="k">Load</span><Meter frac={s.ups.load_pct / 100} /><span className="num">{s.ups.load_w} W · {pct(s.ups.load_pct)}</span></div>
<div className="row"><span className="k">Voltage</span><span /><span className="num">in {s.ups.in_v} V · out {s.ups.out_v} V</span></div>
{s.ups.last_event && <div className="row"><span className="k">Last event</span><span className="num" style={{ gridColumn: '2 / 4', whiteSpace: 'normal' }}>{s.ups.last_event}</span></div>}
</>
)}
</Card>
</div>
</div>
)
}
+18 -11
View File
@@ -1,43 +1,50 @@
import { useState } from 'react' import { useState } from 'react'
import { Term } from './api' import { Term } from './api'
export interface Group { id: string; label: string; icon: string; soon?: boolean } // A group is either a page (clicking the head opens it — Dashboard) or a folder of
// leaves (Terminal). `soon` groups are inert placeholders for the roadmap.
export interface Group { id: string; label: string; icon: string; soon?: boolean; page?: boolean }
interface Props { interface Props {
groups: Group[]; terms: Term[]; active: string | null groups: Group[]; terms: Term[]; active: string | null; view: string
onSelect: (id: string) => void; onNew: () => void onSelect: (id: string) => void; onView: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void onRename: (t: Term) => void; onClose: (t: Term) => void
} }
export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) { export default function Rail({ groups, terms, active, view, onSelect, onView, onNew, onRename, onClose }: Props) {
const [open, setOpen] = useState<string>('terminal') const [open, setOpen] = useState<string>('terminal')
return ( return (
<nav className="rail"> <nav className="rail">
{groups.map((g) => { {groups.map((g) => {
const expanded = open === g.id const expanded = !g.page && open === g.id
const isPage = !!g.page && view === g.id
return ( return (
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}> <div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
<button className="group-head" onClick={() => !g.soon && setOpen(expanded ? '' : g.id)} disabled={g.soon}> <button className={'group-head' + (isPage ? ' active' : '')} disabled={g.soon}
<span className="chev">{expanded ? '▾' : '▸'}</span> onClick={() => { if (g.soon) return; if (g.page) onView(g.id); else setOpen(expanded ? '' : g.id) }}>
<span className="chev">{g.page ? '' : expanded ? '▾' : '▸'}</span>
<span className="gicon">{g.icon}</span> <span className="gicon">{g.icon}</span>
<span>{g.label}</span> <span>{g.label}</span>
{g.soon && <span className="badge">soon</span>} {g.soon && <span className="badge">soon</span>}
</button> </button>
{expanded && g.id === 'terminal' && ( {expanded && g.id === 'terminal' && (
<div className="leaves"> <div className="leaves">
{terms.map((t) => ( {terms.map((t) => {
<div key={t.id} className={'leaf' + (t.id === active ? ' active' : '') + (t.running ? '' : ' exited')} const sel = view === 'terminal' && t.id === active
return (
<div key={t.id} className={'leaf' + (sel ? ' active' : '') + (t.running ? '' : ' exited')}
onClick={() => onSelect(t.id)}> onClick={() => onSelect(t.id)}>
<span className={'sdot' + (t.running ? ' run' : '')} /> <span className={'sdot' + (t.running ? ' run' : '')} />
<span className="lname" title={t.name}>{t.name}</span> <span className="lname" title={t.name}>{t.name}</span>
{t.id === active && ( {sel && (
<span className="leaf-actions"> <span className="leaf-actions">
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button> <button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button> <button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
</span> </span>
)} )}
</div> </div>
))} )
})}
<button className="leaf new" onClick={onNew}>+ New Terminal</button> <button className="leaf new" onClick={onNew}>+ New Terminal</button>
</div> </div>
)} )}
+15 -7
View File
@@ -3,15 +3,18 @@ import { api, Me, Term } from './api'
import Rail, { Group } from './Rail' import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane' import TerminalPane, { TermHandle } from './TerminalPane'
import KeyBar from './KeyBar' import KeyBar from './KeyBar'
import Dashboard from './Dashboard'
const GROUPS: Group[] = [ const GROUPS: Group[] = [
{ id: 'dashboard', label: 'Dashboard', icon: '◔', page: true },
{ id: 'terminal', label: 'Terminal', icon: '>_' }, { id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '⚙', soon: true }, { id: 'services', label: 'Services', icon: '⚙', soon: true },
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true }, { id: 'cron', label: 'Cron', icon: '⏱', soon: true },
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true }, { id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
{ id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
] ]
type View = 'terminal' | 'dashboard'
function fmtUptime(s: number): string { function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m` return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
@@ -20,6 +23,7 @@ function fmtUptime(s: number): string {
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) { export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
const [terms, setTerms] = useState<Term[]>([]) const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null) const [active, setActive] = useState<string | null>(null)
const [view, setView] = useState<View>('terminal')
const [drawer, setDrawer] = useState(false) const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true) const [online, setOnline] = useState(true)
const handles = useRef<Record<string, TermHandle>>({}) const handles = useRef<Record<string, TermHandle>>({})
@@ -36,6 +40,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
const t = await api.createTerminal() const t = await api.createTerminal()
await reload() await reload()
setActive(t.id) setActive(t.id)
setView('terminal')
setDrawer(false) setDrawer(false)
} }
async function rename(t: Term) { async function rename(t: Term) {
@@ -51,6 +56,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
async function logout() { await api.logout(); onLoggedOut() } async function logout() { await api.logout(); onLoggedOut() }
const activeTerm = terms.find((t) => t.id === active) const activeTerm = terms.find((t) => t.id === active)
const termView = view === 'terminal'
return ( return (
<div className={'app' + (drawer ? ' drawer-open' : '')}> <div className={'app' + (drawer ? ' drawer-open' : '')}>
@@ -65,20 +71,22 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
</header> </header>
<div className="body"> <div className="body">
<Rail groups={GROUPS} terms={terms} active={active} <Rail groups={GROUPS} terms={terms} active={active} view={view}
onSelect={(id) => { setActive(id); setDrawer(false) }} onSelect={(id) => { setActive(id); setView('terminal'); setDrawer(false) }}
onView={(id) => { setView(id as View); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} /> onNew={create} onRename={rename} onClose={close} />
<div className="backdrop" onClick={() => setDrawer(false)} /> <div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main"> <main className="main">
{terms.length === 0 && ( {view === 'dashboard' && <Dashboard />}
{termView && terms.length === 0 && (
<div className="center muted"> <div className="center muted">
<div>No terminals.</div> <div>No terminals.</div>
<button className="primary" onClick={create}>+ New Terminal</button> <button className="primary" onClick={create}>+ New Terminal</button>
</div> </div>
)} )}
{terms.map((t) => ( {terms.map((t) => (
<TerminalPane key={t.id} id={t.id} active={t.id === active} <TerminalPane key={t.id} id={t.id} active={termView && t.id === active}
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }} register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
onStatus={(ok) => { if (t.id === active) setOnline(ok) }} onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
onExit={reload} /> onExit={reload} />
@@ -86,14 +94,14 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
</main> </main>
</div> </div>
{activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)} {termView && activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />} setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
<footer className="footer"> <footer className="footer">
<span>v{me.version}</span> <span>v{me.version}</span>
<span>up {fmtUptime(me.uptime)}</span> <span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" /> <span className="spacer" />
<span className="muted">{activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'dashboard' ? 'Dashboard' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+64
View File
@@ -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>
)
}
+20
View File
@@ -20,7 +20,27 @@ async function req<T>(method: string, path: string, body?: unknown): Promise<T>
export interface Me { host: string; user: string; version: string; load: string; uptime: number } export interface Me { host: string; user: string; version: string; load: string; uptime: number }
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number } export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
export interface Temp { chip: string; label: string; c: number; max?: number; crit?: number }
export interface Fan { chip: string; label: string; rpm: number }
export interface NetIf { iface: string; up: boolean; rx_bps: number; tx_bps: number; rx_total: number; tx_total: number }
export interface Disk { name: string; model?: string; read_bps: number; write_bps: number; util_pct: number; temp?: number }
export interface FS { mount: string; type: string; size: number; used: number }
export interface UPS { model: string; state: string; source: string; in_v: number; out_v: number; battery_pct: number; runtime_min: number; load_w: number; load_pct: number; last_event?: string }
export interface Sample {
t: number; cpu: number; cores: number[]; load: [number, number, number]
mem: { total: number; used: number; avail: number }; swap: { total: number; used: number; avail: number }
temps: Temp[]; fans: Fan[]; net: NetIf[]; disks: Disk[]; fs: FS[]; ups: UPS | null; uptime: number
}
export interface Series {
t: number[]; cpu: number[]; mem: number[]
net: Record<string, { rx: number[]; tx: number[] }>
disk: Record<string, { r: number[]; w: number[]; u: number[] }>
temp: Record<string, number[]>
}
export const api = { export const api = {
metrics: () => req<Sample>('GET', '/api/metrics'),
metricsHistory: () => req<Series>('GET', '/api/metrics/history'),
login: (password: string) => req<void>('POST', '/api/login', { password }), login: (password: string) => req<void>('POST', '/api/login', { password }),
logout: () => req<void>('POST', '/api/logout'), logout: () => req<void>('POST', '/api/logout'),
me: () => req<Me>('GET', '/api/me'), me: () => req<Me>('GET', '/api/me'),
+29
View File
@@ -2,6 +2,7 @@
--bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b; --bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b;
--fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff; --fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
--ok: #3fb950; --bad: #ff7b72; --ok: #3fb950; --bad: #ff7b72;
--s1: #3987e5; --s2: #c98500; --track: rgba(57, 135, 229, .18);
--header-h: 44px; --footer-h: 28px; --rail-w: 220px; --header-h: 44px; --footer-h: 28px; --rail-w: 220px;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -77,3 +78,31 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); } .drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
.footer { font-size: 11px; } .footer { font-size: 11px; }
} }
/* dashboard */
.dash { position: absolute; inset: 0; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 12px; background: var(--bg); -webkit-overflow-scrolling: touch; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; align-items: start; }
.tile, .card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; min-width: 0; }
.tile .label, .card h2 { margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.tile .value { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; line-height: 1.2; }
.tile .sub { font-size: 12px; color: var(--muted); margin-top: 2px; font-variant-numeric: tabular-nums; }
.tile .spark { margin-top: 8px; }
.card .empty { color: var(--muted); font-size: 13px; }
.row { display: grid; grid-template-columns: minmax(90px, 1fr) 2fr auto; gap: 10px; align-items: center; padding: 4px 0; font-size: 13px; min-width: 0; }
.row .k { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .row .k small { color: var(--muted); margin-left: 6px; }
.num { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; font-size: 12px; white-space: nowrap; }
.meter { height: 6px; background: var(--track); border-radius: 3px; overflow: hidden; }
.meter .fill { height: 100%; background: var(--s1); border-radius: 3px; transition: width .3s; }
.meter .fill.warn { background: var(--s2); } .meter .fill.bad { background: var(--bad); }
.sub-card { padding: 8px 0; border-top: 1px solid var(--border); } .sub-card:first-of-type { border-top: 0; padding-top: 0; }
.sub-head { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 4px; } .sub-head .spacer { flex: 1; }
.sub-head .model { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.legend { display: flex; gap: 12px; font-size: 11px; color: var(--muted); margin-top: 4px; }
.sw { display: inline-block; width: 12px; height: 2px; vertical-align: middle; margin-right: 5px; border-radius: 1px; }
.spark { position: relative; width: 100%; height: 44px; touch-action: pan-y; }
.spark svg { display: block; width: 100%; height: 100%; }
.spark .tip { position: absolute; top: 0; transform: translateX(-50%); background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; padding: 2px 6px; font-size: 11px; white-space: nowrap; pointer-events: none; font-variant-numeric: tabular-nums; }
.status { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; }
.rail .group-head.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
@media (max-width: 800px) { .dash { padding: 8px; gap: 8px; } .kpis, .cards { gap: 8px; } .cards { grid-template-columns: 1fr; } }