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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user