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>
With per-column filters the page carried two filtering UIs for the same thing:
a segmented All/Active/Failed/Paused row, and the Status column's own menu with
live per-value counts. The segments were the weaker of the two -- fixed
buckets, no combining, and invisible in the chip bar -- so they go.
The presets that used to set the segment now carry real column rules, which
also makes them combinable with anything else you filter on. `status` gains an
explicit `value` accessor and `type: 'enum'` so filtering and grouping key off
the label the pill shows rather than an inferred guess.
Same duplication exists on Servers (status + group), Services and Workspaces;
those follow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps found by re-reading the seams rather than the pages.
useViewLink was wired into useTableChrome but NOT into useGridConfig, so
/domains -- the page the whole grid was built for -- was the one list page that
could not produce or accept a view link. Both hooks share it now, so a DataGrid
page and a DataTable page emit the same URLs.
SavedView had no pre_restore. Deleting a view frees its name AND its slug (the
unique indexes are partial), so either can be taken by the time you press
Restore; clearing the tombstone would then violate the index and 500 at commit.
Same guard Domain already had.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Making Domain soft-deletable silently changed the meaning of every existing
`Domain.query`: before, it could only return live rows. An audit of all 16 call
sites found ten that were now wrong, several with side effects outside the
panel.
The ones that reached the outside world:
setup_reconcile_service.py:267 the only is_primary row after deleting the
primary IS a tombstone -- and it drives a WP-CLI search-replace across
the whole WordPress database. It would have repointed a live site at a
host nginx no longer serves.
setup_reconcile_service.py:83 DNS backfill enumerated deleted domains and
called ensure_a_record() -- creating a real record at Cloudflare/Route53
for a domain the user removed.
api/domains.py:367 (enable_ssl) a real ACME order for a host that is in no
server_name: the HTTP-01 challenge cannot be served, burning Let's
Encrypt failed-validation limits for the whole registered domain, and on
success installing a wrong-CN cert on the LIVE vhost.
doctor_service.py:267 permanent red "does not resolve" rows that no
action could clear, and the same set is the allowlist for one-click DNS
repair -- so Repair created a provider record for a deleted domain.
And the ones that broke the feature itself:
domain_attach_service.py:79 a tombstone counted as a name clash, so you
could not move a hostname to another app after removing it. Migration 083
made the unique index partial precisely to free the name; this re-imposed
the burn in application code.
domain_attach_service.py:71 attach found the tombstone, reported success,
created no live row, published nothing -- and then mutated a recycle-bin
record.
site_domain_service.py:600 same for give_subdomain: success, a URL, a DNS
record, and a site that was never published.
api/domains.py:532 a second vhost renderer (admin Regenerate)
writing tombstones back into server_name.
Application.domains still includes tombstones ON PURPOSE -- filtering it with a
primaryjoin would, with delete-orphan, make SQLAlchemy HARD-delete the row the
recycle bin exists to keep. `Application.live_domains` is the accessor for
"what this app actually serves", and to_dict() uses it so the /apps payload no
longer lists deleted domains.
Per-domain routes now 404 a tombstone instead of editing it.
Seven tests cover the leaks that had real consequences.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create_domain and delete_domain each called NginxService.create_site directly,
passing no ssl_cert / ssl_key / micro_cache. create_site takes those as
arguments, so the regenerated vhost came out HTTP-only and cache-less: on a
site covered by a base wildcard cert, touching ANY domain silently knocked the
whole site — every other domain on it — back to plain HTTP, and dropped its
micro-cache directives. Invisible until someone loaded the site.
Both now publish through SiteDomainService.write_app_vhost via one local seam,
so add and remove cannot disagree about what the served config should be. That
also fixes two lesser asymmetries: the rewrite was gated on app_type == docker,
so php/static/flask/wordpress vhosts kept advertising a domain after it was
removed; and it never called enable_site.
ALSO FIXES A REGRESSION I INTRODUCED WITH SOFT DELETE. app_vhost_kwargs read
`Domain.query`, which before soft delete could only return live rows and now
returns tombstones too. The vhost renderer would have written a deleted domain
straight back into server_name — the domain would keep being served after
deletion, and drift detection (which renders through this same function) would
report correct config as drifted. Now query_active().
Three tests, each failing against the previous code: the wildcard cert and
micro-cache survive in the resolved kwargs, a soft-deleted domain is absent
from them, and an end-to-end delete leaves the surviving domain on HTTPS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The teardown map for delete_domain landed after the hook shipped and found a
real hole in it.
When the deleted domain was the app's LAST one, delete_domain runs
disable_site() + delete_site() -- the sites-enabled SYMLINK is gone, not just
the server_name. The hook called NginxService.create_site and reloaded, which
rewrites the file and leaves it UNSERVED. Restoring the last domain of an app
looked like it worked and served nothing.
SiteDomainService.write_app_vhost is the correct seam and fixes three things at
once:
1. it calls enable_site, so the symlink comes back;
2. app_vhost_kwargs re-attaches a covering wildcard cert and the micro-cache
flag -- a bare create_site drops both, so a restored site would silently
fall back to plain HTTP;
3. it handles every host-nginx app type. delete_domain only touches nginx for
docker apps, so php/static/flask/wordpress vhosts still list the deleted
domain; the rewrite is idempotent, so running it there repairs the
asymmetry rather than preserving it.
New test pins the case that was broken -- restoring an app's only domain must
call BOTH create_site and enable_site -- and it fails against the previous
implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An encoded state made the URL a black box you could only regenerate from the
UI -- you could not widen a threshold or drop a rule in the address bar and hit
enter, which is most of why a link is useful.
Ad-hoc state now travels as plain query params:
/servers?sort=cpu:desc&hide=agent&match=any&f=cpu:gt:85&f=status:any:offline,connecting
Reserved keys are sort / cols / hide / groupby / match / f (repeatable);
`groupby` rather than `group` because pages already own a `group` param. Every
other key is a page-owned primitive and keeps its own name, so ?status=online
reads as what it is.
?view=<slug> combined with readable params now means "that view, tweaked": the
view applies first, the overrides layer on top.
Parsing is tolerant -- a malformed rule or a non-numeric threshold is dropped,
never fatal -- and rule values split on the first two colons only, so a value
may contain one. decodeState still accepts ?v=<base64url> so links shared
before this keep working; nothing generates them any more.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a domain did two things: tombstone the row and rewrite the app's
nginx vhost so traffic stopped. Restoring only undid the first, so a domain
could be "restored" and still not served. on_restore_domain rewrites the vhost
from the app's live domains -- the mirror of what delete_domain does -- and
reloads.
Adds a `pre_restore` hook to the registry, because "restorable" is a moving
target: deleting a domain FREES its name (the unique index covers live rows
only), so something else may hold it by the time you press Restore. Clearing
the tombstone then violates the index and raises IntegrityError at commit -- a
500 for what is really an explainable conflict. Now it refuses with the name of
the app that took it. Same guard for a parent Application that no longer
exists.
A restored domain that was primary does NOT become a second primary: whatever
stayed live keeps the role and this row returns as an alias.
The certificate is deliberately not re-issued. ssl_enabled may still be true
with cert files that expired while the domain sat in the bin, and quietly
talking to Let's Encrypt is not something a "put it back" button should do --
the domain returns on HTTP and SSL is re-enabled explicitly.
Five tests, including the two that would otherwise be 500s: name taken again,
and app gone. A failed vhost write warns and KEEPS the restore rather than
rolling back a row the user asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two shapes, because "share this view" and "look at what I'm looking at" are
different requests:
/domains?view=ssl-expiring a saved view by slug. Short, and it
follows later edits to the view.
/domains?view=builtin:needs-attention built-ins resolve with no DB row.
/domains?v=<base64url> whatever is on screen right now, unsaved
tweaks included. Self-contained, so it
needs no lookup and no permission on
anyone else's saved views.
"Copy link to this view" picks automatically: a CLEAN saved view gets the
handle, anything dirty or unsaved gets the encoded state -- because a handle
would otherwise send the recipient somewhere different from what the sender is
looking at.
useViewLink syncs both directions and routes an incoming link through the SAME
`apply` the view picker uses, so a link and a click cannot drift apart. URL
updates use replace, not push: flipping through views must not fill the back
button with table states.
Decoding treats the URL as untrusted -- only a plain object is accepted, never
an array or primitive that would be spread into page state -- and a slug waits
for the saved views to load before deciding a link is broken.
Wired through useTableChrome, so Cron, Servers and every future page on the
shared chrome get it at once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One list of everything the panel has soft-deleted, with Restore on every row
and a quiet purge for admins. Type-agnostic: `kind` comes from the server's
registry, so a newly restorable model appears here with no change to this
component.
Says out loud what restore does NOT do -- it returns the record, not the
configuration torn down when it was deleted -- because a Recycle Bin that
implies otherwise is worse than none.
The retention countdown per row ("purges in 3d") turns amber in the last
stretch: that is the only warning before a record is gone for good. Purge is
admin-only, sits behind a confirm, and stays a dim icon next to a full-width
Restore -- the safety net should make the safe action the loud one.
Registered as a settings tab with search-index entries, so the command palette
finds "restore", "undelete", "trash".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting is the only action in a control panel that repeating cannot undo, so
records a person destroys by hand now leave a tombstone and the Recycle Bin
hands them back.
SoftDeleteMixin adds deleted_at/deleted_by_id plus soft_delete/restore and
query_active()/query_deleted(). recycle_bin_service is a REGISTRY: a model
registers with a label function and an optional on_restore hook, so the API
lists and restores across types without knowing what a domain is. Adding the
next soft-deletable model is one register() call.
Applied to Domain (a real, destructive case -- deleting one used to lose the
record outright) and SavedView. DELETE /domains/<id> now tombstones and still
tears the vhost down; restoring a row does NOT re-do side effects unless the
type supplies on_restore, which is the honest default.
API: GET /recycle-bin (all kinds, newest first), POST
/recycle-bin/<kind>/<id>/restore, DELETE /recycle-bin/<kind>/<id> (purge,
admin-only -- the one irreversible action), POST /recycle-bin/purge-expired
with a 30-day default retention.
THE PART THAT NEEDED CARE: both tables had a plain UNIQUE covering what is now
a tombstone, so deleting a domain would have permanently burned its name and
deleting a view would have blocked re-creating one by the same name. Migration
083 replaces each with a PARTIAL unique index predicated on deleted_at IS NULL
-- supported by SQLite 3.8+ and Postgres. Tests pin exactly that: a deleted
name can be reused, and the live row stays unique.
Also gives SavedView a `slug` (backfilled), the handle the upcoming
?view=<slug> links resolve against. A tombstoned view does not reserve its
slug, so deleting a view frees the link name too.
Migration is idempotent on both columns and indexes: `deleted_at` is
index=True on the mixin, so a database built from the models already has that
index, and a bare create_index would abort the upgrade on exactly the installs
we most want to keep working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Monitors +4 (worst 30d uptime, paused-and-still-silenced, slow web checks, in
maintenance), Jobs +5 (stuck in flight, backlog, failures by kind, backup
runs, app deploys), Telemetry +4 (critical only, deploy failures, backup
failures, audit trail).
Each state uses the page's OWN `filters` shape, which on these three is the
SERVER-side query object -- {status, type}, {status, kind} and the
EMPTY_FILTERS set respectively -- not the per-column {match, rules} object.
They filter the query rather than the rendered array, so they are strictly
stronger than column rules here. Comments added at each array to stop the two
meanings being conflated later.
These three keep their existing FilterButton/FilterDrawer and their top-bar
ViewMenu; moving their picker to a page heading is a separate change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the toolbar's Views/Sort/Group/Columns buttons with the same chrome
as /domains, /services and /cron: the saved view is the page heading, column
rules get a chip bar and the Filters/Fields drawer, and sort/group/hide move
into each column's own menu.
Presets: "Not reporting" (has an agent but stopped answering, longest silence
first -- `pending` excluded, that means no agent was ever installed),
"Under load" (saturated on EITHER axis, because a box swapping at 95% memory
with calm CPU is the one that falls over), "Disk pressure", and "Ungrouped"
-- fleet commands target a group_id, so an ungrouped server is silently
skipped by every bulk action.
Only `gt` is used on cpu/memory/disk, and there is a comment saying why: those
sortValues are null for a non-live row, so a `lt` rule would sweep in every
offline and pending server.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Was the odd one out: it had saved views but behind the old toolbar ViewMenu
button, so it looked nothing like /domains. Now the same heading picker,
chip bar, filter drawer and tools menu as every other list page, driven by
useTableChrome off its existing sorts/hiddenKeys state.
Adds "Failing now", "Stale schedules" and "Health roll-call" to the three
shipped presets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Swaps the toolbar's Views/Sort/Group/Columns buttons for the shared chrome:
the saved view becomes the page heading, filters get a chip bar and a
Filters/Fields drawer, and Sort/Group/Columns move into each column's own
"⋮" next to the column they act on. The cards/list switch, selection bulk
bar and page-size footer are unchanged. One wrapper, so both pages move
together.
Presets grow from 3 thin ones to 7 (Services) and 5 (Workspaces), each a
question someone opens the page to ask -- "Broken or unknown", "Stale live
services", "Not git-deployed", "Empty workspaces", "Archived but still
staffed".
Services' `status` column gains an explicit `value`. It had only
`sortValue`, which returns a severity RANK (0-5) rather than the status
string, and fieldValue prefers value->sortValue->row[key] -- so a "status is
running" rule compared against the number and matched nothing. Where a
column's sort key differs from its real value, the column has to say so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The view picker, chip bar, filter drawer and tools menu were written against
DataGrid's single `cfg` object; the older list pages keep sorts[]+hiddenKeys
+groupBy instead. Rather than fork the chrome per page -- the duplication a
shared layout exists to stop -- this hook adapts that state once and returns
ready-to-spread props.
Column types are inferred HERE and the same typed list goes to the chip bar,
the drawer and the table. Inferring separately inside DataTable left the
chrome reasoning about untyped columns while the table reasoned about typed
ones, and the drawer's rule editor looked up OPS[undefined] and crashed on
any rule the header menu had just created.
The saved-view key is `columnFilters`, NOT `filters`: Jobs and Monitors
already capture a `filters` key holding the FilterDrawer's {status, kind}
pair. Sharing the name would have silently cross-wired their presets.
GridFilterDrawer/GridToolsMenu gain showRowDetail/showDensity so hosts whose
table auto-sizes its rows don't render controls that change nothing, and the
drawer now skips a rule whose column is no longer filterable instead of
crashing on a stale saved view.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Number(null) === 0, so an unguarded `lt` matched every row whose metric was
absent: "CPU under 20%" returned every offline server in the fleet. lt/gt/eq
now reject null/empty/NaN outright, in both directions.
Type inference also returns null (not 'text') for a column with no readable
values. Several columns render from fields their accessor never exposes --
Servers' `host` and `agent` keys are not row properties, the real fields are
hostname/agent_version -- and those were offering a text filter that could
only ever match nothing. No readable value now means no Filter section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GET /apps responds { apps: [...] } (apps.py:442) but the page read
`.applications`, so setApps() got [] on every load and the Application
dropdown in the backup dialogs was permanently empty -- while still fetching
and discarding the full ~93KB payload.
Verified in the browser against the mock API: the picker went from
["Select Application"] to all five apps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CPU/Memory/Disk gauges read `server.metrics`, the same key the
per-server status endpoint uses (servers.py:757). The list endpoint never
populated it -- to_dict() defaults to include_metrics=False, and even when
asked it emits `latest_metrics` -- so on a live panel every gauge cell fell
back to the "no data" dash.
It survived because the screenshot fixtures DO supply `metrics`
(sections.mjs:294): the UI looked correct in every capture and was empty in
production. The fixture had encoded the intended contract; the backend never
implemented it.
Batched deliberately: Server.metrics is lazy='dynamic', so the obvious
per-row lookup costs one extra SELECT per server. Newest row per server =
highest id (autoincrement on insert), which also avoids the tie a
max(timestamp) join hits for same-second rows -- two queries total,
regardless of server count.
Tests pin all three properties, and each fails without the fix: the key and
field names the table reads, absence (not {}) for a server that never
reported, and that the query count stays flat as servers are added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Saved views as the page heading (personal views persist through
/api/v1/views), filter chips, bulk bar, grouping, density, pagination and
CSV/JSON export; search, the filter icon and the overflow menu move into the
shared top-bar slots. Columns declare type + width, so the header menu
offers real operators instead of inferred ones.
Only fields the API actually returns are columns -- no DNSSEC or record-count
column, because neither /domains nor /dns/portfolio carries them.
Keeps every page invariant: RegistrarPortfolio, the app+provider merged row,
DomainDnsPanel in the drawer, PluginSlot, and the ?open= / ?focus=create
deep links.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Puts the DataGrid column menu into DataTable itself, so all 62 table
instances across 48 files gain sort / group-by / filter / move / hide with
ZERO call-site changes. The audit made this the safe move: renderRow is used
in exactly 1 of 62 instances, `header` is always a string, and no consumer
passes unknown column keys -- so optional keys are additive everywhere.
The part that makes it work without editing 50 files is type inference: a
column that declares no `type` gets one sampled from the data -- boolean,
number, ISO string, or a repeating low-cardinality string (enum: a pick-list
with live per-value counts). Anything else stays text, which is never wrong,
just less specific. Pages that care override by declaring `type`.
Filters and column order are uncontrolled by default; pass filters/
onFiltersChange or columnOrder/onColumnOrderChange to mirror them in a chip
bar or drawer. Filtered-to-nothing keeps the header on screen, because the
control that undoes it lives in the header menu.
Also restyles .sk-dtable to match the grid (top-lit header, reserved menu
lane), and turns the top bar's "search is right-most" rule into explicit
slots -- [actions] [search] [filter/overflow] -- so the search box sits in
the same pixels on every page and does not move when a page gains a button.
Verified live on 12 pages: menus on all 12; enum filters actually move the
row count (servers 4->1, cron 8->1, services 6->1, backups 5->1,
domains 5->1, monitors 5->2), including the one renderRow consumer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ported from the Domains design mock. A CSS grid, not a <table>: header and
rows share one --sk-grid-tpl track list, which is what makes show/hide and
move-left/right a pure data change with no <colgroup> to keep in sync.
fields.js field model + filter-rule engine (text/enum/num/bool/date),
sort, group, CSV/JSON export
DataGrid grid, sticky head, groups, selection, density, sub-detail
ColumnMenu per-column popover: sort / group / filter / move / hide.
Flat, state-model-agnostic props so <DataTable> can drive
it from sorts[]+hiddenKeys and <DataGrid> from one cfg.
useGridConfig the whole chrome as ONE serialisable object, so a saved
view is a snapshot of it and "dirty" is a deep-equal away
useGridRows filter -> sort -> page -> group, memoised
GridViewPicker saved views rendered as the page heading
GridFilterDrawer Filters + Fields panes
GridChips / GridBulkBar / GridFooter / GridToolsMenu / GridFilterButton
Does not replace ds/DataTable -- column descriptors are a superset, so a
list can move up by adding `type` and `width`.
Adds the --surface-0 token (the recessed step below --surface) that group
headers, drawer tab strips and drawer footers sit on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
allViews rebuilt `{...v, builtin: true}` copies on every render, so
activeView had a fresh identity every time. That identity is public --
callers put it in effect dependency arrays -- and useTopbarActions sets
state in the parent layout from such an effect, so the pair looped until
React bailed with "Maximum update depth exceeded".
Keyed on the builtins' names so an inline builtinViews={[...]} literal is
safe as well.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fallback selector matched a bare [role='dialog'], and Radix gives
Popover.Content that role too -- so opening Sort, Columns, Views, Filter or
the topbar overflow blinked the bubble out and straight back in. Paired with
the off-screen popover bug this read as "the button did something to the AI
and nothing else".
Scoped to the actual modal surfaces (.ui-dialog-content / .ui-sheet-content);
the body[data-scroll-locked] signal is unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Button was a plain function component. Used as
`<PopoverTrigger asChild><Button/></PopoverTrigger>` React dropped the ref
("Function components cannot be given refs ... Primitive.button.SlotClone"),
so @radix-ui/react-popper never received a reference element, useFloating
never positioned, and the panel stayed parked at its pre-position
`translate(0, -200%)` -- open, focusable, and entirely off-screen.
Measured before the fix on /domains: the Sort trigger sat at (1232, 106)
while its panel rendered at (0, -623). Every table menu in the app looked
like a dead button. Affects all 45 `asChild` trigger call sites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Windows-side port sweep now respects the same opt-out dev.sh has, and
the startup banner leads with http://localhost:<port> (the WSL interface IP
stays as a printed fallback) so the URL you open is never the dead one.
The queue-bus consumers, job system and metrics collector share one SQLite
file with request handling, and the default journal mode lets any writer
lock the whole file — the sporadic 'database is locked' errors in the
notification/job consumers. Connections now open with journal_mode=WAL
(readers and a writer coexist), busy_timeout=30s and synchronous=NORMAL.
Skipped under TESTING, where conftest owns connection pragmas.
An authenticated user of any role could inject arguments into git
subprocesses running as the backend user (root under systemd):
- repo_url was passed unvalidated to git ls-remote/clone, enabling the
ext:: transport (host command execution on git < 2.52 defaults) and
file:// local-repository disclosure (all git versions).
- app_path on POST /deploy/clone was passed without a '--' terminator,
enabling --upload-pack=<cmd> option injection independent of git
version and protocol.*.allow policy.
- branch flowed into 'git fetch origin <branch>' as an unterminated
refspec.
Fix (new app/utils/git_security.py applied at every affected sink):
- repo_url scheme allowlist (https/http/git/ssh + scp-like SSH);
rejects ext::/file://, local paths, leading '-', whitespace.
- '-c protocol.ext.allow=never -c protocol.file.allow=never' pinned on
every remote-touching git call, plus a restrictive GIT_ALLOW_PROTOCOL
in the subprocess environment.
- '--' positional terminator before URL/path/refspec arguments —
co-load-bearing with protocol pinning, which alone does not stop
option injection such as --upload-pack=<cmd>.
- branch/ref validation (leading '-', ref-format metacharacters) for
clone --branch, fetch refspecs and stored deploy configs.
- POST /deploy/clone now confines app_path to the managed apps root,
mirroring how /apps/from-repository derives it server-side.
Adds 31 regression tests; bumps version to 1.7.86.
Reported-by: CaptBoykin
Co-authored-by: Tyler Boykin <25166954+CaptBoykin@users.noreply.github.com>
Domains and DeliveryLog filtered empties get Clear filters; the extension
catalog filtered empty gets Clear filters and the installed empty gets a
Browse catalog button.
The fleet table gains native selection (DataTable selectable) and a floating
bulk bar with a Set group… picker (including Ungrouped) that applies via
updateServer per row.
The Events tab becomes the CRM record timeline: deployments (expandable,
as before) interleaved newest-first with environment-variable changes and —
for admins — audit-log entries targeting the app, via the new target_id
feed filter. Compact non-expandable rows for config/audit entries.