Skip to content

feat: make the inspector the dashboard - #36

Merged
alsey89 merged 20 commits into
mainfrom
feat/console
Aug 18, 2026
Merged

alsey89 merged 20 commits into
mainfrom
feat/console

Conversation

@alsey89

@alsey89 alsey89 commented Aug 11, 2026

Copy link
Copy Markdown
Owner

The v0.3 inspector was reachable from a 0.8rem grey footer link, next to a link that dumps raw JSON. It is the best thing in that release and almost nobody would find it.

/ is now the console. The routes table becomes a rail on the left that doubles as the domain filter, and it refreshes on a timer instead of only at page load. /inspect redirects, since the README, the CHANGELOG and the 0.3.0 release notes all name it.

Nothing about capture changed. Same storage, same defaults, same config keys.

What is in here

  • The console at /, merged from the two old templates. Every load-bearing behaviour of the v0.3 inspector page survives: the reconciling render, the scroll anchor, cursor paging, store-side filtering, the generation counter.
  • The rail reconciles by domain rather than rebuilding, so it does not snap its own scroll every tick.
  • Routes are live. A dev server coming up shows within ten seconds with no reload.
  • Upstream probing runs concurrently and caches for 5 seconds. It used to dial serially with a 300ms timeout, which the new refresh interval would have made a real cost.
  • Three widths. Below 1100px the rail folds to a chip strip. Below 700px the detail becomes a sheet over the list.
  • Capture-off shows the routes full width and names the config key, instead of an empty page saying "The inspector is off".
  • Column headers, status pills, relative times, keyboard control, copy as curl, marked redacted headers.
  • Each route row gets a small link to open the site, which the old dashboard had and the merge would otherwise have dropped.

Worth a reviewer's attention

asCurl had a command injection hole in an earlier commit on this branch. Every field was quoted except the HTTP method, and Go's net/http accepts any RFC 7230 token as a method, with tchar including backtick, $ and '. A hostile page could plant a poisoned row with a fetch, and it would execute when someone copied that row as curl. Fixed in efd2630; TestConsoleQuotesEveryCurlField now pins every field.

The relative-time re-stamp walks all rows on purpose. Two stopping rules were tried and both are wrong, for different reasons, and the comment above restamp() records why so neither comes back.

Not in here

The console is now mostly JavaScript that no test executes. That is #35, along with three smaller items from the same review.

Design: docs/superpowers/specs/2026-08-11-inspector-console-design.md

alsey89 added 20 commits August 11, 2026 18:20
The v0.3 inspector works but nobody will find it. The dashboard links to it
from a grey footer, next to a link that dumps raw JSON.

This makes / the console. Traffic is the page, routes become a rail that
doubles as the domain filter. Covers the responsive fold, the two states the
merge can fumble, and the concurrent probing the live rail now needs.
The console refreshes routes every ten seconds instead of once per page
load. Dialing serially with a 300ms timeout made five down routes a 1.5
second render, and the page and its API call each paid separately.

Answers are cached for 5 seconds, half the poll interval, so a route that
comes up shows within one tick and never two.
The v0.3 inspector was reachable from a 0.8rem grey footer link, next to a
link that dumps raw JSON. It is the best thing in the release and almost
nobody would find it.

/ is now the console. The routes table becomes a left rail that doubles as
the domain filter, and the rail refreshes on the poll tick instead of only
at page load.
refreshRoutes cleared the list and appended fresh rows every ten seconds.
#rail is its own scroll container, so a user scrolled down a long rail was
snapped back to the top on a tick they did not cause. It now reconciles by
domain the way render() reconciles by record id: unchanged rows are never
touched, only the dot and the address are updated, new routes insert in
place and removed ones are dropped.

A selected route removed from the config left nothing marked in the rail
while the list stayed filtered to it, which read as the inspector going
quiet. The rail now falls back to All traffic and requeries.

Also fold the dead duplicate grid-template-columns rule into the original,
retitle the page now that it is the front door, and pin the rail's filter
contract in the test.
The inspector page moved to /. The README, the CHANGELOG and the 0.3.0
release notes all name the old URL, so it redirects rather than 404s.

302, not 301. A permanent redirect is cached forever and there would be no
way to take /inspect back later.
The inspector page said 'The inspector is off' and offered nothing to do
about it. With routes on the page there is something better to fall back to:
the rail goes full width and the note names the config key.

One code path. setOff already handled a reload flipping capture while the
page stayed open, so the initial load goes through it too.
Three columns do not fit a half screen window, which is the normal way to
watch an inspector.

Below 1100px the rail folds to a chip strip above the list. Below 700px the
detail becomes a sheet over the list, instead of stacking under a list
capped at 45vh where reading a request scrolled the list away.

Added a base display:none for #sheetclose outside the media query. It is a
new direct child of the main grid, and without a default it would auto-place
into an implicit fourth cell at every width, not just below 700px.
body.inspector-off #detail and body.sheet-open #detail tied in specificity,
so source order alone decided which won. A sheet opened below 700px could
survive a capture-off transition, floating stale content over what should
be a full-width routes list. Same tie affected the close button.

