Commit Graph
810 Commits
Author SHA1 Message Date
Juan Denis 2f002c5963 fix(firewall): a failed status probe no longer fabricates inactive
The §D matrix pinned this live: in a capability-restricted environment
(unprivileged LXC — ufw exists, is on PATH, and fails on dropped
CAP_NET_ADMIN) ufw status exits non-zero, and _check_ufw returned
active=False with no error signal — "probe failed" indistinguishable
from "firewall off", the same dishonesty class as plan 74's "not
installed", one rung down the ladder.

active/running are now tri-state: rc=0 parses a determined answer;
non-zero with a dbus/not-running signature is a determined
not-running (verified on Rocky 9: rc=36); anything else is None +
error. Not-installed stays a determined False. any_active propagates
the None, and the firewall tab renders an amber Status Unknown
instead of collapsing it to Inactive.
2026-08-17 22:58:18 -04:00
Juan Denis c75a128473 feat(errors): add Errors page under Monitoring with filters and detail drawer
Stat strip, backend/frontend + resolution SegControls, paginated DataTable,
640px drawer with traceback/context and resolve/delete actions
2026-08-16 18:35:31 -04:00
Juan Denis 6aaf8d9dd9 feat(errors): report client errors and add error-logs API methods
ErrorBoundary now fire-and-forgets crashes to /error-logs/client via raw
fetch so reporting works with an expired token and can never throw
2026-08-16 18:35:22 -04:00
Juan DenisandClaude Opus 5 58223a6b04 fix(ui): make the agent install one-liner fail loudly
The command the panel prints for enrolling a server was
`curl -fsSL <url> | sudo bash -s -- …`. In a pipeline the shell reports bash's
exit status, not curl's, so a failed download exits 0: the operator sees one
line of curl noise, the shell reports success, and nothing was installed.
Measured against a 404 -- `curl -fsSL <404> | bash -s -- --token TOK; echo $?`
prints 0; with `set -o pipefail` it prints 22.

Download to a file first and run it on success, so a failed fetch stops the
command and surfaces curl's non-zero exit. Applied to the Add Server drawer, the
server-detail connection modal and the Downloads page.

The Windows one-liner is left alone: `irm` writes an error and the pipeline
stops, so its failure is already visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 05:27:16 -04:00
Juan DenisandClaude Opus 5 d3f32bf4c2 feat(extensions): show declared vs observed permissions, and pending deps
Plan 55 tasks 6 and 7, UI half — one Permissions dialog per installed
extension, following the existing config-dialog pattern on the same page.

The design problem here is entirely about not overclaiming, so the three
permission states render as three separate sentences rather than one vague
row shared by all of them. A capability the SDK actually mediates and has seen
used says so with a count. One it mediates and has NOT seen says the gate
recorded nothing and names both readings — not needed yet, or declared more
than it uses. One it cannot mediate renders no count, no zero, and no "unused"
badge at all: it says the panel exposes no gated helper, that an extension
wanting it imports the host module directly, and that the question cannot be
answered here.

Refused calls get their own section under their own heading rather than
sitting among the declared rows, because a blocked attempt did not run and
must never be mistaken for a use. Where a non-observable capability does carry
gate-recorded calls — a plugin can call require() voluntarily — the count is
labelled a floor rather than a total.

Two caveats are stated once rather than implied: observations live in memory
in one backend process and are cleared on restart, and declared permissions
are a consent signal rather than a sandbox, since extension code runs inside
the panel process with the panel's privileges.

The requirements section names the env var from the API response rather than a
hardcoded literal, distinguishes "not installed" from "installed at install
time", and covers the case where the opt-in was switched on after the file was
written — the panel only installs during install or update, so it says to
reinstall. There is deliberately no install button; the surface is read-only
and says so.

Timestamps are pinned to UTC before formatting: the backend stamps them with
datetime.utcnow().isoformat() and no zone suffix, which JS would otherwise
read as local time and skew every relative label by the browser's offset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 18:02:38 -04:00
Juan DenisandClaude Opus 5 72d17534c3 fix(doctor): stop reporting a queued repair as a finished one
Completes plan 72 A.1's "surface the job id so the UI can link to it". The
repair dispatcher hands back a job_id for repairs that run in the background,
but the panel answered every success the same way: "Repaired 1 item". For a
restore drill that is not true yet -- the job has been queued and will run for
minutes -- so the toast claimed an outcome that had not happened, on the one
check family whose job is telling you whether your backups can be trusted.

Results carrying a job_id now say the repair was started and finishes in the
background, with an action that goes to the Jobs page. Inline repairs are
unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:29:24 -04:00
Juan DenisandClaude Opus 5 7088298ed7 feat(doctor): connect the fleet doctor that was already written
Plan 72 item A.2. FleetDoctorService, FleetRepairService, the FleetDoctorResult
model, migration 063 and their tests were all complete and reachable from
nothing: no routes, register_jobs() never called, no seeded schedule. The
frontend was further along than the backend -- getFleetDoctorReport() and
runFleetSweep() have been calling /doctor/fleet and /doctor/fleet/run, two
endpoints that did not exist, so the multi-server health story the agent
platform advertises 404'd on contact.

Nothing in those services was redesigned. The one addition is a read-side
aggregator: they could run a sweep and persist rows but had no way to hand
back a per-server report, so fleet_report() composes one. It lists every
registered server rather than only connected ones, which is what makes an
unreachable box a reported state -- last known rows plus connected: false --
instead of a hole in the report. Persisted rows key on check_key while the
UI's field-driven renderer keys on key, so the report emits both.

Three admin-only routes, matching api/doctor.py's gating exactly. The sweep
returns 202 with a job id and never runs on the request thread: it fans out
across agents over the network, and the agent gateway registry is
single-worker and in-memory. Repair is a thin passthrough to FleetRepairService,
which already owns the allowlist, capability gate and audit trail; refusals
come back as per-item results inside a 200, so one offline server cannot fail
a batch. The batch is bounded at 25 so a single request cannot fan an
unbounded number of remote mutations at that single worker.

The schedule is daily like the host doctor but starts 30 minutes behind it,
so the fleet fan-out contends with neither the local sweep nor the boot-time
agent reconnect storm.

The frontend gets a Fleet Doctor section rather than losing its dead methods.
The per-check article was extracted into one component now shared by the host
and fleet lists, so fleet findings render through the same
repairable/repair_ref path instead of a parallel implementation; the confirm
modal says plainly that a fleet repair acts on a remote server through its
agent and is audited.

test_jobs' builtin schedule count moves 21 -> 22 for the new schedule. The
builtin.* kind count is untouched -- this kind is doctor.fleet.run.

Note for anyone reading the suite: tests/test_fleet_doctor_dns_rotation.py is
skipped at module level and proves nothing here. Its rotating-DNS-window API
did not survive the recovery rebuild; that is pre-existing and unrelated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 17:27:59 -04:00
Juan DenisandClaude Opus 5 3c30d2f537 feat(install): put a default-deny firewall and fail2ban on a stock install
Plan 73 item 8. The installer opened 80/443 on whatever firewall it found and
shrugged -- "assuming ports 80/443 are already open" -- when there wasn't one,
which is the state of a stock Ubuntu/Debian VPS. That box then ran ServerKit
with every port a deploy maps onto the host (8001-8999, plus database
containers on their stock ports) facing the internet. fail2ban only shipped
with the "full" capacity profile, so the typical small VPS got neither.

The dangerous half is enabling default-deny without allowing the port sshd
actually answers on, over the SSH session running the installer. detect_ssh_ports
therefore never guesses: it asks `sshd -T` (the effective config, which resolves
the Include that Ubuntu 22.04+ ships), then sshd_config plus its drop-ins with
comments stripped, then a live `ss`. Multiple Port lines all come back. 22 is
used only when a config exists and names no port -- sshd's compiled-in default
-- and if nothing answers at all the function fails and no firewall is
installed. An exposed box is recoverable; a locked-out one usually is not.

