feat: add has_more to pagination responses, disable Next when no more results

Fetch limit+1 rows in get_requests and get_audit_log; if the extra row
exists set has_more=true and truncate back to limit. Admin UI disables
the Next button when has_more is false. Two new integration tests verify
the field is present and false on an empty DB.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
whit3rabbit
2026-04-04 20:30:13 -05:00
co-authored by Claude Sonnet 4.6
parent 01e04fa6b3
commit 0593d1ae1b
3 changed files with 80 additions and 12 deletions
+2
View File
@@ -1078,6 +1078,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
requests.forEach(function(r) { feed.appendChild(makeRequestRow(r)); });
}
document.getElementById('page-info').textContent = 'Page ' + (currentPage + 1);
document.getElementById('next-page').disabled = !data.has_more;
}).catch(function(e) { console.error('Requests load failed:', e); });
}
@@ -1682,6 +1683,7 @@ input:focus,select:focus{outline:none;border-color:#4a9eff}
clearChildren(container, false);
var entries = data.entries || [];
document.getElementById('audit-page-info').textContent = 'Page ' + (auditPage + 1);
document.getElementById('audit-next-page').disabled = !data.has_more;
if (entries.length === 0) {
var auditMsg = auditPage === 0 ? 'No audit entries yet.' : 'No results on this page';
container.appendChild(el('div', {className: 'empty', textContent: auditMsg}));
+26 -12
View File
@@ -1046,7 +1046,7 @@ async fn get_requests(
match crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::query_request_log(
conn,
limit,
limit + 1,
offset,
backend.as_deref(),
since.as_deref(),
@@ -1057,11 +1057,18 @@ async fn get_requests(
})
.await
{
Some(Ok(entries)) => Json(serde_json::json!({
"requests": entries,
"limit": limit,
"offset": offset,
})),
Some(Ok(mut entries)) => {
let has_more = entries.len() > limit as usize;
if has_more {
entries.truncate(limit as usize);
}
Json(serde_json::json!({
"requests": entries,
"limit": limit,
"offset": offset,
"has_more": has_more,
}))
}
Some(Err(e)) => {
tracing::error!(error = %e, "query_request_log failed");
Json(serde_json::json!({
@@ -1696,7 +1703,7 @@ async fn get_audit_log(
match crate::admin::state::with_db(&shared.db, move |conn| {
crate::admin::db::query_audit_log(
conn,
limit,
limit + 1,
offset,
action.as_deref(),
target_type.as_deref(),
@@ -1706,11 +1713,18 @@ async fn get_audit_log(
})
.await
{
Some(Ok(entries)) => Json(serde_json::json!({
"entries": entries,
"limit": limit,
"offset": offset,
})),
Some(Ok(mut entries)) => {
let has_more = entries.len() > limit as usize;
if has_more {
entries.truncate(limit as usize);
}
Json(serde_json::json!({
"entries": entries,
"limit": limit,
"offset": offset,
"has_more": has_more,
}))
}
Some(Err(e)) => {
tracing::error!(error = %e, "query_audit_log failed");
Json(serde_json::json!({
+52
View File
@@ -1095,3 +1095,55 @@ async fn add_model_rejects_unknown_backend() {
body
);
}
// ---------------------------------------------------------------------------
// Pagination has_more field tests
// ---------------------------------------------------------------------------
#[tokio::test]
async fn get_requests_response_has_has_more_field() {
let (app, _state) = test_admin_router();
let req = Request::get("/admin/api/requests?limit=10&offset=0")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
assert!(
body.get("has_more").is_some(),
"response should include has_more field"
);
assert_eq!(body["has_more"], serde_json::Value::Bool(false));
}
#[tokio::test]
async fn get_audit_response_has_has_more_field() {
let (app, _state) = test_admin_router();
let req = Request::get("/admin/api/audit?limit=10&offset=0")
.header("host", "localhost:9090")
.header("authorization", "Bearer test-admin-token")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), 200);
let body: serde_json::Value = serde_json::from_slice(
&axum::body::to_bytes(resp.into_body(), 1 << 20)
.await
.unwrap(),
)
.unwrap();
assert!(
body.get("has_more").is_some(),
"response should include has_more field"
);
assert_eq!(body["has_more"], serde_json::Value::Bool(false));
}