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
+16
View File
@@ -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>) =>
+76
View File
@@ -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 "—";