1818 Commits
Author SHA1 Message Date
Juan Denis eeb3dde6c9 Merge pull request #97 from jhd3197/dev
Finish the chat connection contract and clear the npm advisories
v1.7.94
2026-08-13 15:44:41 -04:00
github-actions[bot] fdb39a4e2d chore: bump version to 1.7.94 [skip ci] 2026-08-13 19:38:33 +00:00
Juan Denis 0613349f7c Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-08-13 15:38:15 -04:00
Juan DenisandClaude Opus 5 81208f2f68 fix(plugins): write manifests with LF instead of the platform newline
.gitattributes pins the whole repo to `text=auto eol=lf`, but plugin_service
wrote the tracked manifests through Python's text mode, which translates \n to
\r\n on Windows. Any install on a Windows dev box therefore rewrote
frontend/src/plugins/*/plugin.json with CRLF, and the backend suite -- which
installs builtin extensions -- left four modified manifests behind on every
run. The diff showed no content change, which made it look like unrelated
churn and made it easy to sweep the files into an unrelated commit.

This is the same failure the comment above the write already documents: the
trailing-newline half was found and fixed earlier, the line-ending half was
missed. Both halves now live in the same explanation.

_regenerate_frontend_manifest's write gets the same treatment for consistency,
though nothing covers it -- it is skipped under the testing config so it cannot
be reached from a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:57:51 -04:00
github-actions[bot] 0d3c7c63ce chore: bump version to 1.7.93 [skip ci] 2026-08-13 18:42:41 +00:00
Juan Denis f651cdc914 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-08-13 14:42:11 -04:00
Juan DenisandClaude Opus 5 f10c04ddc3 fix(notifications): redact transport errors before they reach the log
Review catch on #97. The chat connection test failure log interpolated
DeliveryResult.error verbatim. _deliver_webhook builds that from str(exc) on a
network error, and requests embeds the full URL in its exception text -- so the
line could carry a Discord/Slack/Teams webhook token or a Telegram bot token,
all of which live in the URL *path*. to_dict() masks `destination` even from an
admin, and /api/logs serves the log files back to the panel, so the log line
leaked past the API's own masking.

Dropping the reason would have restored the earlier problem of a failed test
button leaving no diagnostics anywhere, so it is redacted instead: URLs keep
scheme and host and lose the path, and bare bot tokens are substituted. The
host is what makes the line worth keeping and is not a secret.

Also normalise ChatWebhookConnection.raw_credentials() to {} when the stored
JSON is well-formed but not an object. A hand-edited or corrupt row could hold
`[]`, which every caller then treated as a mapping -- credentials() called
.items() on it, so to_dict() and therefore the list endpoint raised before the
write path was even reachable. Guarding at the source covers all callers and
matches the existing categories() pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:36:24 -04:00
github-actions[bot] be8f1b40a0 chore: bump version to 1.7.92 [skip ci] 2026-08-13 18:24:07 +00:00
Juan DenisandClaude Opus 5 807f1f1674 test: ratchet 3262 -> 3379
The floor had drifted. It was last set to 3234 at 9fb0a6ca; the rounds since
then added tests without bumping it, so it sat ~117 below what CI actually
collected. The ratchet only fails on a *drop*, so the gap was silent -- and a
floor that far below reality stops catching the accidental deletions it exists
for.

Collection is deterministic between a dev box and CI here, so the local count
is the right value: every file under backend/tests/ is tracked (only
__pycache__ is ignored), and the one filesystem-globbing parametrization,
test_extension_manifests.py::_all_manifest_paths, walks builtin-extensions/
and frontend/src/plugins/ where all 16 manifests are tracked. Locally
installed extensions change which tests *skip*, never how many are collected.

Note when re-running this: check_test_count.py collects with a bare `pytest`,
while backend-ci.yml deliberately runs `pytest tests` because a bare collect
also walks locally deployed apps under backend/dev-data/. Both report 3379
today, but only because dev-data errors out of collection rather than adding
to it -- don't rely on that holding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 14:04:24 -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 5bbd4533f1 fix(notifications): scope chat credential re-encryption to supplied fields
Follow-up to PR #94. ChatWebhookService.update() decrypted every stored
credential and re-encrypted the lot on any credential-bearing request.
decrypt_secret_safe() returns its input unchanged when a value will not
decrypt, so a wrong-key or not-yet-migrated row got wrapped a second time
and became unrecoverable -- and it happened to fields the request never
named. It now patches the ciphertext dict directly and encrypts only what
it assigns, so untouched credentials survive byte-identical.

Also tolerate an unchanged is_default in a round-tripped body (to_dict()
always emits it, so GET -> edit -> PUT rejected an untouched field), and
record the delivery failure reason in the server log. The reason stays out
of the HTTP response: transport errors embed the full webhook URL and any
Telegram bot token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 09:33:40 -04:00
Marcos Paulo Diniz 7a091d440d fix(notifications): reject null updates 2026-08-13 09:28:15 -04:00
Marcos Paulo Diniz e3e399c59d fix(notifications): hide connection test errors
Transport exceptions may contain webhook URLs or bot tokens. Return a stable public error and reject non-object update payloads.
2026-08-13 09:28:15 -04:00
Marcos Paulo Diniz bcccfa139d feat(notifications): select default chat connection 2026-08-13 09:28:15 -04:00
Marcos Paulo Diniz 017795ba08 feat(notifications): test chat connections 2026-08-13 09:28:15 -04:00
Marcos Paulo Diniz c11b302911 feat(notifications): update chat connections 2026-08-13 09:28:15 -04:00
Marcos Paulo Diniz 0a9ad7c356 chore: ignore local worktrees 2026-08-13 09:28:15 -04:00
Juan Denis d781359deb Merge pull request #95 from jhd3197/dev
Make every list page use the same table
v1.7.91
2026-08-13 09:04:18 -04:00
github-actions[bot] 53f58d8477 chore: bump version to 1.7.91 [skip ci] 2026-08-13 12:37:21 +00:00
Juan DenisandClaude Opus 5 91d6fd39c0 chore: ignore copies of local env files
The env rules were exact-match, so .env.development.local was ignored but a
.env.development.local.bak sitting next to it was not — it showed up as an
untracked file asking to be committed. These hold the WSL IP of whichever
machine wrote them, which is the reason the originals are ignored in the first
place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 08:34:11 -04:00
Juan DenisandClaude Opus 5 c07c73fa5c chore(screenshots): capture the converged Docker, Terminal and Databases tables
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 02:16:47 -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 3d296ff9a4 chore(screenshots): capture the unified incidents table and the SSL conversion
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 01:03:03 -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 36f926ba9a chore(screenshots): capture the selection bar and the fixed KPI band
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 00:19:11 -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 db432afc25 chore(screenshots): refresh captures
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:56:54 -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 8eccbf4e23 chore(screenshots): refresh mock-data captures for the converged list pages
Adds projects/vaults/shared-variables shots and drops services-cards, whose
list/cards toggle no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 23:49:52 -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 116c416ec1 @
feat(ui): one row of chrome per table

A table gets exactly one row of chrome:

    [View name v] ....................... [search] [filter] [...]

and nothing else. The row count is gone from it entirely: GridViewPickers
2026-08-12 23:47:58 -04:00
Juan Denis 3503b4dbec Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-08-12 23:46:29 -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
github-actions[bot] ea2ee463b7 chore: bump version to 1.7.90 [skip ci] 2026-08-13 01:50:21 +00:00
Juan DenisandClaude Opus 5 39c623c4e9 fix(tests): the "absent extension" seam test named a real extension
test_ext_to_ext_seam_absent_side_returns_default asserted that
get_installed_extension_attr returns its default for serverkit-analytics, on
the stated assumption that analytics "is not installed in this test panel".

That assumption is a property of the machine, not of the code. On a checkout
that HAS analytics under backend/app/plugins/, the seam correctly resolved
`inject` and the test failed with:

    assert <function inject at 0x...> is None

which reads as "the seam is broken" and means precisely the opposite -- the
seam did its job.

It now names a slug that cannot exist, so it tests the contract ("callee absent
-> default, never raises") instead of which extensions happen to be installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:48:21 -04:00
Juan DenisandClaude Opus 5 da4346ccee fix(tests): wp_extension_package must mark the extension ACTIVE, not just import it
20 tests across five files failed on any checkout with the serverkit-wordpress
sibling repo present, with the message "the serverkit-wordpress extension is not
installed". That message is what made this hard to see: the extension WAS
available, and the suite is explicitly built so a fresh clone passes without it.

The cause is that wordpress_bridge.ensure_loadable() gates on an ACTIVE
InstalledPlugin ROW, not on importability. That is deliberate (audit F2): a
DISABLED extension's modules stay importable so its blueprints can be
re-mounted, but its services must not be reachable. When that check landed,
wp_extension_package quietly stopped being able to deliver what its docstring
promised -- it only registers the package in sys.modules, and it took no `app`
fixture, so it could not create a row even in principle. Its sibling
wp_extension seeds one, which is exactly why those tests passed and these did
not.

So the fixture now seeds the active row too. Nothing about the extraction was
wrong; a fixture lost a capability and the error message blamed the wrong thing.

Verified: the five affected files plus test_setup_reconcile (the other consumer
of this fixture) -- 96 passed, 0 failed, where 20 failed before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:48:21 -04:00
github-actions[bot] b09f1512fd chore: bump version to 1.7.89 [skip ci] 2026-08-13 00:46:00 +00: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 5f8a3ef204 feat(recycle-bin): applications are soft-deletable, and a delete stops keeping data hostage
Plan 70. Domain went through this and TEN read paths were wrong, four with
effects outside the panel. Application has 211 call sites across 68 files and
drives more -- container lifecycles, nginx, backups, registry pulls, the OS
crontab -- so it was audited in full first (docs/plans/70_...md). 180 sites are
now query_active(); the 35 left unfiltered are each commented in place.

**The delete / purge split is what makes the tombstone honest.** delete_app used
to run compose_down(volumes=True) and rmtree(). Keeping a tombstone AND doing
that would put a Restore button next to a row whose data the delete had already
destroyed -- the one promise a recycle bin cannot break. So:

  DELETE   containers down (VOLUMES KEPT), vhost removed, firewall closed,
           cron suspended, row tombstoned. All reversible.
  RESTORE  vhost rewritten, firewall reopened, cron resumed. Containers left
           STOPPED -- "put it back" must not silently start processes or open
           ports, the same reasoning that stops domain restore re-issuing certs.
  PURGE    volumes removed, uploaded source deleted, cron detached.

register() gained on_purge for that, wired into BOTH purge paths. And
purge_expired had NO scheduler -- fine when a tombstone was just a row, not fine
when it holds data volumes and a source tree, so a daily builtin job now runs
it. Without that, "kept until purge" would have meant kept forever and delete
would have quietly stopped reclaiming disk.

A side effect worth naming: the old _remove_data_flag protection for database
engines is now the DEFAULT for every app, and the flag is carried into
?purge=true so an explicit skip-the-bin delete still honours it.

**Cron was the blocker with no mechanical answer.** before_delete never fires on
a soft delete, and clear_application is LOSSY -- it nulls the association
without recording it, so nothing could put it back. suspend_for_application
instead disables only the jobs that were CURRENTLY ENABLED and stamps each
suspended_by_app; resume re-enables exactly those. That flag is the whole point:
without it a restore switches back on jobs the user deliberately turned off.
clear_application survives as purge-only, where lossiness is correct.

**The indirect reach (audit 3.1) is the class that grep cannot find.**
ContainerSleepPolicy, BackupPolicy, DeploymentJob and WafPolicy are enumerated
on a SCHEDULE and resolve application_id afterwards, so they never appear in a
search for Application.query. Left alone, a tombstoned app's sleep policy is
swept forever and wake_app restarts the containers the delete tore down. Those
loops test Application.deleted_ids().

Deliberately NOT scoped: reservation checks (port, name, directory) -- a
tombstone must keep holding those or a new app takes them and the restore is
refused; api/git.py's webhook VISIBILITY gate, which falls through to
"panel-wide visible" so hiding a tombstone would WIDEN access; and cron
attribution, which only labels a row.

Verified: 15 new tests, the migration replayed on a fresh DB (a tombstone frees
its private_slug, two live rows still collide), and a full delete -> restore
round trip. The 20 remaining full-suite failures are the known
serverkit-wordpress-not-installed class and are identical on a clean tree.

Two traps caught by testing, both recorded in the plan: a bad import made the
ENTIRE purge cleanup silently no-op while purge() still reported success
(_run_purge_hook swallows by design, so assert on the warning, not just ok), and
private_slug had to lose unique=True at the MODEL level or a models-built
database burns the slug regardless of the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 20:26:04 -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 139d5884fe perf(api): stop /status/apps spawning a subprocess per app, and roll up the activity summary
The two worst endpoints in the backend, both measured in plan 69 phase 6.

**/status/apps** ran one `docker compose ps` per app plus a `docker inspect`
per container -- roughly 30 apps x 3 containers = ~120 process spawns per
request -- and it is refilled on a timer, so the cost never amortised. It is
now ONE host-wide `docker ps` whose output is indexed by the compose
project/service/working-dir labels Docker already stamps on every container,
shared between callers through a 3-second in-memory snapshot.

The snapshot is deliberately module memory rather than CacheService: it must be
droppable synchronously, because a status that survives the action that changed
it reads as a broken panel. So `invalidate(app_id)` is called from start, image
apply, deploy and rollback. 3s is short enough that a missed hook self-corrects
within one poll.

**/admin/activity/summary** ran two 90-day loops issuing one COUNT per day =
180 sequential queries. Now one `GROUP BY func.date(...)` per series with the
gaps zero-filled in Python, so the response shape is byte-identical. `func.date`
is portable across the SQLite and PostgreSQL this app supports. A third N+1 fell
out on the way: the top-users block did one SELECT per user, now a single IN.

New tests assert the zero-fill specifically -- a day with no rows must still
appear and the series length must match the window, which is exactly what a
GROUP BY drops if you forget.

1140 backend tests pass. The 3 failures in that run (test_fail2ban_jail_service,
test_url_swap, test_site_routing) all reproduce on a clean tree -- the last is
the known "serverkit-wordpress extension not installed" class.

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