Skip to content

Commit d2b8db5

Browse files
committed
Add Query tab pagination and format large numbers in pagination displays
- Add pagination to Explore > Query tab to prevent browser crashes from large result sets - Results paginated with 25 rows per page - User-specified LIMIT/OFFSET clauses respected when paginating - Count queries correctly handle user's limit/offset via subquery - Empty results show "No results found" message - Add new backend endpoints: /api/query/fetch_override and /api/query/count_raw - Format large numbers with thousand separators across all pagination displays - Applied to: Explore tabs, Alerts, Scan History, Search Results, Roots, Schedules
1 parent b12d7b0 commit d2b8db5

File tree

11 files changed

+307
-83
lines changed

11 files changed

+307
-83
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- **Query tab pagination**: Added pagination support to the Explore > Query tab to prevent browser crashes from large result sets
12+
- Results are now paginated with 25 rows per page
13+
- User-specified LIMIT and OFFSET clauses are respected when paginating through results
14+
- Count queries correctly account for user's limit/offset to show accurate total counts
15+
- Empty result sets display "No results found" message
16+
1017
### Changed
1118
- **Explore page**: Tab state is now preserved when switching between tabs (filters, sort, column visibility/order, pagination)
1219
- Added reset button to restore columns to default settings
20+
- **Number formatting**: Large numbers in pagination displays now show thousand separators (e.g., "1,205,980" instead of "1205980")
21+
- Applied consistently across all paginated views: Explore tabs, Alerts, Scan History, Search Results, Roots, and Schedules
1322

1423
### Fixed
1524
- **Item detail sheet**: Fixed calendar widget appearing over modification date entries in History card

