Roster keyset pager, search, health UI
Part of epic Task 9387 "Operator credentials roster UX fixes at scale". PRD = Task 9385 and architecture = Task 9386, both settled. IDs AC-x.y and D0–D9 are the PRD's; A1–A22 are the architecture's. Everything this child needs is restated here. Base: `develop` already contains Task 9378: sort Created/Name/Type/Org, the "1–25 of N" `RosterSummary` in `apps/web/src/pages/credentials/Credentials.tsx`, and the once-per-row tenant. Build on that shape and never re-implement it.
Original work item (verbatim):
> Follow-up to Task 9378 (operator credentials page UX). That task shipped server-side sort (Created/Name/Type/Org), a "1–25 of N" count, and shows the tenant once per row. These items remain for an operator working across all tenants' credentials, up to millions of rows:
> 1. **Secrets filter scales.** `?secrets=` decrypts every row in scope on each request (`apps/api/app/routes/credentials/reads.py::_secrets_filter`) and puts every id into an `IN` list (Postgres allows at most 65535). Store a "has stored secret" boolean when the credential is written, so the filter becomes an indexed predicate.
> 2. **Paging without full counts.** Replace exact `COUNT(*)` + OFFSET with keyset (cursor) paging and a capped/estimated total ("10,000+"). Add indexes behind each sort order (`lower(name)`, `type`, org name).
> 3. **Search that finds things at scale.** Add a pg_trgm index on name/description. For operators, also match org name/slug and owner email.
> 4. **Owner picker as an async typeahead.** A native select cannot hold thousands of owners across tenants. Make it multi-select.
> 5. **Tree mode beyond 200 rows.** Build tree mode from a server-side folder listing (folders with counts, children loaded on open) instead of one 200-row page.
> 6. **Bulk actions.** Let operators select rows (per page, plus "all N matching") and then delete, change org or move them to a folder.
> 7. **Health columns and filters.** Add last used, last test result, and expired/expiring/never used, each also available as a filter.
>
> Out of scope: the three items Task 9378 shipped; credential editor pages; runner credential resolution.
>
> Done means:
> - Each item above is shipped or explicitly declined with a reason.
> - With 1M credentials in a dev DB, the roster's first page with any filter or sort loads in under 1s (EXPLAIN shows an index scan).
> - An operator can bulk-delete 500 matching credentials in one action.
Shared rules:
- D1: "operator" means a principal on the operator plane holding cross-tenant `credentials:view`.
- No scope widens.
- UI copy follows `docs/standards/ui-copy-standards.md`; layout follows `docs/standards/ui-style-guide.md`.
## This child (architecture C3): roster page UI for paging, search and health, plus reader docs
Covers the web side of items 2, 3 and 7. It consumes the `GET /api/credentials` keyset contract from the "Keyset roster query builder and API" child.
That contract:
- Request: `paging=keyset`, `cursor?`, repeatable `owner_id`, `health?=expired|expiring|never_used|test_failed`.
- Response: `items`, `total` (at most 10,000), `total_capped`, `next_cursor` (null on the last page), `page`, `per`.
- A stale cursor → 422 "This page link is out of date. Reload the list."
- `CredentialOut` carries `last_used_at`, `last_tested_at`, `last_test_ok`, `expires_at`, `has_stored_secret`.
Approach (settled decisions):
- **Paging requests.** `listCredentials` (`apps/web/src/pages/credentials/api.ts`) sends `paging=keyset`, plus `cursor` after the first page.
- **Prev/Next.** A client-held cursor stack (A6: the API has no `prev_cursor`). A reload lands on page 1, which is acceptable because there is no "jump to page N".
- **Footer** through the existing `RosterSummary`:
- "1–25 of 137"; "26–50 of 10,000+" when `total_capped`; Prev/Next buttons.
- Zero rows shows the existing empty state, never "0–0".
- Otherwise keep 9378's count format.
- **Reset.** Any filter or sort change resets to page 1 and clears the stack. A 422 on a stale cursor also resets to page 1, without showing a raw error.
- **Search placeholder** per caller. Operator: "Search name, description, org, owner email". Others: "Search name or description".
- **Health filter** in `CredentialFilterStrip.tsx` with the values Expired, Expiring (7 days), Never used, Last test failed. It persists like the other filters (`apps/web/src/lib/credentialFilterStorage.ts`).
- **Flat-mode cells.**
- **Last used:** relative time; "Never" when NULL.
- **Last test:** OK or Failed, with relative time; "—" when never tested.
- **Expiry badge:** "Expired", "Expires in Nd" (7 days or less), or none.
- At phone width the three cells collapse into the row's secondary line. They are not sortable.
- **Reader docs.** Update `apps/docs/content/console-reference/directory/credentials.md` per `apps/docs/README.md`: paging with "10,000+", what search matches per caller, health columns and the Health filter.
Credentials & reach (architecture §9, verbatim): "Unit/API tests for every AC | Nothing beyond the worktree (`./dc test`). | verified — same." Also: "Each child also runs its own e2e at phone width in the slot (verified: worktree only)."
Out of scope:
- MUST NOT re-implement or change Task 9378's sort control, "1–25 of N" count format (other than the capped variant) or once-per-row tenant display.
- MUST NOT change credential editor pages: `apps/web/src/pages/credentials/CredentialEditPage.tsx`, `AccountEditPage.tsx`, `credentialForm.tsx`, nor the Accounts page (`Accounts.tsx`).
- MUST NOT change runner credential resolution: what `routes/agent/task_secrets.py` and the credential-get path return, who may call them, or path resolution (`runner_path`). Only the additive best-effort `last_used_at` stamp (D6) is allowed.
- MUST NOT change the sealing scheme (`app/credential_crypto.py`) or put config into SQL predicates.
- MUST NOT make health columns sortable, add "jump to page N", or add bulk verbs beyond delete / change org / move to folder.
- MUST NOT change `CredentialSelect` or other `page`-based pickers' behavior.
- Do NOT touch the owner picker (typeahead child), tree mode (tree child), or row selection and the bulk bar (bulk child). Change no API.
Done means (PRD criteria verbatim with their oracles; tightenings marked):
- **2.1 (UI half)** Paging Next through a result and Prev back returns the same rows in the same order with no duplicates or gaps. → verify: a web test clicks Next ×3, then Prev ×3, and asserts identical rows on each page.
- **2.2** UI footer reads "1–25 of 137", "26–50 of 10,000+"; zero rows reads the existing empty state, not "0–0". → verify: web test renders all three states.
- **2.4** Changing any filter or sort returns to the first page. → verify: web test.
- **3.4** Search box placeholder names what it matches for that caller (operator: "Search name, description, org, owner email"; others: "Search name or description"), within `docs/standards/ui-copy-standards.md` brevity. → verify: web test per caller.
- **7.1** Flat rows show **Last used** (relative time, "Never" when NULL), **Last test** (OK / Failed with relative time, "—" when never tested), and an **Expiry** badge: "Expired" / "Expires in Nd" (≤7 days) / none. → verify: web test for each state.
- **7.2 (UI half)** Filter strip offers a Health filter with: Expired, Expiring (7 days), Never used, Last test failed — applied to page and total alike. → verify: a web test selects each value and asserts the request carries `health=`.
- **7.6** Health columns stay readable at the narrowest supported width (collapse into the row's secondary line on phone). → verify: Playwright e2e at the narrowest width listed in `docs/standards/product-testing-requirements.md`.
- Tightened: no horizontal page scroll; no clipped or overlapping text in the row; the footer stays on one readable line.
- Attach the screenshot to the PR. A layout that fits technically but is cramped or illegible fails.
- The reader doc page describes paging, search and health in present tense, and the docs route-coverage check passes.
- `./dc check quick` is green.
Outcome
Credentials roster: keyset Prev/Next, "10,000+" count, per-caller search placeholder, Health filter, Last used/Last test/expiry cells, reader doc. Quick gate and 23 e2e green; visual check at 448/1024/1440 fine. PR #6756 queued after rebase onto bulk (#6754). Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)