From 6ad3bc3d4ba3625b78135d7e920ad1a9e7dbc1ed Mon Sep 17 00:00:00 2001 From: Nacho Date: Wed, 9 Sep 2026 16:24:32 +0200 Subject: [PATCH] feat(ui): port full mockup redesign (capa3 structure) --- internal/httpapi/system.go | 140 +++++++++++++--- web/src/App.tsx | 72 ++++++++ web/src/components/PoolCard.tsx | 65 ++++---- web/src/components/ui.tsx | 5 +- web/src/data/mock.ts | 3 + web/src/data/types.ts | 9 + web/src/index.css | 281 +++++++++++++++++++++++++++++++- web/src/ui/i18n.ts | 6 +- web/src/views/Dashboard.tsx | 57 +++++-- web/src/views/Datasets.tsx | 27 ++- web/src/views/Disks.tsx | 7 +- web/src/views/Snapshots.tsx | 3 +- web/src/views/Tasks.tsx | 3 + 13 files changed, 594 insertions(+), 84 deletions(-) diff --git a/internal/httpapi/system.go b/internal/httpapi/system.go index 1263c98..93d9379 100644 --- a/internal/httpapi/system.go +++ b/internal/httpapi/system.go @@ -4,8 +4,10 @@ package httpapi import ( "context" "net/http" + "os" "runtime" "strconv" + "strings" "time" "easyzfs/internal/db" @@ -35,21 +37,109 @@ func (s *Server) getVersion(w http.ResponseWriter, r *http.Request) { caps = c } } - writeJSON(w, http.StatusOK, map[string]any{ - "name": "EasyZFS", - "version": s.version, - "build": s.build, - "go": runtime.Version(), - "os_arch": runtime.GOOS + "/" + runtime.GOARCH, - "uptime_sec": int64(time.Since(s.started).Seconds()), - "rss_bytes": memRSS(), - "db_bytes": db.SizeBytes(s.cfg.DBPath), - "db_path": s.cfg.DBPath, - "zfs_version": caps.Version, - "capabilities": caps, - "demo": s.cfg.Demo, + out := map[string]any{ + "name": "EasyZFS", + "version": s.version, + "build": s.build, + "go": runtime.Version(), + "os_arch": runtime.GOOS + "/" + runtime.GOARCH, + "uptime_sec": int64(time.Since(s.started).Seconds()), + "rss_bytes": memRSS(), + "db_bytes": db.SizeBytes(s.cfg.DBPath), + "db_path": s.cfg.DBPath, + "zfs_version": caps.Version, + "capabilities": caps, + "demo": s.cfg.Demo, "pendingUpdate": pendingUpdateJSON(s.updater), - }) + } + // Métricas del host (best effort: se omiten si no se pueden leer). + if l1, l5, l15, ok := loadAvg(); ok { + out["load1"], out["load5"], out["load15"] = l1, l5, l15 + } + if total, avail, ok := memInfo(); ok { + out["mem_total_bytes"], out["mem_avail_bytes"] = total, avail + } + if hit, size, ok := arcStats(); ok { + out["arc_hit_pct"], out["arc_size_bytes"] = hit, size + } + writeJSON(w, http.StatusOK, out) +} + +// loadAvg lee /proc/loadavg (load 1/5/15). +func loadAvg() (l1, l5, l15 float64, ok bool) { + b, err := os.ReadFile("/proc/loadavg") + if err != nil { + return 0, 0, 0, false + } + f := strings.Fields(string(b)) + if len(f) < 3 { + return 0, 0, 0, false + } + l1, err1 := strconv.ParseFloat(f[0], 64) + l5, err5 := strconv.ParseFloat(f[1], 64) + l15, err15 := strconv.ParseFloat(f[2], 64) + if err1 != nil || err5 != nil || err15 != nil { + return 0, 0, 0, false + } + return l1, l5, l15, true +} + +// memInfo lee MemTotal/MemAvailable de /proc/meminfo (en bytes). +func memInfo() (total, avail uint64, ok bool) { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0, false + } + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 2 { + continue + } + kb, err := strconv.ParseUint(f[1], 10, 64) + if err != nil { + continue + } + switch strings.TrimSuffix(f[0], ":") { + case "MemTotal": + total = kb * 1024 + case "MemAvailable": + avail = kb * 1024 + } + } + return total, avail, total > 0 +} + +// arcStats calcula el hit ratio del ARC de ZFS a partir de +// /proc/spl/kstat/zfs/arcstats (hits/(hits+misses)). Devuelve también el +// tamaño actual del ARC en bytes. +func arcStats() (hitPct float64, sizeBytes uint64, ok bool) { + b, err := os.ReadFile("/proc/spl/kstat/zfs/arcstats") + if err != nil { + return 0, 0, false + } + var hits, misses, size uint64 + for _, line := range strings.Split(string(b), "\n") { + f := strings.Fields(line) + if len(f) < 3 { + continue + } + v, err := strconv.ParseUint(f[2], 10, 64) + if err != nil { + continue + } + switch f[0] { + case "hits": + hits = v + case "misses": + misses = v + case "size": + size = v + } + } + if hits+misses == 0 { + return 0, size, false + } + return float64(hits) / float64(hits+misses) * 100, size, true } // getSettings — GET /api/settings. @@ -133,17 +223,17 @@ func validateSettings(st settingsBody) string { // settingsBody — body de PUT /api/settings (idéntico a settings.Settings). type settingsBody struct { - Lang string `json:"lang"` - CapWarnPct int `json:"cap_warn_pct"` - CapCritPct int `json:"cap_crit_pct"` - DiskTempC int `json:"disk_temp_c"` - Webhook string `json:"webhook"` - NotifyScrubErrors bool `json:"notify_scrub_errors"` - NotifySmartChange bool `json:"notify_smart_change"` - DemoEnabled bool `json:"demo_enabled"` - BackupEnabled bool `json:"backup_enabled"` - BackupFreqHours int `json:"backup_freq_hours"` - BackupRetentionDays int `json:"backup_retention_days"` + Lang string `json:"lang"` + CapWarnPct int `json:"cap_warn_pct"` + CapCritPct int `json:"cap_crit_pct"` + DiskTempC int `json:"disk_temp_c"` + Webhook string `json:"webhook"` + NotifyScrubErrors bool `json:"notify_scrub_errors"` + NotifySmartChange bool `json:"notify_smart_change"` + DemoEnabled bool `json:"demo_enabled"` + BackupEnabled bool `json:"backup_enabled"` + BackupFreqHours int `json:"backup_freq_hours"` + BackupRetentionDays int `json:"backup_retention_days"` } // listAlerts — GET /api/alerts → últimas 100. diff --git a/web/src/App.tsx b/web/src/App.tsx index 4092569..7baf824 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,8 @@ import ErrorBoundary from './components/ErrorBoundary'; import PullToRefresh from './components/PullToRefresh'; import { getProvider } from './data'; import { subscribeEvents } from './data/events'; +import { fmtBytes, fmtBytesPair, fmtDuration } from './ui/format'; +import type { VersionInfo } from './data/types'; import { timeAgo } from './ui/format'; import { lazyRetry } from './ui/lazyRetry'; import { useUpdateAvailable } from './ui/updatecheck'; @@ -118,6 +120,73 @@ function AlertsPanel({ onClose }: { onClose: () => void }) { ); } +// Panel de métricas del host en el sidebar (uptime, load, RAM daemon/sistema, +// ARC hit). Los campos son best-effort: una fila solo se pinta si el server +// la reporta (/api/version). Se refresca cada 30 s. +function SideStats() { + const [v, setV] = useState(null); + useEffect(() => { + let alive = true; + const load = () => getProvider().getVersion().then((x) => alive && setV(x)).catch(() => {}); + load(); + const id = setInterval(load, 30000); + return () => { alive = false; clearInterval(id); }; + }, []); + if (!v) return null; + const memUsed = v.mem_total_bytes && v.mem_avail_bytes != null + ? v.mem_total_bytes - v.mem_avail_bytes : null; + const memPct = memUsed != null && v.mem_total_bytes + ? Math.round((memUsed / v.mem_total_bytes) * 100) : null; + return ( +
+
+ UPTIME + {fmtDuration(v.uptime_sec)} +
+ {v.load1 != null && ( +
+ LOAD + {v.load1.toFixed(2)} {v.load5?.toFixed(2)} {v.load15?.toFixed(2)} +
+ )} +
+ RAM · daemon + {fmtBytes(v.rss_bytes)} +
+ {memUsed != null && v.mem_total_bytes != null && ( +
+
+ RAM · system + {(() => { const p = fmtBytesPair(memUsed, v.mem_total_bytes); return `${p.used} / ${p.total}`; })()} +
+ {memPct != null &&
} +
+ )} + {v.arc_hit_pct != null && ( +
+ ARC HIT + {v.arc_hit_pct.toFixed(1)}% +
+ )} +
+ ); +} + +// Reloj de consola en la topbar (mono, tabular; oculto < 1100px por CSS). +function Clock() { + const [now, setNow] = useState(() => new Date()); + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(id); + }, []); + const p = (n: number) => String(n).padStart(2, '0'); + return ( + + ); +} + function Shell() { const { t, route, navigate, demo, exitDemo, user, isAdmin, ready, family, mode, themeEff, setMode } = useApp(); const { openModal } = useModal(); @@ -223,6 +292,7 @@ function Shell() { ); })} + {!collapsed && }
+ LIVE +