Skip to content

ui: add Operations and Audit log pages - #1254

Merged
ac-pplx merged 6 commits into
mainfrom
ac/admin-ui-operations-audit
Nov 2, 2026
Merged

ac-pplx merged 6 commits into
mainfrom
ac/admin-ui-operations-audit

Conversation

@ac-pplx

@ac-pplx ac-pplx commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This change adds two read-only pages, each over an admin route the console did not use. Until now a failed or stuck operation, or who changed what, was visible only through curl.

  • Operations (/dashboard/operations) uses GET /admin/api/v1/operations. It lists the newest lifecycle operations across organizations. Each row shows:

    • state;
    • target (sandboxes link to their page; a fork records its source sandbox by the bare UUID, which links to the same page, with a tooltip saying so);
    • organization;
    • API surface;
    • recorded user (emails are looked up through one queue per page, at most 6 in flight across loads; leaving the page cancels them, and a failed lookup is retried the next time a load lists that user);
    • attempts;
    • the progress message or the terminal error's message, with the error code in a tag and the full error in a tooltip. Only failed and expired operations are shown as faults; canceled ones are not.

    It filters by state, organization, kind and target ID, through the API's own filters, and shows at most the API's 250 newest rows. Every path that applies filters (a dropdown, Enter in either text box, clearing a box, Refresh) applies both the kind and the target ID boxes as typed, so a box never shows a filter the rows do not have. A fork records its source sandbox by the bare lower-case UUID, so a target ID that is an sbx_ ID or a bare UUID, dashed or not, in upper or lower case, is lower-cased and searched in both recorded forms (sbx_<uuid> and the bare <uuid>), and the two answers are merged; any other target ID is searched as typed. The box's tooltip, and a fork row's tooltip, say so. The merged list is newest first: each timestamp is compared by its Date.parse milliseconds, then by its fraction padded to nine digits, because the API trims trailing zeros and omits a zero fraction; the operation ID breaks only exact ties. The comparator, the merge and the target forms are in ui/src/lib/operation-list.ts, tested in operation-list.test.mjs.

  • Audit log (/dashboard/audit) uses GET /admin/api/v1/audit-events. It lists every admin API request other than reads (GET, HEAD, OPTIONS) made by a signed-in operator, including ones the API refused, with its actor, method, path, status and duration (admin_request_should_persist_audit_event in crates/libs/api/src/routes/admin/mod.rs). Console actions sent through the public API appear under Operations instead. The search box filters the loaded page, because the route has no filters.

Both pages drop answers to superseded requests, and a failed load says that the rows shown are from the previous filters or load. When a failed load leaves no rows, the table reads "Not loaded" instead of "No operations match these filters" or "No audit events". When the audit log keeps rows from an earlier load and the search matches none of them, it reads "No events match".

The operations "Recorded for" column is labelled that way on purpose. A create through the admin API records the sandbox's owner there, while the audit log records the operator.

Stack

Stacked on #1246, whose menu changes this adds two entries to. Merge #1246 first.

Validation

  • npm ci, npm test (60 pass, including src/lib/operation-list.test.mjs), npx tsc --noEmit -p . (no errors) and npm run build in ui/.
  • A Playwright loop against a local Kind stack with synthetic data, on this branch (the Kind space-ui image):
    • Operations lists the seed's create and delete operations;
    • filtering by a deleted sandbox's ID shows its delete_sandbox operation;
    • the audit log lists the loop's own admin mutations with root@local as actor;
    • a target ID typed without Enter is applied by Enter in Kind (lower-cased), clearing Target ID applies the typed kind alone, and Refresh applies a typed target with one load;
    • after a (synthetic) failed audit reload, a search matching none of the kept rows says "No events match".
  • No new console errors, page errors or failed requests compared with main.
  • src/lib/operation-list.test.mjs covers the ordering, the merge and every accepted target form. Its ordering test fails against the comparator this branch used before: for operations a to d at …00.1234Z, …00.12346Z, …00.12345Z and …00.123451Z, that comparator gave d, b, c, a; the expected order is b, d, c, a. Other cases cover a missing fraction (…00Z sorts below …00.000001Z and above …59.999999999Z) and the ID as the tie-break only for equal instants (.5Z and .500Z).
  • A Playwright run on the same Kind stack compared production builds of an earlier head of this branch and of this head. With Target ID set to a seeded sandbox's UUID in upper case with dashes, then in lower case without dashes, each applied with Enter, this head read target_id=sbx_<uuid> and target_id=<uuid> and listed the sandbox's suspend_sandbox and create_sandbox operations; the earlier head sent only the typed form and listed none. The stack has no fork operations and no two operations in one millisecond, so the merged fork rows and the new ordering rest on the unit tests.

Visual confirmation

operations
audit log

