Skip to content

feat(dashboard): add pagination and filters to the jobs page#35

Open
knutties wants to merge 1 commit into
mainfrom
claude/gifted-bardeen-n11mlb
Open

feat(dashboard): add pagination and filters to the jobs page#35
knutties wants to merge 1 commit into
mainfrom
claude/gifted-bardeen-n11mlb

Conversation

@knutties

@knutties knutties commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds comprehensive filtering and cursor-based pagination to the jobs list in the workspace detail page. Users can now filter jobs by status, trigger type, endpoint type, and endpoint name, with support for navigating through large result sets.

Key Changes

Dashboard Frontend (crates/dashboard/src/)

  • Filter UI: Added filter controls for status, trigger type, endpoint type, and endpoint name search
  • Pagination State: Implemented cursor-based pagination with page history tracking to support Previous/Next navigation
  • Filter Reset: Added "Clear filters" button that resets all filters and pagination
  • Pagination Component: New JobsPagination component with per-page size selector (25/50/100) and Previous/Next buttons
  • Empty State: Updated empty state message to distinguish between "no jobs" and "no jobs matching filters"
  • API Integration: Updated list_jobs call to pass JobListParams with cursor, limit, and filter options

API Backend (crates/api/src/handlers/jobs.rs)

  • Filter Parsing: Added JobListFilters struct to parse filter query parameters from requests
  • Validation: Implemented validation for enum-like filters (status, trigger_type, endpoint_type) to reject invalid values with 400 errors
  • Blank Handling: Blank filter values (e.g., ?status=) are treated as absent, allowing the dashboard to send empty params for "All" selections
  • DB Integration: Updated list handler to convert API filters to database layer filters and pass them through

Database Layer (crates/common/src/db/jobs.rs)

  • JobFilters Struct: New struct to hold optional server-side filters (status, trigger, endpoint, endpoint_type)
  • Query Builder: Added build_list_query function that constructs SQL with proper placeholder numbering for cursor and filters
  • Filter Logic:
    • Cursor filtering uses subquery to find jobs created before a specific job
    • Status, trigger_type, endpoint_type use exact matching
    • Endpoint uses case-insensitive substring matching (ILIKE) for search functionality
  • Unit Tests: Comprehensive tests for query building with various filter combinations

API Client (crates/dashboard/src/api/client.rs)

  • URL Encoding: Added encode_query_value helper for RFC 3986 compliant query parameter encoding
  • Query String Building: Implemented push_query_param to safely construct query strings with filters
  • Response Type: Changed list_jobs return type from Vec<Job> to PaginatedResponse<Job> to include cursor for pagination

Models (crates/dashboard/src/api/models.rs)

  • JobListParams: New struct defining query parameters (cursor, limit, status, trigger, endpoint, endpoint_type) with sensible defaults

Smithy Schema (smithy/model/jobs.smithy)

  • Added endpoint_type query parameter to ListJobsInput for API schema consistency

Implementation Details

  • Filters are ANDed together; all must match for a job to appear in results
  • Pagination uses cursor-based approach (forward-only from backend) with client-side history tracking
  • Changing any filter or page size automatically resets pagination to page 1
  • The cursor stack is maintained to support Previous button without additional backend calls
  • Empty filter values are normalized to None at multiple layers to ensure consistent behavior

https://claude.ai/code/session_01KEhs81eTYAfB64LRGeDiFw

Summary by CodeRabbit

Release Notes

  • New Features

    • Added filtering capabilities for job listings by status, trigger type, endpoint, and endpoint type
    • Implemented cursor-based pagination with navigation controls for browsing job results
    • Added per-page selection option for customizing result display
  • Improvements

    • Invalid filter values now return clear validation errors instead of silently returning no results
    • Enhanced messaging to distinguish between filtered results and jobs not existing in the workspace

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds job-list filtering and cursor-based pagination across the full stack. The API contract extends to accept optional filters; handlers validate and forward them to the database layer, which builds dynamic SQL. The frontend client safely encodes filters into query strings and returns paginated responses; the workspace detail page adds filter inputs and pagination UI controls.

Changes

Job Listing with Filters and Pagination

