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
+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 {