Skip to content

ui: page and filter the sandbox list on the server, and read spaced counts from the API - #1388

Merged
ac-pplx merged 16 commits into
mainfrom
ac/admin-ui-sandbox-list-server
Nov 3, 2026
Merged

ac-pplx merged 16 commits into
mainfrom
ac/admin-ui-sandbox-list-server

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

On a server that pages, the console's sandbox list now reads pages from the server and filters on the server, instead of sampling the newest rows and filtering in the browser. The overview and the templates page now read spaced-version counts from the API instead of tallying a sample. On a server without these routes, the console behaves as it does today.

Sandbox list: paging (ui/src/app/dashboard/sandboxes/page.tsx, ui/src/lib/sandbox-list.ts)

  • The list reads GET /admin/api/v1/sandboxes with cursor, which is empty for the first page, and shows {sandboxes, next_cursor} one page at a time.
  • Previous and Next walk the cursors visited so far. The page size can be 25, 50 (the default), 100, 200 or 500. Rows keep the server's order, newest first.
  • The periodic refresh rereads only the visible page from its cursor, on the existing 10 s cadence, and still skips hidden tabs. It no longer rereads the whole list.
  • The "Load newest N" control and the "Showing the newest N sandboxes" note appear only on a sampled list.

Sandbox list: server-side filters

  • The org filter maps to org_id, and the template filter to template. The template filter now takes a single template, because that is what the server filters on.
  • The status control becomes "Stored lifecycle" and sends lifecycle. Its help icon explains that it matches the lifecycle the control plane stores, not the State column, which shows what the node reports.
  • The age window maps to created_after. A new "Older than" control maps to created_before. The list keeps the relative choice ("last 1h") in its scope and refresh key, and each request computes its bound from the time it is sent. A tab left open keeps showing the last hour, and a poll does not change the key and send the list back to page 1.
  • Clicking a name in the User column filters by that user (user_id), and a closable tag shows the filter. ?user=<uuid> also works.
  • Search. A full sandbox ID, UUID or pod name is still canonicalized by parseSandboxRef and looked up directly with GET /sandboxes/{id}. The ID column shows the last 8 hex characters of the ID, which a server prefix cannot match. Hex alone, such as that label, and text no name can start with (over 256 bytes, or with a NUL) are matched within the loaded page, ignoring case, anywhere in the ID or name. A notice says "matches IDs and names within this page, ignoring case; paste the full ID to look it up". A name, an sbx_ ID prefix, or a dashed UUID start is sent as prefix, as typed: the server matches names case-sensitively and folds the hex case of its ID range itself, so sbx_AB still lists a sandbox named sbx_AB-worker. The server reads only a lowercase sbx_ as the ID's, and parseSandboxRef also takes SBX_, so an ID prefix behind SBX_ is sent behind sbx_, with the rest as typed. The placeholder and a help icon say how names and ID prefixes are matched. A sampled list matches all free text within its rows, as main does.
  • Changing any filter or the page size restarts paging at the first page, also when the request it sends is unchanged. Each request carries its scope, and any change of scope invalidates the requests in flight. An answer is shown only while its scope is still selected, so a filter chosen and undone before its answer arrived does not show that answer. Next uses the cursor only while the page that returned it is selected, and a failed load clears it.
  • A link's filters are parsed against what the list can apply. A template or user ID the server would refuse, a value no control offers, or an "Older than" bound that leaves no time inside the window is left out, and a notice lists it. A status the paged list cannot send, such as unknown from an older link, does not show as a chip. A separate "Not applied" tag marks it.
  • The template filter offers the admin template list (GET /admin/api/v1/templates), plus the loaded rows' templates and the selected one. A template name typed into its search can be chosen. If the admin list cannot be read, the control says its list is partial.
  • The server has no node filter yet. The node filter narrows only the loaded page, and is labelled "Node (this page)".
  • The first page of the unfiltered list also carries the rows nodes report without a database row (warm-pool slots, observed-local sandboxes). They now have their own section, "Reported by nodes, with no database row", which says that any filter but the node filter leaves them out. A filtered list or a later page says where they are listed. A full ID in the search box shows the row it finds in the main table, whatever its kind, without the section or its note, so a warm-pool slot looked up on a filtered list or a later page is shown.