Added a capture-off rule after the sheet-open rules so it wins the tie.

Also commented the two other places where a rule's winner depends on
staying after the one it overrides, so a future reorder does not silently
flip the result.
…addr

The list's path column absorbs whatever the other four fixed-width
columns leave. A 40/60 split against the detail pane left less than
those four columns needed below about 1000px, crushing the path to
nothing at the exact width this task exists to serve. Flipped the
split so the list gets 55%.

The rail's upstream address had no shrink handling, so a long one
overflowed past the rail and got painted over by the list panel next
to it instead of truncating. Give it overflow, ellipsis and a
min-width of 0 so it truncates cleanly and the domain keeps priority.
Five unlabeled columns is a puzzle. The headers stick to the top of the
list.

Recent rows now read as an age, which is the question the clock time was
standing in for, with the full timestamp on hover. Older than an hour goes
back to a clock time.
Seeing the request and reproducing it were separate jobs. Now the detail
pane hands you the command.

Redacted headers are dropped from the command rather than pasted in. A
command that looks right and fails on a [redacted] credential is worse than
one that is visibly missing it.
r.method reached the curl command unquoted. net/http accepts any RFC 7230
token as a method, including backticks and dollar signs, so a captured
request with a method like GET`id` executed on paste. A hostile page a
developer merely visited could plant that row with a fetch call.

Route the method through shellQuote like every other field. Add a
regression test that pins each field in asCurl to its shellQuote call.
Arrows move the selection, / focuses the filter, Esc leaves a field and
closes the detail sheet. j and k work too.

Keys are ignored while a field has focus, so arrows still move the caret in
the filter box.
A value rendering as the literal string [redacted] reads like the header was
sent that way. It is now marked as redacted, with the config key that stores
values as sent on hover.
The page had its own copy of "[redacted]" and the test only checked the
string appeared somewhere in the body. It appears twice, so changing the
JS constant kept the test green while redacted values started rendering
as literal text and leaking into copied curl commands.

The sentinel now comes from inspect.Redacted through the template data,
so there is one definition. The test asserts the whole declaration.

Also fold the two route handlers onto one routeViews helper. They built
the same slice and called UpstreamAddr three times per route. Document
that statuses wants the complete address set, since it drops anything it
is not asked about, and that the probe cache is meant to miss on the ten
second tick so nobody raises the TTL to make it hit.
The old dashboard rendered each route as a link, so clicking it opened
the site. The rail replaced that with click to filter and nothing opened
a route any more. With capture off it was worse: the row looked inert
but the click still filtered a hidden list.

Each row is now two controls. A button filters, a small arrow link opens
the site in a new tab. Both are focusable, so the rail is no longer the
one region that needs a mouse. The link is skipped by the filter click
handler, and its href is set as a property, never as markup.

With capture off the filter button is disabled and the link stays live,
so the routes page keeps a use.
show() awaited fetch and json unguarded while every sibling caught. A
daemon restart threw an unhandled rejection, and the row was already
highlighted, so the pane kept showing the last request. It now lands in
a note.

Holding an arrow key ran a fetch and a full reconcile at the OS repeat
rate. Stepping now moves the highlight on the two rows that changed and
holds the detail fetch for 120ms, so a held key is one request. Landing
on the row you are already on does nothing at all.

The ten second re-stamp wrote every row's age cell whether or not the
label had changed, including rows past the hour whose label is fixed,
and it kept doing it over a hidden table with capture off. It now skips
when capture is off, writes only what changed, and stops at the first
row past the hour.

Capture off left the search box and both selects live, so typing still
fired a refresh that 503s. All five header controls go with the state.

The detail sheet outlived a clear and an off then on cycle, leaving a
sheet over the list saying "Pick a request". Both drop the selection, so
both now close the sheet.

Arrowing up parked the selected row under the sticky header. Row scroll
margin clears it.

Drop the dead display: none on #sheetclose inside the sheet media query.
The base rule outside it is the one that stops grid auto-placement, and
it already applies at that width.
The hour break assumed every row below an hour-old row is older still.
It is not. Rows are ordered by record id, and inspect assigns the id
when a request completes, while the label comes from when it started. A
long poll, an SSE stream or a hung backend that ran for an hour lands at
the top of the list with the oldest start time, and the break returned
there, freezing every label under it for the life of the page.

The upgraded-connection case is already special-cased in the middleware
for the same reason, but that covers 101 and h2 extended CONNECT only.

Drop the break. The capture-off guard and the per-row dirty check carry
the win that mattered: no DOM writes and no table-wide style
invalidation for a row whose label did not change.
@alsey89
alsey89 merged commit f6f33e8 into main Aug 18, 2026
2 checks passed
@alsey89
alsey89 deleted the feat/console branch August 18, 2026 03:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant