839 Commits
Author SHA1 Message Date
Juan DenisandClaude Fable 5 4fcd0db4d5 chore(frontend): delete the dead useContainerStatus hook and its legacy-poller entry
The hook has zero importers yet was counted in the LEGACY_POLLERS baseline,
holding a phantom slot in the ratchet (a place a new raw setInterval could
hide). Poller debt shrinks 11 sites/10 files -> 10 sites/9 files; the
migration inventory is regenerated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:14:36 -04:00
Juan DenisandClaude Fable 5 5d875d8a09 refactor(scss): status-variant mixin — 174 re-rolled color pairs converge (plan 77 D4)
@mixin status-variant($color, $bg, $border) in _mixins.scss is the one
shape for coloring status-bearing elements; every mechanical
background/color token pair across 40+ page/component partials now
includes it (pass-through args -> identical compiled CSS values, build
green). check-status-scss.mjs holds the raw-pair count at zero and runs
inside npm run lint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 05:35:28 -04:00
Juan DenisandClaude Fable 5 a9e76cf317 feat(frontend): useServerStream hook — one join/listen/cleanup dance (plan 77 E4)
joinServerStream()/useServerStream() own connect + join_room + listener
+ teardown, including the re-join-on-reconnect step the hand-rolled
copies missed (a reconnect silently loses server-side rooms).
JobProgressModal adopts the hook form; CloudflaredTab's imperative login
flow adopts joinServerStream. RemoteTerminal keeps its role-gated
subscribe_terminal dance (a different channel contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 05:14:30 -04:00
Juan DenisandClaude Fable 5 00eac38d5d feat(realtime): one room grammar + mirrored event constants (plan 77 E3)
app/sockets_rooms.py owns every Socket.IO room-name builder (the grammar
lived in ~25 scattered f-strings); app/constants.py SOCKET_EVENTS is
mirrored verbatim by frontend/src/constants/events.js and a contract
test parses the JS so a renamed event fails in CI instead of silently
killing a stream. sockets.py / agent_gateway / notification bus and the
two frontend components that hand-derived rooms now use the builders;
ratchet forbids new room f-strings in the realtime layer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 04:58:49 -04:00
Juan DenisandClaude Fable 5 ba09ec75b6 chore(frontend): wire status one-door ratchet into npm run lint (plan 77 D3)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 04:50:15 -04:00
Juan DenisandClaude Fable 5 dc32ac6e86 refactor(frontend): one status->tone vocabulary in ds/status.js (plan 77 D3)
statusKind()/statusVariant()/statusDotClass()/statusLabel() drive both
Pill kinds and Badge variants from ONE table; ~40 per-page maps across
35+ files migrate to it. Scoped overrides stay in the same file:
serviceStatusKind (running IS the healthy steady state for services/
units/previews -> green), processStateVariant, alertStatusKind,
envActionKind, impactTone. Unifications: run-shaped 'running' is cyan
everywhere (Git.jsx's amber gone), QueueDetail/QueueOperations' invalid
blue/yellow kinds fixed, severity tones (critical/high/medium/low)
agree across security surfaces. Ratchet: npm run check:status fails on
new local maps outside ds/status.js (frozen allowlist for domain maps
and D4-territory hex/css-class renderers). Lint + build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 04:49:35 -04:00
Juan DenisandClaude Fable 5 3953090526 feat(conformance): generated remaining-migration inventory (H closure)
docs/MIGRATION_INVENTORY.md is generated from the live censuses and the
frontend boundary checker's new --inventory JSON mode — no hand-typed
numbers, so the plan cannot claim completion while a door is bypassed. A
freshness test fails whenever a ratchet number moves without regenerating
the document, making the regeneration part of every migration commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 00:16:39 -04:00
Juan DenisandClaude Fable 5 39a6e35d42 test(contracts): ratchet the four remaining adoption populations
Backend: hand-shaped {'error': ...} bodies in app/api get an AST census
and ceiling (1159) — milestone B's migrate-when-touched policy made
enforceable. Frontend: check-frontend-boundaries.mjs gains adoption
ceilings for raw api.* calls in pages (460, E1), per-page toast.error
extraction (264, E1), form-group blocks (343, F2), and unencoded query
interpolations in services/api (97, C4-interim) — totals that may only go
down, with a slack guard so a ceiling cannot drift above reality.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 00:13:59 -04:00
Juan DenisandClaude Fable 5 6337066fad refactor(secrets): vaults + webhook gateway onto the typed-error contract
SecretVaultService, SecretService, and WebhookGatewayService raise typed
errors and return bare data; secrets_webhooks.py drops all twelve
{'success': bool} translations including its two prose sniffs. Bulk
create keeps per-item error collection (catching ApplicationError).
Replay now answers 200 with success:false when the forward target refuses
— previously a failed forward was a 404 with error:null — and the
WebhooksTab toast distinguishes the two. The public /webhooks/receive
wire protocol is intentionally unchanged (external senders depend on it).
Sniffing census 6 -> 4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 00:00:36 -04:00
Juan DenisandClaude Opus 5 38ba85f07a test(styles): ratchet the colour literals runtime skins cannot reach
Plan 76 milestone G, last item. 151 hex literals live outside the token files
across 35 partials. A theme is a JSON map of CSS custom properties (plan 60), so
only values written as var(--token) change when someone switches skin — every
literal is a spot that stays the default palette on every theme, which is a
functional hole in the themes feature rather than untidiness.

Per-file exact counts rather than a ban, for the reason milestone G gives
explicitly: legitimate brand and data-visualisation colours stay. They just have
to be listed, so the number is honest and can only go down.

Not migrated: picking the right token per site is a semantic judgement (is this
`--green` or `--success`?) with visual consequences, not a substitution — the
same reason .empty-state was ratcheted rather than merged.

Verified non-vacuous: adding one literal to _terminal.scss fails it (20 vs 19),
and it passes again on revert.

Gate: boundary check green, npm run lint 0 errors / 953 warnings, build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:09:50 -04:00
Juan DenisandClaude Opus 5 b182deaff9 test(styles): ratchet class ownership across partials
Plan 76 milestone G. 114 top-level class definitions share 50 names across
partials, so those classes have no owner: every definition applies, merged
per-property in main.scss import order, and editing one file changes only the
properties that file happens to win.

`.empty-state` is the worked example the audit named. Three partials define it —
components/_empty-state.scss (flex column, min-height 200px), _cards.scss (card
background, solid border, radius), _users.scss (padding, colour) — and the
compiled sheet carries five competing top-level rules. What renders takes its
background and *dashed* border from one, its padding and display from another,
and its colour from a third. No author wrote that element.

Ratcheted rather than merged, on purpose. Reproducing the cascade's computed
result has no byte-identical-output proof available (unlike the @keyframes
sweep, where deleting seven dead copies left the CSS unchanged to the byte), so
it needs eyes on the affected pages — plan invariant 9, no repo-wide mechanical
rewrite without behavioural tests.

The ratchet counts DEFINITIONS, not names: counting names would let an
already-shared class spread to a fourth and fifth partial without moving the
number, which is the failure mode worth stopping. Mutation-checked both ways —
adding a definition of an existing shared class and introducing a brand-new
shared name each fail it, and both pass on revert.

Wired into `npm run lint`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:59:28 -04:00
Juan DenisandClaude Opus 5 0b3854824d fix(styles): one definition per @keyframes name
Plan 76 milestone G. A @keyframes name is global and last-definition-wins across
every partial main.scss pulls in, so a duplicate name is not wasted bytes — one
copy is silently dead, and which one depends on import order.

`spin` was defined 8 times. The build already emitted exactly one, so seven were
dead: deleting them left the compiled CSS byte-for-byte identical (same content
hash, 958878 bytes), which is the proof rather than an argument. Three of the
seven also differed — `to`-only rather than `from`/`to` — so anyone editing
those was changing nothing.

`pulse-dot` was the one that actually mattered: components/_uptime defined it
with a scale (1 -> 1.2) and pages/_services defined it opacity-only, and
_services is imported later, so the uptime dot has been running the services
animation. Renamed the uptime one to `uptime-pulse-dot`; service-detail keeps
the shared `pulse-dot` it already got.

**Visual change**: the uptime status dot now scales as its own stylesheet always
said it should. Nothing else moves.

`pulse` (identical twice) and `drawer-fade`/`drawer-slide` (identical in
_docker and _file-manager) are consolidated into base/_utilities.scss, so
neither page owns the other page's animation.

Ratchet: the boundary check now scans every .scss partial and fails on any
@keyframes name defined more than once. 66 unique names, zero duplicates.
Verified non-vacuous: an injected second `spin` fails it, revert passes.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings, boundary check
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:56:55 -04:00
Juan DenisandClaude Opus 5 89c2e13233 refactor(ui): move the last fetching pollers onto usePolling
Plan 76 milestone E2, continuing 0bfcf69c. Ratchet 16 sites / 12 files -> 11 / 10.

Dashboard's remote-metrics poll and its socket-down HTTP fallback, ImportWizard's
active-import poll, and TestSandbox's run poll plus its open-log fan-out.

TestSandbox's log refresher fanned out one request per open log per tick and
returned nothing, so a tick reported done before any of them had come back. It
now returns a single Promise.all, which is what the in-flight guard needs to
mean anything for a fan-out.

That leaves 11 raw setInterval sites, and they are deliberate:

- 5 are clock ticks with nothing to overlap and nothing to fetch — an elapsed
  timer, a countdown, a board refresh pulse, two wall clocks.
- 3 are the socket-fallback pollers in useMetrics, useContainerStatus and
  useDeployJobStream, driven by imperative startPolling()/stopPolling() from
  socket handlers with a 3s grace period. Expressing that as a declarative
  `enabled` is a real restructure of each hook and they only poll when the
  socket is already down.
- 2 belong to the serverkit-gui extension, whose source is the sibling repo;
  its frame loop already carries its own in-flight guard.
- 1 is ServerGui's user-pausable capture interval.

The ratchet keeps all of them at exact counts, so the reasoning above has to be
revisited rather than forgotten if one changes.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:53:28 -04:00
Juan DenisandClaude Opus 5 0bfcf69c0b refactor(ui): make the two job pollers declarative, and finish the container tabs
Plan 76 milestone E2, continuing 89c3896e. Ratchet 22 sites / 15 files -> 16 / 12.

DoctorPanel's fleet sweep and ScannerTab's malware scan were imperative
pollers: a function started an interval, and the interval cleared itself once
the job reached a terminal status, with a second clearInterval in an unmount
cleanup to catch the case where it never did. Both now hold the job id in state
and let usePolling own the schedule —

    const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []);
    usePolling(tick, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId) });