Sandbox list: node cell. The Node tag links to the node page, /dashboard/nodes/{id}, which lists the sandboxes placed on the node. The tooltip is kept. The node filter's tooltip says what the node page lists, and where the node-reported rows are.

Spaced counts (ui/src/lib/spaced-counts.ts, ui/src/app/dashboard/page.tsx, ui/src/app/dashboard/templates/page.tsx)

  • The overview's sandbox count and the templates page's "spaced in running sandboxes" table use GET /admin/api/v1/sandboxes/spaced-versions. That route counts the population the console tallied: every listed row that is not deleted, by status, effective template and resource type, including the rows nodes report without a database row.
  • With the server count, the note that "the list stops at its newest N sandboxes" is dropped.
  • The overview now says what it counts: "Not deleted, in every organization", or "among the newest 1,000 listed" on the fallback.

On servers without these routes (the rules are in ui/src/lib, with tests)

  • The first answer of a page view (the page opening, or a reload) decides the list shape for that view. A bare array in answer to the first paged request means the server does not page. That server ignores cursor and every filter except org_id. The list then reads its sample, filters in the browser, and keeps its notes.
  • The sample request is limit=<sample size>&cursor=&org_id. An old server ignores cursor and answers the same sample main reads.
  • A later answer in the other shape (a bare array for a page request, or a page for the sample request) means the API changed version while the page was open, as in a rolling deploy. The list does not switch shape in place. It stops reading: no reread, no background refresh, and Refresh, Previous and Next are disabled. Rows that are not the selected scope's (another org, filter or page) are cleared, and a notice says "The API changed version while this page was open. Reload to continue." Its Reload button reloads the page, and the URL keeps the filters.
  • A 400, 404 or 405 from spaced-versions, or a body of another shape (a bare array, for example), brings back the sampled tally. An old server answers 400, because it reads the path segment as a sandbox ID, or that handler's 404. Any other error is shown as an error.
  • ui: manage tenant keys, terminals, snapshots and rollback across orgs, and show node and template state #1387 adds ui/src/lib/admin-fallback.ts. Its isMissingRoute is narrower than this count route's rule, on purpose: it takes a 405, and a 404 only from the API's fallback (no JSON body, or a numeric code), and refuses a 400 and a handler's own 404. On spaced-versions both of those mean the route is missing, because an older server's GET /sandboxes/{id} answers them. Both treat a bare array as the older list shape. This PR keeps its checks local, because ui: manage tenant keys, terminals, snapshots and rollback across orgs, and show node and template state #1387 is not merged. Whichever of the two merges second consolidates them onto one helper, and must keep the 400 and the handler's 404 as a missing route for this count.
  • Nothing sends a sandbox ID form an old server refuses: lookups still use sbx_ IDs, and prefix is sent only alongside cursor.

Screenshots

Synthetic fixtures on a local Kind stack. "Before" is main's console against the same API. These were taken before round 1. Since then the search box has a new placeholder and a help icon (reworded again in round 2), and the controls added in round 1 are not shown: the page-search notice, the link notice, the "Not applied" tag, the partial template list and the node-reported section.

Sandbox list, before, then pages 1 and 2 after:

Sandbox list before
Sandbox list, page 1
Sandbox list, page 2

Stored-lifecycle filter and its help text:

Status filter before
Stored lifecycle filter after

Node filter on the loaded page, user filter and direct lookup:

Node filter labelled for this page
User filter
Direct lookup notice on a paged list

Node cell, before and after (now a link to the node page):

Node cell before
Node cell after
Node page opened from the cell

Overview:

Overview before
Overview after

Spaced counts on the templates page:

Spaced counts before
Spaced counts after

Against main's API (the fallbacks):

Sampled list and its note, before
Sampled list and its note, after, on main's API
Status filter on main's API
Overview on main's API
Spaced counts on main's API

Validation

At this head, in ui/: npm ci, npm test (198 pass), npx tsc --noEmit -p . and npm run build. Each commit also passes the type check and the tests on its own.

