Skip to content

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

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

ac-pplx merged 8 commits into
mainfrom
ac/admin-ui-sandbox-list-followups

Conversation

@ac-pplx

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

Copy link
Copy Markdown
Contributor

Summary

Follows up the review of #1388. It fixes low-severity problems in the console's sandbox list, mostly on a server that pages and during a rolling deploy.

  • Hex-only search beyond the page. On a server that pages, a search made only of hex digits and dashes (other than the dashed start of an ID) was matched only within the loaded page and never sent to the server. A sandbox named e2e-1, cafe or 1234 could not be found beyond that page. Such a search still matches anywhere in the IDs and names of the page on screen, so the ID column's label still finds its row. It is now also sent as prefix in a request of its own (fragmentSearchScope in ui/src/lib/sandbox-list.ts). The names and IDs that start with it, across the whole list, are listed in a "More matches beyond this page" section after the page. That section leaves out rows the main table already shows, and is left out itself once an answer leaves nothing to show. At most one of these requests is in flight (PrefixRequests in ui/src/lib/prefix-search.ts): a new one aborts the one before, and only the newest answer for the search now typed applies. The search help, the notice above the table and the section say which match is made where.
  • Lifecycle filter keeps "Not applied" statuses. The lifecycle control shows only the applied statuses, and its onChange replaced the whole filter. Editing it silently dropped the statuses listed under "Not applied", from state and from the URL. Those statuses are now kept (chooseAppliedStatuses in ui/src/lib/sandbox-lifecycle.ts); their own tag still removes them.
  • Next survives a failed refresh. Any failed load cleared the next-page cursor, including a background poll of the page already on screen. A single retryable 503 then disabled Next on a page whose rows and next_cursor were still valid. The cursor is now cleared only when the rows on screen are dropped.
  • Mixed versions during a rolling deploy. Replicas with and without paging can answer behind one load balancer, in either order. Before, any change of answer shape after the first answer stopped the list for the rest of the page view, which froze open lists for the whole rollout. Now the list (ListSelection in ui/src/lib/sandbox-list.ts) 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 refresh. The page on screen keeps its rows and its Next, and a notice says "The API answered in an older format; retrying". The next poll retries.
    • A sampled view that gets a page back reads page 1 with every filter, since the sample request sent only the org. It switches to paging only when that page answers, and the page replaces the sample. Until then the sample stays on screen. If a filter changes while that read is in flight, page 1 is read again for the filters now chosen, so a page for the earlier filters is never shown under the new ones.
    • The list stops with the reload notice only when every answer for a minute (STOP_AFTER_MISMATCH_MS) has been a bare array answering a paged request, with at least twenty of them in a row (STOP_AFTER_MISMATCHES): a real downgrade. Any page starts both over. A failed refresh is retried on the next one-second check, so a count alone would have been reached within seconds while some replicas are old. Time alone also passes while nothing is sent: a hidden tab does not refresh, and a sleeping laptop or a clock jump skips ahead. With time alone, one bare array, a minute in a hidden tab, and two more on return would stop the list. Twenty is about a third of the retries a minute sends, so a pause cannot stand in for them, and a downgrade still stops the list after about a minute. The rule keeps a count rather than starting the run over after a gap, because a slow request also lengthens the gap between retries and is not a pause.
    • The prefix search's answers count toward the same rule. A bare array answering it fails a refresh of its section, which keeps its rows. The prefix request is sent only once the list's first answer has decided its shape (ListSelection.shapeDecided), and an answer before then neither counts nor starts the minute over.

Validation

At 74c037c177c4, in ui/: npm ci, npm test (216 tests pass), npx tsc --noEmit -p . and npm run build all pass.

Each item has unit tests:

  • sandbox-id.test.mjs: "on a paging server, hex digits and dashes are also sent as a prefix, so such names are found beyond the page".
  • prefix-search.test.mjs: the single-flight and newest-ticket rules, and the matches left out of the section.
  • sandbox-lifecycle.test.mjs: "changing the lifecycle control keeps the statuses a paged list does not apply".
  • sandbox-list.test.mjs: "a failed refresh of the page on screen keeps its rows and its Next". For mixed versions: "a bare array on a refresh of a paged list fails it, keeping its rows and Next", "a paged list stops only once bare arrays alone have answered for the window", "a pause after a bare array, as in a hidden tab, does not count toward stopping the list", "interleaved old and new answers over several minutes never stop a paged list", "a bare array answering the prefix search counts toward stopping the list", "a prefix answer before the first answer of the list neither counts nor starts over", "a page answering a sampled list switches it to paging at the first page, with every filter", "a sampled list keeps its sample until a first page answers, through interleaved shapes", and "a filter changed while a sampled list reads its first page is read again before it switches". The stopping tests inject the clock into ListSelection, so ten minutes of answers run without waiting.

Fail-before: sandbox-list.test.mjs imports exports that the earlier libraries lack, so against either one, unchanged, the whole file fails to load. To see which tests fail, the file at this head was run against a copy of each earlier sandbox-list.ts, with the missing exports added as stubs (OlderFormatAnswer as a bare Error subclass, STOP_AFTER_MISMATCHES = 3, and STOP_AFTER_MISMATCH_MS = 60_000) and nothing else changed:

  • Against 6ae6726a, which stopped after a minute and three bare arrays in a row: 1 of 41 fails, the pause test above. The other 40 pass. (This head's library with STOP_AFTER_MISMATCHES set back to 3, which matches 6ae6726a's apart from comments.)
  • Against 5169e750, which stopped after three bare arrays in a row: 5 of 41 fail. They are the two stopping-window tests, the pause test, the prefix-before-first-answer test and the filter-change test above. The other 36 pass.
  • Against 3f03059c, which stopped on the first mismatch: 11 of 41 fail, the mixed-version tests above and "a bare array after a filter change fails the load and clears the other scope's rows" and "a bare array answering Next fails it and clears the previous page". The other 30 pass.

In a browser, the previous head's (6ae6726a) built ui/ was served with next start and driven with Playwright 1.64.1. Every API request was answered by route interception with synthetic data: a signed-in operator, an org, and five sandboxes. The list endpoint answered a page or a bare array as each scenario chose. Each scenario sampled the main table's rows every 100 ms:

  • Bare arrays and pages in turn, after a first page, for 150 s (12 bare arrays, 13 pages): the list never stopped, and the table never had fewer than its 5 rows. The older-format notice showed for about a second after each bare array, until the next page cleared it.
  • Runs of five bare arrays, one second apart, between pages, for 150 s (45 bare arrays, 10 pages): the list never stopped, and the table never had fewer than 5 rows.
  • Only bare arrays after a first page: the first came at 11.3 s, and the list stopped with the reload notice at 72.3 s. That is the first bare array a full minute after the first. The rows were kept, and no list request was sent in the 17 s after it. About sixty bare arrays answered in that minute, more than the twenty this head requires, so the timing is expected to be unchanged at this head; the browser run was not repeated.
  • A hex search (cafe) on a server without paging whose first answer took 4 s: no prefix request was sent, before or after, and no older-format notice showed.
  • The same search on a server that pages, with the first answer taking 4 s: the first prefix request was sent 0.3 s after that answer, not before it.

At the previous head (5169e750), on a local Kind stack from this branch with Playwright interception of the list requests and synthetic fixtures seeded and deleted afterwards:

  • A paged view meeting one bare array kept its rows and showed the older-format notice. The retry, about a second later, answered a page and cleared the notice.
  • A view whose first answers were bare arrays sampled. Its polls were then answered with a page, followed by a reread answering a bare array, twice, then a 503, then a page. It kept its sample with the notice through each of these, and switched to page 1 only when that page answered. Its table was never empty.
  • A bare array answering the prefix request showed the section's own warning and the older-format notice, and did not stop the list.
  • A prefix search whose matches were all on the page left the section out.

These paths are unchanged since, apart from the stopping rule and the prefix request's timing, which the browser check above covers at 6ae6726a. Kind was not rerun at that head or at this one.

Not validated: a hidden tab or a sleeping laptop in a browser; the pause test covers it with the injected clock. Next's cursor in a browser. The Kind stack had a single page, so Next was disabled throughout; keeping the cursor through a mismatch is covered by the unit tests. The filter change during a sampled view's switch to paging is covered only by its unit test, since its timing is hard to force in a browser.

Severity and risk

Severity: low. Each problem hides rows, freezes the list, or disables a control in the operator console; no data is changed.

Risk: a hex-only search on a paging server sends one extra list request (first page, at the page size, with the selected filters and prefix). That request is debounced and refreshed with the list. Statuses that are not applied now persist until their tag is closed. During a rolling deploy, a list can show rows that are a few polls old under the older-format notice. A sampled view switches to paging, and to page 1, once a page 1 answers. A failed refresh is retried on the next one-second check, so while old replicas answer, an open list can send about one list request a second. The list stops with the reload notice only after a minute in which every answer was a bare array, with at least twenty of them in a row. While any replica that pages still answers, a page soon arrives and starts both over. A tab that is hidden, or a laptop that sleeps, sends nothing, so that time adds no bare arrays: a rollout stops an open list only if at least twenty retries in a row, spanning a minute, all reach old replicas. At one retry a second that is usually about sixty, and never fewer than twenty, which is rare even when most replicas are old. After a real downgrade, a list shows its last rows under the older-format notice for about a minute before the reload notice, or longer when each request takes more than about three seconds. To roll back, revert these commits.

Rollout

This change is about how the console behaves while old and new API versions run together; see the last item of the summary. It needs no ordering with the API.

AI assistance: Claude Code drafted the code, the tests and this description.

… in more cases

Follow-ups from the review of #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.
@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.

… 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".
@ac-pplx
ac-pplx marked this pull request as ready for review November 3, 2026 16:20
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 4 findings

Medium

  1. ui/src/lib/sandbox-list.ts:380-409 — the mixed-version fix covers one direction of a rolling deploy, and the direction it covers can now end on an empty table instead of a stale one.
    During a rollout both versions answer behind one load balancer, so a single open list can meet both shapes in either order:

    • Paged view, old replica answers a poll. decided && response.mode === 'sampled' still stops the list for the rest of the page view (:399-401). That's the freeze from the ui: page and filter the sandbox list on the server, and read spaced counts from the API #1388 review, for any view whose first answer came from a new replica.
    • Sampled view, new replica answers. The view switches to paged and re-reads page 1 (:405-409). If that re-read fails, kept = sameScope(this.shown, ticket.scope) compares the sampled scope on screen with the new paged scope, finds them different, and drops the rows (:383-389). The page then clears the table. If the re-read instead lands on an old replica, the bare array stops the list with nothing kept. On main, the same first answer stopped the list but kept the sample on screen. The new test "a list that switched to paging stops when its first page answers a bare array" pins the empty outcome.

    There's a cost on each side. Never stopping risks joining rows of two shapes, which is what the stop exists to prevent. Stopping on the first mismatch freezes lists for the whole rollout. A middle that keeps both properties: keep the rows on screen and don't append, treat a bare array as a failed refresh rather than a stop, and keep showing the sample until the paged first page actually succeeds. Stopping only after a few consecutive mismatches would also stop on a real downgrade without freezing on one old replica. api: page and filter the admin sandbox list, count spaced versions, and share one sandbox ID parser #1321's rollout section, "restart from the first page or fall back", reads that way too.

Low

  1. ui/src/app/dashboard/sandboxes/page.tsx:774-775 — the listStopped comment still says the list stops when "an answer came in the other list shape from the first". Since this PR, only a bare array answering a paged list stops it.
  2. page.tsx:1934 — the section's count is taken after removing rows already on the page and rows the node filter drops, so it can read "(0+)" when every server match is already listed and more is set.
    The section has no way past its first page of matches, so in that case it shows nothing and offers only "type more". Counting before the exclusion, or leaving out the "+" when nothing remains to show, would avoid the empty "(0+)".
  3. page.tsx:1326 — a bare array answering the prefix request is shown only as the section's "changed version" warning, while the main paged list keeps polling and rendering as if nothing happened.
    The page then reports two different version states at once. Routing that case through the same handling as the main list, whichever you settle on in finding 1, would keep them consistent.

Verified against 3f03059ce750f9a78f27401687287673debc062c. Your validation was at the first commit, so I re-ran it at this head, in ui/: npm ci, npm test (210 of 210 pass), npx tsc --noEmit -p . (clean) and npm run build (succeeds).

I traced the two mode-switch paths in finding 1 through ListSelection.load and stop(). I didn't exercise the page in a browser.

@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.

…wers

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.
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.
…ist'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.
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.
…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.
…ell 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.
@ac-pplx

ac-pplx commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. All four are addressed; the head is now 74c037c1.

  1. (Medium) Rolling deploys. The list now takes the middle course you suggested:

    • Rows of two shapes are never joined or appended, and the rows on screen are kept through a mismatch.
    • A bare array answering a paged request is a failed refresh: the rows and Next stay, with an "older format; retrying" notice.
    • A sampled view keeps its sample until a page-1 read with every filter answers, then replaces the sample with that page. If the filters change during that read, it reads again for the current ones.
    • The list stops, with the reload notice, only after bare arrays alone have answered paged requests for 60 s and at least 20 times in a row. So neither one old replica nor a hidden tab can stop it, and any page starts the count over.

    The test that pinned the empty outcome is replaced. With an injected clock, the tests now cover interleaved shapes in both directions over ten simulated minutes, and a pause between mismatches.

  2. (Low) The listStopped comment describes that rule.

  3. (Low) The prefix section is left out when nothing remains to show, and the "+" appears only next to rows.

  4. (Low) The prefix request follows the same rule. A bare array fails its section's refresh, keeps its rows, and counts toward the same run. The prefix request is sent only after the list's first answer has decided the shape.

Could you take another look?

@ac-pplx
ac-pplx requested a review from the-gigi-pplx November 3, 2026 18:05
@ac-pplx
ac-pplx added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 57ffbe1 Nov 3, 2026
23 checks passed
@ac-pplx
ac-pplx deleted the ac/admin-ui-sandbox-list-followups branch November 3, 2026 19:12
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review (re-review at 74c037c177) — 4 findings

All four points from my earlier review are addressed:

  • Medium, mixed-version freeze and dropped sample: fixed. A bare array answering a paged request now fails the load with OlderFormatAnswer and keeps the rows (ui/src/lib/sandbox-list.ts:477-479, fail()). The sampled-to-paged re-read fails against the sample's scope, so the sample stays on screen until page 1 actually answers (:485-494). Stopping now needs both the minute and the count. The prefix request is sent only for a paged selection (fragmentSearchScope, :259), so a sampled view can't add mismatches to the count.
  • Stale listStopped comment: fixed (page.tsx:778-784).
  • "(0+)": fixed. The "+" now needs remaining rows, and the section hides when nothing is left (page.tsx:1973-1977). There's a side effect, in finding 4 below.
  • Prefix bare array: fixed. It now feeds the same counter and notice as the list (page.tsx:1352-1359).

No new High.

Medium

  1. ui/src/lib/sandbox-list.ts:432-447 — a pause after the first mismatch still counts toward the stop window, and the test written to rule that out can't detect it.
    mismatchedSince is set at the first mismatch (:440) and is never moved by a gap, so a hidden tab, a sleeping laptop, or a run of 5xx failures (which "neither count nor start over") all count toward the minute. I ran a scratch copy of a pause after a bare array, as in a hidden tab, does not count toward stopping the list: one bare array, the 10-minute pause, then 19 more bare arrays 1 s apart. The list stopped (stopped=true, mismatches=20) 19 s after the tab came back, though a paging replica had answered before the pause. The committed test applies only 3 mismatches in total, so the 20-count alone keeps it from stopping, and it passes either way.
    The comments at :296-303 and :428 ("cannot stop the list on its own") describe a minute of retries, which the window doesn't guarantee. Restarting mismatchedSince when the gap since the previous mismatch is longer than a few retry intervals would close it. A monotonic clock (performance.now()) would also keep NTP or manual clock steps out of the window. The cost on the other side: a real downgrade seen by an intermittently hidden tab takes longer to stop, and it keeps its rows on screen meanwhile. That seems the right side to err on. A test with 19 post-pause mismatches would pin it.

Low

  1. page.tsx:890-897 — an old replica answering Next, Previous or a filter change still clears the table and shows the red "Could not load page N" error, because the failed scope isn't the one on screen.
    That matches how main treats any failed load of a new scope, so it's not a regression. But the header's "keeps the rows on screen through a mismatch" (sandbox-list.ts:10) reads wider than refreshes of the page on screen. Either narrow the sentence, or keep the previous page's rows with the info notice when the failure is an OlderFormatAnswer.
  2. page.tsx:879, :1355 — olderFormat is derived from the counter the list and the prefix search share, so either stream can clear the other's notice.
    A paged answer to the prefix search resets mismatches and hides "retrying, and keeping the rows on screen" while the main list's last refresh still failed. In the other direction, a bare array followed by 503s keeps the older-format notice up while the 503 text goes only to the console. Tracking the notice from the main list's own last outcome would keep it accurate.
  3. page.tsx:1973-1977 — the section now hides whenever no rows remain after excluding the page's rows, even when the answer says more.
    If the newest prefix matches are all already on screen but older ones exist, the "type more of the name or ID" hint, the only sign of them, disappears. Keeping the section, with a short "all of the first N matches are on this page" line, when more is set would avoid that.

Verified against 74c037c177.

What I ran in ui/: npm ci, npm test (216 of 216 pass), npx tsc --noEmit -p . (clean) and npm run build (succeeds). I also ran the scratch probe in finding 1 and then removed it.

What I traced by reading: the load, re-read and stop paths in ListSelection, and the page's handling of each result.

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