fix(web): unify dashboard pagination + bump fleet-health page size

Dashboard: removed the standalone "Load 100 more sessions..." button. The
session table now has a single Prev/Next surface that transparently fetches
the next server batch when the user pages past locally-loaded sessions —
the existing auto-load effect in page.tsx already drives loadMore() once
currentPage * sessionsPerPage exceeds sessions.length, but handleNextPage
was clamping to totalPages so currentPage could never overshoot, leaving
"Next" disabled until the user clicked the separate Load-More button.

  - useDashboardFilters now takes hasMore + loadingMore; handleNextPage
    advances past totalPages when hasMore && !loadingMore. A snap-back
    effect resets currentPage to totalPages once a load finishes that
    didn't grow totalPages (filter case: new batch yielded zero matches).
  - SessionTable renders the Prev/Next controls whenever there's somewhere
    to navigate (totalPages > 1 || hasMore), shows "Page X of Y+" while
    more is on the server, disables Next only at the true end, and shows
    an inline spinner inside Next during loadingMore. The empty tbody
    distinguishes "loading sessions..." from "no results".

Fleet Health: bumped FLEET_PAGE_SIZE from 200 to 1000 (backend MaxPageSize
is 1000). A 90-day window on a busy install now drains in ~1/5 the
round-trips, eliminating the 5x roundtrip-amplification users observed
on initial load.

Type-check clean, vitest 249 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-06 21:15:37 +02:00
parent 70382e991b
commit 9b4100f7d6
4 changed files with 53 additions and 57 deletions
@@ -71,7 +71,6 @@ interface SessionTableProps {
onSessionsPerPageChange: (value: number) => void;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
onLoadAll: () => void;
adminMode: boolean;
globalAdminMode: boolean;
@@ -109,7 +108,6 @@ export function SessionTable({
onSessionsPerPageChange,
hasMore,
loadingMore,
onLoadMore,
onLoadAll,
adminMode,
globalAdminMode,
@@ -722,7 +720,17 @@ export function SessionTable({
{paginatedSessions.length === 0 ? (
<tr>
<td colSpan={colSpan} className="px-6 py-8 text-center text-gray-500">
No sessions found matching your search.
{loadingMore ? (
<span className="inline-flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading sessions...
</span>
) : (
"No sessions found matching your search."
)}
</td>
</tr>
) : (
@@ -752,75 +760,39 @@ export function SessionTable({
</table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
{/* Pagination Controls — single Prev/Next surface that transparently fetches
the next server batch when the user pages past locally-loaded sessions.
Renders whenever there's somewhere to navigate (more local pages OR more
on the server). */}
{(totalPages > 1 || hasMore) && (
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-700">
Page {currentPage} of {totalPages} ({sortedSessions.length}{hasMore ? '+' : ''} total sessions)
Page {currentPage} of {totalPages}{hasMore ? '+' : ''} ({sortedSessions.length}{hasMore ? '+' : ''} total sessions)
</div>
<div className="flex gap-2">
<button
onClick={onPreviousPage}
disabled={currentPage === 1}
disabled={currentPage === 1 || loadingMore}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
← Previous
</button>
<button
onClick={onNextPage}
disabled={currentPage === totalPages}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={(currentPage >= totalPages && !hasMore) || loadingMore}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
{loadingMore && (
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
Next →
</button>
</div>
</div>
)}
{/* Load More Button */}
{hasMore && (
<div className="mt-4 flex justify-center">
{searchQuery.trim() ? (
// Search active: offer to load all remaining sessions
<button
onClick={onLoadAll}
disabled={loadingMore}
className="px-6 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingMore ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Searching... ({sessions.length} sessions loaded)
</span>
) : (
'Search all sessions...'
)}
</button>
) : (
// No search: standard load-more (100 at a time)
<button
onClick={onLoadMore}
disabled={loadingMore}
className="px-6 py-2 bg-gray-100 text-gray-700 border border-gray-300 rounded-md hover:bg-gray-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingMore ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</span>
) : (
'Load 100 more sessions...'
)}
</button>
)}
</div>
)}
</div>
);
}
@@ -13,6 +13,8 @@ interface UseDashboardFiltersParams {
tenantId: string | null | undefined;
globalAdminMode: boolean;
tenantIdFilter: string;
hasMore: boolean;
loadingMore: boolean;
}
interface DashboardStats {
@@ -57,6 +59,8 @@ export function useDashboardFilters({
tenantId,
globalAdminMode,
tenantIdFilter,
hasMore,
loadingMore,
}: UseDashboardFiltersParams): UseDashboardFiltersReturn {
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
@@ -213,7 +217,24 @@ export function useDashboardFilters({
}, [sortedSessions, currentPage, sessionsPerPage]);
const handlePreviousPage = () => setCurrentPage((prev) => Math.max(1, prev - 1));
const handleNextPage = () => setCurrentPage((prev) => Math.min(totalPages, prev + 1));
// Allow advancing past totalPages when the server has more pages; the auto-load
// effect in page.tsx will fetch the next batch and totalPages catches up. Without
// this overshoot the user could never trigger a load while staying inside a single
// Prev/Next control — they would have to click a separate "Load more" button.
const handleNextPage = () => setCurrentPage((prev) => {
if (prev < totalPages) return prev + 1;
if (hasMore && !loadingMore) return prev + 1;
return prev;
});
// Snap currentPage back to the last valid page once a load finishes that did NOT
// grow totalPages (e.g. an active filter narrowed the new batch to zero matches).
// Without this the user would be stuck on an empty paginated slice with no way
// to recover except clicking Prev.
useEffect(() => {
if (loadingMore) return;
if (totalPages > 0 && currentPage > totalPages) setCurrentPage(totalPages);
}, [currentPage, totalPages, loadingMore]);
return {
searchQuery, setSearchQuery,
@@ -66,6 +66,8 @@ export default function Home() {
tenantId,
globalAdminMode,
tenantIdFilter,
hasMore,
loadingMore,
});
// Redirect regular users (non-admin, non-operator) to progress portal – they must never see the session list
@@ -275,7 +277,6 @@ export default function Home() {
onSessionsPerPageChange={handleSessionsPerPageChange}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMore}
onLoadAll={loadAll}
adminMode={adminMode}
globalAdminMode={globalAdminMode}
@@ -14,7 +14,9 @@ import FleetStatCard from "./components/FleetStatCard";
import { Session } from "@/types";
import { useAdminMode } from "@/hooks/useAdminMode";
const FLEET_PAGE_SIZE = 200;
// Backend caps pageSize at 1000 — use the maximum so a 90-day window on a busy
// install (10k+ sessions) drains in a handful of round-trips instead of 50+.
const FLEET_PAGE_SIZE = 1000;
interface AppMetric {
appName: string;