Unit tests in ui/src/lib (sandbox-list.test.mjs, sandbox-id.test.mjs, sandbox-lifecycle.test.mjs, spaced-counts.test.mjs, org-filter.test.mjs) cover:

  • the page-response detection;
  • the mapping from filters to query parameters;
  • relative created bounds: computed per request, with the scope unchanged across a mocked three hours;
  • the list's loads through ListSelection, the class the page's refresh, filter changes, Previous and Next call: dropping an answer whose scope is not selected (select B, go back to A, select B again before B answers), keeping or reloading after a change of scope, and Next after a failed load (the cursor is cleared, and the selection moves with Next);
  • the list shape with a fake list read: the first answer decides it (a bare array reads the sample); a later answer in the other shape stops the list, with no reread and no later request, on a refresh of the shown scope (rows kept), after a filter change and after Next (rows cleared), in both shapes;
  • a filter or page-size change restarting paging, while a refresh of page 2 or the first sample is still pending;
  • the search: lookup, server prefix sent as typed (mixed-case names that look like ID prefixes, and the SBX_ alias), or page match;
  • the exact lookup of a node-reported row on a filtered list and on a later page;
  • link-parameter validation and the "not applied" statuses;
  • where the node-reported rows are;
  • the template filter options;
  • the spaced-count fallback, including 405.

Two tests that restated their definitions now check behaviour: the per-shape filter table is replaced by tests of the loaded-row filter, and the stored-lifecycle constant by the split of applied and not-applied statuses. Mutating the cursor-scope check, the label rule, the request invalidation, the link's "older than" check, the 405 rule or the time-independent key fails a test. So does each of these round 2 and 3 mutations: rereading instead of stopping on a later shape change, still loading once stopped, keeping the rows or the Next cursor of another scope when the list stops, not letting the first answer decide the shape, not restarting at page 1 on a filter change, keeping the cursor after a failed load, not moving or not invalidating on Previous and Next, not invalidating on a filter change, splitting an exact lookup's row into the node-reported section, and lowercasing the prefix. The page's own code (its calls into ListSelection, what it renders, the stopped notice and its Reload button) has no unit tests; the type check and the build cover it.

In round 0, on a local Kind stack built from main with #1321, #1303 and #1302 applied, the production build was driven through a browser:

  • Paged walk: 33 synthetic sandboxes, 25 on page 1 and 8 on page 2.
  • Filters: each filter sent its parameter and showed the same rows as main's console filtering in the browser.
  • Refresh: on page 2 it reread only page 2's cursor.
  • Filter change: restarted at the first page.
  • Node cell: opened the node page.
  • Spaced counts: the server's count matched the old tally of the full list.

The same build was then run against main's API alone. It switched to sampling, filtered in the browser with the same results, kept the truncation note, and fell back to the sampled tallies.

Not validated:

  • the round 1, 2 and 3 changes in a browser or on Kind; only the unit tests, the type check and the build cover them;
  • a rolling deploy with requests reaching servers of both versions; the fake list read in the unit tests stands in for it;
  • a list over 1000 rows (the note was shown with a sample size of 25);
  • a Direct-substrate stack, so no rows reported by nodes without a database row were listed, and their new section is unseen;
  • the 404 and 405 branches of the count fallback, which only the unit tests cover.

Severity and risk

Severity: medium. At fleet scale, every refresh of the list reread its whole sample, 1000 rows by default and up to 5000, and the filters saw only the rows sampled. The spaced-version table missed the long-lived sandboxes most likely to run an older spaced.

Risk: low to medium. These are UI-only changes, but the sandbox list's loading logic changes.

  • On a server without paging, the first load sends one extra request, the paged probe, before the sampled read. Later refreshes send the sample request only.
  • During a rolling deploy, an open list stops at the first answer in the other shape and asks for a reload, rather than switching shape in place.
  • On a paging server, a hex-only search (such as the ID label) and a name made only of hex characters and dashes are matched within the loaded page, not on the server. Server-side suffix and case-insensitive search are a follow-up to api: page and filter the admin sandbox list, count spaced versions, and share one sandbox ID parser #1321.
  • The template filter now takes one template.
  • The overview's fallback count leaves out the rows the list returns with status deleted.
  • The templates page changes only in its spaced-count read and caption.

Roll back by reverting the commits.

Stack

Merge after #1302, #1303 and #1321; it falls back cleanly on servers without them. #1387 duplicates the missing-route and bare-array checks; whichever of the two merges second consolidates them.