frontend/src/pages/alerts/AlertsPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ export function AlertsPage() {
460460
{/* Pagination */}
461461
<div className="flex items-center justify-between">
462462
<div className="text-sm text-muted-foreground whitespace-nowrap">
463-
Showing {totalCount > 0 ? start : 0} - {end} of {totalCount} alerts
463+
Showing {(totalCount > 0 ? start : 0).toLocaleString()} - {end.toLocaleString()} of {totalCount.toLocaleString()} alerts
464464
</div>
465465
<div className="flex gap-2">
466466
<Button

frontend/src/pages/browse/SearchResultsList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export function SearchResultsList({ rootId, searchQuery, showTombstones }: Searc
159159
{/* Results count and pagination info */}
160160
<div className="flex items-center justify-between px-4 py-2 text-sm text-muted-foreground border-b">
161161
<span>
162-
{totalCount} result{totalCount !== 1 ? 's' : ''} found
162+
{totalCount.toLocaleString()} result{totalCount !== 1 ? 's' : ''} found
163163
</span>
164164
{totalPages > 1 && (
165165
<span>

frontend/src/pages/explore/DataExplorerView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -414,8 +414,8 @@ export function DataExplorerView({ domain }: DataExplorerViewProps) {
414414
{/* Pagination */}
415415
<div className="flex items-center justify-between p-4 border-t border-border">
416416
<div className="text-sm text-muted-foreground">
417-
Showing {(currentPage - 1) * ITEMS_PER_PAGE + 1} to{' '}
418-
{Math.min(currentPage * ITEMS_PER_PAGE, totalCount)} of {totalCount}
417+
Showing {((currentPage - 1) * ITEMS_PER_PAGE + 1).toLocaleString()} to{' '}
418+
{Math.min(currentPage * ITEMS_PER_PAGE, totalCount).toLocaleString()} of {totalCount.toLocaleString()}
419419
</div>
420420
<div className="flex items-center gap-2">
421421
<button

frontend/src/pages/explore/QueryView.tsx

Lines changed: 120 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface QueryResult {
1717
alignments: Alignment[]
1818
}
1919

20+
const ITEMS_PER_PAGE = 25
21+
2022
const SAMPLE_QUERIES = [
2123
{
2224
label: 'Basic',
@@ -58,6 +60,30 @@ export function QueryView() {
5860
const [loading, setLoading] = useState(false)
5961
const [error, setError] = useState<string | null>(null)
6062
const [result, setResult] = useState<QueryResult | null>(null)
63+
const [totalCount, setTotalCount] = useState(0)
64+
const [currentPage, setCurrentPage] = useState(1)
65+
const [executedQuery, setExecutedQuery] = useState('')
66+
67+
const fetchPage = async (queryStr: string, page: number) => {
68+
const response = await fetch('/api/query/fetch_override', {
69+
method: 'POST',
70+
headers: {
71+
'Content-Type': 'application/json',
72+
},
73+
body: JSON.stringify({
74+
query: queryStr,
75+
limit_override: ITEMS_PER_PAGE,
76+
offset_add: (page - 1) * ITEMS_PER_PAGE,
77+
}),
78+
})
79+
80+
if (!response.ok) {
81+
const errorText = await response.text()
82+
throw new Error(errorText || 'Query execution failed')
83+
}
84+
85+
return response.json()
86+
}
6187

6288
const handleExecuteQuery = async () => {
6389
if (!query.trim()) {
@@ -69,24 +95,49 @@ export function QueryView() {
6995
setError(null)
7096

7197
try {
72-
const response = await fetch('/api/query/execute', {
98+
// Get count first
99+
const countResponse = await fetch('/api/query/count_raw', {
73100
method: 'POST',
74101
headers: {
75102
'Content-Type': 'application/json',
76103
},
77104
body: JSON.stringify({ query: query.trim() }),
78105
})
79106

80-
if (!response.ok) {
81-
const errorText = await response.text()
82-
throw new Error(errorText || 'Query execution failed')
107+
if (!countResponse.ok) {
108+
const errorText = await countResponse.text()
109+
throw new Error(errorText || 'Count query failed')
83110
}
84111

85-
const data = await response.json()
112+
const countData = await countResponse.json()
113+
setTotalCount(countData.count)
114+
setExecutedQuery(query.trim())
115+
setCurrentPage(1)
116+
117+
// Fetch first page
118+
const data = await fetchPage(query.trim(), 1)
86119
setResult(data)
87120
} catch (err) {
88121
setError(err instanceof Error ? err.message : 'Failed to execute query')
89122
setResult(null)
123+
setTotalCount(0)
124+
} finally {
125+
setLoading(false)
126+
}
127+
}
128+
129+
const handlePageChange = async (newPage: number) => {
130+
if (!executedQuery) return
131+
132+
setLoading(true)
133+
setError(null)
134+
135+
try {
136+
const data = await fetchPage(executedQuery, newPage)
137+
setResult(data)
138+
setCurrentPage(newPage)
139+
} catch (err) {
140+
setError(err instanceof Error ? err.message : 'Failed to fetch page')
90141
} finally {
91142
setLoading(false)
92143
}
@@ -96,6 +147,8 @@ export function QueryView() {
96147
setQuery(sampleQuery)
97148
setError(null)
98149
setResult(null)
150+
setTotalCount(0)
151+
setCurrentPage(1)
99152
}
100153

101154
return (
@@ -178,35 +231,69 @@ export function QueryView() {
178231
{result && (
179232
<Card>
180233
<CardContent className="p-0">
181-
<div className="overflow-auto">
182-
<table className="w-full border-collapse">
183-
<thead className="bg-muted sticky top-0">
184-
<tr>
185-
{result.columns.map((col, index) => (
186-
<th
187-
key={index}
188-
className="border border-border px-4 py-2 font-medium text-center uppercase text-xs tracking-wide"
189-
>
190-
{col}
191-
</th>
192-
))}
193-
</tr>
194-
</thead>
195-
<tbody>
196-
{result.rows.map((row, rowIndex) => (
197-
<tr key={rowIndex} className="hover:bg-muted/50">
198-
{row.map((cell, cellIndex) => (
199-
<td
200-
key={cellIndex}
201-
className={`border border-border px-4 py-2 ${getAlignmentClass(result.alignments[cellIndex])}`}
202-
>
203-
{cell}
204-
</td>
234+
<div className="flex flex-col h-full">
235+
{result.rows.length > 0 ? (
236+
<div className="overflow-auto">
237+
<table className="w-full border-collapse">
238+
<thead className="bg-muted sticky top-0">
239+
<tr>
240+
{result.columns.map((col, index) => (
241+
<th
242+
key={index}
243+
className="border border-border px-4 py-2 font-medium text-center uppercase text-xs tracking-wide"
244+
>
245+
{col}
246+
</th>
247+
))}
248+
</tr>
249+
</thead>
250+
<tbody>
251+
{result.rows.map((row, rowIndex) => (
252+
<tr key={rowIndex} className="hover:bg-muted/50">
253+
{row.map((cell, cellIndex) => (
254+
<td
255+
key={cellIndex}
256+
className={`border border-border px-4 py-2 ${getAlignmentClass(result.alignments[cellIndex])}`}
257+
>
258+
{cell}
259+
</td>
260+
))}
261+
</tr>
205262
))}
206-
</tr>
207-
))}
208-
</tbody>
209-
</table>
263+
</tbody>
264+
</table>
265+
</div>
266+
) : (
267+
<div className="p-8 text-center text-muted-foreground">
268+
No results found
269+
</div>
270+
)}
271+
272+
{/* Pagination */}
273+
{totalCount > 0 && (
274+
<div className="flex items-center justify-between p-4 border-t border-border">
275+
<div className="text-sm text-muted-foreground">
276+
Showing {((currentPage - 1) * ITEMS_PER_PAGE + 1).toLocaleString()} to{' '}
277+
{Math.min(currentPage * ITEMS_PER_PAGE, totalCount).toLocaleString()} of {totalCount.toLocaleString()}
278+
</div>
279+
<div className="flex items-center gap-2">
280+
<button
281+
onClick={() => currentPage > 1 && handlePageChange(currentPage - 1)}
282+
disabled={currentPage === 1 || loading}
283+
className="px-3 py-1.5 border border-border rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent hover:text-accent-foreground transition-colors"
284+
>
285+
Previous
286+
</button>
287+
<button
288+
onClick={() => currentPage * ITEMS_PER_PAGE < totalCount && handlePageChange(currentPage + 1)}
289+
disabled={currentPage * ITEMS_PER_PAGE >= totalCount || loading}
290+
className="px-3 py-1.5 border border-border rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-accent hover:text-accent-foreground transition-colors"
291+
>
292+
Next
293+
</button>
294+
</div>
295+
</div>
296+
)}
210297
</div>
211298
</CardContent>
212299
</Card>

frontend/src/pages/monitor/RootsTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ export function RootsTable({ onAddRoot, onScheduleCreated, externalReloadTrigger
341341
{totalCount > ITEMS_PER_PAGE && (
342342
<div className="flex items-center justify-between pt-4">
343343
<div className="text-sm text-muted-foreground">
344-
Showing {startIndex + 1} - {endIndex} of {totalCount} roots
344+
Showing {(startIndex + 1).toLocaleString()} - {endIndex.toLocaleString()} of {totalCount.toLocaleString()} roots
345345
</div>
346346
<div className="flex items-center gap-2">
347347
<Button

frontend/src/pages/monitor/SchedulesTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,7 @@ export const SchedulesTable = forwardRef<SchedulesTableRef, SchedulesTableProps>
307307
{totalCount > ITEMS_PER_PAGE && (
308308
<div className="flex items-center justify-between pt-4">
309309
<div className="text-sm text-muted-foreground">
310-
Showing {startIndex + 1} - {endIndex} of {totalCount} schedules
310+
Showing {(startIndex + 1).toLocaleString()} - {endIndex.toLocaleString()} of {totalCount.toLocaleString()} schedules
311311
</div>
312312
<div className="flex items-center gap-2">
313313
<Button

frontend/src/pages/scans/ScanHistoryTable.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ export function ScanHistoryTable() {
377377
{totalCount > ITEMS_PER_PAGE && (
378378
<div className="flex items-center justify-between">
379379
<div className="text-sm text-muted-foreground whitespace-nowrap">
380-
Showing {totalCount > 0 ? start : 0} - {end} of {totalCount} scans
380+
Showing {(totalCount > 0 ? start : 0).toLocaleString()} - {end.toLocaleString()} of {totalCount.toLocaleString()} scans
381381
</div>
382382
<div className="flex gap-2">
383383
<Button

src/api/routes/query.rs

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,18 @@ pub struct MetadataResponse {
6767
pub columns: Vec<ColumnMetadata>,
6868
}
6969

70+
/// Request structure for raw query count (for Query tab)
71+
#[derive(Debug, Deserialize)]
72+
pub struct RawCountRequest {
73+
pub query: String,
74+
}
75+
7076
/// Request structure for raw query execution (for Query tab)
7177
#[derive(Debug, Deserialize)]
7278
pub struct RawQueryRequest {
7379
pub query: String,
80+
pub limit_override: i64,
81+
pub offset_add: i64,
7482
}
7583

7684
/// Response structure for raw query results
@@ -215,19 +223,20 @@ pub async fn fetch_query(
215223
}
216224
}
217225

218-
/// POST /api/query/execute
219-
/// Accepts raw FsPulse query text and executes it (for Query tab)
220-
pub async fn execute_raw_query(
226+
/// POST /api/query/fetch_override
227+
/// Accepts raw FsPulse query text and executes it with limit/offset overrides (for Query tab)
228+
pub async fn fetch_override_query(
221229
Json(req): Json<RawQueryRequest>,
222230
) -> Result<Json<RawQueryResponse>, (StatusCode, String)> {
223231
let db = Database::new().map_err(|e| {
224232
error!("Database connection failed: {}", e);
225233
(StatusCode::INTERNAL_SERVER_ERROR, "Database connection failed".to_string())
226234
})?;
227235

228-
debug!("Executing raw query: {}", req.query);
236+
debug!("Executing raw query: {} (limit_override: {}, offset_add: {})",
237+
req.query, req.limit_override, req.offset_add);
229238

230-
match QueryProcessor::execute_query(&db, &req.query) {
239+
match QueryProcessor::execute_query_override(&db, &req.query, req.limit_override, req.offset_add) {
231240
Ok((rows, column_headers, alignments)) => {
232241
Ok(Json(RawQueryResponse {
233242
columns: column_headers,
@@ -243,6 +252,28 @@ pub async fn execute_raw_query(
243252
}
244253
}
245254

255+
/// POST /api/query/count_raw
256+
/// Returns count for a raw FsPulse query (for Query tab)
257+
pub async fn count_raw_query(
258+
Json(req): Json<RawCountRequest>,
259+
) -> Result<Json<CountResponse>, (StatusCode, String)> {
260+
let db = Database::new().map_err(|e| {
261+
error!("Database connection failed: {}", e);
262+
(StatusCode::INTERNAL_SERVER_ERROR, "Database connection failed".to_string())
263+
})?;
264+
265+
debug!("Counting raw query: {}", req.query);
266+
267+
match QueryProcessor::execute_query_count(&db, &req.query) {
268+
Ok(count) => Ok(Json(CountResponse { count })),
269+
Err(e) => {
270+
let error_msg = e.to_string();
271+
error!("Raw count query failed: {}", error_msg);
272+
Err((StatusCode::BAD_REQUEST, error_msg))
273+
}
274+
}
275+
}
276+
246277
/// POST /api/validate-filter
247278
/// Validates a filter value for a given column in a domain
248279
pub async fn validate_filter(

0 commit comments

Comments
 (0)