which is what makes the visibility pause and the in-flight guard apply to them
too. A job poller is the shape that most wants both: it runs while someone
watches a spinner, and its endpoint is doing real work.

Also ContainersTab's stats poll and log tail, and OnboardingWizard's status
poll (its 1s elapsed-time clock stays a plain interval — nothing to overlap).

Two `useRef`s and one `clearInterval` in an unmount cleanup went with them.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:51:30 -04:00
Juan DenisandClaude Opus 5 89c3896ec4 refactor(ui): move the log-tail pollers onto usePolling
Plan 76 milestone E2, continuing f9657d38. Ratchet 27 sites / 18 files -> 22 / 15.

Terminal's three tab components (file logs, journal, processes), ServicesTab's
unit log tail, and the GUI extension's synthetic desktop. All were the
auto-refresh tail shape: poll only while the toggle is on, never fetch on mount
from the interval effect.

These are the sites where a hidden tab mattered most — a log tail at 3s left
open in a background tab is the single most wasteful poller in the panel, and
every one of them also re-ran on `lineCount`/`priority`/`sort` changes via a
dependency array with `// eslint-disable-line` on it. That silent restart is now
explicit: the tail's own load effects still handle those, the poll handles the
repeat.

Four `intervalRef`s and one `logIntervalRef` went with them.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:48:31 -04:00
Juan DenisandClaude Opus 5 f9657d38de refactor(ui): move four multi-request pollers onto usePolling
Plan 76 milestone E2, continuing 3ac566cb. Ratchet 31 sites / 22 files -> 27 / 18.