Layer / File(s) Summary
API Contract & Request Types
smithy/model/jobs.smithy, crates/api/src/handlers/jobs.rs
Smithy model adds optional endpoint_type query parameter to ListJobsInput. Handler defines JobListFilters struct that parses and validates filter values, rejecting invalid enums as InvalidRequest.
Database Filtering & Query Building
crates/common/src/db/jobs.rs
New JobFilters struct and build_list_query helper construct dynamic SQL with ANDed filter WHERE clauses and cursor pagination. Correct bind placeholder numbering handles scenarios with/without cursor. Unit tests validate SQL generation and bind order.
Handler Integration
crates/api/src/handlers/jobs.rs
list handler accepts filter query parameters, converts them via into_db_filters() validation, and passes JobFilters to database list function.
Frontend Models & Client Integration
crates/dashboard/src/api/models.rs, crates/dashboard/src/api/client.rs
JobListParams struct defines cursor, limit, and optional filter fields with limit=50 default. list_jobs client percent-encodes filter values into query string and returns PaginatedResponse<Job> instead of raw job vector. SSR stub updated to match new signature.
Frontend UI with Filters and Pagination
crates/dashboard/src/pages/workspace_detail.rs
JobsTab component tracks cursor history and page state, renders filter bar with status/trigger/endpoint/endpoint_type inputs and "Clear filters" button, and builds JobListParams on filter/page-size changes. New JobsPagination component manages per-page selection and Previous/Next navigation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A rabbit hops through filters and rows,
Pagination cursors guide where it goes,
From Smithy to SQL, through client and page,
Job listings now sparkle across every stage!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title directly and accurately summarizes the main changes: adding pagination and filters to the jobs page, which is the primary focus across all modified files (frontend, API, and database layers).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/gifted-bardeen-n11mlb

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/common/src/db/jobs.rs`:
- Around line 181-215: The pagination uses only created_at, which is unstable;
update the cursor logic and ORDER BY to use a deterministic tie-breaker
(job_id). Replace the single-row cursor condition "created_at < (SELECT
created_at FROM {t} WHERE job_id = ${n})" with a composite condition like
"(created_at < (SELECT created_at FROM {t} WHERE job_id = ${n}) OR (created_at =
(SELECT created_at FROM {t} WHERE job_id = ${n}) AND job_id < ${m}))" and add
the job_id value to binds (note the new bind ${m}); then change the ORDER BY in
the sql string from "ORDER BY created_at DESC" to "ORDER BY created_at DESC,
job_id DESC" so results are stably ordered for cursor pagination (update the
binds vector where the cursor variable c is handled and the final sql variable).
- Around line 202-205: The endpoint filter currently treats user input percent
and underscore as SQL wildcards; update the block that handles filters.endpoint
so you escape '%' and '_' (and backslashes) in the endpoint value before pushing
it to binds, then use an ILIKE with an explicit ESCAPE clause; specifically,
transform filters.endpoint (e.g. replace '\' with '\\', '%' with '\%', '_' with
'\_'), push the escaped string to binds, change the condition pushed into
conditions from "endpoint ILIKE '%' || ${n} || '%'" to include "ESCAPE '\\'"
(e.g. "endpoint ILIKE '%' || ${n} || '%' ESCAPE '\\'") and leave n increment
logic as-is so the query matches literals correctly.

In `@crates/dashboard/src/pages/workspace_detail.rs`:
- Around line 1195-1204: The on_next closure can push the same next_cursor
repeatedly if clicked before data updates; modify on_next (the closure using
next_cursor, page_index, set_page_cursors, set_page_index) to first read the
current idx and cursors inside the set_page_cursors.update closure and only
truncate/push when the cursor at position idx+1 is not already equal to
Some(nc); if the existing entry equals Some(nc) skip pushing (but still
set_page_index to idx+1), otherwise perform the truncate + push and then
set_page_index; this prevents double-advancing/appending duplicates when
next_cursor is stale.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8de77983-74dd-4671-a7f1-0a2550e0338d

📥 Commits

Reviewing files that changed from the base of the PR and between f44bf96 and 8c42787.

📒 Files selected for processing (6)
  • crates/api/src/handlers/jobs.rs
  • crates/common/src/db/jobs.rs
  • crates/dashboard/src/api/client.rs
  • crates/dashboard/src/api/models.rs
  • crates/dashboard/src/pages/workspace_detail.rs
  • smithy/model/jobs.smithy

Comment on lines +181 to +215
conditions.push(format!(
"created_at < (SELECT created_at FROM {t} WHERE job_id = ${n})"
));
binds.push(c.to_string());
n += 1;
}
if let Some(status) = &filters.status {
conditions.push(format!("status = ${n}"));
binds.push(status.clone());
n += 1;
}
if let Some(trigger) = &filters.trigger {
conditions.push(format!("trigger_type = ${n}"));
binds.push(trigger.clone());
n += 1;
}
if let Some(endpoint_type) = &filters.endpoint_type {
conditions.push(format!("endpoint_type = ${n}"));
binds.push(endpoint_type.clone());
n += 1;
}
if let Some(endpoint) = &filters.endpoint {
conditions.push(format!("endpoint ILIKE '%' || ${n} || '%'"));
binds.push(endpoint.clone());
n += 1;
}

let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};

let sql = format!("SELECT * FROM {t}{where_clause} ORDER BY created_at DESC LIMIT ${n}");
(sql, binds)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use a stable sort key for cursor pagination.

Line 182 and Line 214 paginate/order by created_at only, so rows sharing the same timestamp can be skipped or repeated across pages.

Suggested fix
-        conditions.push(format!(
-            "created_at < (SELECT created_at FROM {t} WHERE job_id = ${n})"
-        ));
+        conditions.push(format!(
+            "(created_at, job_id) < (SELECT created_at, job_id FROM {t} WHERE job_id = ${n})"
+        ));
@@
-    let sql = format!("SELECT * FROM {t}{where_clause} ORDER BY created_at DESC LIMIT ${n}");
+    let sql = format!(
+        "SELECT * FROM {t}{where_clause} ORDER BY created_at DESC, job_id DESC LIMIT ${n}"
+    );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
conditions.push(format!(
"created_at < (SELECT created_at FROM {t} WHERE job_id = ${n})"
));
binds.push(c.to_string());
n += 1;
}
if let Some(status) = &filters.status {
conditions.push(format!("status = ${n}"));
binds.push(status.clone());
n += 1;
}
if let Some(trigger) = &filters.trigger {
conditions.push(format!("trigger_type = ${n}"));
binds.push(trigger.clone());
n += 1;
}
if let Some(endpoint_type) = &filters.endpoint_type {
conditions.push(format!("endpoint_type = ${n}"));
binds.push(endpoint_type.clone());
n += 1;
}
if let Some(endpoint) = &filters.endpoint {
conditions.push(format!("endpoint ILIKE '%' || ${n} || '%'"));
binds.push(endpoint.clone());
n += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!("SELECT * FROM {t}{where_clause} ORDER BY created_at DESC LIMIT ${n}");
(sql, binds)
conditions.push(format!(
"(created_at, job_id) < (SELECT created_at, job_id FROM {t} WHERE job_id = ${n})"
));
binds.push(c.to_string());
n += 1;
}
if let Some(status) = &filters.status {
conditions.push(format!("status = ${n}"));
binds.push(status.clone());
n += 1;
}
if let Some(trigger) = &filters.trigger {
conditions.push(format!("trigger_type = ${n}"));
binds.push(trigger.clone());
n += 1;
}
if let Some(endpoint_type) = &filters.endpoint_type {
conditions.push(format!("endpoint_type = ${n}"));
binds.push(endpoint_type.clone());
n += 1;
}
if let Some(endpoint) = &filters.endpoint {
conditions.push(format!("endpoint ILIKE '%' || ${n} || '%'"));
binds.push(endpoint.clone());
n += 1;
}
let where_clause = if conditions.is_empty() {
String::new()
} else {
format!(" WHERE {}", conditions.join(" AND "))
};
let sql = format!(
"SELECT * FROM {t}{where_clause} ORDER BY created_at DESC, job_id DESC LIMIT ${n}"
);
(sql, binds)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/src/db/jobs.rs` around lines 181 - 215, The pagination uses
only created_at, which is unstable; update the cursor logic and ORDER BY to use
a deterministic tie-breaker (job_id). Replace the single-row cursor condition
"created_at < (SELECT created_at FROM {t} WHERE job_id = ${n})" with a composite
condition like "(created_at < (SELECT created_at FROM {t} WHERE job_id = ${n})
OR (created_at = (SELECT created_at FROM {t} WHERE job_id = ${n}) AND job_id <
${m}))" and add the job_id value to binds (note the new bind ${m}); then change
the ORDER BY in the sql string from "ORDER BY created_at DESC" to "ORDER BY
created_at DESC, job_id DESC" so results are stably ordered for cursor
pagination (update the binds vector where the cursor variable c is handled and
the final sql variable).

