feat: add mergerfs mover and snapraid integration

New 'Almacenamiento' page with:
- Auto-detection of rsync, mergerfs, snapraid binaries and mergerfs mount
- Configurable pool settings (source/dest, macOS cleanup, rsync flags)
- Mergerfs mover with dry-run preview and live SSE output streaming
- SnapRAID diff/sync/scrub/check with live SSE output
- Async job system (1 concurrent job) with SSE streaming
- Job history table

Backend:
- internal/storage/ package with capabilities, mergerfs, snapraid, jobs
- storage_config and storage_jobs DB tables (migration 0008)
- GET/PUT /api/storage/config, GET /api/storage/capabilities
- POST/GET /api/storage/jobs, GET /api/storage/jobs/{id}/stream
- Storage operations disabled when NASCTL_EXEC_SYSTEM=false

Closes #new-feature
This commit is contained in:
2026-07-06 23:23:22 -04:00
parent 319030848f
commit 27a52d2986
21 changed files with 2062 additions and 1 deletions
+2
View File
@@ -11,6 +11,7 @@ import Nfs from "./pages/Nfs";
import Log from "./pages/Log";
import Settings from "./pages/Settings";
import Files from "./pages/Files";
import Storage from "./pages/Storage";
type AuthState = { loading: boolean; authenticated: boolean; username: string };
@@ -56,6 +57,7 @@ export default function App() {
<Route path="/files" element={<Files />} />
<Route path="/samba" element={<Samba />} />
<Route path="/nfs" element={<Nfs />} />
<Route path="/storage" element={<Storage />} />
<Route path="/log" element={<Log />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
+63
View File
@@ -146,6 +146,58 @@ export interface FilePreview {
size: number;
}
export type JobKind =
| "mergerfs_preview"
| "mergerfs_move"
| "snapraid_diff"
| "snapraid_sync"
| "snapraid_scrub"
| "snapraid_check";
export interface StorageCapabilities {
rsync: boolean;
mergerfs: boolean;
mergerfs_mounted: boolean;
snapraid: boolean;
}
export interface StorageConfig {
id: number;
mover_source: string;
mover_dest: string;
mover_clean_macos: boolean;
mover_remove_source: boolean;
mover_inplace: boolean;
mover_rsync_options: string;
snapraid_content: string;
snapraid_data_dirs: string;
snapraid_parity_dir: string;
snapraid_scrub_plan: number;
updated_at: string;
}
export interface StorageJob {
id: number;
kind: JobKind;
status: "queued" | "running" | "success" | "failed" | "cancelled" | "interrupted";
args_json: string;
pid: number;
started_at: string | null;
finished_at: string | null;
exit_code: number;
output: string;
error: string;
created_at: string;
}
export interface StorageEvent {
type: "line" | "status" | "end" | "error";
line?: string;
status?: string;
exit_code?: number;
message?: string;
}
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
@@ -243,6 +295,17 @@ export const api = {
searchFiles: (path: string, q: string, limit = 100) =>
request<{ results: SearchHit[] }>(`GET`, `/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(q)}&limit=${limit}`),
fileDownloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`,
// storage
storageCapabilities: () => request<StorageCapabilities>("GET", "/storage/capabilities"),
getStorageConfig: () => request<StorageConfig>("GET", "/storage/config"),
updateStorageConfig: (c: Partial<StorageConfig>) => request<StorageConfig>("PUT", "/storage/config", c),
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>) =>
request<StorageJob>("POST", "/storage/jobs", { kind, args }),
cancelStorageJob: (id: number) => request<{ ok: boolean }>("POST", `/storage/jobs/${id}/cancel`),
storageJobStreamUrl: (id: number) => `/api/storage/jobs/${id}/stream`,
};
export function formatBytes(bytes: number): string {
+1
View File
@@ -9,6 +9,7 @@ const navItems = [
{ to: "/files", label: "Archivos" },
{ to: "/samba", label: "SMB / Samba" },
{ to: "/nfs", label: "NFS" },
{ to: "/storage", label: "Almacenamiento" },
{ to: "/log", label: "Historial" },
{ to: "/settings", label: "Ajustes" },
];
+563
View File
@@ -0,0 +1,563 @@
import React, { useEffect, useRef, useState } from "react";
import {
api,
StorageCapabilities,
StorageConfig,
StorageJob,
StorageEvent,
JobKind,
} from "../api";
const KIND_LABELS: Record<JobKind, string> = {
mergerfs_preview: "Mergerfs — Vista previa",
mergerfs_move: "Mergerfs — Mover",
snapraid_diff: "SnapRAID — Diff",
snapraid_sync: "SnapRAID — Sync",
snapraid_scrub: "SnapRAID — Scrub",
snapraid_check: "SnapRAID — Check",
};
const STATUS_COLORS: Record<string, string> = {
queued: "bg-slate-600 text-slate-300",
running: "bg-blue-600 text-blue-100 animate-pulse",
success: "bg-emerald-600 text-emerald-100",
failed: "bg-red-600 text-red-100",
cancelled: "bg-amber-600 text-amber-100",
interrupted: "bg-orange-600 text-orange-100",
};
export default function Storage() {
const [caps, setCaps] = useState<StorageCapabilities | null>(null);
const [config, setConfig] = useState<StorageConfig | null>(null);
const [jobs, setJobs] = useState<StorageJob[]>([]);
const [activeJob, setActiveJob] = useState<StorageJob | null>(null);
const [outputLines, setOutputLines] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [scrubPlan, setScrubPlan] = useState(8);
const [scrubPlanInput, setScrubPlanInput] = useState("8");
const [dirtyConfig, setDirtyConfig] = useState(false);
const [pendingConfig, setPendingConfig] = useState<Partial<StorageConfig>>({});
const outputRef = useRef<HTMLPreElement>(null);
const esRef = useRef<EventSource | null>(null);
useEffect(() => {
loadCaps();
loadConfig();
loadJobs();
}, []);
async function loadCaps() {
try {
const c = await api.storageCapabilities();
setCaps(c);
} catch (e) {
console.error(e);
}
}
async function loadConfig() {
try {
const c = await api.getStorageConfig();
setConfig(c);
setPendingConfig({});
setDirtyConfig(false);
setScrubPlan(c.snapraid_scrub_plan);
setScrubPlanInput(String(c.snapraid_scrub_plan));
} catch (e) {
console.error(e);
}
}
async function loadJobs() {
try {
const res = await api.listStorageJobs(50);
setJobs(res.jobs);
} catch (e) {
console.error(e);
}
}
function handleConfigChange(field: keyof StorageConfig, value: unknown) {
setPendingConfig((prev) => ({ ...prev, [field]: value }));
setDirtyConfig(true);
}
async function saveConfig() {
if (!config) return;
setSaving(true);
try {
const merged = { ...config, ...pendingConfig };
const updated = await api.updateStorageConfig(merged);
setConfig(updated);
setPendingConfig({});
setDirtyConfig(false);
setScrubPlan(updated.snapraid_scrub_plan);
setScrubPlanInput(String(updated.snapraid_scrub_plan));
} catch (e) {
alert(`Error saving: ${e}`);
} finally {
setSaving(false);
}
}
async function startJob(kind: JobKind) {
try {
const job = await api.startStorageJob(kind);
setActiveJob(job);
setOutputLines([]);
subscribeStream(job.id);
await loadJobs();
} catch (e: unknown) {
if (e && typeof e === "object" && "status" in e && (e as { status: number }).status === 409) {
alert("Ya hay una operación en curso. Espera a que termine.");
} else {
alert(`Error: ${e}`);
}
}
}
function subscribeStream(jobId: number) {
if (esRef.current) {
esRef.current.close();
}
const es = new EventSource(api.storageJobStreamUrl(jobId));
esRef.current = es;
es.addEventListener("line", (e) => {
const ev: StorageEvent = JSON.parse(e.data);
if (ev.line !== undefined) {
setOutputLines((prev) => [...prev, ev.line as string].slice(-500));
}
});
es.addEventListener("end", async (e) => {
const ev: StorageEvent = JSON.parse(e.data);
setActiveJob((prev) =>
prev ? { ...prev, status: ev.status as StorageJob["status"], exit_code: ev.exit_code ?? prev.exit_code } : prev
);
es.close();
esRef.current = null;
await loadJobs();
});
es.addEventListener("error", (e) => {
console.error("SSE error", e);
es.close();
});
}
async function cancelJob() {
if (!activeJob) return;
try {
await api.cancelStorageJob(activeJob.id);
} catch (e) {
console.error(e);
}
}
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [outputLines]);
useEffect(() => {
return () => {
if (esRef.current) esRef.current.close();
};
}, []);
const mergedConfig: StorageConfig = config
? { ...config, ...pendingConfig }
: ({} as StorageConfig);
return (
<div className="space-y-8">
<h1 className="text-2xl font-bold text-white">Almacenamiento</h1>
{/* Capabilities */}
<div className="card">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
Capacidades detectadas
</h2>
<div className="flex flex-wrap gap-3">
<CapChip
label="rsync"
ok={caps?.rsync ?? false}
hint="Requerido para mover archivos"
/>
<CapChip
label="mergerfs (binario)"
ok={caps?.mergerfs ?? false}
hint="Binario de mergerfs instalado"
/>
<CapChip
label="mergerfs (montado)"
ok={caps?.mergerfs_mounted ?? false}
hint="Pool de mergerfs activo en /proc/mounts"
/>
<CapChip
label="snapraid"
ok={caps?.snapraid ?? false}
hint="Binario de SnapRAID instalado"
/>
</div>
{caps && !caps.rsync && (
<p className="mt-3 text-sm text-red-400">
Instala <code className="text-red-300">rsync</code> para usar el mover de archivos.
</p>
)}
</div>
{/* Config */}
<div className="card">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-400">
Configuración del pool
</h2>
<button
className="btn-primary disabled:opacity-50"
disabled={!dirtyConfig || saving}
onClick={saveConfig}
>
{saving ? "Guardando…" : "Guardar"}
</button>
</div>
<div className="grid gap-6 md:grid-cols-2">
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-slate-300">Mergerfs Mover</legend>
<Field
label="Origen SSD"
value={mergedConfig.mover_source ?? ""}
onChange={(v) => handleConfigChange("mover_source", v)}
placeholder="/mnt/disks/ssd1"
/>
<Field
label="Destino pool"
value={mergedConfig.mover_dest ?? ""}
onChange={(v) => handleConfigChange("mover_dest", v)}
placeholder="/mnt/pool"
/>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="accent-brand-500"
checked={mergedConfig.mover_clean_macos ?? true}
onChange={(e) => handleConfigChange("mover_clean_macos", e.target.checked)}
/>
Limpiar archivos macOS (.DS_Store, ._*)
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="accent-brand-500"
checked={mergedConfig.mover_remove_source ?? true}
onChange={(e) => handleConfigChange("mover_remove_source", e.target.checked)}
/>
Borrar archivos del origen tras mover
</label>
<label className="flex items-center gap-2 text-sm text-slate-300">
<input
type="checkbox"
className="accent-brand-500"
checked={mergedConfig.mover_inplace ?? true}
onChange={(e) => handleConfigChange("mover_inplace", e.target.checked)}
/>
Modo inplace
</label>
<div>
<label className="mb-1 block text-xs text-slate-500">
Opciones rsync extra (una por línea, sin guiones finales)
</label>
<textarea
className="w-full rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder-slate-600 focus:border-brand-500 focus:outline-none"
rows={3}
placeholder={"# Ejemplos:\n--exclude=*.tmp\n--max-size=2G"}
value={mergedConfig.mover_rsync_options ?? ""}
onChange={(e) => handleConfigChange("mover_rsync_options", e.target.value)}
/>
</div>
</fieldset>
<fieldset className="space-y-3">
<legend className="text-sm font-medium text-slate-300">SnapRAID</legend>
<Field
label="Content file"
value={mergedConfig.snapraid_content ?? ""}
onChange={(v) => handleConfigChange("snapraid_content", v)}
placeholder="/mnt/pool/snapraid.content"
/>
<Field
label="Directorios de datos (CSV)"
value={mergedConfig.snapraid_data_dirs ?? ""}
onChange={(v) => handleConfigChange("snapraid_data_dirs", v)}
placeholder="/mnt/pool/disk1,/mnt/pool/disk2"
/>
<Field
label="Directorio de paridad"
value={mergedConfig.snapraid_parity_dir ?? ""}
onChange={(v) => handleConfigChange("snapraid_parity_dir", v)}
placeholder="/mnt/pool/parity"
/>
<div>
<label className="mb-1 block text-xs text-slate-500">
Plan de scrub (199)
</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={scrubPlanInput}
onChange={(e) => {
setScrubPlanInput(e.target.value);
const n = parseInt(e.target.value, 10);
if (!isNaN(n)) {
setScrubPlan(n);
handleConfigChange("snapraid_scrub_plan", n);
}
}}
/>
</div>
</fieldset>
</div>
</div>
{/* Mergerfs Mover */}
<div className="card">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
Mergerfs Mover
</h2>
<div className="mb-4 flex gap-3">
<button
className="btn-secondary disabled:opacity-40"
disabled={!caps?.rsync || activeJob?.status === "running"}
onClick={() => startJob("mergerfs_preview")}
>
Vista previa (dry-run)
</button>
<button
className="btn-primary disabled:opacity-40"
disabled={!caps?.rsync || activeJob?.status === "running"}
onClick={() => startJob("mergerfs_move")}
>
Mover ahora
</button>
{activeJob && (activeJob.status === "running" || activeJob.status === "queued") && (
<button className="btn-ghost text-red-400 hover:bg-red-900/30" onClick={cancelJob}>
Cancelar
</button>
)}
</div>
<OutputPanel
job={activeJob}
lines={outputLines}
outputRef={outputRef}
filterKind={"mergerfs"}
/>
</div>
{/* SnapRAID */}
<div className="card">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
SnapRAID
</h2>
<div className="mb-4 flex flex-wrap gap-3">
<button
className="btn-secondary disabled:opacity-40"
disabled={!caps?.snapraid || activeJob?.status === "running"}
onClick={() => startJob("snapraid_diff")}
>
Diff
</button>
<button
className="btn-primary disabled:opacity-40"
disabled={!caps?.snapraid || activeJob?.status === "running"}
onClick={() => startJob("snapraid_sync")}
>
Sync
</button>
<div className="flex items-center gap-2">
<button
className="btn-secondary disabled:opacity-40"
disabled={!caps?.snapraid || activeJob?.status === "running"}
onClick={() => startJob("snapraid_scrub")}
>
Scrub (plan {scrubPlan})
</button>
</div>
<button
className="btn-secondary disabled:opacity-40"
disabled={!caps?.snapraid || activeJob?.status === "running"}
onClick={() => startJob("snapraid_check")}
>
Check
</button>
{activeJob && (activeJob.status === "running" || activeJob.status === "queued") && (
<button className="btn-ghost text-red-400 hover:bg-red-900/30" onClick={cancelJob}>
Cancelar
</button>
)}
</div>
<OutputPanel
job={activeJob}
lines={outputLines}
outputRef={outputRef}
filterKind={"snapraid"}
/>
</div>
{/* History */}
<div className="card">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-400">
Historial de operaciones
</h2>
{jobs.length === 0 ? (
<p className="text-sm text-slate-500">Sin operaciones registradas.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700 text-left text-slate-400">
<th className="pb-2">Tipo</th>
<th className="pb-2">Estado</th>
<th className="pb-2">Exit</th>
<th className="pb-2">Fecha</th>
<th className="pb-2">Duración</th>
</tr>
</thead>
<tbody>
{jobs.slice(0, 20).map((j) => (
<tr
key={j.id}
className="cursor-pointer border-b border-slate-800 text-slate-300 hover:bg-slate-800/50"
onClick={() => {
setActiveJob(j);
setOutputLines(j.output ? j.output.split("\n") : []);
if (j.status !== "running") {
if (esRef.current) {
esRef.current.close();
esRef.current = null;
}
}
}}
>
<td className="py-2">{KIND_LABELS[j.kind] ?? j.kind}</td>
<td className="py-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${STATUS_COLORS[j.status] ?? "bg-slate-700"}`}>
{j.status}
</span>
</td>
<td className="py-2 font-mono">{j.exit_code >= 0 ? j.exit_code : "—"}</td>
<td className="py-2">{new Date(j.created_at).toLocaleString()}</td>
<td className="py-2">
{j.started_at && j.finished_at
? duration(new Date(j.started_at), new Date(j.finished_at))
: j.started_at
? "en curso"
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
function CapChip({ label, ok, hint }: { label: string; ok: boolean; hint: string }) {
return (
<span
className={`rounded-full px-3 py-1 text-sm font-medium ${
ok ? "bg-emerald-500/20 text-emerald-300" : "bg-slate-700 text-slate-400"
}`}
title={hint}
>
{label} {ok ? "✓" : "✗"}
</span>
);
}
function Field({
label,
value,
onChange,
placeholder,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
}) {
return (
<div>
<label className="mb-1 block text-xs text-slate-500">{label}</label>
<input
type="text"
className="w-full rounded border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-slate-200 placeholder-slate-600 focus:border-brand-500 focus:outline-none"
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
function OutputPanel({
job,
lines,
outputRef,
filterKind,
}: {
job: StorageJob | null;
lines: string[];
outputRef: React.RefObject<HTMLPreElement | null>;
filterKind: "mergerfs" | "snapraid";
}) {
const filtered = job?.kind.startsWith(filterKind) ? lines : [];
if (!job || !job.kind.startsWith(filterKind)) {
return (
<pre className="max-h-64 overflow-auto rounded bg-slate-900 p-3 text-xs text-slate-500">
Sin salida aún. Ejecuta una operación para ver el resultado.
</pre>
);
}
return (
<>
<div className="mb-2 flex items-center gap-2 text-xs text-slate-400">
<span className={`rounded-full px-2 py-0.5 text-xs ${STATUS_COLORS[job.status] ?? "bg-slate-700"}`}>
{job.status}
</span>
{job.kind}
{job.exit_code >= 0 && job.status !== "running" && (
<span className="text-slate-500">exit={job.exit_code}</span>
)}
</div>
<pre
ref={outputRef as React.RefObject<HTMLPreElement>}
className="max-h-80 overflow-auto rounded bg-slate-900 p-3 text-xs text-slate-300 font-mono"
>
{filtered.length === 0
? job.output
? job.output.split("\n").slice(-200).join("\n")
: "iniciando…"
: filtered.join("\n")}
</pre>
</>
);
}
function duration(start: Date, end: Date): string {
const ms = end.getTime() - start.getTime();
if (ms < 0) return "—";
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}