Target ID set to a sandbox's UUID in upper case with dashes, on an earlier head of this branch and on this head: both recorded forms are now searched:

Before After
before after

The same UUID in lower case without dashes, before and after:

Before After
before after

Severity and risk

Severity: medium. Operations are where a support case's failure is recorded.

Risk: low. Two new pages, read-only. With no organization selected, no index serves the page's sort, and only the state filter has an index that leads with its column (idx_public_operations_state_next_attempt). The default view and the kind and target ID filters can read the whole table, and a sandbox target ID (an sbx_ ID or a bare UUID) sends two such reads. The index is filed separately. Roll back by reverting the commits.

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

@github-actions github-actions Bot added the triage:pending Waiting for a maintainer to triage label Oct 31, 2026
@github-actions

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.

@ac-pplx
ac-pplx marked this pull request as ready for review November 1, 2026 02:20
@the-gigi-pplx

Copy link
Copy Markdown
Collaborator

Review — 3 findings

Medium

  1. ui/src/app/dashboard/operations/page.tsx:137: the Target ID filter only widens an sbx_ ID, so a bare UUID silently returns a subset.
    SBX_TARGET matches only sbx_<uuid>. Anything else goes to the server as typed, and target_id matches exactly. A bare UUID pasted from a log tag returns only the fork operations (which record their source by the bare UUID), while every create, suspend and delete recorded under sbx_<uuid> is missing, with no hint that anything was left out. An upper-case bare UUID matches nothing, because the lower-casing happens only inside the sbx_ branch. Recognize a bare or undashed UUID too, lower-case it, and query both forms. parseSandboxRef in the sandbox-detail stack does exactly this and could replace SBX_TARGET once it lands.

Low

  1. ui/src/app/dashboard/operations/page.tsx:29: newerFirst's premise is false. The API's RFC 3339 strings don't have a fixed fractional width.
    time::serde::rfc3339 trims trailing zeros (truncated_subsecond_from_nanos) and leaves out the fraction when it's zero, so same-length comparison covers only some pairs. Mixed-width pairs fall back to millisecond Date.parse and then the ID. Running the comparator on …00.12346Z, …00.123451Z, …00.12345Z and …00.1234Z sorts .1234Z first in every input order, so the merged list can disagree with the server's ORDER BY created_at DESC within a millisecond. Comparing [Date.parse(ms), fraction padded to 9 digits] would make it exact, or use a plain Date.parse + ID comparator and fix the comment.
  2. ui/src/app/dashboard/operations/page.tsx:17: this page re-implements what the sandbox-detail stack adds for the same endpoint: AdminOperation (≈ AdminOperationRecord), STATE_COLORS (= OPERATION_COLORS), mergeNewest (≈ mergeOperationPages, with a different tie order) and failureSummary (≈ operationError). Once both stacks land, a new operation field or state has to be added twice. One shared module, after whichever lands second rebases, would avoid that.

