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.
AuditService.get_logs gains a target_id filter (paired with target_type) and
/admin/activity/feed passes both through — the per-resource activity
timeline can now ask 'what happened to THIS app'.
Service/Server/Monitor/Workspace/Project detail pages record visits for the
palette's Recently used section and host a FavoriteStar in their title areas
that pins the entity into the palette's Favorites group.
Removing an IP list entry or deleting a cron job now toasts with an Undo
action that re-POSTs the captured row (comment/description preserved). The
useConfirm gate stays; cron's ?focus=create:cron deep-link rides along.
Each QuickCreate destination opens its create surface via useFocusParam:
?focus=create:server|monitor|domain|workspace|project (cron lands with the
jobs commit).
The CRM '+' lives in the sidebar footer and mobile top bar next to the
notification bell: one menu for New Service/Server/Monitor/Domain/Cron/
Workspace/Project. Route-based flows navigate directly; modal-based flows
use ?focus=create:<kind> deep links. FavoriteStar pins entities from detail
pages.
utils/recents.js tracks detail-page visits (10 max) and pinned favorites in
localStorage; useRecordVisit is the one-line detail-page hook. The palette
gains a Favorites group (weighted high, searchable) and folds visited
entities into Recently used.
Services groups by project or status, Workspaces and Servers by status; the
hand-rolled __select columns are gone in favor of DataTable's native
selection; Servers captures groupBy in its saved views.
ResourceListPage owns persisted groupBy state (captured in saved views),
renders GroupMenu in the toolbar, accepts selectable/selectedIds/
onToggleSelect/onSelectAll for the native checkbox column, and turns the row
cursor on by default for list pages.
useTableViews deep-compares the live table state against the active view
(hiddenKeys set-normalized, sorts order-sensitive); a dirty view shows an
amber dot on the trigger and Update / Reset-to-saved actions in the popover.
Group-by: groupable columns + groupBy prop render collapsible group header
rows (chevron + label + count); GroupMenu popover picks the grouping. Keyboard
nav: j/k (or arrows) move a cursor row, Enter opens it, x toggles selection —
ignored while typing or in dialogs. Selection: selectable + controlled
selectedKeys/onToggleRow/onToggleAll render a real checkbox column with an
indeterminate select-all, ending the hand-rolled __select column pattern.
One anatomy diagram, the toolbar placement rule, the drawer width scale, and
the EmptyState/Modal/useConfirm/row-click conventions; primitives list gains
ListToolbar, SortChipBar, ViewMenu and the views hook.
ResourceListPage renders the shared ListToolbar and gains a searchInTopbar
flag; Workspaces moves its search into the shared topbar to match Services
(search state still flows through for views). Superseded .wp-list__toolbar /
__tabletools SCSS removed; the .empty-state specificity comment is updated
now that the page-partial copies are gone.
Drawer widths snap to the 380/520/640/720 scale; thirteen raw ui/dialog uses
(services, projects, vaults, serverdetail tabs, webhooks, doctor panel)
convert to the shared Modal wrapper. ConnectProviderModal keeps its custom
chrome (branded header + scroll region Modal can't express).
Security tabs' legacy empty-state markup becomes the component, their bespoke
confirmDialog flows (firewall, fail2ban, ip lists, ssh keys) move to
useConfirm, and the remaining window.confirm calls (telemetry cleanup,
service settings, connections hub) are replaced. The duplicate .empty-state
block in _security.scss is gone.
DataTable emptyState overrides and legacy empty-state markup become the
shared component (wording and CTAs preserved). Also fixes DataTable passing
emptyMessage to a nonexistent EmptyState prop — it now lands on
'description', so custom empty messages actually render.
servers/deployments/server-services/packages/survey toolbars and the email
accounts/queue rows move to ListToolbar (selects get .sk-listhead__select);
email table emptyState overrides become emptyTitle/emptyMessage; the legacy
unscoped .empty-state in _servers.scss is scoped under .servers-page.
cron/bk/incidents listheads move to ListToolbar (counts and menus preserved);
bk-empty and mon-empty become EmptyState; run-history drawer width snapped to
the 640 step of the standard scale.
The .dom-listhead rows (incl. Git's six borrowed section headers) move to the
shared ListToolbar; .dom-empty filtered-empty blocks become EmptyState;
retired SCSS deleted.
.sk-listhead [title][filters][extras] [count][tools] replaces the five
hand-rolled listhead patterns; includes a standard __select style for
secondary pickers. Codifies the placement rule in its docstring.