feat(storage): paginate operations history with prev/next controls

Backend:
- ListStorageJobs(limit, offset) adds OFFSET for pagination
- CountStorageJobs() returns total row count for UI
- handler returns { jobs, total, limit, offset }
- JobManager.List(limit, offset) updated signature

Frontend:
- loadJobs(offset) with default 0
- Pagination UI: 'Mostrando X-Y de Z' + Anterior/Siguiente buttons
- After job start/end, reloads from offset 0
- listStorageJobs(limit, offset) API updated

Tests: fix List/ListStorageJobs calls to include offset=0
This commit is contained in:
2026-07-07 09:27:06 -04:00
parent 5b459c0b2e
commit 56e5fabe8d
8 changed files with 118 additions and 71 deletions
+3 -1
View File
@@ -316,7 +316,9 @@ export const api = {
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}`),
listStorageJobs: (limit = 20, offset = 0) =>
request<{ jobs: StorageJob[]; total: number; limit: number; offset: number }>(
"GET", `/storage/jobs?limit=${limit}&offset=${offset}`),
getStorageJob: (id: number) => request<StorageJob>("GET", `/storage/jobs/${id}`),
startStorageJob: (kind: JobKind, args?: Record<string, unknown>) =>
request<StorageJob>("POST", "/storage/jobs", { kind, args }),
+85 -58
View File
@@ -34,6 +34,8 @@ export default function Storage() {
const [config, setConfig] = useState<StorageConfig | null>(null);
const [diskUsage, setDiskUsage] = useState<StorageDiskUsage | null>(null);
const [jobs, setJobs] = useState<StorageJob[]>([]);
const [jobOffset, setJobOffset] = useState(0);
const [jobTotal, setJobTotal] = useState(0);
const [activeJob, setActiveJob] = useState<StorageJob | null>(null);
const [outputLines, setOutputLines] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
@@ -78,10 +80,12 @@ export default function Storage() {
}
}
async function loadJobs() {
async function loadJobs(offset = 0) {
try {
const res = await api.listStorageJobs(50);
const res = await api.listStorageJobs(20, offset);
setJobs(res.jobs ?? []);
setJobOffset(res.offset);
setJobTotal(res.total);
} catch (e) {
console.error(e);
}
@@ -137,7 +141,7 @@ export default function Storage() {
setActiveJob(job);
setOutputLines([]);
subscribeStream(job.id);
await loadJobs();
await loadJobs(0);
} 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.");
@@ -168,7 +172,7 @@ export default function Storage() {
);
es.close();
esRef.current = null;
await loadJobs();
await loadJobs(0);
});
es.addEventListener("error", (e) => {
@@ -484,61 +488,84 @@ export default function Storage() {
{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">Error</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">
{j.error ? (
<span className="text-red-400" title={j.error}>
{j.error.split("\n")[0].slice(0, 50)}{j.error.split("\n")[0].length > 50 ? "…" : ""}
</span>
) : null}
</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>
<>
<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">Error</th>
<th className="pb-2">Fecha</th>
<th className="pb-2">Duración</th>
</tr>
))}
</tbody>
</table>
</div>
</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">
{j.error ? (
<span className="text-red-400" title={j.error}>
{j.error.split("\n")[0].slice(0, 50)}{j.error.split("\n")[0].length > 50 ? "…" : ""}
</span>
) : null}
</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 className="mt-3 flex items-center justify-between text-sm text-slate-400">
<span>
Mostrando {jobOffset + 1}{Math.min(jobOffset + 20, jobTotal)} de {jobTotal}
</span>
<div className="flex gap-3">
<button
className="disabled:opacity-30"
disabled={jobOffset === 0}
onClick={() => loadJobs(jobOffset - 20)}
>
Anterior
</button>
<button
className="disabled:opacity-30"
disabled={jobOffset + 20 >= jobTotal}
onClick={() => loadJobs(jobOffset + 20)}
>
Siguiente
</button>
</div>
</div>
</>
)}
</div>