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