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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE storage_config
|
||||
ADD COLUMN mover_warning_threshold INTEGER NOT NULL DEFAULT 80;
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<StorageCapabilities>("GET", "/storage/capabilities"),
|
||||
getStorageConfig: () => request<StorageConfig>("GET", "/storage/config"),
|
||||
updateStorageConfig: (c: Partial<StorageConfig>) => request<StorageConfig>("PUT", "/storage/config", c),
|
||||
storageDiskUsage: () => request<StorageDiskUsage>("GET", "/storage/disk-usage"),
|
||||
listStorageJobs: (limit = 50) => request<{ jobs: StorageJob[] }>("GET", `/storage/jobs?limit=${limit}`),
|
||||
getStorageJob: (id: number) => request<StorageJob>("GET", `/storage/jobs/${id}`),
|
||||
startStorageJob: (kind: JobKind, args?: Record<string, unknown>) =>
|
||||
|
||||
@@ -3,10 +3,13 @@ import {
|
||||
api,
|
||||
StorageCapabilities,
|
||||
StorageConfig,
|
||||
StorageDiskUsage,
|
||||
PathStat,
|
||||
StorageJob,
|
||||
StorageEvent,
|
||||
JobKind,
|
||||
} from "../api";
|
||||
import { formatBytes } from "../api";
|
||||
|
||||
const KIND_LABELS: Record<JobKind, string> = {
|
||||
mergerfs_preview: "Mergerfs — Vista previa",
|
||||
@@ -29,6 +32,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
export default function Storage() {
|
||||
const [caps, setCaps] = useState<StorageCapabilities | null>(null);
|
||||
const [config, setConfig] = useState<StorageConfig | null>(null);
|
||||
const [diskUsage, setDiskUsage] = useState<StorageDiskUsage | null>(null);
|
||||
const [jobs, setJobs] = useState<StorageJob[]>([]);
|
||||
const [activeJob, setActiveJob] = useState<StorageJob | null>(null);
|
||||
const [outputLines, setOutputLines] = useState<string[]>([]);
|
||||
@@ -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)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-500">
|
||||
Umbral de aviso (%) — alerta al alcanzar este porcentaje
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-24 rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-200 focus:border-brand-500 focus:outline-none"
|
||||
min={1}
|
||||
max={99}
|
||||
value={mergedConfig.mover_warning_threshold ?? 80}
|
||||
onChange={(e) => handleConfigChange("mover_warning_threshold", parseInt(e.target.value, 10))}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-3">
|
||||
@@ -329,6 +358,7 @@ export default function Storage() {
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
|
||||
Mergerfs Mover
|
||||
</h2>
|
||||
<DiskUsageBar stat={diskUsage?.source} threshold={config?.mover_warning_threshold ?? 80} />
|
||||
<div className="mb-4 flex gap-3">
|
||||
<button
|
||||
className="btn-secondary disabled:opacity-40"
|
||||
@@ -551,6 +581,52 @@ function OutputPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function DiskUsageBar({ stat, threshold }: { stat: PathStat | undefined; threshold: number }) {
|
||||
if (!stat) {
|
||||
return null;
|
||||
}
|
||||
if (!stat.available) {
|
||||
return (
|
||||
<div className="mb-4 text-sm text-slate-500">
|
||||
Origen no accesible
|
||||
{stat.error && <span className="text-slate-600"> — {stat.error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-4 space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-sm">
|
||||
<span className="text-slate-300">
|
||||
Uso del origen {stat.path && <span className="text-slate-500">({stat.path})</span>}
|
||||
</span>
|
||||
<span className="text-slate-400 text-xs">
|
||||
{formatBytes(stat.used_bytes)} / {formatBytes(stat.total_bytes)} · {pct.toFixed(1)}% usado · {formatBytes(stat.free_bytes)} libres
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded bg-slate-800">
|
||||
<div className={`h-full ${color}`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||
</div>
|
||||
{message && <p className={`text-xs ${messageColor}`}>{message}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function duration(start: Date, end: Date): string {
|
||||
const ms = end.getTime() - start.getTime();
|
||||
if (ms < 0) return "—";
|
||||
|
||||
Reference in New Issue
Block a user