Not covered

  • Server-side suffix or case-insensitive search: the list's prefix stays a case-sensitive name or ID prefix, so the console matches the ID label within the loaded page.
  • A server-side node filter; the node filter still narrows the loaded page only.

AI assistance: Claude Code wrote the code, the tests and this description, and ran the validation. A maintainer reviews it.

The sandbox list reads pages through the admin list's cursor and sends
its filters as the server's parameters: org, stored lifecycle, template,
user, creation time and a name or ID prefix. A filter change restarts at
the first page, and the periodic refresh rereads only the visible page.
The node filter still narrows the loaded page, and says so.

A server that answers the paged request with a bare array does not page;
the list then samples and filters in the browser as before, with its
notes.
The node tag opens /dashboard/nodes/{id}, which lists every sandbox on
the node, and keeps its tooltip.
The overview and the templates page's spaced table use
GET /sandboxes/spaced-versions, which counts every listed sandbox that
is not deleted, and drop the note that the list stops at its newest
rows. A server without the route answers 400 or 404, and the console
then tallies a sample of the list as before.
@github-actions github-actions Bot added the triage:pending Waiting for a maintainer to triage label Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown

Thanks for opening this pull request! A SPACE maintainer will take a look.

CONTRIBUTING.md explains how we review outside contributions.

"Last 1h" and "Older than" were turned into timestamps when the filter
was chosen, so a tab left open kept showing the hour it was opened in.
The scope and its refresh key now keep the relative choice, and each
request computes its bounds from the time it is sent, so a poll rereads
the same view instead of starting a new one on page 1.
Selecting a filter and going back before its answer arrived skipped the
reload, since the rows on screen were the selected filter's, and then
showed the other filter's answer when it came. Each request now carries
its scope, every change of scope invalidates the requests in flight,
and an answer applies only while its scope is still the selected one.
A change that keeps the rows on screen ends the loading state itself.

Next took the last cursor any page returned, so after a page failed to
load it skipped a page. The next-page cursor is now kept with the scope
and page position that returned it, cleared when a load fails, and Next
is enabled only while that page is selected.
…erver

One bare array kept the tab sampling until it was reloaded, although in
a rolling deploy the next request may reach a server that pages. A
sampled tab now asks for its sample as the first page of the org's
unfiltered list at the sample size: a server without paging ignores the
cursor and answers the same sample as before, and one that pages answers
a page, on which the tab switches back and reads the first page.

The switch between the shapes is decided in the list library and
tested: an answer in the other shape is never shown, and the selected
scope is read again in the new one.
On a paging server every search that was not a full ID went to the
server as a prefix, so the 8-character label the ID column shows, the
end of the ID, found nothing, and a name became a case-sensitive
prefix, where the search had matched any part of an ID or name in any
case.

The search box now picks what each input can do. A full ID in any
accepted form is still looked up directly. Hex alone, such as the
label, and text no name can start with are matched within the loaded
page, ignoring case, and a notice says so and points to the full ID. A
name, an `sbx_` ID prefix or a dashed UUID start goes to the server as
a prefix, and the help text says that it is case-sensitive. A sampled
list matches all free text within its rows, as before.

The filter of the loaded rows moves into the list library, and its test
checks rows instead of restating which filters apply.
A status the paged list cannot send, such as `unknown` from an older
link or one chosen while the list was sampled, showed as a filter chip
although the request dropped it, and so did an "Older than" bound from
a link that leaves no time inside the window.

A list link is now parsed against what the list can apply: a template
or user ID the server would refuse, a value no control offers, and an
"older than" bound outside the window are left out, and a notice lists
them. The lifecycle control shows only the statuses the list applies,
and a separate tag marks the others as not applied.
The first page of the unfiltered list also carries every warm-pool slot
and other row a node reports without a database row, and with the
table's own paging off they ran on after the page's sandboxes. Any
server filter left them out without a word, and the node filter's
tooltip said a node's page lists every sandbox on the node, although it
lists only the sandboxes placed there.

Those rows now have their own section on the first page, which says
what they are and that filters leave them out; a filtered list or a
later page says where they are listed instead. The tooltip now says
what the node page lists and where the other rows are.
The template filter offered only the templates of the loaded page and of
the operator's own organization, so a template used only in another
organization could not be chosen on a server that filters by it.

