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:
@@ -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 />} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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 (1–99)
|
||||
</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`;
|
||||
}
|
||||
Reference in New Issue
Block a user