From 56e5fabe8d6e9ac7799c29f29e2725e0a965d2fa Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Tue, 7 Jul 2026 09:27:06 -0400 Subject: [PATCH] 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 --- Makefile | 2 +- internal/db/queries_storage.go | 15 ++- internal/db/queries_storage_test.go | 2 +- internal/storage/jobs.go | 4 +- internal/storage/jobs_test.go | 4 +- internal/web/handlers_storage.go | 15 ++- web/src/api.ts | 4 +- web/src/pages/Storage.tsx | 143 +++++++++++++++++----------- 8 files changed, 118 insertions(+), 71 deletions(-) diff --git a/Makefile b/Makefile index 590f52a..cb55f93 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.8.2 +VERSION?=0.8.3 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) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/internal/db/queries_storage.go b/internal/db/queries_storage.go index 512e4b5..496e1f4 100644 --- a/internal/db/queries_storage.go +++ b/internal/db/queries_storage.go @@ -159,13 +159,13 @@ func (d *DB) TrimJobOutput(id int64, maxLines int) error { return nil } -func (d *DB) ListStorageJobs(limit int) ([]StorageJob, error) { +func (d *DB) ListStorageJobs(limit, offset int) ([]StorageJob, error) { if limit <= 0 { - limit = 50 + limit = 20 } rows, err := d.conn.Query(` 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 { 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() } +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) { row := d.conn.QueryRow(` SELECT id, kind, status, args_json, pid, started_at, finished_at, exit_code, output, error, created_at diff --git a/internal/db/queries_storage_test.go b/internal/db/queries_storage_test.go index 03ff07f..90efef5 100644 --- a/internal/db/queries_storage_test.go +++ b/internal/db/queries_storage_test.go @@ -14,7 +14,7 @@ func TestListStorageJobsEmptyReturnsSlice(t *testing.T) { t.Fatalf("migrate: %v", err) } - jobs, err := d.ListStorageJobs(50) + jobs, err := d.ListStorageJobs(50, 0) if err != nil { t.Fatalf("ListStorageJobs: %v", err) } diff --git a/internal/storage/jobs.go b/internal/storage/jobs.go index 2cdfd3b..957312d 100644 --- a/internal/storage/jobs.go +++ b/internal/storage/jobs.go @@ -285,8 +285,8 @@ func (jm *JobManager) Get(id int64) (db.StorageJob, error) { return jm.db.GetStorageJob(id) } -func (jm *JobManager) List(limit int) ([]db.StorageJob, error) { - return jm.db.ListStorageJobs(limit) +func (jm *JobManager) List(limit, offset int) ([]db.StorageJob, error) { + return jm.db.ListStorageJobs(limit, offset) } func (jm *JobManager) Cancel(id int64) error { diff --git a/internal/storage/jobs_test.go b/internal/storage/jobs_test.go index 7add1f7..4ef92e7 100644 --- a/internal/storage/jobs_test.go +++ b/internal/storage/jobs_test.go @@ -54,7 +54,7 @@ func TestJobManagerListEmpty(t *testing.T) { defer d.Close() jm := NewJobManager(d, false) - jobs, err := jm.List(10) + jobs, err := jm.List(10, 0) if err != nil { t.Fatalf("List: %v", err) } @@ -85,7 +85,7 @@ func TestJobManagerResetOrphans(t *testing.T) { t.Fatalf("ResetOrphans: %v", err) } - jobs, _ := jm.List(10) + jobs, _ := jm.List(10, 0) runningCount := 0 queuedCount := 0 for _, j := range jobs { diff --git a/internal/web/handlers_storage.go b/internal/web/handlers_storage.go index 9a308c7..6d70bc0 100644 --- a/internal/web/handlers_storage.go +++ b/internal/web/handlers_storage.go @@ -111,14 +111,23 @@ func (s *Server) handleStorageUpdateConfig(w http.ResponseWriter, r *http.Reques func (s *Server) handleStorageListJobs(w http.ResponseWriter, r *http.Request) { limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) 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 { writeError(w, http.StatusInternalServerError, err.Error()) 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) { diff --git a/web/src/api.ts b/web/src/api.ts index bbc614d..739f1d3 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -316,7 +316,9 @@ export const api = { getStorageConfig: () => request("GET", "/storage/config"), updateStorageConfig: (c: Partial) => request("PUT", "/storage/config", c), storageDiskUsage: () => request("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("GET", `/storage/jobs/${id}`), startStorageJob: (kind: JobKind, args?: Record) => request("POST", "/storage/jobs", { kind, args }), diff --git a/web/src/pages/Storage.tsx b/web/src/pages/Storage.tsx index 1debea4..43e8441 100644 --- a/web/src/pages/Storage.tsx +++ b/web/src/pages/Storage.tsx @@ -34,6 +34,8 @@ export default function Storage() { const [config, setConfig] = useState(null); const [diskUsage, setDiskUsage] = useState(null); const [jobs, setJobs] = useState([]); + const [jobOffset, setJobOffset] = useState(0); + const [jobTotal, setJobTotal] = useState(0); const [activeJob, setActiveJob] = useState(null); const [outputLines, setOutputLines] = useState([]); 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 ? (

Sin operaciones registradas.

) : ( -
- - - - - - - - - - - - - {jobs.slice(0, 20).map((j) => ( - { - setActiveJob(j); - setOutputLines(j.output ? j.output.split("\n") : []); - if (j.status !== "running") { - if (esRef.current) { - esRef.current.close(); - esRef.current = null; - } - } - }} - > - - - - - - + <> +
+
TipoEstadoExitErrorFechaDuración
{KIND_LABELS[j.kind] ?? j.kind} - - {j.status} - - {j.exit_code >= 0 ? j.exit_code : "—"} - {j.error ? ( - - ⚠ {j.error.split("\n")[0].slice(0, 50)}{j.error.split("\n")[0].length > 50 ? "…" : ""} - - ) : null} - {new Date(j.created_at).toLocaleString()} - {j.started_at && j.finished_at - ? duration(new Date(j.started_at), new Date(j.finished_at)) - : j.started_at - ? "en curso" - : "—"} -
+ + + + + + + + - ))} - -
TipoEstadoExitErrorFechaDuración
-
+ + + {jobs.slice(0, 20).map((j) => ( + { + setActiveJob(j); + setOutputLines(j.output ? j.output.split("\n") : []); + if (j.status !== "running") { + if (esRef.current) { + esRef.current.close(); + esRef.current = null; + } + } + }} + > + {KIND_LABELS[j.kind] ?? j.kind} + + + {j.status} + + + {j.exit_code >= 0 ? j.exit_code : "—"} + + {j.error ? ( + + ⚠ {j.error.split("\n")[0].slice(0, 50)}{j.error.split("\n")[0].length > 50 ? "…" : ""} + + ) : null} + + {new Date(j.created_at).toLocaleString()} + + {j.started_at && j.finished_at + ? duration(new Date(j.started_at), new Date(j.finished_at)) + : j.started_at + ? "en curso" + : "—"} + + + ))} + + + +
+ + Mostrando {jobOffset + 1}–{Math.min(jobOffset + 20, jobTotal)} de {jobTotal} + +
+ + +
+
+ )}