The options now come from the admin template list, with the loaded
rows' templates, which may name a deleted one, and the selected one.
When that list cannot be read, the control says its list is partial.
A template name typed into its search can be chosen either way.
The console's other fallbacks to an older API treat a 404 from the API's
fallback and a 405 as a route the server lacks, and a bare array as the
older list answer. The count route's check took 400 and 404 only; it
now takes a 405 as well, and its comment says why its 400 and the
sandbox handler's 404 also mean the route is missing on this path.
The test that a relative created bound keeps its scope built both
scopes in the same millisecond, so a key that held the time would have
passed. It now advances a mocked clock by three hours between them.
A search that looked like an ID prefix was lowercased before it was sent
as `prefix`. The server matches names case-sensitively, so `sbx_AB` sent
`sbx_ab` and a sandbox named `sbx_AB-worker` was not listed. The server
folds the hex of its ID range itself, so the prefix now goes as typed.

`parseSandboxRef` reads `SBX_` as the ID's `sbx_`, which the server reads
only in lower case; an ID prefix behind it is sent behind `sbx_`, with the
rest as typed. The search help says how each part is matched.
A full ID in the search box shows the one row it names. On a paged list
the rows were then split into sandboxes and node-reported rows, and the
node-reported section shows only on the first page of the unfiltered
list. With a filter set, or on a later page, a warm-pool slot or
observed-local row found by its full ID moved into that hidden section:
the notice said it was found by direct lookup above an empty table.

The row an exact lookup finds now goes in the main table, whatever its
kind, and the node-reported section and its note are not shown with it.
An answer in the other list shape from the request (a bare array for a
page request, or a page for a sampled one) switched the list's shape and
called `refresh` again, with no limit. When requests alternate between
servers with and without paging during a rolling deploy, one refresh
switched back and forth for good, with requests sent continuously and
the spinner never ending.

A load now rereads at most once. A second answer in the other shape
settles it: two switches lead back to the shape the load started in, so
the selection is left as it was, loading ends, and the next poll reads
the list again.

When a sampled tab switched to paging, the shape on screen switched
before the paged answer arrived, so the sample was shown unfiltered as
"page 1", and the reread was sent without the search prefix, which was
computed for the sampled shape. The shape and the page position on
screen now change with the rows, and the filters every request carries
are those of a paged request, so the reread sends all of them.

The selection, its loads and its Next cursor move from the page to
`ListSelection`, so the tests drive the code the page runs: an
alternating-answers test, a reread that carries every filter, and Next
after a failed load, which fails if the failure keeps the cursor or Next
does not move the selection.
The list switched between paged and sampled in place whenever an answer
came in the other shape, with a bounded reread. Each interleaving of
that renegotiation with the selection opened another bug. Selecting org
B while org A's page was shown, then getting a bare array and a page on
the reread, ended loading with A's rows under B's controls; the same
happened after Next. Changing the template while a reread was pending
was "unchanged" for the sampled request, and settling restored the old
filter's page-2 cursor, so the next poll sent the new template with the
old cursor.

The first answer of a page view now decides the list shape: a page
keeps paging, and a bare array switches to sampling and reads the
sample. A later answer in the other shape means the API changed version
while the page was open. The list stops: no reread, no background
refresh, Refresh, Previous and Next disabled, rows that are not the
selected scope's cleared. A notice asks for a reload, and its Reload
button starts a fresh page view with the filters kept in the URL.

The in-place switch, the one-reread budget and `listAnswerPlan` go,
with their tests. A filter or size change now always restarts at the
first page, also when the request it sends is unchanged. New tests
cover a shape change on a refresh of the shown scope, after a filter
change and after Next, in both shapes, and paging reset by filter and
page size changes, including while a refresh or the sample is pending.
@ac-pplx
ac-pplx marked this pull request as ready for review November 3, 2026 08:30
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 4 findings

I checked the client against PR #1321's server at 411c536b. The client sends only parameters the server accepts: limit, cursor (empty for the first page), org_id, lifecycle, template, user_id, created_after, created_before and prefix. STORED_LIFECYCLE_FILTER_STATES matches the server's stored vocabulary. The fallbacks follow #1321's rollout notes. A bare array on a later page stops the list rather than appending rows, and a 400, 404 or 405 from spaced-versions falls back to the sampled tally. Nothing blocks.

