Loading states
The OrbitalReg frontend follows a small, deliberate set of loading patterns. The intent is that an evaluating engineer can predict — by looking at any one page — what the rest of the app will do while data is in flight, while a long-running job is grinding, or while a list is being walked one page at a time.
This page is the audit reference for that surface. It documents which primitives ship under frontend/src/components/ui/, which list endpoints speak the cursor-paginated envelope, and when to reach for refreshInterval polling instead of a streaming protocol.
1. Loading-state primitives
Four shared components live under frontend/src/components/ui/. They all declare the appropriate ARIA role (status for indeterminate visuals, progressbar for determinate progress) and respect prefers-reduced-motion via overrides in frontend/src/styles.css.
| Component | When to use it |
|---|---|
Skeleton | Replace empty rows / cards while the SWR cache is cold so the layout stays stable on first render. |
Spinner | Inline 16 px ring inside a button, an empty-state cell, or a "Show more" affordance during a refetch. |
ProgressBar | Determinate progress for backend-tracked jobs (pct, tone, optional eta) or indeterminate-stripe mode for the pre-claim window. |
LoadingBar | Top-of-page indeterminate strip for whole-page navigation transitions, with a 250 ms fade-out. |
The single keyframe source — @keyframes orbital-shimmer plus three siblings (orbital-spin, orbital-progress-indeterminate, orbital-loadingbar) — lives in frontend/src/styles.css so inline-style consumers and utility-class consumers share one animation table. Adding a fifth primitive means adding one more keyframe there, not duplicating an animation across components.
Why no naked Loading… strings
The bot-validation guard in BOT-INSTRUCTIONS.md is a single ripgrep:
rg 'isLoading.*\?.*Loading' frontend/src/pages/It must match zero lines. Every spot that previously rendered the literal Loading… text now either renders a Skeleton row scaffold (list pages, card grids), an inline <Spinner size="xs" /> (button captions, "Show more" affordances), or — for empty actions cells in detection / security pages — a <Spinner /> paired with a one-word status string like Scanning. The regex is the contract.
2. Cursor-paginated list endpoints
The pagination shape is opt-in. A request with neither ?paginated=1 nor a non-empty ?cursor= returns the legacy unwrapped JSON array, so the Terraform provider, the orbital CLI, and any scripted caller already in the field stay unchanged. A request that opts in gets a small envelope:
{
"items": [/* row type, same as the legacy array */],
"next_cursor": "eyJ1cGRhdGVkX2F0Ijoi…",
"has_more": true
}next_cursor is omitted when the page is the last one (has_more: false).
Cursor format
The token is base64-url(<RFC3339Nano>|<id>) — the keyset tuple of the last row on the page. Both endpoints listed below order by (created_at DESC, id DESC); the cursor encodes that key. A malformed cursor — bad base64, missing pipe, unparseable timestamp — returns 400 {"error": "invalid_cursor"} from every handler.
The encoder + decoder live in api/internal/handlers/search.go (encodeCursor, decodeCursor) and the over-fetch + trim helper lives in api/internal/handlers/pagination.go (pageEnvelope, pageParams, parsePageParams, trimAndCursor). Both are shared across handlers so a single client-side helper rides every list.
Endpoints
| Endpoint | Default limit | Max limit | Sort key |
|---|---|---|---|
GET /api/v1/projects | 50 | 200 | (created_at DESC, id DESC) |
GET /api/v1/tokens | 50 | 200 | (created_at DESC, id DESC) |
Repos, AdminAudit, and Search remain on their existing list shapes — Search already exposes an X-Next-Cursor response header which predates this envelope. Rolling those onto the new contract is straight-line follow-up; the cursor format already matches.
Frontend usage
frontend/src/api/client.ts ships a generic usePaginated<T> helper that wraps useSWRInfinite. The typed wrappers are useProjectsPaginated and useTokensPaginated. Each page renders a "Show more" button that hides itself when has_more is false and swaps to a <Spinner size="xs" /> while the next page is in flight:
const { items, hasMore, isValidating, loadMore } = useProjectsPaginated(50);
return (
<>
{/* render items */}
{hasMore && (
<button onClick={loadMore} disabled={isValidating}>
{isValidating ? <Spinner size="xs" /> : 'Show more'}
</button>
)}
</>
);3. Long-running operations
For backend-tracked jobs — bulk imports, pull-gate refresh, geo-sync — the page polls SWR while at least one job is live and stops polling once every visible row has terminated. This keeps an admin tab left open overnight from hammering the API just to redraw a list of finished rows.
The rule is wired through SWR's refreshInterval callback rather than a constant interval. Example from frontend/src/api/bulkImports.ts:
function listRefreshInterval(jobs?: BulkImportJob[]): number {
if (!jobs) return 5_000;
return jobs.some((j) => j.status === 'pending' || j.status === 'running')
? 5_000
: 0;
}When refreshInterval returns 0, SWR stops polling. The next mutation on the page (a new upload, a manual refresh) re-arms it.
Determinate progress
<ProgressBar percent={pct} tone={...} eta={...} /> renders a determinate fill. The tone is status-driven:
| Status | Tone | Notes |
|---|---|---|
running | accent | Default in-flight tone. |
completed | success | Locked at 100 %. |
failed | danger | Locked at last observed percent so the bar tells the same story. |
cancelled | warning | Locked at last observed percent. |
pending | indeterminate stripe | rows_total = 0 — dispatcher hasn't claimed a row yet. |
The ETA line is derived from observed throughput (rows-processed-per- second × rows-remaining, rounded to s / min / h) with a rate-floor that suppresses the line when the dispatcher is still spinning up. The pre-claim window flips to indeterminate-stripe so the operator sees the dispatcher is alive instead of staring at a stuck 0 % bar.
Pages that drive a ProgressBar today
| Page | Source |
|---|---|
frontend/src/pages/admin/AdminBulkImports.tsx | bulk_imports.status, progress_percent |
Pull-Gate (item 47) and Geo-Sync (item 48) live progress visualisations are straight-line follow-up — their progress models (in-process engine, cursor lag) need a separate backend probe before they can drive a determinate bar.
4. Poll vs. stream — when to reach for which
Today the entire long-operation surface uses SWR polling on a 2–5 s tick that auto-stops when no jobs are live. We have not added Server-Sent Events or WebSockets, and the recommendation for new operator-facing surfaces is to start with poll and only escalate when the use case forces it.
| Choose poll when | Choose stream when |
|---|---|
| Update cadence is bounded (≥ 1 s) and predictable. | Sub-second freshness is part of the UX promise (it almost never is). |
| The set of "live" rows shrinks to zero on its own. | The page must react to events with no upper bound on inter-event delay. |
| Existing SWR cache layer can dedupe requests across tabs. | A new UI must subscribe to events that have no equivalent REST poll. |
| The team already operates Postgres + Redis, not Pub/Sub. | The team is willing to operate an event bus and reconnect logic. |
For OrbitalReg's actual customer-scale long-operation use cases — bulk imports, retention runs, scan queues, geo-sync — the 5 s SWR tick with auto-stop is sufficient. Adding SSE or WebSockets would mean a second wire protocol, a second auth path (cookie auth across SSE has its own gotchas), and a second reconnect strategy, for a UX gain that customers do not request. The decision can be revisited when a use case appears that needs it; until then, poll-with-auto-stop is the house rule.
5. Out of scope
The following are explicitly not part of this surface and live as separate roadmap items:
- Virtual scrolling (
react-windowand similar) for tables with 10k+ visible rows — separate item, will land when a real customer requests it. - Optimistic-UI updates (mutation patterns that paint the new state before the server confirms) — separate item.
- Page-transition animations (Framer Motion etc.) — polish work that follows once the loading surface is fully consistent.
- Server-Sent Events / WebSockets for real-time updates — see section 4. Today's SWR poll-with-auto-stop covers the long-operation use cases at realistic customer scale.
6. Where to look in the code
| Concern | Path |
|---|---|
| Skeleton / Spinner / ProgressBar / LoadingBar primitives | frontend/src/components/ui/ |
| Animation keyframes (single source) | frontend/src/styles.css |
usePaginated<T> SWR-Infinite wrapper | frontend/src/api/client.ts |
| Cursor encode / decode | api/internal/handlers/search.go |
pageEnvelope + parsePageParams + trimAndCursor | api/internal/handlers/pagination.go |
Long-operation refreshInterval callback example | frontend/src/api/bulkImports.ts |
ProgressBar consumer with status-driven tone | frontend/src/pages/admin/AdminBulkImports.tsx |
| Cursor-roundtrip integration test | tests/integration/pagination_cursor_test.go |