The env rules were exact-match, so .env.development.local was ignored but a
.env.development.local.bak sitting next to it was not — it showed up as an
untracked file asking to be committed. These hold the WSL IP of whichever
machine wrote them, which is the reason the originals are ignored in the first
place.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Docker's four remaining tabs (Compose, Images, Volumes, Networks) drop their raw
<table className="docker-table"> markup and bespoke toolbars for the shape
ContainersTab already used. Each gets a distinct viewPageKey AND urlScope, since
one route now mounts five tables and a shared scope has them writing each other's
?view=. Views were chosen from what the payload actually carries rather than what
would read well: Volumes has no "Unused" view because neither transport reports a
reference count, and a Networks "Containers" column was dropped because the agent
sends the count and the local service does not — a column that is permanently
blank on half your hosts is the exact failure this round exists to stop.
Terminal stops hand-rolling two surfaces that already had converged twins in the
repo, imported by nothing but the Style Guide: Processes renders ProcessTable,
and Services renders serverdetail/ServicesTab, taught to serve the local host as
well as a paired agent. This was mostly deletion.
Databases: the explorer's tables move onto the shared chrome, losing three raw
search inputs, two page-level row counts and their segmented rows.
Deployments: the fake table (a header row over <button> rows) becomes a real one,
and the target-server <select> becomes the Target column's own menu.
Templates keeps its card grid — a catalog is a legitimate place for tiles — but
loses the row count, filter-chip row and sort control the shared chrome replaces.
FTP and WorkspaceDetail get chrome on tables that had none.
components/SearchFilterBar.jsx is deleted: a complete competing search/filter/sort
bar with zero importers, confirmed not on the SDK export list.
Fixes for regressions the verification pass caught, before they shipped:
- Terminal > Processes swapped the whole table out on every `loading` tick. It
auto-refreshes on a timer, so the chrome, active view, column rules, sort and
scroll position were being unmounted and rebuilt every few seconds. Only the
first load swaps now.
- The Databases backups pane returned its empty state INSTEAD of the chrome, so
a tab with no backups had no Refresh — exactly when you want one, because a
backup that just finished is why the list looks empty.
- Every open table tab kept its chrome mounted while sharing one urlScope, so
four open tables fought over ?dbstruct.view=. Scoped per tab. The process list
is namespaced per ENGINE too: `state` reads p.command on MySQL and p.state on
PostgreSQL, so one namespace let a MySQL view apply itself to Postgres and
match nothing.
- The Images size column disagreed with itself: the rule value was base-1000 MB
while the cell rendered base-1024, so "Size is over 400 MB" matched a row whose
own cell read "392.9 MB".
- Deployments' server filter used to reach the whole history through ?server_id=;
as a column rule it only reaches what was fetched. The window is explicit now —
a full page offers Load more instead of quietly filtering a truncated list.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This is the change behind the headline symptom. A DevTools capture of /domains
showed ~70s of network activity and a 404 taking 28.77 SECONDS. The backend
measured that same request at 1.0ms in its own api_usage_logs table. A routing
404 runs no handler and touches no database, so it cannot spend 28s working —
the entire gap was Stalled/Queueing in the browser.
The cause: in dev the API was same-origin with the Vite dev server, which serves
each of the ~760 source files as its own request. A browser allows six
concurrent HTTP/1.1 connections per origin, so twenty API calls queued behind
hundreds of module requests. Splitting the origin doubles the usable sockets
and, more to the point, stops a slow API call from blocking a JS module.
Same-origin was not an arbitrary choice — the comment it replaces warned about
"CORS/preflight redirects". Both halves of that are handled:
- CORS: config.py DEFAULT_CORS_ORIGINS already lists the Vite dev origin on
both localhost and 127.0.0.1, so nothing new is being allowed.
- Preflight: every panel request carries an Authorization header, which makes
it non-simple. Without Access-Control-Max-Age the browser re-runs the
preflight every few seconds (Chrome caches 5s by default), so a cross-origin
panel would pay two round trips per call forever — worse than what it
replaces. CORS now sends max_age=3600, verified against the real create_app()
rather than assumed: a preflight from http://localhost:41921 returns 200 with
Access-Control-Max-Age: 3600 and the matching allow-origin. That header is
worth having regardless, since any deployment serving the panel from a
different origin than the API was paying the same tax.
Escape hatch: VITE_API_PROXY=true restores same-origin for a setup with an
origin CORS does not expect. Socket.IO is deliberately untouched — it stays on
window.location.origin in dev, because a WebSocket upgrades out of the HTTP
connection pool and gains nothing from the split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Incidents was showing the same idea twice: a converged "All incidents" table, and
under it a second table headed FLEET ALERTS with its own Active/Acknowledged/
Resolved segment row. A host alert IS an incident whose source is a host alert —
the SOURCE column already said so. FleetAlertsPanel is deleted (188 lines, and
Incidents was its only importer); alerts are folded into the existing row merge
with server -> subject, metric/value/threshold -> what happened, severity ->
impact, status -> state. Ack and Resolve move into the row-actions cell, shown
only for rows that have an alert id and are not resolved. The segment became an
"Acknowledged" builtin view alongside the Active/Resolved ones that already
covered its other two buckets.
Config Templates: the Compliant/Drifted/Unknown KPI band and its progress bar are
gone, replaced by a per-row Compliance column plus Drifted/Compliant builtin
views. The band could say three assignments had drifted but never which template
caused it; the column answers that per row. This needed the template payload to
carry its own tallies, which is the same single query the bare assignment count
already cost.
Recycle bin: the All / domains / saved-views segment row is gone — those buckets
are a filter on the Type column, and the Type column's own menu does it with live
per-value counts and can be saved into a view. Settings tab panes now sit on the
same card surface every other table in the app does.
Users: Add User and Invite User move onto the chrome line next to the filter and
"..." instead of each occupying a row of their own above its table.
SSL certificates: was a card list with a KPI band, one tab away from /domains
being a full table. Now a table with saved views (Expiring soon / Valid / Failed
or missing), search, column filters and export. The leading tile takes the row's
state too — a fixed green shield beside an expired certificate contradicted the
Status cell next to it.
Service detail: the Environment and Events tabs become tables with the inline
chrome an inner tab gets.
Fixes found by verification, in code this round touched:
- ServerTemplates had four columns with a `value` accessor and no `render`, so
DataTable fell back to row[key]: two rendered blank, one printed a raw array,
and `services` printed a list of OBJECTS, which React refuses to render — that
one took the whole tab down for any template declaring a service. Pre-existing,
and invisible because the screenshot fixture sent `services: ['sshd']` as
strings. The fixture now matches to_dict() so it cannot hide it again.
- useTopbarChrome returns a ready `actions` value. All eight hoisting pages
destructured only `portal` and dropped the fallback, so outside a tab group the
filter button and "..." would have gone nowhere rather than inline.
- sameViewState compares only the `page` keys a page still captures. Retiring a
key (as the recycle bin just did) otherwise stranded every view a user had
already saved with it, pinning "Unsaved" on with no way to clear it.
- The environment tab's "Applies to" column was gated on the app's CURRENT
compose services, so a variable targeting a since-renamed service showed
nowhere and read as applying to everything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two shared surfaces that pages had quietly forked.
KPI tiles: six places built their own grid container instead of using <KpiBand>.
Three re-declared the primitive's rules verbatim, and TWO used class names that
no stylesheet defines at all -- Git's `dom-kpis` and ProtectionStatusCard's
`protection-status-card__kpis` -- so those tiles were full-width stacked blocks
from the day they were written. The SCSS still carried comments reading "the
rule was lost in the recovery rebuild, so the tiles stacked full-width", which
is the same failure being patched per page, twice. All six now render through
KpiBand and the duplicate grid rules are deleted, so the grid is declared once
and there is no copy of it left to lose.
Selection bar: it existed twice. GridBulkBar rendered inline above the table
(Domains, Docker containers) while ResourceListPage hand-rolled a floating pill
at the bottom (Services, Workspaces) -- same feature, different place depending
on which page you were on. GridBulkBar is now the single implementation and
floats at the bottom: it does not reflow the rows you are picking, and it stays
reachable from row 400 of a long list.
Made it legible while there. A dark pill on a dark page at --surface with a
hairline border read as a smudge; it sits on --surface-2 now with a deeper
shadow and an accent-tinted rim, since two greys a few percent apart will never
separate on their own. Its actions also style bare <button> children, which is
what GridBulkBar's documented API takes -- Domains and Docker were passing them
and getting white OS-default controls inside the pill.
Adds a screenshot section that actually ticks a checkbox, because none of this
is visible in a plain page shot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two callers asking for the same URL at the same moment now share one network
request. This is NOT a cache -- the entry is dropped the instant the request
settles, so nobody can read a stale body. It only collapses the overlap, which
is where the duplicates were coming from: StrictMode double-invokes every effect
in dev, two independent components own the same endpoint, and a poller whose
previous tick has not returned fires again on schedule.
Only plain GETs with no body, no custom headers (X-DB-Password and friends
change what comes back) and no abort signal (one caller aborting must not cancel
someone else's request) are shared, keyed by workspace since the same path
returns different rows under a different X-Workspace-Id. A joining caller gets a
structured clone, so the two keep the independent objects they had when each ran
its own fetch.
Also: refreshContributions() always started a new request, so using it to mean
"make sure this is loaded" fetched the plugin envelope twice on every app load.
Split into ensureContributions() (load once -- what the dashboard shell and
useContributions want) and refreshContributions() (deliberate re-fetch, correct
after installing or removing an extension).
Measured with the new probe-requests.mjs against a 700ms-per-reply backend,
which is what exposes pollers stacking. /domains went from a capture showing
domains x4, deployment-jobs x8 across two statuses, notices x3, status x3 and
five more x2 endpoints, to 20 requests / 19 distinct -- the only repeat is the
deploy poller's second tick inside the 9s dwell, which is the poller working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds projects/vaults/shared-variables shots and drops services-cards, whose
list/cards toggle no longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Measured from a DevTools capture of /domains: ~70s of network activity, requests
at 3-34s, dozens stuck Pending. The backend was not slow. Its own api_usage_logs
table records the 404 that DevTools reported at 28.77s as taking 1.0ms
server-side. The time is queueing in the browser -- in dev the API is same-origin
with the Vite dev server, so every call shares the 6-connection HTTP/1.1 pool
with Vite's ~760-module graph. Two changes here reduce what is put into that
queue; the origin split itself is a separate change.
DeployPill lives in the global shell, so it polls on every authenticated route.
It fired two requests every 6s with no guard against the previous pair still
being outstanding -- at 30s responses that is five concurrent copies of the same
request, which is exactly the "deployment-jobs?status=running x5" in the
capture. It now refuses to start a tick while one is in flight, skips ticks
while the tab is hidden (catching up on visibilitychange), and asks once instead
of twice: the endpoint returns each job's status, so querying per-status doubled
the request count to learn the same thing. The socket-driven refreshes go
through the same guard.
The Sidebar unconditionally probed /gpu/ and /wordpress/standalone/status on
every app load. Both routes belong to extensions -- serverkit-gpu and
serverkit-wordpress -- and when the extension is absent the blueprint is never
registered, so both are guaranteed 404s. They could not change a rendered pixel
either: the only manifest declaring requiresCondition "gpuAvailable" ships with
the GPU extension itself, so with it gone nothing reads the answer. Both probes
are now gated on a contributed nav item actually asking for that condition.
They were not free -- two doomed requests held a third of the page's connection
budget for their entire lifetime.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three things the table was getting wrong now that it is the page:
Health is hidden by default. On a typical host almost every row read "No
healthcheck" -- the column spent its width restating that the image never
declared one, and Status already says "(healthy)" for the few that do. It is one
click away in the column menu, and the "Unhealthy or unwatched" view turns it
back on, which is where it actually says something.
Created rendered docker's raw "2026-08-12 09:12:33 -0400 EDT" -- 29 characters
of which the last 14 are never what you came to read -- in an auto-width column,
so it ran underneath the row actions. It shows an age now, with the full stamp
on hover; the filter still works on the real date because `value` is untouched.
Created, Resources and the actions column all get explicit widths so they stop
colliding.
Selection: checkboxes plus a bulk bar for restart/stop/start. Remove is
deliberately absent -- bulk-deleting containers from a checkbox is a mistake you
cannot undo, and the per-row action already asks. Protected system containers
are skipped with a count, not silently failed, and each container is actioned
individually so one failure is reported against the container that failed rather
than aborting the rest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A stacked 32px icon chip plus its 14px gap cost every tile ~46px of height that
carried no information, so a four-tile summary strip was spending a fifth of the
page above the content it summarises. Laid out as a row -- [icon] [value+label]
[trend], spark still bleeding along the bottom -- the same tile is ~64px instead
of ~110px and reads in one line.
The skeleton follows the new geometry so nothing jumps on load.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
These three were the odd ones out in the Organization tab group: Projects was a
card grid, Vaults and Shared Variables were master-detail panes with a fixed
sidebar. None of them could be searched, sorted, filtered or saved as a view,
which every other list in the panel can do.
All three go through ResourceListPage now. Vaults and Shared Variables keep
their two depths -- a table of vaults/groups, then a table of that one's
secrets/variables -- with the top bar following the depth (create at the list,
back-and-manage inside one) and the search term cleared on the way in, since a
term that matched a vault name is not a secret name.
Note for the next column author: DataTable renders a cell as column.render(row)
and falls back to row[column.key]. A `value` accessor feeds sorting and the
filter rules ONLY -- a derived count column (environments, secrets, attached)
with no `render` renders blank. Name cells want sk-cell-name.
The footer's page-size control now hides itself when the table is shorter than
the smallest page it could cut to. "25 / 50 / 100 / All" over three rows is a
control with nothing to control.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A table gets exactly one row of chrome:
[View name v] ....................... [search] [filter] [...]
and nothing else. The row count is gone from it entirely: GridViewPicker's
`total` prop is deleted, not deprecated. It printed the number DataTableFooter
already prints under the rows it counts, and on Servers it appeared twice --
once next to the view name, once on a second toolbar -- plus an "N online" that
no filter, sort or view ever referred to.
Placement is decided by who owns the top bar. A top-level page in a tab group
hoists search, filter and the overflow menu up there next to its own actions;
an inner table (the Docker, security, settings and server-detail tabs) keeps
them inline on the view line, because that route's bar belongs to the page
around it.
useTopbarChrome PORTALS into a slot TabGroupLayout renders rather than handing
the node up through state. That is not a style choice: an effect that publishes
a node upward must name everything the node closes over in its dependency list,
and useTableChrome rebuilds toolsProps on every render -- so it fired every
render, set state on the layout, re-rendered the child, and looped
("Maximum update depth exceeded"). A portal has no dependency list. Using
display:contents on the slot keeps the portaled children as flex items of the
real container, so the top bar's slot ordering still sorts them into fixed
positions -- the order rules name both depths for that reason.
A ListToolbar now survives only if it carries something real: a quick-filter
segment, a scope select, a checkbox. Its `count` prop is deprecated with zero
core render sites; it stays accepted because ListToolbar is SDK surface.
The list/cards layout toggle is removed from core. A layout switch is a third
way to change what the table shows, next to views and column filters, and
unlike those two nothing can save or share it. renderCard keeps working for
extensions since ResourceListPage is SDK surface -- no core page passes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(ui): one row of chrome per table
A table gets exactly one row of chrome:
[View name v] ....................... [search] [filter] [...]
and nothing else. The row count is gone from it entirely: GridViewPickers
Replace the full-width Create pill with a small circular accent '+'
(AI chat-bubble style) riding on the first sidebar category row next to
the section label; survives the collapsed rail as a bare FAB and shows
as a circle at the start of the topbar nav strip. Move Test Sandbox out
of the System category and into the dev-only Dev Tools section, which
now renders whenever useDevMode is true (dev build or admin Developer
mode), keeping the nav entry and route guard on the same flag.
Relocate the global '+' quick-create from the crowded sidebar footer
(next to the notification bell) to a prominent Gmail 'Compose'-style
extended Create button pinned below the brand section. The menu now
opens downward. Collapses to a circular mini-FAB in the rail layout
and a compact accent icon button in the topbar layout; the mobile top
bar keeps the compact icon variant.
test_ext_to_ext_seam_absent_side_returns_default asserted that
get_installed_extension_attr returns its default for serverkit-analytics, on
the stated assumption that analytics "is not installed in this test panel".
That assumption is a property of the machine, not of the code. On a checkout
that HAS analytics under backend/app/plugins/, the seam correctly resolved
`inject` and the test failed with:
assert <function inject at 0x...> is None
which reads as "the seam is broken" and means precisely the opposite -- the
seam did its job.
It now names a slug that cannot exist, so it tests the contract ("callee absent
-> default, never raises") instead of which extensions happen to be installed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
20 tests across five files failed on any checkout with the serverkit-wordpress
sibling repo present, with the message "the serverkit-wordpress extension is not
installed". That message is what made this hard to see: the extension WAS
available, and the suite is explicitly built so a fresh clone passes without it.
The cause is that wordpress_bridge.ensure_loadable() gates on an ACTIVE
InstalledPlugin ROW, not on importability. That is deliberate (audit F2): a
DISABLED extension's modules stay importable so its blueprints can be
re-mounted, but its services must not be reachable. When that check landed,
wp_extension_package quietly stopped being able to deliver what its docstring
promised -- it only registers the package in sys.modules, and it took no `app`
fixture, so it could not create a row even in principle. Its sibling
wp_extension seeds one, which is exactly why those tests passed and these did
not.
So the fixture now seeds the active row too. Nothing about the extraction was
wrong; a fixture lost a capability and the error message blamed the wrong thing.
Verified: the five affected files plus test_setup_reconcile (the other consumer
of this fixture) -- 96 passed, 0 failed, where 20 failed before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both delete dialogs said "permanently delete the application and all its data",
and the cron note said scheduled tasks "will be kept but detached". Neither is
true since applications became soft-deletable: the app goes to the recycle bin
with its files and data volumes intact, and its cron jobs are suspended and
resumed on restore rather than detached.
A confirmation that overstates the damage is not harmlessly cautious -- it is
the screen someone reads before deciding, and it was describing a delete that no
longer exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.
**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:
DELETE containers down (VOLUMES KEPT), vhost removed, firewall closed,
cron suspended, row tombstoned. All reversible.
RESTORE vhost rewritten, firewall reopened, cron resumed. Containers left
STOPPED -- "put it back" must not silently start processes or open
ports, the same reasoning that stops domain restore re-issuing certs.
PURGE volumes removed, uploaded source deleted, cron detached.
register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.
A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.
**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.
**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().
Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.
Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.
Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the remaining pages from the plan 69 triage.
Converted, each with its own view bucket and presets driven in a browser:
- Docker containers (docker-containers) 4 rows -> 3/1/2
- Server templates (server-templates) 3 rows -> 2/1/1
- Backups snapshots (backups-snapshots) 5 rows -> 2/2/1/2
- Git repositories (git-repositories) 3 rows -> 2/1/1
- Monitor checks, and the three ServerDetail tabs (cron, services, tunnels)
Two KPI bands STAY, by audit rather than omission: Docker's four tiles are
unfiltered `.length` of four different collections, and ServerTemplates'
count `ServerTemplateAssignment` rows -- a different entity from the cards --
with "Unknown" being a remainder, not a predicate. Neither is a filter.
ServerTemplates' templates grid did become a table, and the reasoning is worth
keeping: the card was a table row wearing a box -- six labelled scalars and two
buttons, no image, no identity -- and it HID the two fields that decide
anything (`auto_remediate` / `remediation_approval_required`, i.e. "may this
rewrite my servers without asking"). Its onClick set state nothing ever read.
The Library tab keeps cards: it is a catalogue you browse, not an inventory you
administer.
Docker's `status` column was the sortValue trap again -- `(c) => running ? 0 : 1`
typed it `num`, so any string status rule matched nothing. It now declares
type/value, and a `health` column was derived from the `docker ps` status text
because the list endpoint carries no health field at all.
Verified: lint 0 errors (3 guards; warnings 999 -> 988), production build,
render smoke 46/47, and the full prober -- 110 presets across 15 targets, zero
EMPTY, INERT, UNSCOPED or BARCOUNT flags.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two worst endpoints in the backend, both measured in plan 69 phase 6.
**/status/apps** ran one `docker compose ps` per app plus a `docker inspect`
per container -- roughly 30 apps x 3 containers = ~120 process spawns per
request -- and it is refilled on a timer, so the cost never amortised. It is
now ONE host-wide `docker ps` whose output is indexed by the compose
project/service/working-dir labels Docker already stamps on every container,
shared between callers through a 3-second in-memory snapshot.
The snapshot is deliberately module memory rather than CacheService: it must be
droppable synchronously, because a status that survives the action that changed
it reads as a broken panel. So `invalidate(app_id)` is called from start, image
apply, deploy and rollback. 3s is short enough that a missed hook self-corrects
within one poll.
**/admin/activity/summary** ran two 90-day loops issuing one COUNT per day =
180 sequential queries. Now one `GROUP BY func.date(...)` per series with the
gaps zero-filled in Python, so the response shape is byte-identical. `func.date`
is portable across the SQLite and PostgreSQL this app supports. A third N+1 fell
out on the way: the top-users block did one SELECT per user, now a single IN.
New tests assert the zero-fill specifically -- a day with no rows must still
appear and the series length must match the window, which is exactly what a
GROUP BY drops if you forget.
1140 backend tests pass. The 3 failures in that run (test_fail2ban_jail_service,
test_url_swap, test_site_routing) all reproduce on a clean tree -- the last is
the known "serverkit-wordpress extension not installed" class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every page built its own "N of M" from its OWN array, which is only
search-filtered -- the column rules are applied inside DataTable. So the bar
read "4 of 4" while the table showed 2, on every page, the moment any rule was
active. Moving filtering into the columns is what introduced it.
useTableChrome now exposes `shownRows`/`shownCount`, computed with the same
engine the table uses, so the two cannot disagree. Fifteen call sites moved
onto it, plus the DataTableFooter counts that told the same lie.
ResourceListPage had a worse version: it sliced `items` for the page size and
DataTable filtered afterwards, so a page size of 25 took 25 UNFILTERED rows and
then filtered them -- "show 25" could render three. It pages `chrome.shownRows`
now, so the slice and the render agree.
The Security tabs already computed this correctly by calling applyFilters
themselves; they are left alone rather than churned.
Guarded going forward: the preset prober now reads `.sk-viewbar__count` after
selecting each view and fails on !BARCOUNT if the number disagrees with the
rendered rows. 0 mismatches across 11 targets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
You were right that plan 69 was not finished. /monitoring/incidents carried
BOTH old affordances -- a KPI band whose tiles set a filter, and an
Active/Resolved/All segment row -- while having no column menu, no search and
no saved views at all. My own phase 5 note called it "column-menu only", which
it could not be: there was no DataTable on the page to hang a column menu off.
The list was hand-rolled <button className="incident-row"> rows in a div, even
though `items` was already perfectly table-shaped. It is a DataTable now, which
is what earns it the column menu, saved views, chips, a tools menu and a search
box in one step.
The band's four tiles split the way the audit predicted they would:
- "Active" and "Resolved (recent)" were counts of a subset -> preset views.
- "Open incidents" and "Host alerts firing" are TRUE aggregates: they count
the raw incidents array and the alerts source, NOT the rendered rows. They
ride the view bar's meta line. Worth noting "Open incidents" was never the
same set as the Active segment -- it excludes host alerts -- which is why
that tile's number never matched the row count next to it. As a view it now
says so: Active selects 2, Open incidents 1.
The segment row is gone; its three buckets are rules on a new `resolved` bool
column, and `kind` distinguishes a monitor outage from a host threshold alert.
Verified in a browser: all 5 presets move the row count (4 baseline -> 2/2/1/2).
Both alert sources had EMPTY fixtures, so the "Host alerts" view selected
nothing and looked identical to a broken rule -- fixture now carries one firing
and one historical alert so the page renders both of its sources.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things I got wrong in the phase 2/3 round, both visible on
/monitoring/monitors.
**Two filter buttons.** These pages kept their server-side FilterDrawer AND
gained a GridFilterButton, so the page showed two identical filter icons
opening two drawers that both narrow the same list -- exactly the duplicate
affordance this round exists to delete. The plan said these pages gain "the
heading picker, the chip bar and the tools menu"; a second filter button was me
over-applying. The remaining one is the top bar's, which filters the QUERY and
so sees rows the client never loaded -- strictly the stronger of the two.
Column rules still come from each column's own menu and still show as chips.
The now-unreachable GridFilterDrawer goes with it.
**Two rows of chrome.** The view name sat on one line and a ListToolbar sat
under it holding nothing but two right-aligned icons -- a 32px empty bar
pretending to be a row. GridViewPicker takes an `actions` slot now, so the
table's own controls sit on the view-name line. Jobs keeps its ListToolbar
because it has a real count in it; Monitors and Telemetry drop theirs.
Verified: lint 0 errors (3 guards), build, render smoke 46/47, and every preset
on all three pages still drives its server query.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last class from plan 69's triage. These pages carry 8 and 7 tables across
13 and 20 tabs, so a page-heading view picker was never right -- it would sit
above whichever tab happened to be open. Each TABLE now has its own view bucket
and its own picker, inside its own tab.
Twelve tables adopted: security-firewall, -fail2ban, -ssh-keys, -ip-allowlist,
-ip-blocklist, -scanner-findings, -scan-history, -quarantine, -integrity, and
settings-users, -invitations, -migrations, -recyclebin, -api-keys.
Two tables deliberately did NOT get one. ApiSettingsTab's "Recent Deliveries"
is a nested drill-down and "Top Endpoints" is a five-row analytics readout --
neither is a surface you return to and narrow, and a picker on them would
repeat exactly the over-application the KPI-band audit rejected. They keep the
column menu, which is the right stopping point.
The three multi-table tabs pass `urlScope`, so IP lists produce
`?allowlist.view=` / `?blocklist.view=` instead of two chrome instances
overwriting one `?view=`.
Presets are deliberately few and only where the DATA supports them. Notable
refusals, all verified against the real payloads rather than assumed:
- Fail2ban gets 2, not 3: a ban row is literally {ip, jail} with NO
timestamp, so "most recently banned" is not implementable client-side.
- Firewall gets no action/source preset: `action` and `from` exist only on
UFW rows and are not columns.
- SSH "unlabelled keys" could not use `comment is ''` -- ruleIsArmed() drops
an empty-string rule, so the view would have shown every key. The accessor
substitutes the em-dash the cell already renders and the rule matches that.
FirewallTab's protocol column gained a UFW fallback: that service returns
`{port:'8080/tcp'}` with no protocol field, so the column parsed blank on every
UFW host. `value` and `render` now share one accessor -- a preset has to read
the same way the row does, or it filters on something invisible.
Verified: lint 0 errors (3 guards; warnings 999 -> 990 as legacy .card markup
went), production build, render smoke 46/47, and every preset driven in a
browser. The IP-lists probe asserts each table writes only its own namespaced
param.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every link param is a GLOBAL name -- `view`, `sort`, `hide`, `f`, `match`,
`cols`. That is fine while one table owns the route and wrong the moment a tab
renders two: Security's IP lists (allowlist + blocklist) and Settings' API tab
would both read and write the same keys, so the allowlist's sort arrives as the
blocklist's and each overwrites the other's ?view=.
`urlScope` prefixes them to `<scope>.view`, `<scope>.sort`, ... Threaded
useTableChrome -> useGridViews -> useViewLink -> viewLinks. No scope keeps
today's names byte-for-byte, so every single-table page produces and accepts
exactly the URLs it always did -- that is the first thing the tests assert.
Two details that only show up with two readers on one query string: an UNSCOPED
reader now skips any key containing a dot, so it cannot swallow another table's
`blocklist.status` as its own page state; and `withViewParam` edits only its own
scope's handle, leaving the sibling table's untouched.
Also fixes a regression from the envelope commit: page-owned primitives moved
into the `page` bag, and stateToParams skips objects -- so a shared link
silently stopped carrying the search box and status filter it used to carry.
The bag is flattened into the URL again, and round-trips back through
toEnvelope.
12 tests, `node --test src/components/ds/grid/__tests__/viewLinks.test.mjs`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second endpoint after /apps, same pattern. Server.to_dict grows a `fields`
parameter so $select skips the derived work rather than only trimming the
payload: `onboarding_progress` is a JSON walk that is ~29% of a row and is read
by exactly one screen, and `group_name` is a relationship load. A caller that
needs {id, name} -- Domains and Backups both do -- now pays for neither.
`group_name` is eager-loaded when it IS wanted, so the list stops issuing one
SELECT per server for it.
Two details this route needed that /apps did not: it answers with a bare JSON
array, so no paging envelope is emitted (that would break every caller), and an
explicit $orderby now wins over the hardcoded by-name sort while the default is
preserved when absent.
3 more tests; the 293 existing server/fleet/agent tests still pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sweeps the last 23 <SortMenu> + <ColumnsMenu> pairs off 16 files. They are a
toolbar-level copy of what DataTable's per-column "..." menu does next to the
column it acts on, which is the duplicate affordance plan 69 exists to remove.
These pages are the triage's COLUMN-MENU-ONLY and PER-TAB-SCOPE classes --
Security (8 tables over 13 tabs), ServerDetail (6 over 12), Settings, Git,
FTPServer, Backups, MonitorDetail, Docker, the monitoring panels. Several
tables share one route behind tabs, so a page-heading view picker does not work
for them; the column menu IS the right stopping point, and only the duplicate
toolbar menus go.
Applied the plan's own rule -- never delete an affordance until the replacement
is verified present -- as a per-table precondition: no columnMenu={false}, no
global sortable={false}, and something left to manage. Where a table passed
`hiddenKeys` WITHOUT `onHiddenKeysChange` (DataTable then nulls the setter, so
the header menu's Hide silently does nothing) the setter was wired up FIRST --
otherwise removing ColumnsMenu would have deleted the only working control.
That leaves SortMenu, ColumnsMenu, GroupMenu and ViewMenu with zero render
sites; GroupMenu never had one. They stay exported and are marked deprecated in
ds/index.js: extensions may deep-import '@/components/ds', and none of the four
is on the SDK's blessed list, so removal belongs in the next SDK major rather
than here.
Verified: lint 0 errors (3 guards), production build, render smoke 44/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
on_restore_domain deliberately does not re-issue a certificate -- ssl_enabled
can still be True over cert files that expired while the row sat in the bin,
and quietly talking to Let's Encrypt is not something a "put it back" button
should do. That decision stays.
What was missing is telling anyone. The domain returns on plain HTTP while its
row still reads ssl_enabled, so the first sign is a browser warning.
A restore now has three outcomes instead of two: the side effect FAILED
(warning, error toast), it succeeded but left something actionable (notice,
warning toast), or it just worked. An on_restore hook returns a string to raise
a notice; it rides on the item so it cannot be confused with the error slot.
The notice only fires when nothing is actually serving HTTPS for that name --
app_vhost_kwargs re-attaches a covering wildcard, and warning about a domain
that came back on HTTPS anyway would just be noise.
3 tests: notice present, notice suppressed under a covering wildcard, and no
notice for a domain that was never HTTPS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 + the four Phase 5 pages the triage classified FULL-CHROME (one table
IS the route, no tabs).
Email. The plan said 8 rule-based presets were "already authored but held back
because they would be inert". That is not what is in the tree: there were FOUR,
all pure-sort, all live, and git history has never contained a rule-based preset
for this page. So they are written here rather than landed.
The conversion fixes three real bugs on both Email tables at once, all from
passing half of DataTable's controlled-props contract:
- `hiddenKeys` was passed WITHOUT `onHiddenKeysChange`, so DataTable set its
setter to null and "Hide column" silently did nothing.
- `filters` and `columnOrder` were not passed at all, so header-menu rules and
column moves lived in DataTable-local state and every saved view dropped
them on capture.
Tabs are separate ROUTES here, so only one picker ever mounts -- the plan's "two
pickers on one page" worry does not apply, and each tab keeps its own.
DeliveryLog is server-filtered AND server-paginated (limit 50), so its status
presets drive the SERVER filter through the page bag rather than a column rule.
That matters: its KPI tiles were whole-table GROUP BY counts, so a client-side
rule over the loaded page would have disagreed with the number it replaced. The
"Total" tile is a true aggregate and survives on the view-bar meta line.
The queue pages and FleetProxy get the chrome they never had. Their bespoke
command bars stay -- those are actions (retry, purge, promote), not a second way
to narrow rows.
SortMenu/ColumnsMenu deleted from all five: the per-column menu already does
sort and hide, next to the column it acts on.
Typed accessors added wherever a preset filters, per the trap that has now bitten
four times. Email `state` gains a string `value` while KEEPING its 1/0
`sortValue`, so "Active first" still orders correctly; a new `used` column
returns null for an unlimited mailbox so `gt` excludes it instead of reading it
as 0%.
Verified: lint 0 errors (3 guards), production build, render smoke 44/45, and
FleetProxy's presets driven in a browser (3 rows -> 1 on all four rule-based
views).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2 + 3 of plan 69 for the three pages that had BOTH problems.
Views out of the top bar. Each page put <ViewMenu> in useTopbarActions, which
also forced a hand-rolled `activeViewKey` string on every one of them purely to
stop the publish effect looping on the views object identity. All three now go
through useTableChrome, so the view name is the page heading and that stable-key
workaround is gone.
Their `filters` key -- the SERVER-side query object -- becomes
`page.serverFilters` in the envelope, with rename:{filters:'serverFilters'} so
views users already saved keep resolving. Their FilterDrawer is UNTOUCHED: it
filters the query, which is strictly stronger than a column rule over one page
of loaded rows. Each page now has both, which is the point -- they are different
tools, not two copies of one.
KPI bands. Audited every tile before deleting anything, and only three of the
twelve were really "a count of a filter":
- Monitors: Operational and Degraded became column-rule presets. Degraded
MERGES the old server-side {status:'degraded'} view, which silently dropped
every partial_outage row -- the backend tile sums both states and the pill
labels both "Degraded", so only the label rule reproduces the tile.
- Jobs: Running became a column rule. Failed and Backlog already existed as
SERVER-side views and stayed that way -- the grid holds 50 rows, so a
client-side duplicate would have been strictly weaker.
- Telemetry: the five severity tiles became presets that set
page.serverFilters.severity, not column rules, for the same reason.
The true aggregates survive, moved next to the row count instead of a
full-width band: Monitors "uptime (30d)" (a backend mean), Telemetry "in the
last 24h" (a count over a window the table does not model), Jobs total.
Two column traps fixed, both of which would have made a preset match nothing:
Telemetry's `severity` sorted by a numeric rank, so the column typed as `num`
and a severity string rule was unmatchable -- it now has an explicit string
`value` while KEEPING the rank as `sortValue`. Monitors' `status` filters on the
pill label, now pinned with an explicit type/value rather than the sortValue
fallback.
Verified by driving all 25 presets in a browser: the 3 client-side ones move the
row count (Monitors 5->2 / 5->1, Jobs 4->1) and the 15 server-side ones each
re-request with the right params (status=major_outage, severity=critical,
kind=deploy.app...). The rest are sort-only and correctly declare no rules.
Plus lint 0 errors (3 guards), production build, render smoke 44/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Field selection did not exist anywhere in the backend (`fields=`/`select=`: 0
hits across 90+ API files) and neither did eager loading (joinedload/
selectinload/subqueryload: 0 matches). /apps sends ~2,319 B per row where the
list UI reads ~411, over an unbounded `query.all()` -- for an admin with no
workspace header that is literally SELECT * FROM applications.
Ports the author's conuco_v2 query layer as app/api/_query.py, with two
deliberate differences:
- ORM, not Core. conuco resolves fields through `table.c`; here they resolve
against the mapper's column set, and that set is the ALLOWLIST -- an unknown
field is a 400, never a getattr into arbitrary model attributes.
- $select is not only a column narrowing. That is where the win actually is:
Application.to_dict() costs ~3 extra queries per row, and two of them
(image_scans, image_update_checks) are lazy='dynamic', so the attribute IS a
Query and NO loader option can hoist them. Declining a field is the only way
not to pay for it. to_dict(fields=...) now skips the loads it was not asked
for, so $select=id,name costs zero extra queries per row instead of three.
/apps also eager-loads server/project/environment/linked_app -- but only when
the request actually asked for the names they resolve, since a joinedload for
a field nobody selected is a wider row for nothing.
Every parameter is optional and to_dict(fields=None) is the full legacy
payload, so adopting this changed no existing caller's response. Paging meta
appears only once a caller pages.
$view is deliberately not implemented: ServerKit's saved views are per-user
rows already resolved in the browser, so a second server-side copy of the same
concept would only be somewhere for the two to disagree.
19 new tests. The 2 failures in the wider run (test_fail2ban_jail_service,
test_url_swap) reproduce on a clean tree -- pre-existing, not from this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same recipe as 1994562b did for Cron. A segment row is a second, weaker way to
narrow rows than the column's own menu: fixed buckets, not combinable, and
invisible in the chip bar -- while the menu combines, carries live per-value
counts and saves into a view.
Per page:
- Services: presets rewritten to columnFilters. "Stopped" meant `!== running`,
so it becomes `is none of [running]` rather than `is any of [stopped]`, which
would have quietly dropped failed/deploying/building rows.
- Workspaces: same, plus the ONE real bug here. Its status column had no `type`
and no `value`, so the rule engine fell back to sortValue -- which returns
0/1 -- typed the column `num`, and offered "is under / is over" instead of a
pick-list. A `status is any of [active]` rule matched nothing at all. It now
declares type:'enum' and value:(ws)=>ws.status.
- Servers: the status segments AND the "All groups" select. Group was not a
column (it renders inside the name cell), so removing the select would have
lost the capability -- a filterable `group` column lands first, with the
accessor coercing a null group to the label "Ungrouped" so the enum engine
can match it (it compares String(value), so a null is only matchable as the
literal 'null'). "Under load" keeps no status rule deliberately: `match` is
per view, so adding one to its `any` list would OR it in and widen the view;
the metrics are null on a non-live row and `gt` rejects null, so liveness is
already enforced.
ResourceListPage's segment props are DEPRECATED rather than deleted -- the
plugins SDK re-exports the component, which pins its props as contract, so an
extension may still pass them. No core page does.
Every preset driven in a browser, rows out of baseline:
services Running 4, Stopped 2, Broken 2, Stale live 4, Not git-deployed 2 (of 6)
workspaces Active 3, Inactive 2, Empty 2, Archived-but-staffed 1 (of 5)
servers Online 2, Offline 1, Not reporting 1, Under load 1,
Disk pressure 1, Ungrouped 1 (of 4)
Plus lint 0 errors (3 guards), production build, render smoke 44/45.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Domains authors its built-in presets as raw cfg objects (cols/sort/filters),
and the envelope has no cfg keys -- so toEnvelope filed all three into the
per-page bag as unknown keys and every rule the preset declared was dropped.
All four Domains views rendered their name, showed no chips and filtered
nothing, which is exactly the failure mode that is invisible by inspection.
useGridViews gains a `migrate` pre-pass for inbound state (built-ins, saved
views, shared links); useGridConfig uses it to convert a legacy cfg through
cfgToEnvelope, which needs the column list only it has.
Caught by a new preset prober, not by reading: lint and the render smoke pass
either way, because a view that filters nothing still renders.
Domains presets, before -> after (rows out of 5): Needs attention 5 -> 2,
SSL expiring 5 -> 3, Renewals <= 60d 5 -> 1, Unlinked 5 -> 1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Views were three hooks and eleven private state shapes that happened to share
an endpoint. `filters` meant column rules on three pages and a SERVER-side
query object on three others; every addition to the chrome then had to be
re-remembered per page, and each one was in fact missed somewhere -- shareable
links never reached useGridConfig, the pre_restore guard landed on one path
only.
Collapse it:
- viewState.js documents ONE envelope -- {sorts, hiddenKeys, columnOrder,
groupBy, columnFilters, page} -- where `page` is the only per-page bag.
toEnvelope() folds any unrecognised top-level key into it, so views users
already saved in the flat shape keep working with no migration pass.
- useGridViews is now the single place views + shareable links are wired.
useTableChrome (DataTable pages) and useGridConfig (DataGrid pages) both go
through it, so a Domains view is the same JSON as a Cron view.
- useTableViews moves into ds/grid as the storage layer. The hooks/ path is a
deprecated shim for the four pages not yet migrated; deleting it later is the
compile-time proof that nothing reaches past the chrome.
- ResourceListPage carried its OWN copy of the adapter, which is how it ended
up the one host with no useViewLink: Services and Workspaces silently had no
?view= links and no "copy link" item at all. It now delegates to
useTableChrome and gains both.
Two fixes fall out of having one place to make them:
- sameViewState() migrates before comparing and ignores generated rule ids, so
a legacy view no longer reads as dirty the instant it is applied.
- "Save view" with "start from current" cleared now persists an explicit empty
envelope. resetToView() is a React state update that has not landed when
saveView runs, so capture() was still returning exactly what the checkbox
said to discard.
Verified: lint 0 errors (3 guards pass), production build, render smoke 43/44
(wp-pipelines is the known pre-existing miss) -- services, services-cards,
domains, servers and cron all still render.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cron_service.py persists jobs to backend/app/data/cron_jobs.json, which sits in
the same directory as shipped data (themes, the registry index), so it cannot be
ignored wholesale. Ignore the one runtime file instead -- it has been showing up
untracked in every git status.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>