Low

  1. ui/src/lib/sandbox-id.ts:182-185 — on a paging server, a search made only of hex digits and dashes is matched within the loaded page and never sent as prefix.
    That keeps the ID column's 8-hex suffix searchable, which the body explains. The cost is that a sandbox named with only those characters (e2e-1, cafe, 1234) can't be found beyond the page on screen, although the server's prefix matches names too (starts_with(s.name, $7) beside the ID range). Free text also moves from a case-insensitive substring match to a case-sensitive name prefix. Sending such a fragment as prefix while still highlighting suffix matches within the page would keep both.
  2. ui/src/app/dashboard/sandboxes/page.tsx:1650-1651 — the lifecycle control's value is statusSplit.applied, and its onChange replaces the whole statusFilter.
    Adding or removing any lifecycle therefore drops the statuses shown under "Not applied" from state and from the URL, without a notice. A link's filter is lost on the first edit. Merging the not-applied ones back into the new value would keep them.
  3. ui/src/lib/sandbox-list.ts:367-368 — any failed load clears the continuation, including a background poll of the page already on screen (kept).
    One retryable 503 on a 10 s poll disables Next on a page whose rows and next_cursor are still valid, until a later poll succeeds. Clearing it only when the shown page is dropped would keep Next working.
  4. ui/src/lib/sandbox-list.ts:381-384 — once the API answers in the other shape, the list stops for the rest of the page view.
    That's the safe reading of api: page and filter the admin sandbox list, count spaced versions, and share one sandbox ID parser #1321's rollout note, and it never mixes shapes. During a rolling deploy, though, it freezes every open list on the first mixed answer, and a reload can freeze again on the next poll until every replica is upgraded. Treating a page answered to the sampled probe as a switch to paged mode, restarting at page 1, would avoid that dead end without appending rows across shapes.

