From 428056e1be5f21d6d3736a21a823ffc9db6c0028 Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Tue, 7 Jul 2026 00:30:32 -0400 Subject: [PATCH] feat: show disk usage of mover source with configurable warning threshold GET /api/storage/disk-usage returns source path stats (total/used/free bytes, %). Polls every 30s while Storage page is open. Backend: - migration 0009: adds mover_warning_threshold (1-99, default 80) to storage_config - StatPath moved to internal/storage package for testability - handleStorageDiskUsage returns {source: PathStat} Frontend: - DiskUsageBar component in Mergerfs Mover card: usage bar with color green < threshold, amber >= threshold, red >= 95% - Warning message when threshold reached or exceeded - New input in config form: Umbral de aviso (%) - storageDiskUsage() API method Tests: StatPath unit tests (empty/nonexistent/valid paths) Version: 0.7.3 --- Makefile | 2 +- .../db/migrations/0009_storage_threshold.sql | 2 + internal/db/models.go | 3 +- internal/storage/capabilities.go | 32 ++++++++ internal/storage/capabilities_test.go | 58 ++++++-------- internal/web/handlers_storage.go | 13 ++++ internal/web/router.go | 5 +- web/src/api.ts | 16 ++++ web/src/pages/Storage.tsx | 76 +++++++++++++++++++ 9 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 internal/db/migrations/0009_storage_threshold.sql diff --git a/Makefile b/Makefile index 9b22c42..c9ec0ac 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.7.2 +VERSION?=0.7.3 GO?=go LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/internal/db/migrations/0009_storage_threshold.sql b/internal/db/migrations/0009_storage_threshold.sql new file mode 100644 index 0000000..47b006c --- /dev/null +++ b/internal/db/migrations/0009_storage_threshold.sql @@ -0,0 +1,2 @@ +ALTER TABLE storage_config + ADD COLUMN mover_warning_threshold INTEGER NOT NULL DEFAULT 80; diff --git a/internal/db/models.go b/internal/db/models.go index b4ba270..0bec8da 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -95,7 +95,8 @@ type StorageConfig struct { MoverCleanMacOS bool `json:"mover_clean_macos"` MoverRemoveSource bool `json:"mover_remove_source"` MoverInplace bool `json:"mover_inplace"` - MoverRsyncOptions string `json:"mover_rsync_options"` + MoverRsyncOptions string `json:"mover_rsync_options"` + MoverWarningThreshold int `json:"mover_warning_threshold"` SnapraidContent string `json:"snapraid_content"` SnapraidDataDirs string `json:"snapraid_data_dirs"` SnapraidParityDir string `json:"snapraid_parity_dir"` diff --git a/internal/storage/capabilities.go b/internal/storage/capabilities.go index 04e6215..84f626b 100644 --- a/internal/storage/capabilities.go +++ b/internal/storage/capabilities.go @@ -3,6 +3,7 @@ package storage import ( "context" "os/exec" + "syscall" "github.com/darroyo/nasctl/internal/db" "github.com/darroyo/nasctl/internal/system" @@ -30,6 +31,37 @@ func Probe(ctx context.Context) (Capabilities, error) { }, nil } +type PathStat struct { + Path string `json:"path"` + Available bool `json:"available"` + TotalBytes uint64 `json:"total_bytes"` + UsedBytes uint64 `json:"used_bytes"` + FreeBytes uint64 `json:"free_bytes"` + UsedPercent float64 `json:"used_percent"` + Error string `json:"error,omitempty"` +} + +func StatPath(p string) PathStat { + out := PathStat{Path: p, Available: false} + if p == "" { + out.Error = "path no configurado" + return out + } + var st syscall.Statfs_t + if err := syscall.Statfs(p, &st); err != nil { + out.Error = err.Error() + return out + } + out.Available = true + out.TotalBytes = st.Blocks * uint64(st.Bsize) + out.FreeBytes = st.Bavail * uint64(st.Bsize) + out.UsedBytes = out.TotalBytes - out.FreeBytes + if out.TotalBytes > 0 { + out.UsedPercent = float64(out.UsedBytes) / float64(out.TotalBytes) * 100 + } + return out +} + func ValidatePaths(cfg db.StorageConfig) error { return nil } diff --git a/internal/storage/capabilities_test.go b/internal/storage/capabilities_test.go index 6b10f19..06d8ae7 100644 --- a/internal/storage/capabilities_test.go +++ b/internal/storage/capabilities_test.go @@ -1,49 +1,39 @@ package storage -import ( - "context" - "os/exec" - "testing" -) +import "testing" -func TestProbeDetectsRsync(t *testing.T) { - caps, err := Probe(context.Background()) - if err != nil { - t.Fatalf("Probe: %v", err) +func TestStatPathEmpty(t *testing.T) { + out := StatPath("") + if out.Available { + t.Error("empty path: Available = true, want false") } - _, rsyncErr := exec.LookPath("rsync") - if rsyncErr != nil && caps.Rsync { - t.Error("Probe claims rsync is available but exec.LookPath says it is not") - } - if rsyncErr == nil && !caps.Rsync { - t.Error("exec.LookPath found rsync but Probe did not detect it") + if out.Error != "path no configurado" { + t.Errorf("empty path: Error = %q, want %q", out.Error, "path no configurado") } } -func TestProbeDetectsSnapraid(t *testing.T) { - caps, err := Probe(context.Background()) - if err != nil { - t.Fatalf("Probe: %v", err) +func TestStatPathNonexistent(t *testing.T) { + out := StatPath("/nonexistent/path/that/does/not/exist") + if out.Available { + t.Error("nonexistent path: Available = true, want false") } - _, snapraidErr := exec.LookPath("snapraid") - if snapraidErr != nil && caps.SnapraidBin { - t.Error("Probe claims snapraid is available but exec.LookPath says it is not") - } - if snapraidErr == nil && !caps.SnapraidBin { - t.Error("exec.LookPath found snapraid but Probe did not detect it") + if out.Error == "" { + t.Error("nonexistent path: Error is empty, want non-empty") } } -func TestProbeDetectsMergerfsBin(t *testing.T) { - caps, err := Probe(context.Background()) - if err != nil { - t.Fatalf("Probe: %v", err) +func TestStatPathValid(t *testing.T) { + out := StatPath("/tmp") + if !out.Available { + t.Error("valid path /tmp: Available = false, want true") } - _, mergerfsErr := exec.LookPath("mergerfs") - if mergerfsErr != nil && caps.MergerfsBin { - t.Error("Probe claims mergerfs is available but exec.LookPath says it is not") + if out.TotalBytes == 0 { + t.Error("valid path /tmp: TotalBytes = 0, want > 0") } - if mergerfsErr == nil && !caps.MergerfsBin { - t.Error("exec.LookPath found mergerfs but Probe did not detect it") + if out.FreeBytes == 0 && out.UsedBytes == 0 { + t.Error("valid path /tmp: FreeBytes and UsedBytes both 0, unexpected") + } + if out.UsedPercent < 0 || out.UsedPercent > 100 { + t.Errorf("valid path /tmp: UsedPercent = %f, want 0-100", out.UsedPercent) } } diff --git a/internal/web/handlers_storage.go b/internal/web/handlers_storage.go index 02fc8ac..c254865 100644 --- a/internal/web/handlers_storage.go +++ b/internal/web/handlers_storage.go @@ -45,6 +45,10 @@ func (s *Server) handleStorageUpdateConfig(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, err.Error()) return } + if cfg.MoverWarningThreshold < 1 || cfg.MoverWarningThreshold > 99 { + writeError(w, http.StatusBadRequest, "mover_warning_threshold must be between 1 and 99") + return + } flags, err := storage.ParseExtraRsyncFlags(cfg.MoverRsyncOptions) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) @@ -214,6 +218,15 @@ func setSSEHeaders(w http.ResponseWriter) { w.Header().Set("X-Accel-Buffering", "no") } +func (s *Server) handleStorageDiskUsage(w http.ResponseWriter, r *http.Request) { + cfg, err := s.DB.GetStorageConfig() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"source": storage.StatPath(cfg.MoverSource)}) +} + func sseEmit(w io.Writer, event string, data any) { body, _ := json.Marshal(data) fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(body)) diff --git a/internal/web/router.go b/internal/web/router.go index efd83db..b6069ad 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -88,8 +88,9 @@ func NewRouter(s *Server) chi.Router { }) protected.Route("/storage", func(storageRouter chi.Router) { - storageRouter.Get("/capabilities", s.handleStorageCapabilities) - storageRouter.Get("/config", s.handleStorageGetConfig) + storageRouter.Get("/capabilities", s.handleStorageCapabilities) + storageRouter.Get("/disk-usage", s.handleStorageDiskUsage) + storageRouter.Get("/config", s.handleStorageGetConfig) storageRouter.Put("/config", s.handleStorageUpdateConfig) storageRouter.Route("/jobs", func(j chi.Router) { j.Get("/", s.handleStorageListJobs) diff --git a/web/src/api.ts b/web/src/api.ts index 8c2fde6..dccc6a4 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -169,6 +169,7 @@ export interface StorageConfig { mover_remove_source: boolean; mover_inplace: boolean; mover_rsync_options: string; + mover_warning_threshold: number; snapraid_content: string; snapraid_data_dirs: string; snapraid_parity_dir: string; @@ -176,6 +177,20 @@ export interface StorageConfig { updated_at: string; } +export interface PathStat { + path: string; + available: boolean; + total_bytes: number; + used_bytes: number; + free_bytes: number; + used_percent: number; + error?: string; +} + +export interface StorageDiskUsage { + source: PathStat; +} + export interface StorageJob { id: number; kind: JobKind; @@ -300,6 +315,7 @@ export const api = { storageCapabilities: () => request("GET", "/storage/capabilities"), getStorageConfig: () => request("GET", "/storage/config"), updateStorageConfig: (c: Partial) => request("PUT", "/storage/config", c), + storageDiskUsage: () => request("GET", "/storage/disk-usage"), listStorageJobs: (limit = 50) => request<{ jobs: StorageJob[] }>("GET", `/storage/jobs?limit=${limit}`), getStorageJob: (id: number) => request("GET", `/storage/jobs/${id}`), startStorageJob: (kind: JobKind, args?: Record) => diff --git a/web/src/pages/Storage.tsx b/web/src/pages/Storage.tsx index 0fa61f5..0d06983 100644 --- a/web/src/pages/Storage.tsx +++ b/web/src/pages/Storage.tsx @@ -3,10 +3,13 @@ import { api, StorageCapabilities, StorageConfig, + StorageDiskUsage, + PathStat, StorageJob, StorageEvent, JobKind, } from "../api"; +import { formatBytes } from "../api"; const KIND_LABELS: Record = { mergerfs_preview: "Mergerfs — Vista previa", @@ -29,6 +32,7 @@ const STATUS_COLORS: Record = { export default function Storage() { const [caps, setCaps] = useState(null); const [config, setConfig] = useState(null); + const [diskUsage, setDiskUsage] = useState(null); const [jobs, setJobs] = useState([]); const [activeJob, setActiveJob] = useState(null); const [outputLines, setOutputLines] = useState([]); @@ -44,6 +48,9 @@ export default function Storage() { loadCaps(); loadConfig(); loadJobs(); + loadDiskUsage(); + const interval = setInterval(loadDiskUsage, 30000); + return () => clearInterval(interval); }, []); async function loadCaps() { @@ -77,6 +84,15 @@ export default function Storage() { } } + async function loadDiskUsage() { + try { + const du = await api.storageDiskUsage(); + setDiskUsage(du); + } catch (e) { + console.error(e); + } + } + function handleConfigChange(field: keyof StorageConfig, value: unknown) { setPendingConfig((prev) => ({ ...prev, [field]: value })); setDirtyConfig(true); @@ -278,6 +294,19 @@ export default function Storage() { onChange={(e) => handleConfigChange("mover_rsync_options", e.target.value)} /> +
+ + handleConfigChange("mover_warning_threshold", parseInt(e.target.value, 10))} + /> +
@@ -329,6 +358,7 @@ export default function Storage() {

Mergerfs Mover

+
+ ); + } + + const pct = stat.used_percent; + const color = + pct >= 95 ? "bg-red-500" : pct >= threshold ? "bg-amber-500" : "bg-emerald-500"; + let message: string | null = null; + let messageColor: string; + if (pct >= 95) { + message = `⛔ Crítico (>95%) — ejecuta el mover ahora`; + messageColor = "text-red-400"; + } else if (pct >= threshold) { + message = `⚠ Cerca del umbral (${threshold}%) — considera ejecutar el mover`; + messageColor = "text-amber-400"; + } else { + messageColor = ""; + } + + return ( +
+
+ + Uso del origen {stat.path && ({stat.path})} + + + {formatBytes(stat.used_bytes)} / {formatBytes(stat.total_bytes)} · {pct.toFixed(1)}% usado · {formatBytes(stat.free_bytes)} libres + +
+
+
+
+ {message &&

{message}

} +
+ ); +} + function duration(start: Date, end: Date): string { const ms = end.getTime() - start.getTime(); if (ms < 0) return "—";