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
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=nasctl BINARY=nasctl
VERSION?=0.8.2 VERSION?=0.8.3
GO?=go GO?=go
LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_FLAGS=CGO_ENABLED=0 BUILD_FLAGS=CGO_ENABLED=0
+12 -3
View File
@@ -159,13 +159,13 @@ func (d *DB) TrimJobOutput(id int64, maxLines int) error {
return nil return nil
} }
func (d *DB) ListStorageJobs(limit int) ([]StorageJob, error) { func (d *DB) ListStorageJobs(limit, offset int) ([]StorageJob, error) {
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 20
} }
rows, err := d.conn.Query(` rows, err := d.conn.Query(`
SELECT id, kind, status, args_json, pid, started_at, finished_at, exit_code, output, error, created_at SELECT id, kind, status, args_json, pid, started_at, finished_at, exit_code, output, error, created_at
FROM storage_jobs ORDER BY created_at DESC LIMIT ?`, limit) FROM storage_jobs ORDER BY created_at DESC LIMIT ? OFFSET ?`, limit, offset)
if err != nil { if err != nil {
return nil, fmt.Errorf("list storage jobs: %w", err) return nil, fmt.Errorf("list storage jobs: %w", err)
} }
@@ -182,6 +182,15 @@ func (d *DB) ListStorageJobs(limit int) ([]StorageJob, error) {
return jobs, rows.Err() return jobs, rows.Err()
} }
func (d *DB) CountStorageJobs() (int, error) {
var count int
err := d.conn.QueryRow(`SELECT COUNT(1) FROM storage_jobs`).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count storage jobs: %w", err)
}
return count, nil
}
func (d *DB) GetRunningJob() (*StorageJob, error) { func (d *DB) GetRunningJob() (*StorageJob, error) {
row := d.conn.QueryRow(` row := d.conn.QueryRow(`
SELECT id, kind, status, args_json, pid, started_at, finished_at, exit_code, output, error, created_at SELECT id, kind, status, args_json, pid, started_at, finished_at, exit_code, output, error, created_at
+1 -1
View File
@@ -14,7 +14,7 @@ func TestListStorageJobsEmptyReturnsSlice(t *testing.T) {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
jobs, err := d.ListStorageJobs(50) jobs, err := d.ListStorageJobs(50, 0)
if err != nil { if err != nil {
t.Fatalf("ListStorageJobs: %v", err) t.Fatalf("ListStorageJobs: %v", err)
} }
+2 -2
View File
@@ -285,8 +285,8 @@ func (jm *JobManager) Get(id int64) (db.StorageJob, error) {
return jm.db.GetStorageJob(id) return jm.db.GetStorageJob(id)
} }
func (jm *JobManager) List(limit int) ([]db.StorageJob, error) { func (jm *JobManager) List(limit, offset int) ([]db.StorageJob, error) {
return jm.db.ListStorageJobs(limit) return jm.db.ListStorageJobs(limit, offset)
} }
func (jm *JobManager) Cancel(id int64) error { func (jm *JobManager) Cancel(id int64) error {
+2 -2
View File
@@ -54,7 +54,7 @@ func TestJobManagerListEmpty(t *testing.T) {
defer d.Close() defer d.Close()
jm := NewJobManager(d, false) jm := NewJobManager(d, false)
jobs, err := jm.List(10) jobs, err := jm.List(10, 0)
if err != nil { if err != nil {
t.Fatalf("List: %v", err) t.Fatalf("List: %v", err)
} }
@@ -85,7 +85,7 @@ func TestJobManagerResetOrphans(t *testing.T) {
t.Fatalf("ResetOrphans: %v", err) t.Fatalf("ResetOrphans: %v", err)
} }
jobs, _ := jm.List(10) jobs, _ := jm.List(10, 0)
runningCount := 0 runningCount := 0
queuedCount := 0 queuedCount := 0
for _, j := range jobs { for _, j := range jobs {
+12 -3
View File
@@ -111,14 +111,23 @@ func (s *Server) handleStorageUpdateConfig(w http.ResponseWriter, r *http.Reques
func (s *Server) handleStorageListJobs(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStorageListJobs(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 20
} }
jobs, err := s.JM.List(limit) offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if offset < 0 {
offset = 0
}
jobs, err := s.JM.List(limit, offset)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs}) total, err := s.DB.CountStorageJobs()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": jobs, "total": total, "limit": limit, "offset": offset})
} }
func (s *Server) handleStorageGetJob(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStorageGetJob(w http.ResponseWriter, r *http.Request) {
+3 -1
View File
@@ -316,7 +316,9 @@ export const api = {
getStorageConfig: () => request<StorageConfig>("GET", "/storage/config"), getStorageConfig: () => request<StorageConfig>("GET", "/storage/config"),
updateStorageConfig: (c: Partial<StorageConfig>) => request<StorageConfig>("PUT", "/storage/config", c), updateStorageConfig: (c: Partial<StorageConfig>) => request<StorageConfig>("PUT", "/storage/config", c),
storageDiskUsage: () => request<StorageDiskUsage>("GET", "/storage/disk-usage"), 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}`), getStorageJob: (id: number) => request<StorageJob>("GET", `/storage/jobs/${id}`),
startStorageJob: (kind: JobKind, args?: Record<string, unknown>) => startStorageJob: (kind: JobKind, args?: Record<string, unknown>) =>
request<StorageJob>("POST", "/storage/jobs", { kind, args }), 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 [config, setConfig] = useState<StorageConfig | null>(null);
const [diskUsage, setDiskUsage] = useState<StorageDiskUsage | null>(null); const [diskUsage, setDiskUsage] = useState<StorageDiskUsage | null>(null);
const [jobs, setJobs] = useState<StorageJob[]>([]); const [jobs, setJobs] = useState<StorageJob[]>([]);
const [jobOffset, setJobOffset] = useState(0);
const [jobTotal, setJobTotal] = useState(0);
const [activeJob, setActiveJob] = useState<StorageJob | null>(null); const [activeJob, setActiveJob] = useState<StorageJob | null>(null);
const [outputLines, setOutputLines] = useState<string[]>([]); const [outputLines, setOutputLines] = useState<string[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -78,10 +80,12 @@ export default function Storage() {
} }
} }
async function loadJobs() { async function loadJobs(offset = 0) {
try { try {
const res = await api.listStorageJobs(50); const res = await api.listStorageJobs(20, offset);
setJobs(res.jobs ?? []); setJobs(res.jobs ?? []);
setJobOffset(res.offset);
setJobTotal(res.total);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
@@ -137,7 +141,7 @@ export default function Storage() {
setActiveJob(job); setActiveJob(job);
setOutputLines([]); setOutputLines([]);
subscribeStream(job.id); subscribeStream(job.id);
await loadJobs(); await loadJobs(0);
} catch (e: unknown) { } catch (e: unknown) {
if (e && typeof e === "object" && "status" in e && (e as { status: number }).status === 409) { 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."); alert("Ya hay una operación en curso. Espera a que termine.");
@@ -168,7 +172,7 @@ export default function Storage() {
); );
es.close(); es.close();
esRef.current = null; esRef.current = null;
await loadJobs(); await loadJobs(0);
}); });
es.addEventListener("error", (e) => { es.addEventListener("error", (e) => {
@@ -484,61 +488,84 @@ export default function Storage() {
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<p className="text-sm text-slate-500">Sin operaciones registradas.</p> <p className="text-sm text-slate-500">Sin operaciones registradas.</p>
) : ( ) : (
<div className="overflow-x-auto"> <>
<table className="w-full text-sm"> <div className="overflow-x-auto">
<thead> <table className="w-full text-sm">
<tr className="border-b border-slate-700 text-left text-slate-400"> <thead>
<th className="pb-2">Tipo</th> <tr className="border-b border-slate-700 text-left text-slate-400">
<th className="pb-2">Estado</th> <th className="pb-2">Tipo</th>
<th className="pb-2">Exit</th> <th className="pb-2">Estado</th>
<th className="pb-2">Error</th> <th className="pb-2">Exit</th>
<th className="pb-2">Fecha</th> <th className="pb-2">Error</th>
<th className="pb-2">Duración</th> <th className="pb-2">Fecha</th>
</tr> <th className="pb-2">Duración</th>
</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> </tr>
))} </thead>
</tbody> <tbody>
</table> {jobs.slice(0, 20).map((j) => (
</div> <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> </div>