Verified against c6429af800b51e7e734900d09337824ee8c63225. I ran node --experimental-strip-types --test src/lib/*.test.mjs in ui/: 196 of 197 pass. The one failure, curiosity-theme.test.mjs, fails the same way on main and isn't touched here. I read the rest, including the overview, which fetches spaced-versions once per page view and doesn't poll. I didn't run the console against a server.

@the-gigi-pplx the-gigi-pplx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approved. 4 non-blocking findings in the review comment above.

@ac-pplx
ac-pplx added this pull request to the merge queue Nov 3, 2026
@ac-pplx

ac-pplx commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Merging this as approved; the four lows go into a small follow-up PR against main:

  1. A search of only hex digits and dashes will also go to the server as prefix, so a name like e2e-1 or cafe is found beyond the loaded page, while suffix matches within the page stay highlighted.
  2. Editing the lifecycle control will keep the "Not applied" statuses in state and in the URL instead of dropping them.
  3. A failed background poll of the page on screen will keep its continuation, so Next stays usable. It is cleared only when the shown page is dropped.
  4. A paged answer to the sampled probe will switch the view to paged mode at page 1, the one-way upgrade a rolling deploy produces, instead of stopping. A paged view that then gets a bare array still stops.

Merged via the queue into main with commit b6aa5e0 Nov 3, 2026
39 checks passed
@ac-pplx
ac-pplx deleted the ac/admin-ui-sandbox-list-server branch November 3, 2026 15:41
meefs pushed a commit to meefs/SPACE that referenced this pull request Nov 3, 2026
… in more cases (perplexityai#1397)

* ui: keep the sandbox list's search, lifecycle filter and Next working in more cases

Follow-ups from the review of perplexityai#1388.

- On a paging server, a search of only hex digits and dashes (other than
  a dashed ID start) is still matched anywhere in the IDs and names of the
  loaded page, so the ID column's label finds its row, and is now also
  sent as `prefix` in a request of its own. Names such as `e2e-1`, `cafe`
  or `1234`, and IDs starting with the text, are then found beyond the
  page and listed after it. The search help and notices say which match
  is made where.
- Editing the lifecycle control no longer drops the statuses listed under
  "Not applied" from the filter and the URL.
- A failed load keeps Next when the rows on screen are kept, so a
  retryable error on a background poll no longer disables Next on a page
  whose rows and next_cursor are still valid.
- A page answering a sampled list switches it to paging and rereads the
  first page with every filter, instead of stopping the list for the rest
  of the page view. A bare array answering a paged list still stops it
  with the reload notice.

* ui: keep one prefix request in flight and list only matches the table lacks

An older prefix response could overwrite a newer one: a slow request
overtaken by a refresh's newer one still passed the key check, and
searching A, B, A accepted the first A's answer. Each request now has an
AbortController and a ticket; starting one, or an effect cleanup, aborts
the previous, and an answer applies only if its ticket is the newest.

The supplemental section also repeated rows already in the main table.
It now excludes them, counts what remains, and reads "More matches
beyond this page".

* ui: keep the sandbox list's rows through a rolling deploy's mixed answers

During a rolling deploy or its rollback, replicas with and without paging
answer behind one load balancer, in either order. A paged view whose poll
reached an old replica stopped for the rest of the page view, and a sampled
view that met a paging replica switched to paging before its first page
answered, so a failed or bare-array reread left it with no rows.

The list now never joins or appends rows of two shapes, and keeps the rows
on screen through a mismatch:

- A bare array answering a paged request fails that load. The page on
  screen keeps its rows and its Next, and a notice says the API answered
  in an older format and the list is retrying.
- A sampled view that gets a page back reads page 1 with every filter, and
  switches to paging only when that page answers, replacing the sample
  with it. Until then the sample stays.
- The list stops with the reload notice only after three bare arrays in a
  row answer paged requests. Any other answer resets the count.

The prefix search's request counts toward the same count, and a bare array
fails a refresh of its section, which keeps its rows.

* ui: leave out the prefix section when it has nothing left to show

When every match of the prefix request was already in the main table and
the server had more, the section read "(0+)" over an empty table. Once an
answer leaves no row to show, and no error to report, the section is left
out, and the "+" appears only next to rows.

* ui: read the first page again when a filter changed under a sampled list's switch

A sampled list whose poll answers a page reads page 1 with every filter
before it switches to paging. The sample request sends only the org, so
choosing another template while that read is in flight leaves the selected
request unchanged and invalidates nothing. The page for the earlier filters
then passed the ticket check and was shown under the new filter, without
Next, until the next poll.

After that read answers, the list now checks that it is still the first
page for the filters chosen, and otherwise reads it again for them. The
load is never skipped there, since the page may be waiting on it with its
spinner on.

* ui: stop the sandbox list only after a minute of older-format answers

A failed refresh is retried on the next one-second check, so three bare
arrays in a row came within about three seconds. With a fraction f of old
replicas, each refresh cycle stopped a paged view with probability about
f cubed, and a long rollout with half its replicas old still ended most
open lists on the reload notice.

The list now stops only when every answer for a minute
(`STOP_AFTER_MISMATCH_MS`) has been a bare array answering a paged request,
with at least three of them. Any page, for the list or the prefix search,
starts the minute over. The clock is injected so the tests can run
interleaved answers over ten minutes, and old answers alone through the
window, without waiting.

* ui: send the prefix search only once the list's first answer decided its shape

The page assumes paging until the first list answer arrives, and sent the
prefix request before then. On a server without paging, each bare array
answering it counted as a mismatch: with a slow first load it could show
the older-format notice before the sample loaded, and under the earlier
count rule three of them stopped the list for good.

The prefix request now waits for `ListSelection.shapeDecided`, and
`noteAnswer` neither counts nor starts over before then (`undecided`); the
page treats such an answer as a failed refresh of its section.

* ui: stop the sandbox list only after twenty bare arrays in a row as well as a minute

The stop window ran from the first bare array of a run and kept running
while nothing was sent: a hidden tab does not refresh, and a sleeping
laptop or a clock jump skips ahead. One bare array, a minute in a hidden
tab, then two more on return stopped the list after three answers.

The list now stops only when bare arrays alone have answered for a minute
and numbered at least twenty in a row. Twenty is about a third of the
retries a minute of one-second checks sends, so a pause cannot stand in
for them and a real downgrade still stops the list after about a minute.
A count is kept rather than restarting the run after a gap, since a slow
request lengthens the gap between retries without meaning a pause.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

triage:pending Waiting for a maintainer to triage

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants