- List all installer-supported distros (Ubuntu 22.04+, Debian 12+,
Fedora, RHEL/Rocky/AlmaLinux 9+) instead of Ubuntu only
- Add Arch row (x86_64, ARM64) matching installer platform detection
- Clarify Docker is optional for the panel itself
- Note the panel's ~180 MB RAM / ~500 MB disk footprint and that it
runs on 1 GB VPSs, spare laptops, and Raspberry Pi homelabs
`MigrationService.get_status()` had no tests, and it had just grown a third
state: a database stamped at a revision this build does not contain. That state
previously rendered as "80 pending updates" — every revision in history — behind
a Continue button that could only fail.
Covers up-to-date, genuinely-behind, and orphaned, plus the two edges worth
nailing down: the orphan flag has to survive the HTTP response because the
screen keys its warning off it, and a database with no revision at all is a
fresh install rather than a stranded one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An install a long way behind head listed all 80 pending migrations at full
size, so reaching the Continue button meant scrolling past a page of revisions
nobody reads. Show the newest four — the ones that say what you're actually
getting — with the rest behind "Show all N", and let the expanded list scroll
inside itself so the button stays put no matter how far behind you are. Panel
and row padding tightened along the way.
The 80 was also a lie, and that turned out to be a real bug. Pending migrations
are computed by walking history backwards and breaking when the walk reaches the
database's current revision. If that revision isn't in the chain at all — a
branch that added a migration and was then reverted, leaving the database
stamped at a file that no longer exists — the walk never breaks and reports
every revision as pending. `upgrade` would then fail with "Can't locate
revision" anyway.
So detect it: an unknown current revision is reported as `orphaned_revision`
instead of a fabricated backlog, the screen explains what happened and prints
the `flask db stamp` that fixes it, and Continue is disabled because there is
nothing it could successfully do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
You could not install a database from ServerKit. The tree's four roots were
literals, only MySQL and PostgreSQL reported real state, and no install path
existed anywhere in the backend.
Rather than add one, this makes a database engine an ordinary app template that
declares a top-level `engine:` block — family, wire protocol, admin user, which
variable holds the password, what it calls a "table". Presence of that block is
the only thing that lists a template in the Databases catalog, and installing it
is the template pipeline that already existed: same deployment job, same
streaming console.
The consequence is the point. Adding CockroachDB is adding a YAML file. So is
adding something we have never heard of — author it locally or sync a template
repo — and the install form still works, because it is generated from the
template's own `variables[]` rather than hand-written per engine. There is no
engine model, no engine table, no engine install mechanism to keep in step.
`test_dropping_a_new_engine_yaml_lists_it` is the design; if it ever needs code
to pass, this went wrong.
Twelve engines ship: postgresql, mysql, mariadb, mongodb, redis, valkey,
cockroachdb, clickhouse, influxdb, neo4j, meilisearch, couchdb. Each pins its
image, binds to loopback, names a data volume, and was checked against the
upstream image's actual environment contract — a wrong variable name is a
container that boots with authentication silently disabled.
Two safety notes worth reading. Engines bind 127.0.0.1 unless explicitly
exposed, and the flag is applied after caller variables so a handcrafted payload
cannot quietly publish one. And CockroachDB single-node runs `--insecure`
because password auth needs a certificate-backed cluster: for engines with no
credentials at all the loopback bind is the only control, so public exposure is
refused outright rather than warned about. A test pins the passwordless list to
exactly cockroachdb, so a future template that drops its password variable
fails loudly instead of opening a hole.
Also surfaced at last: `ManagedDatabase.owner_application_id` has been accepted
by the create endpoints for ages with no UI to set it. Creating a database now
offers "Attach to application".
Deferred deliberately: new-table builder, SQL dump import, engine extensions
(pgvector, TimescaleDB) — each a data-mutation surface deserving its own round.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`test_streams_lines_in_order_with_plain_flags` asserted `--progress plain`
reaches the command, but left `_is_compose_v2()` real. That probe shells out to
`docker compose version`, so on any machine without a working Docker it fell
back to v1, the flag was legitimately omitted, and the test failed for a reason
that had nothing to do with what it was testing.
Pin the compose flavour instead, and add the case the production comment
actually warns about: v1 answers an unknown flag with its usage text and exit 1,
which is how template installs on a v1 host once died at "Start Docker Compose
stack" with a wall of help text. Nothing asserted that `--progress` stays off
the v1 command until now.
No production code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard now renders a 12-column grid the operator owns: drag a widget by
its header, drag its corner to resize, click it to configure, and it stays where
you put it — across boards, across browsers.
Thirteen widget types ship (stat, time series, gauge, top-N, resource table, log
stream, deploys, alerts, status grid, activity feed, quick actions, host details,
note). Every one is wired to a real endpoint. Where there is no honest backing —
network history on the panel host, load average on a remote agent — the widget
says so instead of drawing a flat zero line, and the inspector won't offer a
metric the selected resource can't answer.
Board-wide variables replace per-widget fiddling: one time range, one refresh
pulse, and a `$server` variable so switching host re-points every widget that
follows it. TV mode drops the chrome for a wall display.
Editing is local until saved, which is what makes Discard honest — the snapshot
is restored verbatim with nothing having reached the server. A failed save keeps
the layout on screen rather than throwing away the work.
Plugin-contributed widget types merge into the library and are namespaced
`<plugin>:<id>`, so two extensions can both ship an `activity` widget.
Settings' "Dashboard Widgets" card, which could only toggle and reorder, now
points at the dashboard itself; `useDashboardLayout` and its localStorage key
are retired.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard's layout lived in localStorage as `{id, visible}` — a visibility
toggle wearing a layout's clothes. It carried no geometry, so widget width was
baked into SCSS (`.dash-kpi { grid-column: span 3 }`) and "make the chart wider"
was a code change. And it stayed on one machine.
`DashboardBoard` gives a user real boards: named, ordered, each holding a JSON
array of placed widgets with x/y/w/h and per-instance config. Three defaults
ship — overview, infrastructure, apps & deploys — seeded on a user's first GET
and restorable per board.
Validation is deliberate about the boring cases: geometry must fit the 12-column
grid, ids must be unique within a board, bools are rejected as coordinates
(`bool` subclasses `int`), and a rejected PUT leaves the stored board untouched
rather than half-applied. A board belonging to another user is a 404, not a 403
— there is no reason to confirm it exists.
The shipped defaults are checked by rebuilding their occupancy map cell by cell,
so editing DEFAULT_BOARDS into an overlap fails the suite instead of shipping a
broken first-run layout.
31 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`contributions.widgets` mounts a component at a host-chosen slot. That is the
wrong shape for the new dashboard, where the user decides what goes on a board
and where. So this adds `dashboard_widgets` alongside it: a registry of
placeable widget TYPES that surface in the widget library, each instance
positioned, sized and configured by the person looking at it.
The two are easy to confuse, so the docstring, the manifest spec and
EXTENSIONS.md all now say plainly which is which, and a test asserts a
dashboard widget never leaks into the fixed-slot list.
SDK_VERSION 1.0.0 -> 1.1.0 (added surface, older extensions unaffected),
bumped in lock-step across the JS export and the backend mirror.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fleet list carried a rail of its own — a health dial, a status nav and a
groups nav — plus a workbar, a command bar, bulk-select and a per-row action
menu. Every number those showed is a column or a filter here, so the page is
one filter row and one table: status segments with counts, a group select,
"4 of 4 servers · 2 online", then Server / Host / Agent / Status / CPU /
Memory / Disk / Last seen. Row actions are gone because ping and delete
already live on the server's own page, which is where rebooting or deleting a
machine belongs.
Offline and pending rows show a dash rather than empty gauges: a 0% bar is a
claim, and those hosts are not reporting.
Add Server was written as a right-side drawer, then pointed at the centred
Dialog primitive while keeping its own `position: fixed; right: 0` geometry.
The primitive's `left: 50%` and -50% transform still applied on top, which is
what rendered the panel half off-screen and clipped. It runs on the shared
Drawer now, so the slide-in, focus trap and overlay come from one place.
`.servers-page { padding: 0 }` was a leftover from the old full-bleed
workspace and was overriding the tab group's padding, running the table under
the right edge of the viewport. Removed, with its layout hook.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The summary band rendered above every section of the group, so "Total
backups" sat on top of Schedules, Storage and Settings, where it answered
nothing you opened those tabs to ask. It belongs to one section, and that
section is now an Overview rather than the archive table.
Overview answers "is my data safe?": protected resources, storage used,
success rate and next scheduled; an 18-week activity heatmap with its failure
days marked; a recent-activity feed; destination bars; latest snapshots. This
needed two endpoints the frontend had never called — /backups/policies and
/backups/runs — which is where the heatmap, the success rate and the
durations come from. A panel that has only ever taken manual backups falls
back to the archive listing instead of showing an empty page.
Schedules gets the mock's columns (frequency, retention, destination, next
fire, an on switch) and only its own action in the top bar; Create Backup was
answering a question nobody on that tab was asking. Snapshots moves its
search to the top bar with every other list page. Storage becomes destination
cards over the provider form.
Two places the mock has data we do not, left out rather than faked: per-policy
size and run-now on Schedules (the schedule API exposes neither), and
duration on Snapshots (filesystem entries do not record it).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fleet Monitor and /monitoring were the same questions at two scopes, so they
were two pages restating each other: both grew an alerts surface and both
grew thresholds. They are now one page with a scope, not two pages with a
gap between them.
The top bar carries a server picker, URL-backed as ?server= so the choice
survives a tab change, a refresh and a pasted link. Overview, Alerts and
Capacity all follow it. The fourth KPI tile is whichever reading the current
scope actually has — load average on the panel host, container count on an
agent — rather than a blank where the other one would be.
The overview is ported from the design mock: KPI band with trends and
sparklines, two trend charts, and a Host health strip below. That strip is
what the standalone heatmap became, except each card is a button that
re-scopes everything above it instead of a cell you can only read.
There is deliberately no second tab strip inside the page. Overview / Alerts
/ Rules / Capacity / Doctor live in the group's own top bar, the way the mock
puts them — a nav under a nav reads as two competing headers.
Also: the sidebar said "Observability" for a page, a route and a title that
all said "Monitoring", and the delivery channels rendered Discord, Slack and
a raw webhook with the same generic glyph. Both fixed.
/fleet-monitor redirects; the Servers tab strip loses the entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Search now lives in the top bar, right-most, on every list page — Services
kept its copy in the table toolbar, and Domains had none at all. The view
switch claims the free space so it stays right-aligned where a page gave its
toolbar search up; where one is still present it hands the margin back.
Domains loses its four-tile KPI strip. Every number on it was already the
count of a segment you would click to act on, so the segments carry them
instead: All 5 / Expiring SSL 1 / Attention 1. Search narrows the set the
counts come from, so they always describe what a click would show.
Fleet Proxy loses its title, subtitle and five-tile band — the tab strip
names the page and every tile re-counted a column of the table beneath it.
What the page is for now lives in the empty state, where it is needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two more of the same fault as the logs: the frontend reading fields the backend
does not send at that level.
Resource Usage showed 0.0% CPU, 0.0% memory and N/A for everything else on a
healthy container. The stats route answers {stats: {...}} carrying docker's own
CLI keys, and the caller read the top level of the envelope — so every field was
undefined and fell through to its zero. It unwraps `stats` now, which is where
CPUPerc, MemUsage, NetIO and PIDs have been all along.
Related Resources sat on "Loading…" permanently because GET /apps/<id>/related
was never implemented. The client has always called it, the 404 was swallowed by
an empty catch, and the card reads null as "still loading". The endpoint now
exists — domains, managed databases, backup policy and deploy count, scoped by
the same access check as the rest of the app routes, each section gathered
independently so a missing table degrades that row instead of failing the card.
The catch also settles to an empty object now, so a future failure shows "no
related resources" rather than spinning forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three symptoms, two of them the same mistake — the frontend reading fields the
backend does not send.
Logs said "No logs available" for every local Docker service no matter how much
output there was. A local app answers with the standard multi-source envelope
(`lines[]`), a remote one with a single `logs` string, and both readers only
checked `logs`. A shared normaliser now takes either.
Resource Usage was empty for every Docker service for the same reason: the
container filter matched on `Names[]` and the compose-project label, but the API
normalises `docker ps` to lowercase keys and exposes no labels at all — so the
filter never matched anything. The following line then read `.Id`, which would
have been undefined too even on a match. Matching is now on the name the API
actually returns, which still catches compose's project-service-N containers.
And a service you can reach only by port had no link anywhere: the number sat
as dead text and you assembled the URL yourself. There is now an open-in-new-tab
icon before the status pill, and the port in Service Info is a link. Only when
no domain fronts it (the domain link is the one to follow), only while running
(a link to a stopped container reads as the panel being wrong), and never for an
app on a remote server — the payload carries `server_name`, a label rather than
a hostname, and guessing an address from it would hand out links that fail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An install writes its directory before it starts containers, so failing at
"Start Docker Compose stack" leaves the directory behind. The guard against
clobbering an existing app then refused the retry with "App directory already
exists" — and the only way forward was a shell. A dead end in exactly the flow
where someone is most likely to try again, and the one this happened in:
Uptime Kuma failed on the compose flag bug, then could not be reinstalled.
A directory with no Application row pointing at it was left by a failure — that
row is only written once the plan completes. Those are reusable, since the plan
rewrites every file it owns. Anything else is somebody's data: an unowned
directory still has to be empty or carry the .serverkit-template.json marker
before it can be touched, and an unreadable one counts as occupied.
When it genuinely is a conflict, the message now names a way out instead of
just stating the fact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two halves of the same complaint — the log never grew.
The dead space under a short log was a flex bug. The console set
`min-height: calc(100vh - 200px)` to reach the bottom of the viewport, but the
tab-group content region is a column flex container where a child sizes to its
content: the min-height set a floor for the page while the log pane inside kept
stopping at its own last line. `flex: 1` makes it actually grow, and the log
takes the slack.
Expand gives the transcript the whole page: the failure card, the pipeline strip
and the run identity line step aside, the max-width comes off (long build output
is exactly what this mode is for), and the log runs to at least 60vh. Not a
browser fullscreen — the sidebar and tabs stay, because reading a log is
usually a step on the way to doing something else, and losing the nav to read
output is a poor trade.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failure card opened with the whole persisted tail expanded — about 350px
above the fold — which squeezed the live log into a strip barely a few lines
tall. Worse, it was showing the same output as the log directly beneath it, so
the space bought a duplicate.
The tail now sits behind "Show output (N lines)" in the actions row, leaving the
card around 100px: the failed step, the one-line error, the plain-language hint
when one matched, and Retry / Copy / Ask AI. Everything needed to decide what to
do stays visible; the transcript is one click away and 180px when opened.
This is only reasonable now that a failed deploy scrolls itself to the offending
line and the severity map marks where it is — the tail stopped being the only
way to see the error, so it stopped needing to be permanently open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five pieces the mock had and we didn't, all in service of the same thing —
finding the failure without reading the log.
Auto-jump to the first error. A failed deploy now opens on the line that
explains it rather than on the tail. Centred, not top-aligned, because the
lines leading up to an error are usually where the cause is. Once per job, and
never while following a live run.
Prev/next navigation. With the search box empty these step through the errors,
which is what someone opening a failed deploy is looking for; with a term they
walk the matches. The landed-on line keeps a marker so the eye finds it after
the scroll.
Error and warning counts, which double as filters. Counted from the unfiltered
log, so "3 errors" stays true whichever filter is on, and clicking one filters
to it (clicking again clears).
Step sections. Consecutive output from one step folds into a collapsible
section whose header carries its line and error counts. The header is
`position: sticky`, so the step you are reading stays named at the top of the
pane while its lines scroll past — the mock's pinned header, without measuring
offsets. Grouped by consecutive runs rather than by step id: if output ever
interleaves, the transcript still reads in the order it happened.
Off-screen lines are skipped with content-visibility instead of JS windowing.
The mock could window because its rows are a fixed height; ours can wrap, so
heights vary and offset maths would be wrong. This keeps a multi-thousand-line
log responsive, survives wrapping, and leaves find-in-page working.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finding the failure in a few thousand lines meant scrolling and reading. The
mock has answered this since plan 51.6 with a coloured strip beside the log,
and it was the one part of that console never ported.
Each of 150 slices is painted by the WORST level it contains — worst-wins,
because one error among two hundred info lines is exactly what someone is
hunting for, and an average would hide it. Errors additionally get full opacity,
a minimum height and a slight glow so a single line in a long log still catches
the eye. Slices with nothing in them stay empty rather than drawing a bar for
"nothing happened here".
The current viewport is outlined on the strip, and clicking or dragging
anywhere on it jumps there and drops follow-mode — the navigation half of the
request, not just the seeing half.
It only appears past 40 lines; below that the scrollbar already shows
everything and a second gutter is noise. The log reserves its 16px only when
the map is actually rendered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console rendered edge-to-edge with no padding. It lives inside the Services
tab group, whose content region is deliberately full-bleed so pages can manage
their own scroll; centred pages opt back in by wrapping content in
.sk-tabgroup__inner, which supplies the 1400px max-width, the centring and the
padding. 25 pages use it — this one never did.
Applied to all three render paths, so the loading and error states are framed
the same way as the console itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every template install on a docker-compose v1 host failed at "Start Docker
Compose stack". The streaming path passed `--progress plain`, which v1 has no
global flag for, so it printed its entire usage text and exited 1 — the deploy
console filled with help output and nothing anywhere said the argument was the
problem. Reproduced deploying Uptime Kuma locally.
The service already detects v1 vs v2 and falls back to docker-compose; the flag
simply wasn't gated on it. `--ansi never` stays for both, since v1 does accept
that one.
Tests pin the shape rather than the symptom: v1 never receives --progress, v2
still gets plain progress, both keep their global flags before the subcommand,
and detection falls back to v1 when docker is missing entirely instead of
raising into the deploy path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The generator was changed to write one (dddf090) but the committed file never
was, so every plugin sync reproduced the same one-byte diff and left the tree
permanently dirty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capacity check existed but nothing surfaced it, so the answer arrived only
if you went looking for it. It now sits directly under the server picker in the
deploy drawer — the natural place, because the answer depends on which server
you just chose, and switching servers re-asks.
Four states, each a sentence rather than a code: fits comfortably, fits but
will be tight (with what it would leave spare), won't fit (with what it is
short of), or can't tell — the last covering both a server with no recent
readings and a template that publishes no estimate.
It never disables Deploy. An estimate is an estimate, and the operator knows
things the panel does not: a box about to be resized, an app that idles far
below its documented minimum. The job is to make sure nobody is surprised, not
to hold the door shut.
Two details that matter in use: the note dims while re-asking after a server
switch, so a stale answer never passes for the new one, and it renders nothing
on first paint rather than flashing 'unknown' a beat before the real answer.
The tone borders use the -raw colour variables, since / resolve
to CSS var()s that rgba() cannot take at build time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compliance summary rendered as '0Compliant / 0Drifted / 0Unknown' stacked
down the card, because the page hand-rolled .stat-item markup that no
stylesheet ever defined — while _server-templates.scss still carried a rule for
the .sk-kpiband-wrap it used to render. It now uses KpiBand + MetricCard, the
primitives the sheet was already expecting and the rest of the app already
uses.
The progress bar was also drawing full green above three zeroes, which reads as
'100% compliant' when nothing has been measured at all. With no servers
assigned to a template it now says that in words instead.
Templates was the default tab and is empty on a fresh panel, so the first thing
anyone saw was an empty page. Library leads now, and the opening tab follows
the data rather than being fixed: no templates of your own means Library, and
once you have some, those are what you came for. Decided once on load, so a
refresh after creating one doesn't move the tab under you. Templates carries
its count, and its empty state offers the library instead of only mentioning it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying checked three things — the name is free, the server row exists, the
directory isn't taken. Nothing asked whether the box could carry the thing, so
a 2 GB app landed on a 1 GB VPS at full speed and the first sign of trouble was
the OOM killer.
Every piece was already here and unconnected: templates describe what they cost
(`requirements: memory / storage`), servers report what they have
(Server.total_memory / total_disk) and what they are using (the newest
ServerMetrics sample). This is the join, answering in the shape a person asks
it — does it fit, will it be tight, or can't we tell?
Advisory on purpose. It reports; it never refuses. An estimate is an estimate,
real usage depends on configuration and traffic, so the operator keeps the
final say and "tight" is a sentence to read rather than a wall to argue with.
A full server still returns valid:true, and a test asserts that so this cannot
quietly become a gate.
Three judgements worth stating:
- The reserve is a floor-and-fraction pair (10% of the box, at least 256 MB RAM
/ 2 GB disk). A bare percentage is wrong at both ends: 10% of 1 GB is
nothing, 10% of 64 GB is more than most apps need.
- `unknown` outranks `ok`. No recent sample, or a template that declares
nothing, says so — a confident wrong answer is worse than no answer.
- Free memory comes from psutil's `available`, not `free`: cache and buffers
are reclaimable, and counting them as used calls every healthy Linux box
full.
Catalog coverage goes from 52/106 to 106/106. The 54 estimates are each app's
typical idle-to-light-use footprint, taken from the project's own documented
minimum where it publishes one and rounded to the values the existing 52
already use — sonarqube 4 GB because that is its documented heap floor, immich
4 GB for the ML models, prometheus 20 GB because retention dominates, traefik
256 MB.
Surfaced two ways: a capacity block alongside (never inside) `errors` on
validate-install, and a read-only GET the deploy drawer can poll while the
operator is still choosing a target, so the answer updates as they switch
servers instead of waiting for a submit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The six sidebar profiles were only reachable from Settings and the sidebar
context menu -- places nobody visits during onboarding. Setup inferred one
silently via presetForUseCases(), but only when exactly one use case was
checked and only for wordpress/devops, so 4 of 6 profiles were unreachable
from a fresh install and most users landed on "Recommended" without knowing
a choice had been made for them.
Surface it at the end instead of adding a step: the Summary step now shows
the pre-selected profile with its item count and a "Change" toggle that
expands the six cards inline. Accepting the default stays one click, so this
costs nothing for anyone who does not care.
Also:
- widen presetForUseCases() so combos resolve (web-apps alone, or with
devops, now maps to DevOps rather than falling through to Recommended)
- add visibleCountForPreset() to describe a profile before committing to it
- fix the Minimal description, which claimed "dashboard, servers, terminal"
while actually leaving 13 core items visible
Note: the count reads "N of 18" because it covers core items only --
extension nav is not resolvable at wizard time, since the recommended
extensions install during finish.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Should X be a registry too?" gets re-asked every few months, and the answer is
usually no. This records the rule and the per-catalog verdicts so the assessment
does not get redone from scratch.
A catalog earns a registry only when all three hold: it changes faster than the
panel releases, it is data rather than code (a new entry must work on an
existing panel with no code change), and someone outside the release cycle would
contribute to it. Extensions, themes and app templates pass. Seven other
catalogs were assessed and rejected, each with the reason -- server templates,
notification events, AI providers, DB tuning settings, sanitization profiles,
agent survey probes and build packs.
Also names the failure mode that makes a bad extraction expensive: panels that
have not upgraded read the registry forever, so an entry that needs new panel
code either gets ignored or renders as a broken item. That is what forces a
min_panel_version gate, and why serverkit-extensions has one and the other two
do not need one.
Records one open candidate rather than leaving it implicit. YARA rules pass all
three tests and have no update path -- 16 curated rules frozen at release time.
That got sharper this session: the image now builds without ClamAV by default,
and ClamAV was the component with a self-updating feed via freshclam, so YARA is
now the only malware detection in a default container. Operators can already
drop custom .yar files in CUSTOM_RULES_DIR; what is missing is a curated
upstream. Not built -- noted so the gap is deliberate.
Adds the templates registry and this doc to the README documentation table.
Every claim was checked against the code rather than written from memory: rule
counts (16 in both BUILTIN_RULES and webshells.yar), the CUSTOM_RULES_DIR
default, ARG INSTALL_CLAMAV=false, and that ServerTemplateService already
exposes create_template so its 12-entry library is a seed, not a catalog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
serverkit-gui called the agent registry directly, so its manifest permissions
were decoration — the install dialog said "screenshot and capabilities" while
/synthetic also asked for system:info and system:processes, neither declared.
Going through plugins_sdk.agents enforces the declaration, so the first thing
the retrofit needed was for the manifest to tell the truth: all four actions
are now declared, in both permissions and requires_agent_actions.
Per-endpoint behaviour is deliberately unchanged. /capabilities still answers
200 with the synthetic fallback when the agent can't do it, /frame still 502s a
failed capture and 503s an offline agent.
One case needed care. declared_permissions() reads the manifest captured in the
database at install time, not plugin.json on disk, so an install predating this
lists only the two gui actions and would have started 403-ing on /synthetic.
That endpoint exists to render something for a host with no display, and it
already degrades when the agent won't answer — so a permission this plugin
hasn't been granted now lands in the same place, with a log line naming the
fix. An older install keeps working with less detail instead of breaking, and
regains it on update. There is a test for exactly that install.
Two things improve for free: a handler failure arriving inside a
transport-successful reply now reports the agent's real error instead of the
vague "agent returned no frame", and a non-dict payload can no longer crash the
capabilities handler.
The registry index still advertises the published 0.1.0-beta.1 zip and its
sha256, so it is left alone — permissions there move when the release is cut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a "By the Numbers" table under the badges in all four READMEs (EN, ES, PT,
zh-CN). Every figure is measured from this repo at this commit, not estimated:
1,519 REST endpoints walked from app.url_map after create_app(); a raw
@route grep undercounts at 1,276 because plugins
register routes dynamically
106 app templates backend/templates/*.yaml, matches index.json
2,633 backend tests pytest tests --collect-only
1.75 MB gzipped UI frontend/dist, gzip -6 over every asset
~180 MB resident gunicorn --workers 1 --threads 100, FLASK_ENV=production
501 MB image docker build . on linux/amd64
Three claims were corrected against the code rather than written from memory:
the OpenAPI docs live at /api/v1/docs (not /api/docs); "no telemetry" was wrong
-- there is a telemetry subsystem, it just writes local SystemEvent rows -- so
it now says nothing phones home, which was verified (no sentry/posthog/segment/
mixpanel, no version ping, and the SPA's only external URLs are documentation
links, not asset loads).
Numbers only ever grow here, so a stale figure undercounts rather than
overstates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The AI assistant is a core primitive, so app/api/ai.py is registered by
create_app() like every other blueprint. It reached ai_service, which imported
prompture at module scope -- and `import prompture` costs ~69 MB of RSS, pulling
~300 prompture modules plus rich, pydantic, httpx, pygments and jsonschema.
Every panel paid that at startup whether or not anyone ever opened the
assistant. Measured in the production container (gunicorn --workers 1
--threads 100):
before worker 177.8 MB total 204.9 MB
after worker 153.1 MB total 180.5 MB
The saving is smaller than 69 MB because pydantic and httpx are still pulled by
other subsystems; only the prompture/rich/pygments-exclusive share is recovered.
`from __future__ import annotations` was already in both files, so every
annotation is a string and nothing needed a signature change:
- ai_service: prompture imports moved under TYPE_CHECKING, with real imports
pushed into the four functions that construct a Conversation, ToolRegistry or
ProviderEnvironment.
- ai_service: the two module-level guardrail singletons (PromptInjectionDetector,
PIIRedactor) were the only objects forcing the import at load time. They are
now built on first use behind the same double-checked lock that
ensure_initialized() already uses.
- ai_tool_registry: tool_from_function deferred into register(). This one counts
twice -- plugins_sdk/ai.py imports that module at load time too.
No behaviour change: the first assistant request pays the import instead of
every boot. Prompture is unmodified.
test_ai_lazy_import.py guards it. The checks run in a subprocess on purpose: by
the time pytest reaches that file another test has usually imported prompture
already, so an in-process sys.modules assertion would pass no matter what the
boot path does. Verified the guard actually bites by reintroducing a
module-level `from prompture import Conversation` -- 2 of 3 tests fail, and pass
again once removed.
Suite: 2586 passed, 47 skipped, run in a clean worktree with a private
TEST_DATABASE_URL. That isolation was needed because tests/conftest.py hardcodes
one shared %TEMP%/serverkit_test.db for every pytest process on the machine, so
a concurrent run stamps it to another branch's migration and mass-errors an
unrelated suite. Left alone here; it deserves its own fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The module docstring described only the plumbing a plugin imports, not the six
surfaces it can now contribute to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SDK table listed the surfaces an extension consumes but not the ones it can
now contribute to, and deploys had been missing since it shipped. Adds all six
with one worked example each, plus the rule that ties them together: namespace
what you register, the panel owns the UI and normalises what you hand back.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything an extension could do stopped at the machine the panel runs on,
which is at odds with a product built around a fleet of servers. A plugin can
now run an agent command and get its result back.
This is also where agent.command:<action> starts meaning something. The
manifest permission has existed as a declaration — collected in the manifest,
surfaced at install time for consent — with nothing enforcing it, because
plugins called the agent registry directly. Dispatching through the SDK checks
it first, matched verbatim, so a plugin can only run the actions it asked the
operator for by name. The one bundled extension that uses agent commands still
calls the registry directly; retrofitting it onto this is a follow-up.
Nothing underneath is re-implemented: the per-server ACL still decides whether
that server permits the action, and the ServerCommand audit row is still
written — pass user_id so it names a person rather than "the panel".
Three things the wrapper adds over a bare send_command:
- it unwraps the double envelope. A transport-successful reply can carry a
handler failure inside, which reads as success to anyone checking only the
outer flag.
- it fails before dispatch on an offline agent or a missing capability, so the
caller gets a reason instead of an unknown-action error from the far side.
- the default timeout is 45s rather than the registry's 30s. An agent on the
HTTP long-poll fallback can sit ~25s before it even sees the command, which
leaves a 30s budget almost no room to actually run anything.
Failures raise with a .code, so a plugin can tell an offline agent from a
refused one from a timeout, and map them to its own HTTP responses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An extension with something worth backing up had to hand-roll it — no policy,
no cron mirror, no run history, no retention, no offsite copy, no verification,
no restore, and no place in the Protection panel. A registered kind inherits
every one of those, because everything downstream of the target_type switches
is already generic: the run lifecycle, retention (count, age, incremental-chain
ancestry), the API blueprint, and the panel itself, which threads target_type
through without ever switching on its value.
No migration: target_type is an unconstrained String, and the allowed set lived
purely in a Python tuple. A kind supplies resolve() and execute(), plus an
optional restore().
Four switches had to learn about the registry, and the order matters in three
of them:
- validate_target_type is the keystone — every policy route funnels through it,
so accepting a registered kind opens the whole blueprint at once.
- _resolve_target and _execute_backup check the registry BEFORE their core
branches, because both end in an application fallthrough that swallows any
unrecognised type.
- run_restore_job likewise. This is the sharp one: without it a plugin's
archive would be unpacked over an app directory by core's else-branch.
A kind that didn't implement restore is refused at request time with a reason,
rather than queueing a job that takes the policy lock and runs a safety backup
before failing.
The registry normalises what plugins hand back — target_type is forced rather
than defaulted, so a kind cannot describe itself as an 'application' and be
restored as one; execute() must return (path, size, meta) with a real path; and
the metadata retention and the panel read is filled in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep was a fixed sequence of core producers, so an extension had nowhere
to say "the thing I manage is unhealthy, and here is what to do about it" — on
the page an operator opens to ask exactly that. A plugin now registers a
provider and its checks appear beside the core ones, with a working Repair
button, and no frontend change: the panel's status pill, repair button and diff
toggle all key off fields rather than a known list of check ids.
Because these run third-party code, the registry gives them two things core's
own producers don't have:
- an error boundary. run() calls its producers bare — each hand-rolls its own
try/except and anything that escapes 500s the whole sweep. A provider that
raises becomes one warning attributed to the plugin instead.
- a time budget. Nothing bounds a core producer today, so a hung one hangs the
synchronous POST /doctor/run for whoever asked. A provider that overruns is
reported as a warning and the sweep moves on. Note the executor is shut down
with wait=False on purpose: its context manager joins the worker on exit,
which would have made the timeout worthless — a test pins that down.
Providers run in a worker thread with the app context carried across, so a
check can query the database the way a core one does.
Contributed keys are namespaced, statuses outside ok/warn/fail are reported as
warnings rather than rendering as a blank pill, and a check claiming to be
repairable without a registered handler is demoted — showing a button that
dispatches nowhere is the state two core checks are already in, and it isn't
worth reproducing. Repairs route back through the registry, which is itself the
allowlist: a crafted ref for an unregistered namespace reaches nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An extension that needed to remember anything had to add a model to core AND an
Alembic migration for it — which is why extension tables like
ext_serverkit_minecraft_* sit in the panel's own migration history. That is a
steep tax on a small extension and it puts plugin schema in the panel's upgrade
path. A plugin now writes to a key/value store instead:
mine = store.for_plugin('serverkit-minecraft')
mine.set('world:1:seed', 8675309)
Values are JSON, so dicts and lists round-trip without hand-serialising, and
rows are namespaced by slug so two plugins cannot collide on a key. Reach for a
real table when you need to query BY the data — this is a key/value store, not
a database, and the docstring says so.
Because the table is shared by every installed plugin, three things are refused
rather than trusted: a value that isn't JSON-serializable, a value over 256 KB,
and an empty or overlong key. Validation happens at set() rather than at commit
— by commit time the error is opaque and the session is already poisoned, so
the next unrelated write fails too.
has() exists because get() cannot distinguish a stored None from a missing key,
and a plugin storing None deliberately would otherwise have no way to tell.
Concurrent writers race on insert; the unique constraint makes that safe and
the loser applies its write to the winner's row instead of failing the caller.
Store rows follow the same rule as extension-declared tables: an ordinary
uninstall leaves them, so a reinstall finds its state where it left it, and
"uninstall and delete data" removes them. The purge runs after the uninstall
lifecycle hook, since a hook may legitimately read its own store on the way out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite pointed every run at one fixed file, %TEMP%/serverkit_test.db,
deleted once at conftest import — and the delete swallowed OSError. On Windows
that is not a rare edge: a finished pytest whose app threads outlive it still
holds the file, the delete fails, the error is discarded, and the next run
quietly inherits the previous run's rows. create_all then collides with the
already-seeded settings row, and tests error at fixture setup — in a different
place every run, never in the test that caused it. Two suites running at once
share one database and truncate each other's tables mid-test, which is the same
symptom from the other direction.
That is the "flaky tests" folklore in this repo, and it is why a full-suite run
reported four errors that all passed when re-run alone.
Each process now uses its own serverkit_test_<pid>.db and sweeps stale ones at
startup, so a lingering process can no longer poison the next run and two
suites can run side by side. Session finish disposes the engine and removes the
file; if a thread still holds it, the next run's sweep gets it.
Verified by running two suites concurrently — both green, where the shared file
previously corrupted whichever one started second.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The omnisearch fan-out was a hardcoded sequence of core entity blocks, so an
extension's objects were absent from the command palette — the one place the
UI tells you to look. A plugin now registers a provider and its rows appear
alongside services, servers and domains, with no frontend change: the palette
already renders unknown types, bucketing them under "Results".
Scoping stays the provider's job, because search invents no ACL — every core
block scopes its own query and the provider is handed the user and workspace
to do the same. The shape, though, is not trusted:
- rows are always tagged with the registered type, so a plugin cannot mint a
row that claims to be a 'service' or a 'vault' (which are trusted to have
been scoped by core);
- the per-type cap is re-applied host-side rather than trusted from the
provider, so one contributed type can't drown the core ones;
- a path must be an in-app router path. The palette hands it straight to
navigate(), so a protocol-relative '//host' would walk the user off the
panel entirely.
A malformed row is dropped on its own, a broken provider degrades to "no rows
of that type" — the same isolation each core block already has.
The lazy accessor in plugins_sdk now carries a table of surfaces rather than a
single special case for deploys, since the other SDK surfaces land the same way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promoting between WordPress environments ran in the request thread: no
console, no log stream, no retry, and a progress bar the pipeline page had
to keep alive itself. It now runs as a registered deployment kind, so it
inherits the Deploy Console, live logs, retry and a row in Deploy Activity.
POST /promote returns 202 with a job_id instead of blocking, and the UI
opens the console with it.
PromotionJob is kept, not replaced. It carries pre_promotion_snapshot_id,
the rollback pointer, which is WordPress domain a generic DeploymentJob has
no business holding — so the deployment records which promotion rows it
produced instead. They are collected by id watermark rather than from the
service's return value, because promote_* only reports the job on success
and a failed promotion is exactly when someone needs the snapshot.
A full promotion runs two five-step halves and restarts its own numbering
at 1 for the second, so the progress bridge carries an offset across the
reset — otherwise the console's step strip walks backwards halfway through.
Two gaps this exposed in the SDK, both fixed here:
- Plugin kinds never closed their log stream, so they got no per-step
timings, no failure tail and no hint, and no terminal status push. The
handler and the runner also built separate streams, so the timings one
recorded were dropped by the other's close; they now share one per job.
- A handler that reported failure by return value logged nothing, leaving
a failed run with no reason visible in the console.
The console now titles a run from plan.title, so a contributed kind names
itself without a branch per kind in the frontend.
A locked environment is refused by the endpoint rather than by the job:
queueing first would send the user to a console that fails a second later.
The pipeline service still re-checks, since a lock can be taken in between.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DeploymentJobService.run_job dispatched on a hardcoded switch over three
core kinds, so a plugin could not own work that behaves like a
deployment. Its only option was the generic job system, which gives no
step plan, no streaming console and no place in Deploy Activity — the
reason promoting a WordPress environment, which is deploy-shaped in every
respect, is invisible in the one page you open to see what ran.
plugins_sdk.deploys lets a plugin register a kind and start one. The job
is a real DeploymentJob, so it inherits the Deploy Console, live logs,
retry and a row in the feed with no frontend work at all — those surfaces
key off the job, not off any particular kind, and the feed already
renders an unknown kind by its id.
Three things the SDK guarantees, each pinned by a test:
- Core kinds cannot be hijacked. A plugin registering app_deploy would
silently take over real deployments.
- A handler that raises still ends the job visibly failed. Third-party
code must not be able to leave a job spinning at 'running' with no
explanation — the same promise _reconcile_interrupted_jobs makes for a
lost process, extended to a lost handler.
- A handler that finalises the job itself keeps that outcome, even if its
return value contradicts it.
deploys.steps() advances the job through its plan as the handler works,
so the console's pipeline strip and the feed's tick bar fill in, and a
failure records which step failed rather than only a stack trace.
The implementation is deploys_sdk.py, not deploys.py: a submodule of that
name is bound as a package attribute on import and would shadow the lazy
accessor, handing callers the module instead of the SDK.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The single-container image shipped its own build toolchain and an optional
antivirus in every running panel. Measured on linux/amd64:
before 1.07 GB
multi-stage venv 553 MB
+ venv prune 501 MB (-53%)
Four changes:
1. Split a python-builder stage. gcc and the *-dev headers build wheels into
/opt/venv; the runtime stage copies the venv and leaves the toolchain behind.
The apt layer goes from 284 MB to 5 MB. No runtime -dev packages are needed:
the xmlsec, lxml and cryptography wheels are self-contained, verified by
importing all of them in the built image.
2. ClamAV behind ARG INSTALL_CLAMAV=false (153 MB). Safe because
malware_scan_service already treats clamscan as best-effort -- it reports
clamav_available and falls back to the builtin YARA matcher -- which is what
the old Dockerfile comment claimed but did not do. Build with
--build-arg INSTALL_CLAMAV=true to bundle it; that path is verified too.
3. COPY --chown instead of a trailing `chown -R /app`, which rewrote every
file's metadata and duplicated the whole tree into a 22 MB layer.
4. Prune the venv (~50 MB): third-party bundled test suites, and botocore's API
models for 400+ AWS services the panel never talks to. ServerKit constructs
exactly three clients -- s3, route53, ses -- plus sts for signing.
pip stays in the runtime image on purpose: PluginService installs an extension's
Python requirements with `sys.executable -m pip install -r`, so PATH points at
the venv and the venv is owned by the serverkit user.
The botocore prune fails open in dev and closed in the image -- a new
boto3.client('sns') would work locally and raise UnknownServiceError only in the
built image, when a user hits the feature. test_botocore_prune_contract.py
parses ARG BOTOCORE_KEEP and every call site and fails the suite on drift.
Verified in the built image: 14 optional deps import, all four boto3 clients
construct, sns is gone, no gcc, no __pycache__ or venv leaked into /app,
/api/v1/system/health and the SPA index both return 200. Suite: 2546 passed,
47 skipped. Idle RSS is unchanged at ~208 MB -- image size and resident memory
are separate problems.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`docker build .` at the repo root failed outright:
error from sender: open backend\.venv-wsl\lib64:
The file cannot be accessed by the system
With no .dockerignore the daemon walked the entire working tree -- ~750 MB of
node_modules (283), .venv-wsl (366) and .git (82) -- and died on that venv's
lib64 symlink, which Windows cannot stat. Only CI could build the image.
Exclude everything the Dockerfile does not COPY. Context drops to ~21 MB, the
build works from a clean checkout, and unrelated edits stop busting layer cache.
backend/tests is excluded too: release.yml builds and pushes the image, it never
runs pytest inside it, and nothing in backend/app imports from tests/ -- the only
references are source comments.
Deliberately no `!exception` patterns. A single negation makes Docker descend
into every excluded directory in case a nested file is re-included, which both
slows the walk and brings the unreadable .venv-wsl symlink back. That is why the
file lists directories rather than carving out keeps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WordPressProjects hand-rolled a card grid, an inline <svg> WordPress
glyph, and a wp-project-card SCSS family (88 lines) that restated what
ResourceListPage already does — while missing everything that page has
gained since: search, status filters with counts, the table/cards switch,
and a shaped loading skeleton.
It now passes columns and a card renderer like Services does, so those
come for free and there is one list implementation to change instead of
two. The dead SCSS is deleted; wp-env-status and wp-site-icon stay, they
are still used by the environment cards.
Note this page is NOT merged into Deploy Activity, despite the similar
name: it answers "which projects have an environment pipeline", while
Deploy Activity answers "what ran, when, and did it work". Both are worth
having — what they should share is the run history, which is the next
commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shared sheet sets padding and a gap on the panel itself, but
.sk-drawer lays out three full-height regions inside it — head, body,
foot — each managing its own padding. The panel's padding therefore inset
all of them: the header's divider stopped short of both edges and the
footer floated above a band of dead space instead of sitting on the
bottom edge.
Zero the panel's padding and gap, and give scrolling to __body alone —
with the panel itself scrollable the footer could not stay put either.
Fixed at .sk-drawer rather than per page, so every drawer gets it: the
template deploy and filter drawers had the same gap.
Measured after the slide-in settles: 0px below the footer, and head/foot
now span the full panel width.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>