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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user