QueueDetail and QueueOperations each fired two requests per tick. Their ticks
now return a single awaited Promise.all, because the in-flight guard only knows
a tick has finished when the promise it was handed settles — a bare `.then()`
inside the callback reports done immediately and lets the next tick start on top
of a request still in flight, which is the exact overlap the hook exists to
prevent.

ServerDetail and LinkPanelForm keep their mount-time load as its own effect;
the poll is `immediate: false` so nothing fetches twice.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:46:31 -04:00
Juan DenisandClaude Opus 5 1503c493f4 refactor(ui): move ten more pollers onto usePolling
Plan 76 milestone E2, continuing f21abe7a. Poller ratchet 41 sites / 31 files
-> 31 / 22.

Migrated: Jobs, Monitors, MonitorDetail, DeliveryLog, Deployments, Databases,
VulnerabilityTab, and the service-detail Logs/Metrics/Overview tabs.

The trap flagged in the plan bit on four of them, so it is worth restating: the
interval effect's dependency array was doing double duty as the "reload because
the query changed" trigger —

    useEffect(() => {
        load();                                  // <- reload on filter change
        pollRef.current = setInterval(load, POLL_MS);
        return () => clearInterval(pollRef.current);
    }, [isAdmin, load]);                         // <- load() is a useCallback of the filters

usePolling holds the callback in a ref precisely so it does not restart on every
render, which means a naive swap silently drops that reload and nothing fails to
compile. Each of those four keeps an explicit `useEffect(() => load(), [load])`
alongside the poll, with `immediate: false` so mount does not fetch twice.

Six magic interval literals became named constants while their lines were being
touched (LOG_REFRESH_MS, METRICS_REFRESH_MS, SCAN_POLL_MS, DEPLOY_POLL_MS,
ENGINE_POLL_MS).

Not migrated, and not mechanical: useMetrics, useContainerStatus and
useDeployJobStream are socket-fallback pollers driven by imperative
startPolling()/stopPolling() calls from socket event handlers, with a 3s grace
period before falling back. Expressing that as a declarative `enabled` flag is a
real restructure of each hook, and they only poll when the socket is already
down. Left for when they are next touched.

Gate: npm run build clean, npm run lint 0 errors / 953 warnings (one below the
954 baseline — four orphaned useRef imports removed), npm test 72 passed,
boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:44:46 -04:00
Juan DenisandClaude Opus 5 375677ab0c fix(ui): one download helper, and delete the dead wordpress client
Plan 76 milestone F3 (the blob half) plus the C4-interim dead-file deletion the
audit called out.

The six-line "save this to disk" anchor ritual was pasted into 14 components,
and the copies had drifted in two ways that decide whether the download
actually happens:

- only 3 of 14 appended the anchor to the document. Chrome dispatches a click
  on a detached anchor; Firefox has not always done so, which makes "the
  download button does nothing" a browser-specific bug that never reproduces
  for whoever wrote it.
- most revoked the object URL on the very next line after click(). The download
  starts asynchronously, so revoking immediately can cancel it — showing up as
  a flaky empty-or-missing file rather than a clean failure.

utils/downloadBlob.js does both correctly (append, click, remove, revoke on a
later task) and accepts either a string or an existing Blob, which is what
FleetCapacityPanel needs since its CSV arrives from the API already blobbed.

Also deletes src/services/wordpress.js: 494 lines, zero importers, and a stale
duplicate of the live src/services/api/wordpress.js that the ApiService barrel
actually wires in. Verified by import graph and a clean production build.

Ratchet: the boundary check now fails on any `.download =` assignment outside
the helper — a zero baseline, not a shrinking one, since every site is
migrated. Verified non-vacuous: an injected anchor fails it, revert passes.

Gate: npm run build clean, npm run lint 0 errors / 954 warnings (unchanged),
npm test 72 passed, boundary check green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:39:36 -04:00
Juan DenisandClaude Opus 5 f21abe7af5 feat(ui): one visibility-aware polling convention, and a ratchet on the rest
Plan 76 milestone E2, drift/bug row 4. 45 hand-rolled setInterval sites across
35 files, with `visibilitychange` handled in exactly one of them and an
in-flight guard in exactly one of them — the documented poller-stampede shape.

Two failures every hand-rolled `setInterval(load, 5000)` shares:

- it starts a request every 5s whether or not the previous one returned. A slow
  endpoint does not slow the poller down, it makes it stack; each extra request
  makes the server slower, which makes the next overlap more likely. This is
  what a DevTools capture of /domains showed as a wall of pending rows.
- it keeps polling a tab nobody is looking at, where browsers throttle timers
  unevenly anyway, so it is not even a reliable refresh.

utils/pollScheduler.js holds the rules as a plain object so they are testable
without a DOM (the repo has no jsdom — this follows formState.js/useForm).
hooks/usePolling.js is the React binding, and useServerQuery gains
`refetchInterval` built on the same scheduler, so there is one implementation
rather than a second one for query users.

Two design points worth stating, since both differ from what the sites did:

- the next tick is armed by the previous run *finishing*, not by setInterval.
  With setInterval the gap is measured tick-to-tick regardless of how long the
  work took, so slow work silently turns a 5s gap into no gap. The in-flight
  guard therefore protects the out-of-band paths — refresh() and the visibility
  catch-up — rather than the schedule, and the test says so instead of implying
  a guard the schedule can no longer exercise.
- the callback is held in a ref, so an inline arrow re-created each render does
  not restart the timer. That is the usual reason a hand-rolled poller runs far
  faster than its stated interval.

Migrated four as reference implementations, per the plan's "migrate a
representative flow before broader adoption": DeployPill (which had worked all
of this out by hand and loses ~20 lines), MonitorsSummary, appdetail/LogsTab,
and ProcessListPanel. MonitorsSummary keeps its `refreshKey` prop as an explicit
out-of-band refresh rather than folding it into the schedule.

Ratchet: check-frontend-boundaries.mjs gains LEGACY_POLLERS with exact per-file
counts, now 41 sites in 31 files, so every future cleanup must shrink it and a
new bare setInterval fails lint. Genuine non-fetching timers (clock ticks,
countdowns) are listed too rather than pattern-matched away — they are harmless
but the exact count is what makes the ratchet trustworthy.

Gate: npm test (72 passed, 11 of them new), npm run lint (0 errors; 954
warnings, one fewer than the 955 baseline), npm run build clean, boundary check
green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 20:45:59 -04:00
Juan DenisandClaude Opus 5 e1a5a5e102 fix(ui): route every copy button through the clipboard door
Plan 76 milestone F3, drift/bug row 5. 33 call sites in 25 files reached for
navigator.clipboard.writeText directly, against 4 files using the shared
copyToClipboard/useClipboard helpers.

This is a fix rather than a tidy-up: navigator.clipboard is undefined in an
insecure context, and ServerKit's SSL is optional by policy, so on an
HTTP-served panel every one of those copy buttons did nothing. The failure was
silent at most sites — a bare fire-and-forget writeText, or a `.catch` that
swallowed. utils/clipboard.js already carries the execCommand fallback that
makes them work; it just had almost no callers.

Two of the bypasses were local functions *named* copyToClipboard
(ServerSettingsTab, Downloads) — the same shadowing shape as the backend's
private get_current_user() helpers, and the reason the call sites read as
though they were already correct. ServerSettingsTab's was a hand-rolled
useClipboard (copy + toast) and now uses the hook; Downloads' is renamed
copyCommand since it also owns per-command copied state.

Behaviour preserved per site, with two deliberate improvements the helper's
contract allows:

- state that was set unconditionally (`copied` flags, keyed copy markers) is
  now set only when the copy actually succeeded
- CreateTableModal's "this browser will not give the page clipboard access"
  notice is gone; the fallback is what it was warning about

`.then(onOk, onErr)` chains became `.then(ok => ...)` because copyToClipboard
resolves false instead of rejecting — the second callback would never have run.