Verified against 242dd6e0e73262bf7fedd0198322f8f8ea9bb05b. I checked the page against the server: /v1/operations takes limit/org_id/state/kind/target_id, caps limit at 250 and rejects an unknown kind with 400. /v1/audit-events returns { events }, caps at 250, and records non-GET/HEAD/OPTIONS requests after the operator check. antd 6's Alert takes title. I reproduced finding 2 by running the comparator in Node. npm test in ui/ passes (54/54). I didn't run next build or a typecheck (I couldn't install dependencies), and I didn't exercise the pages 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. 3 non-blocking findings in the review comment above.

@ac-pplx
ac-pplx added this pull request to stack #1276 November 1, 2026 04:54
zibi-pplx
zibi-pplx previously approved these changes Nov 1, 2026

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

One small addition to the existing review.

  • [low] The UI copy documents a backend inconsistency as expected behaviour.
    ui/src/app/dashboard/operations/page.tsx:257,259: the tooltips tell operators that "Forks are listed under their source sandbox's bare UUID". That is true because fork operations record target_id as the bare source UUID (crates/libs/api/src/fork_operations.rs:986), while every other sandbox operation uses sbx_. Suggestion: open a tracking issue for recording forks under the sbx_ ID, and reference it from the comment at :133, so the second query and this copy are removed together when it lands.

@zibi-pplx
zibi-pplx dismissed their stale review November 1, 2026 22:30

Withdrawing this approval for now. Any feedback above still stands.

@ac-pplx
ac-pplx force-pushed the ac/admin-ui-operator-gate branch from f45c57f to 521216d Compare November 2, 2026 06:56
@ac-pplx
ac-pplx force-pushed the ac/admin-ui-operations-audit branch from 9987790 to ba981c2 Compare November 2, 2026 06:56
@ac-pplx
ac-pplx force-pushed the ac/admin-ui-operations-audit branch from ba981c2 to 8f4dc49 Compare November 2, 2026 16:53
@ac-pplx
ac-pplx added this pull request to the merge queue Nov 2, 2026
An error occurred while trying to automatically change base from ac/admin-ui-operator-gate to main November 2, 2026 17:11
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 2, 2026
The admin API records every lifecycle operation (GET /admin/api/v1/operations,
with state, kind, org and target filters) and every admin mutation
(GET /admin/api/v1/audit-events), but the console showed neither: a
failed or stuck operation, or who changed what, was only visible through
curl.

Operations lists the newest operations across organizations with their
state, target (sandboxes link to their page), organization, surface,
recorded user, attempts and result or terminal error, filterable by
state, organization, kind and target ID. Audit log lists admin
mutations with actor, method, path, status and duration, searchable
within the loaded page. Both read at most the API's 250 newest rows.
Operations:
- A fork records its source sandbox by the bare UUID; its target now
  links to the sandbox page, with a tooltip saying why it is listed
  under that UUID.
- The Result column shows the progress message or the terminal
  error's message, with the error code in a tag and the full error in a
  tooltip. Only failed and expired operations are shown as faults;
  canceled is not.
- The kind and target ID boxes apply on Enter and on clear, so typed
  text never looks applied before it is.
- Actor emails are looked up at most 6 at a time, and a failed lookup
  is retried on the next load instead of being remembered as the ID.

Both pages drop answers to superseded requests, and a failed load says
the rows shown are from the previous filters or load. The Audit log
subtitle now says what it records: every admin API request other than
GET, HEAD and OPTIONS by a signed-in operator, including refused ones.
- Text typed in the kind or target ID box without Enter is applied
  when another filter changes, so a box never shows a filter that the
  rows do not have.
- A failed load with no rows to keep reads "Not loaded", on Operations
  and on the Audit log, instead of "No operations match these filters"
  or "No audit events".
- A fork records its source sandbox by the bare UUID: an `sbx_` target
  ID now also reads that form and merges the answers, and the box says
  so.
- Actor lookups go through one queue per page, at most 6 in flight
  across loads, and leaving the page cancels them (`adminApi.get`
  takes an abort signal). The per-load helper and its test, which had
  no other callers, are removed.
…t matches

- Enter or clear in one text box, and Refresh, applied only that box:
  type an sbx_ ID in Target ID without Enter, press Enter in Kind, and
  the rows were not filtered by target while the box still showed it.
  One function now applies both boxes as typed, from the dropdowns,
  Enter in either box, clear (with the cleared value passed explicitly)
  and Refresh; loading still follows the applied filters.
- An sbx_ target's UUID is lower-cased before querying: target IDs match
  exactly, and forks record the bare lower-case UUID.
- A fork row's tooltip suggests filtering by the source's sbx_ ID,
  which also finds forks.
- Merged rows (an sbx_ target reads twice) sort by their timestamps at
  full precision; the operation ID breaks only exact ties.
- The audit log says "Not loaded" only when a failed load left no rows.
  With rows kept from an earlier load and a search that matches none of
  them, it says "No events match".
The merged Operations list sorted timestamps as text when they had the
same length, assuming a fixed fractional width. The API's RFC 3339
strings trim trailing zeros and leave out a zero fraction, so
timestamps of different lengths fell back to Date.parse, which keeps
whole milliseconds, and the operation ID decided their order. Each
timestamp now compares as its milliseconds, then its fraction padded to
nine digits; the ID breaks only exact ties. The comparator moves to a
library module with a test.
Only an sbx_ ID in Target ID also searched the bare UUID that forks
record their source by. A pasted bare UUID went to the server as
typed and returned only the forks, and an upper-case one returned
nothing. An sbx_ ID or a bare UUID, dashed or not, in either case, is
now lower-cased and searched in both forms, and the answers merged.
@ac-pplx
ac-pplx force-pushed the ac/admin-ui-operations-audit branch from 8f4dc49 to 91bdd9a Compare November 2, 2026 17:13
@ac-pplx
ac-pplx removed this pull request from stack #1276 November 2, 2026 17:13
@ac-pplx
ac-pplx changed the base branch from ac/admin-ui-operator-gate to main November 2, 2026 17:13
@ac-pplx
ac-pplx enabled auto-merge November 2, 2026 17:17
@ac-pplx ac-pplx closed this Nov 2, 2026
auto-merge was automatically disabled November 2, 2026 17:17

Pull request was closed

@ac-pplx ac-pplx reopened this Nov 2, 2026
@ac-pplx
ac-pplx enabled auto-merge November 2, 2026 17:18
@ac-pplx
ac-pplx added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit e47fb56 Nov 2, 2026
23 checks passed
@ac-pplx
ac-pplx deleted the ac/admin-ui-operations-audit branch November 2, 2026 17:31
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.

3 participants