Comment on lines +202 to +205
if let Some(endpoint) = &filters.endpoint {
conditions.push(format!("endpoint ILIKE '%' || ${n} || '%'"));
binds.push(endpoint.clone());
n += 1;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Escape wildcard characters in endpoint search.

Line 203 treats % and _ inside user input as SQL wildcards, so endpoint-name filtering can return incorrect matches for literal names containing those characters.

Suggested fix
+fn escape_like(value: &str) -> String {
+    value
+        .replace('\\', "\\\\")
+        .replace('%', "\\%")
+        .replace('_', "\\_")
+}
@@
-    if let Some(endpoint) = &filters.endpoint {
-        conditions.push(format!("endpoint ILIKE '%' || ${n} || '%'"));
-        binds.push(endpoint.clone());
+    if let Some(endpoint) = &filters.endpoint {
+        conditions.push(format!("endpoint ILIKE '%' || ${n} || '%' ESCAPE '\\'"));
+        binds.push(escape_like(endpoint));
         n += 1;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(endpoint) = &filters.endpoint {
conditions.push(format!("endpoint ILIKE '%' || ${n} || '%'"));
binds.push(endpoint.clone());
n += 1;
if let Some(endpoint) = &filters.endpoint {
conditions.push(format!("endpoint ILIKE '%' || ${n} || '%' ESCAPE '\\'"));
binds.push(escape_like(endpoint));
n += 1;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/common/src/db/jobs.rs` around lines 202 - 205, The endpoint filter
currently treats user input percent and underscore as SQL wildcards; update the
block that handles filters.endpoint so you escape '%' and '_' (and backslashes)
in the endpoint value before pushing it to binds, then use an ILIKE with an
explicit ESCAPE clause; specifically, transform filters.endpoint (e.g. replace
'\' with '\\', '%' with '\%', '_' with '\_'), push the escaped string to binds,
change the condition pushed into conditions from "endpoint ILIKE '%' || ${n} ||
'%'" to include "ESCAPE '\\'" (e.g. "endpoint ILIKE '%' || ${n} || '%' ESCAPE
'\\'") and leave n increment logic as-is so the query matches literals
correctly.

Comment on lines +1195 to +1204
let on_next = move |_| {
if let Some(nc) = next_cursor.clone() {
let idx = page_index.get_untracked();
set_page_cursors.update(|cursors| {
// Drop any forward history, then record the next page's cursor.
cursors.truncate(idx + 1);
cursors.push(Some(nc));
});
set_page_index.set(idx + 1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard against stale next_cursor double-advance.

Line 1196 can run multiple times before new data arrives, appending the same cursor repeatedly and navigating to duplicate pages.

Suggested fix
     let on_next = move |_| {
         if let Some(nc) = next_cursor.clone() {
             let idx = page_index.get_untracked();
-            set_page_cursors.update(|cursors| {
+            let mut advanced = false;
+            set_page_cursors.update(|cursors| {
+                if cursors.get(idx).and_then(|c| c.as_ref()) == Some(&nc) {
+                    return;
+                }
                 // Drop any forward history, then record the next page's cursor.
                 cursors.truncate(idx + 1);
                 cursors.push(Some(nc));
+                advanced = true;
             });
-            set_page_index.set(idx + 1);
+            if advanced {
+                set_page_index.set(idx + 1);
+            }
         }
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/dashboard/src/pages/workspace_detail.rs` around lines 1195 - 1204, The
on_next closure can push the same next_cursor repeatedly if clicked before data
updates; modify on_next (the closure using next_cursor, page_index,
set_page_cursors, set_page_index) to first read the current idx and cursors
inside the set_page_cursors.update closure and only truncate/push when the
cursor at position idx+1 is not already equal to Some(nc); if the existing entry
equals Some(nc) skip pushing (but still set_page_index to idx+1), otherwise
perform the truncate + push and then set_page_index; this prevents
double-advancing/appending duplicates when next_cursor is stale.

Wire up cursor-based pagination and server-side filtering for the Jobs
tab in the workspace dashboard.

Backend:
- common/db/jobs::list now accepts a JobFilters struct (status, trigger,
  endpoint substring, endpoint_type), built via a pure, unit-tested query
  builder so the cursor + filter placeholder bookkeeping is verifiable.
- api jobs::list parses a JobListFilters query struct alongside the
  existing pagination params, validating enum-like values up front so a
  typo returns 400 rather than silently empty results.
- smithy ListJobsInput gains the endpoint_type query param (endpoint,
  trigger_type, status were already declared).

Dashboard:
- list_jobs sends cursor/limit/status/trigger_type/endpoint/endpoint_type
  via JobListQueryParams and returns the full PaginatedResponse (the
  cursor was previously discarded, capping the view at the first 50 jobs).
- JobsTab gains a filter bar (status, trigger, endpoint type, endpoint
  search + clear) and pagination controls (Prev/Next with a cursor stack
  for back-navigation, plus a per-page size selector). Changing a filter
  or page size resets pagination.
@knutties knutties force-pushed the claude/gifted-bardeen-n11mlb branch from 2ef502a to 9b0a56c Compare June 10, 2026 03:10
@knutties knutties changed the title Add filtering and pagination to jobs list feat(dashboard): add pagination and filters to the jobs page Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants