Repository navigation
ui: page and filter the sandbox list on the server, and read spaced counts from the API - #1388
Conversation
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.
|
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.
Review — 4 findingsI checked the client against PR #1321's server at Low
Verified against |
the-gigi-pplx
left a comment
There was a problem hiding this comment.
Approved. 4 non-blocking findings in the review comment above.
|
Thanks. Merging this as approved; the four lows go into a small follow-up PR against
|
… 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.
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)GET /admin/api/v1/sandboxeswithcursor, which is empty for the first page, and shows{sandboxes, next_cursor}one page at a time.Sandbox list: server-side filters
org_id, and the template filter totemplate. The template filter now takes a single template, because that is what the server filters on.lifecycle. Its help icon explains that it matches the lifecycle the control plane stores, not the State column, which shows what the node reports.created_after. A new "Older than" control maps tocreated_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.user_id), and a closable tag shows the filter.?user=<uuid>also works.parseSandboxRefand looked up directly withGET /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, ansbx_ID prefix, or a dashed UUID start is sent asprefix, as typed: the server matches names case-sensitively and folds the hex case of its ID range itself, sosbx_ABstill lists a sandbox namedsbx_AB-worker. The server reads only a lowercasesbx_as the ID's, andparseSandboxRefalso takesSBX_, so an ID prefix behindSBX_is sent behindsbx_, 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, asmaindoes.unknownfrom an older link, does not show as a chip. A separate "Not applied" tag marks it.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.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)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.On servers without these routes (the rules are in
ui/src/lib, with tests)cursorand every filter exceptorg_id. The list then reads its sample, filters in the browser, and keeps its notes.limit=<sample size>&cursor=&org_id. An old server ignorescursorand answers the same samplemainreads.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/src/lib/admin-fallback.ts. ItsisMissingRouteis 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 numericcode), and refuses a 400 and a handler's own 404. Onspaced-versionsboth of those mean the route is missing, because an older server'sGET /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.sbx_IDs, andprefixis sent only alongsidecursor.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:
Stored-lifecycle filter and its help text:
Node filter on the loaded page, user filter and direct lookup:
Node cell, before and after (now a link to the node page):
Overview:
Spaced counts on the templates page:
Against
main's API (the fallbacks):Validation
At this head, in
ui/:npm ci,npm test(198 pass),npx tsc --noEmit -p .andnpm 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: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);SBX_alias), or page match;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
mainwith #1321, #1303 and #1302 applied, the production build was driven through a browser:main's console filtering in the browser.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:
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.
deleted.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
prefixstays a case-sensitive name or ID prefix, so the console matches the ID label within the loaded page.AI assistance: Claude Code wrote the code, the tests and this description, and ran the validation. A maintainer reviews it.