That interlock had a real bug in its first draft, caught before it shipped and
now pinned by a named regression test: most boxes have no sshd_config.d/, the
unmatched glob makes cat exit non-zero, and under `set -o pipefail` that wiped
a correctly parsed answer and fell through to the 22 default -- returning 22 on
a box whose sshd is on 2222, which is precisely the lockout this function
exists to prevent.

Ordering is the other safety property and is asserted: every allow rule (all
detected SSH ports, 80, 443, and PANEL_PORT when non-standard) lands before
`default deny incoming`, and the policy before `--force enable`. The bootstrap
refuses to act inside a container, when a firewall is already active (it is the
operator's -- opening ports on it is fine, rewriting its default policy is not),
under SERVERKIT_SKIP_FIREWALL=1, and when a previous run bootstrapped but the
firewall is off now, since the operator turned it off deliberately. Non-Debian
installs are byte-identical to before: RHEL-family boots with firewalld already
active, and a firewalld default-deny bootstrap for boxes where it is inactive
is marked as an explicit follow-up rather than guessed at.

fail2ban moves to the standard profile with an sshd jail that watches the same
ports detect_ssh_ports found -- a jail pinned to :22 on a box listening on :2222
bans nobody -- and declares `backend = systemd` when there is no auth.log to
tail, without which the jail refuses to start and fail2ban looks installed while
protecting nothing. certbot stays full-only.

setup-health gains firewall (critical) and fail2ban (recommended) items. The
split is deliberate: this file grades critical by silent breakage, and an
unfirewalled box qualifies -- the operator set it in motion by deploying,
nothing in the UI says the port is public, and the failure mode is compromise
rather than an error. A missing fail2ban thins defence in depth but breaks
nothing silently, since the panel already throttles logins per IP. Both are
gated on actually owning the host -- Linux, /etc/serverkit present, not
containerised -- so a dev checkout or CI runner drops them entirely instead of
reporting a fake ok that would inflate the score.

SetupHealthWidget was orphaned; so, it turns out, was its stylesheet, which
main.scss never imported. Both are wired up, as a plain admin-only card above
the board rather than a placeable widget: an unfirewalled box has to be visible
without the operator first opting into showing it. It collapses to one line
when everything is clean.

Implementing this inverts a shipped test that asserted provision_hardening was
a no-op on the standard profile; it is rewritten to the new contract rather
than deleted, with a minimal-profile case added alongside.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:33:47 -04:00
Juan DenisandClaude Opus 5 fc000a3b97 feat(ssl): remember one Let's Encrypt contact instead of asking every time
Plan 73 item 5. The Domains SSL modal asked for a contact email on every
single certificate, and the backend hard-required one per call. The app-detail
Settings tab did remember it -- in localStorage, so the memory was per browser
and invisible to the other surface. The two never agreed, and neither could
help the paths with no form at all: domain attach passed `admin@<host>` when
it had nothing else, an address that usually does not exist, which is where
every expiry notice for those certificates went to die.

There is now a single panel-wide ACME contact in system settings.
get_acme_contact() resolves an explicit address first and falls back to the
stored one; remember_acme_contact() writes it back whenever a request carries
an explicit address. Both are best-effort on the settings side by design: a
settings failure must not break issuance, and failing to remember an address
must not turn a successful certificate into an error. An empty input never
clears a stored contact, since the automatic paths pass whatever they were
given and that is frequently nothing.

Wired into every issuance entry point -- the domain SSL enable route, the
generic certificate route, and domain attach, which now consults the stored
contact before falling back to the admin@<host> guess. The address is only
remembered after issuance actually succeeds; remembering one Let's Encrypt
rejected would be worse than remembering none.

Prefill needed its own endpoint. The settings routes are all admin-gated, but
issuing a certificate for an app you can edit is not an admin action, so
GET /ssl/acme-contact is jwt_required only -- it returns an address the
operator chose for notices, not a credential. The Domains modal and the
Settings tab both prefill from it, and the Settings tab keeps its localStorage
seed so a browser that already has the value shows it without a flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:22:33 -04:00
Juan DenisandClaude Opus 5 fcc3babe68 fix(dns): catch the stray AAAA record that silently breaks SSL issuance
Plan 73 item 7. Let's Encrypt prefers IPv6 whenever a AAAA record exists, so a
domain whose A record points at this server perfectly still fails HTTP-01
issuance when its AAAA points somewhere else. That shape looked healthy
everywhere in the panel, which is what made it expensive: the operator saw a
verified domain, a green doctor report, and a certificate error naming nothing
either of them had mentioned.

Both DNS surfaces were blind to it for different reasons. The doctor's sweep
returned ok as soon as the server IP was *among* the resolved addresses, so
the extra IPv6 answer never registered -- and that branch is reached precisely
when the A record is correct, i.e. exactly the case worth reporting.
verify_domain used socket.gethostbyname, which is IPv4-only and cannot see a
AAAA record at all.

Both now compare every resolved address against this server: the configured
public IP plus the host's own interface addresses, so a correct dual-stack
setup stays quiet and only a genuine stray is named. Comparison is by parsed
value rather than by spelling, since an expanded 2001:0db8:... form and its
compressed equivalent are the same address, and psutil's zone ids (fe80::1%eth0)
are stripped first. Interface enumeration happens once per doctor sweep
instead of once per domain.

The doctor reports this as a warn, not a fail: the site itself is reachable
and it is a stray record rather than a broken one. Both messages say what to
do -- remove the AAAA record or point it at this server's IPv6 address --
rather than only naming the symptom.

verify_domain keeps its existing response shape. ip_address still carries a
single IPv4 address for the callers that predate this, with the full set added
alongside as ip_addresses, plus aaaa_conflict and a warning string. The
Domains page needed the last part to matter: it printed a green "Domain
verified!" toast for anything that resolved, so a conflict the API now
reports would have gone straight back to being invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:17:23 -04:00
Juan DenisandClaude Opus 5 e1a7e55128 fix(ui): honour the service URL in the ⋯ menu and surface email install errors
Plan 73 items 4 and 6.

The ServiceDetail ⋯-menu "Open in Browser" entry still pointed at
http://localhost:<port>. It predates the serviceUrl.js work and was missed
when the title and port links were converted, so on any remote panel -- the
normal case -- it opened a link to the operator's own machine. It now uses the
same openUrl the neighbouring links already use, which also means it correctly
disappears when a domain fronts the service and the domain link is the one to
follow.

The email install toast threw the server's answer away: the API returns
per-component results with each component's stderr, and the page rendered a
bare "Install failed" for all of it. The failing components and their reasons
are now dug out of err.data.results and rendered inline under the hero, where
they stay after the toast has gone; the toast keeps the top-line reason plus
the first failure so it remains one line. All failing components are listed,
not just the first -- install_all only returns early on postfix and dovecot,
so dkim and spamassassin can both fall through and fail in the same call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:05:38 -04:00
Juan DenisandClaude Opus 5 ad32930d4f chore(deps): clear the six high npm advisories in the frontend
js-yaml was pinned to 4.3.0 in the overrides block, so `npm audit fix`
could never reach it -- the advisory (GHSA-5p4m-2wfm-xmqj, quadratic CPU
in !!omap) sat permanently HIGH and masked anything landing behind it.
Bumping the pin to 4.3.1 keeps the deliberate supply-chain pin and clears
it. The other five were stale-lockfile problems: every fixed version
already satisfied its parent's declared range, so re-resolution was enough
and no dependency range changed.

  js-yaml           4.3.0  -> 4.3.1   GHSA-5p4m-2wfm-xmqj
  socket.io-parser  4.2.6  -> 4.2.7   GHSA-2m8v-j782-fhvr
  react-router      7.18.1 -> 7.18.2  GHSA-qwww-vcr4-c8h2
  nanoid            3.3.16 -> 3.3.18  GHSA-2v37-7h3g-55p8
  brace-expansion   1.1.16 -> 1.1.18  GHSA-mh99-v99m-4gvg, GHSA-rgw5-rvv9-x895

Only one of the six is reachable in a shipped panel at all: socket.io-parser
runs in the browser, though an attacker must already control or MITM the
panel's own websocket and the payoff is a hung operator tab. The react-router
CSRF bypass is RSC-mode only and this is a declarative BrowserRouter SPA with
no data router; js-yaml and brace-expansion are eslint-only; nanoid is
build-time via postcss. Hygiene, not incident response.

package.json and package-lock.json move together -- `npm ci` runs in
frontend-ci.yml and release-smoke.yml and hard-fails if they disagree.
Verified: npm audit reports 0 vulnerabilities, npm run lint passes with
0 errors, npm run build succeeds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 09:35:22 -04:00
Juan DenisandClaude Opus 5 258b125532 feat(ui): the last hand-rolled list surfaces join the convention
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>
2026-08-13 02:16:47 -04:00
Juan DenisandClaude Opus 5 78f1de64a7 perf(dev): give the API its own origin instead of sharing Vite's connection pool
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>
2026-08-13 01:14:00 -04:00
Juan DenisandClaude Opus 5 2b4e360a2c feat(ui): incidents absorbs fleet alerts, and four more pages join the convention
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>
2026-08-13 01:03:03 -04:00
Juan DenisandClaude Opus 5 6c1626ee14 fix(ds): KPI tiles use the KpiBand primitive everywhere, and one selection bar
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>
2026-08-13 00:19:11 -04:00
Juan DenisandClaude Opus 5 2634fdc483 perf(api): coalesce identical GETs that are in flight at the same moment
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>
2026-08-12 23:56:53 -04:00
Juan DenisandClaude Opus 5 bc51ac47cb perf(frontend): stop pollers stacking, and stop probing absent features
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>
2026-08-12 23:49:51 -04:00
Juan DenisandClaude Opus 5 bc91040076 feat(docker): the containers table gets multi-select, and stops wasting columns
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>
2026-08-12 23:49:05 -04:00
Juan DenisandClaude Opus 5 bf43ea2a2d feat(ds): KPI tiles put the icon beside the value instead of above it
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>
2026-08-12 23:48:49 -04:00
Juan DenisandClaude Opus 5 449c849b27 feat(ui): Projects, Vaults and Shared Variables are tables like everything else
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>
2026-08-12 23:48:38 -04:00
Juan DenisandClaude Opus 5 1ede777242 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: 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>
2026-08-12 23:48:23 -04:00
Juan Denis 71c8abc40a style(sidebar): drop brand-section bottom margin
The first category label's own margin already spaces the nav from the
brand row, so the extra 1rem just read as a dead gap.
2026-08-12 23:36:55 -04:00
Juan Denis 72b5e1886e style(sidebar): enlarge quick-create FAB to 28px 2026-08-12 23:35:15 -04:00
Juan Denis 03f075162f feat(sidebar): compact quick-create FAB, move Test Sandbox into Dev Tools
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.
2026-08-12 23:32:11 -04:00
Juan Denis 5594cc9300 feat(sidebar): move quick-create to top as extended Create button
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.
2026-08-12 23:20:59 -04:00
Juan DenisandClaude Opus 5 6166158b26 fix(services): the delete confirmation no longer promises something untrue
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>
2026-08-12 20:26:15 -04:00
Juan DenisandClaude Opus 5 c0bb135b9e feat(views): the last list surfaces -- Docker, templates, backups, monitor checks, git, server detail
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>
2026-08-12 18:16:09 -04:00
Juan DenisandClaude Opus 5 002fb1bccb fix(views): the view bar counted rows the table was not showing
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>
2026-08-12 17:38:58 -04:00
Juan DenisandClaude Opus 5 086073d7e5 refactor(incidents): give the page the chrome it never had
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>
2026-08-12 17:09:23 -04:00
Juan DenisandClaude Opus 5 c2c04ee342 fix(list): one filter button per page, and one row of chrome instead of two
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>
2026-08-12 17:09:23 -04:00
Juan DenisandClaude Opus 5 aa0c51a099 feat(views): per-tab saved views on Security and Settings
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>
2026-08-12 14:48:18 -04:00
Juan DenisandClaude Opus 5 5b63620ad6 feat(views): scope shareable-link params, so two tables can share a route
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>
2026-08-12 14:24:37 -04:00
Juan DenisandClaude Opus 5 8eacb30c5f refactor(ds): drop the toolbar Sort/Columns menus -- the column menu already does that
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>
2026-08-12 13:13:44 -04:00
Juan DenisandClaude Opus 5 8ad1bd467f fix(recycle-bin): say when a restored domain comes back without HTTPS
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>
2026-08-12 13:08:49 -04:00
Juan DenisandClaude Opus 5 6347292e7f refactor(list): full chrome on Email, DeliveryLog, FleetProxy and the queue pages
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>
2026-08-12 13:05:20 -04:00
Juan DenisandClaude Opus 5 6dfe9d61ee refactor(list): heading view picker on Monitors, Jobs and Telemetry; KPI bands out
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>
2026-08-12 12:17:56 -04:00
Juan DenisandClaude Opus 5 e0a1758094 refactor(list): drop the status segments on Services, Workspaces and Servers
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>
2026-08-12 11:56:41 -04:00
Juan DenisandClaude Opus 5 70ed72d01c fix(views): migrate a raw DataGrid cfg before the envelope normalises it
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>
2026-08-12 11:56:25 -04:00
Juan DenisandClaude Opus 5 37d2437a53 refactor(views): one saved-view envelope, one place that wires it up
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>
2026-08-12 11:42:26 -04:00
Juan DenisandClaude Opus 5 1994562bde refactor(cron): drop the status segments -- the column menu already does that
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>
2026-08-12 11:09:13 -04:00
Juan DenisandClaude Opus 5 e659773bfc fix(views): shareable links on Domains, and a restore guard for saved views
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>
2026-08-12 11:09:13 -04:00
Juan DenisandClaude Opus 5 16ca091d0c refactor(views): hand-editable link params instead of a base64 blob
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>
2026-08-12 08:15:41 -04:00
Juan DenisandClaude Opus 5 48d2a371bc feat(views): shareable links -- ?view=<slug> for saved, ?v=<encoded> for ad-hoc
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>
2026-08-12 07:30:01 -04:00
Juan DenisandClaude Opus 5 ac4d3bf227 feat(recycle-bin): Recycle Bin UI under Settings > Admin
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>
2026-08-12 07:25:00 -04:00
Juan DenisandClaude Opus 5 fab29ce9b9 feat(views): grounded presets for Monitors, Jobs and Telemetry
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>
2026-08-12 06:57:11 -04:00
Juan DenisandClaude Opus 5 09cecff92f feat(servers): /servers on the shared list chrome, with 4 new presets
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>
2026-08-12 06:57:11 -04:00
Juan DenisandClaude Opus 5 b113bd0af9 feat(cron): /cron on the shared list chrome, with 6 presets
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>
2026-08-12 06:48:48 -04:00
Juan DenisandClaude Opus 5 b0f46510fd feat(list): ResourceListPage on the grid chrome -- Services + Workspaces
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>
2026-08-12 06:48:47 -04:00
Juan DenisandClaude Opus 5 43c2babcf8 feat(ds): useTableChrome -- one list chrome for every DataTable page
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>
2026-08-12 06:48:34 -04:00