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
This commit is contained in:
2026-07-07 00:30:32 -04:00
parent e5b9b233b8
commit 428056e1be
9 changed files with 169 additions and 38 deletions
@@ -0,0 +1,2 @@
ALTER TABLE storage_config
ADD COLUMN mover_warning_threshold INTEGER NOT NULL DEFAULT 80;
+2 -1
View File
@@ -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"`
+32
View File
@@ -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
}
+24 -34
View File
@@ -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)
}
}
+13
View File
@@ -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))
+3 -2
View File
@@ -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)