diff --git a/internal/http/admin.go b/internal/http/admin.go index 2d1cf0f..9287009 100644 --- a/internal/http/admin.go +++ b/internal/http/admin.go @@ -102,14 +102,20 @@ func (s *Server) handleAdminPatchSettings(w http.ResponseWriter, r *http.Request func (s *Server) handleAdminAuditLog(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() + limit := atoiDefault(q.Get("limit"), 50) + if limit <= 0 || limit > 200 { + limit = 50 + } entries, err := s.store.ListAudit(r.Context(), q.Get("actorId"), q.Get("entityId"), - q.Get("cursor"), atoiDefault(q.Get("limit"), 50)) + q.Get("cursor"), limit) if err != nil { s.internalError(w, r, "list audit", err) return } + // Only advertise a next cursor when the page is full; otherwise this is the + // last page and a further fetch would return nothing (the "click twice" bug). next := "" - if len(entries) > 0 { + if len(entries) == limit { next = entries[len(entries)-1].ID } writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "nextCursor": next}) diff --git a/web/static/css/app.css b/web/static/css/app.css index 34300af..02b57e7 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -326,7 +326,6 @@ body[data-nav=side] .topnav .links { overflow-y: auto; z-index: 40; } -:root[data-theme=light] body[data-nav=side] .topnav .links { background-image: none; } body[data-nav=side] .topnav .links a { border-bottom: none; border-left: 3px solid transparent; @@ -503,6 +502,22 @@ table.list tbody tr:nth-child(even) { background: color-mix(in srgb, var(--surfa .grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); } .grid > .card { display: flex; flex-direction: column; } /* equal-height rows */ +/* pin the action row to the bottom so buttons line up across cards */ +.grid > .card > .spread:last-child { margin-top: auto; } + +/* Audit log: span the full screen width; long detail values wrap, never overflow */ +#tab-audit { + width: calc(100vw - 32px); + margin-left: calc(50% - 50vw + 16px); + margin-right: calc(50% - 50vw + 16px); +} +body[data-nav=side] #tab-audit { + width: calc(100vw - 214px - 32px); + margin-left: 0; + margin-right: 0; +} +#audit-table td, #audit-table th { white-space: normal; overflow-wrap: anywhere; vertical-align: top; } +#audit-table td:last-child { font-family: var(--font-mono); font-size: 0.82rem; } /* ---- toolbar: filter rows boxed and baseline-aligned ---- */ .toolbar { diff --git a/web/static/js/admin.js b/web/static/js/admin.js index 2f5a0e9..8081fe5 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -362,6 +362,7 @@ async function loadAudit(reset = true) { try { const entity = encodeURIComponent(document.getElementById('audit-entity').value.trim()); if (reset) auditCursor = ''; + else if (!auditCursor) return; // already at the last page const res = await api('GET', `/api/v1/admin/audit-log?entityId=${entity}&cursor=${auditCursor}`); auditCursor = res.nextCursor; const rows = res.entries.map((e) => { @@ -380,6 +381,7 @@ async function loadAudit(reset = true) { const tbody = document.querySelector('#audit-table tbody'); if (reset) tbody.replaceChildren(...rows); else tbody.append(...rows); + document.getElementById('audit-more').hidden = !auditCursor; } catch (e) { showErr(e); } } document.getElementById('audit-apply').addEventListener('click', () => loadAudit(true));