The existing frontend boundary ratchet held an exact per-file legacy baseline;
it is now empty and documented as needing to stay that way, so a new direct
call fails `npm run lint`.

Gate: node scripts/check-frontend-boundaries.mjs (0 clipboard files ratcheted),
npm run lint (0 errors, 955 pre-existing warnings — unchanged), npm test
(61 passed), npm run build (clean).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:42:14 -04:00
Juan Denis aa3407750c refactor(extensions): own remote access styles 2026-08-18 03:14:15 -04:00
Juan Denis d0f4fa03e5 refactor(frontend): standardize form lifecycle 2026-08-18 03:10:55 -04:00
Juan Denis ec3b9bb6f9 refactor(frontend): centralize route manifest 2026-08-18 03:10:29 -04:00
Juan Denis b5c6efb3c3 feat(frontend): add workspace-aware server state 2026-08-18 03:09:53 -04:00
Juan Denis 2ee8d9ed42 refactor(frontend): centralize active workspace state 2026-08-18 02:58:20 -04:00
Juan Denis 4c4ccb4a18 refactor(frontend): enforce shared browser boundaries 2026-08-18 02:48:56 -04:00
Juan Denis 3c25680351 feat(api): add typed endpoint contracts and envelopes 2026-08-18 02:47:57 -04:00
Juan DenisandClaude Opus 5 2ab1fd5e8c fix(errors): only the newest Errors query may paint
load() never set loading back to true, so after the first paint the flag was
permanently false and three consumers were dead: the Refresh button's disabled,
its spin class, and the table's first-paint skeleton.

setLoading(true) alone -- the literal review comment -- does not fix the race
it was aimed at, and adds a small lie of its own. Two identical loads cannot
race: ApiService coalesces in-flight GETs by URL, and getErrorLogs folds every
param into the query string. What can race is a DIFFERENT-params overlap, and
those come from the SegControls and SearchField, which disabled={loading} does
not gate -- change a filter while a slow search is in flight and the older
response repaints the table with rows the toolbar no longer describes. With two
loads running, the first finally would also stop the spinner while the second
was still going.

So: a request-sequence token gating both the data commit and setLoading(false).
Same shape as the drawer's own stale-response check in openDetail a few lines
below, which already does this for the detail fetch in this very file.

`loading && entries.length === 0` on the table stays as it is -- entries is
only overwritten on success, so that guard is what stops Refresh blanking the
current page, and it becomes load-bearing now that loading actually toggles.

Unproven by test: the panel has no test runner for pages. eslint and the
production build are clean.

Reported by Copilot on the error-tracker PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:58:19 -04:00
Juan DenisandClaude Opus 5 dcb8810635 fix(errors): attribute a frontend crash report to the logged-in user
reportClientError sent no Authorization header, so every source='frontend' row
was anonymous no matter who was signed in -- which made ErrorLog.user_id,
to_dict()['username'] and the "User #N" row in pages/Errors.jsx dead end to
end, and the route's own docstring ("a valid JWT attaches user_id") false.

Attaching the header by hand does not violate the deliberate design here: the
comment forbids this.request() -- its 401 handling, refresh loop and throwing
-- not a header. The raw fetch and the swallow-everything contract are intact,
and Content-Type already made this a non-simple CORS request, so no new
preflight is added.

The review's stated rationale was wrong and the corrected version is in the
comment: verify_jwt_in_request(optional=True) tolerates a MISSING token only.
In Flask-JWT-Extended 4.6.0 it catches NoAuthorizationError alone, so an
expired token raises ExpiredSignatureError and garbage raises DecodeError. A
stale token is harmless here only because the route wraps that call in
try/except and falls back to an anonymous report -- tightening that except
would turn every expired session's crash report into a 500.

Three backend tests pin the contract the header relies on: valid token
attributes the row, no token stays anonymous, junk token stays anonymous
rather than 500ing. The frontend side that sends it is unproven -- the panel
has no test runner for pages.

Known ceiling: dedup bumps count on an existing unresolved row without
backfilling user_id, so attribution is first-writer-wins per fingerprint.

Reported by Copilot on the error-tracker PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:58:04 -04:00
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