1546 Commits
Author SHA1 Message Date
Juan Denis 0be785fcaf Merge pull request #82 from jhd3197/dev
Rebuild the dashboard and databases, and open the panel to extensions
v1.7.65
2026-07-26 11:55:14 -04:00
github-actions[bot] d83d812a02 chore: bump version to 1.7.65 [skip ci] 2026-07-26 15:17:47 +00:00
Juan DenisandClaude Opus 5 e172289850 docs(screenshots): show the app, not just the page
The dashboard shots were clipped to `.dashboard-page`, which cropped the
sidebar out and made them the only images in the set without the app chrome
around them — next to Services and Docker in the README it read as a bug.

The clip was there for a reason: `.main-content` owns the scroll, so a plain
shot stops at the viewport and `fullPage` does not reach the bottom widgets.
The fix is a per-section viewport instead — a taller frame captures the whole
board WITH the navigation, and `capture.mjs` now honours `section.viewport`
for any page with the same internal-scroll shape.

Also stops the shots claiming work is in flight. The dashboard fixtures
answered the deploy pill with running jobs, so every capture carried a
"2 deploys running" pill over the sidebar. The pill is correct behaviour; it
just has nothing to do with a screenshot of an idle board, so the fixture now
reports settled jobs rather than the CSS hiding a true statement.

31/31 sections still render.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:16:44 -04:00
github-actions[bot] 30f5ca71da chore: bump version to 1.7.64 [skip ci] 2026-07-26 14:34:50 +00:00
Juan Denis f935fd4b6e Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-07-26 10:34:35 -04:00
Juan DenisandClaude Opus 5 640e7e827e docs(screenshots): recapture, and fix two bugs the capture exposed
Six new sections cover the pages rebuilt this round — the dashboard as it ships,
its edit mode, the widget library, the widget editor, the databases explorer and
the engine catalog drawer. 31/31 sections render.

The capture is a render smoke test, not just a picture, and it earned that here
by surfacing two real bugs that no test could see:

`SearchField` merges its `className` onto its OWN root, so `.skw-lib__search`
IS the `.sk-searchfield` element rather than a wrapper around one. The rule
styling it as a parent matched nothing, leaving the shared `flex: 1 1 240px`
in force — and inside a column-direction filter bar that 240px basis is a
HEIGHT. The widget library's search box was rendering 240px tall.

`GET /auth/me` answers `{user: {...}}`, but Databases read `.role` straight off
the envelope, so `isAdmin` was always false. Every admin got the read-only
console — and with the DDL builder now shipped, would never have seen the button
that runs a CREATE TABLE.

Also carries `postgresql.yaml`, which I left out of the extensions commit.
Without `engine.extensions: true` stock PostgreSQL is not a host for pgvector at
all; the extension tests passed only because the working tree had the change the
commit did not.

Fixture note for whoever edits these next: `/metrics/history` points are NESTED
(`cpu.percent`, `load['1m']`) and carry no network data, `/servers/available` is
a bare array, and `DeployPill` queries `/deployment-jobs` twice and sums — a
single unfiltered payload makes it claim eight running deploys.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:59:06 -04:00
Juan DenisandClaude Opus 5 6edeba2ff5 fix(databases): scope the extension listing to apps the caller can see
`GET /databases/engines/<app_id>/extensions` carried only `@jwt_required()`, so
any signed-in user could enumerate any instance on the panel — the image it
runs, its databases, and what it can load. All facts about someone else's
application.

It gates on `can_access_app` now, mirroring the per-app docker DB read next to
it: owner, admin, or an explicit grant. Read access rather than admin-only is
deliberate — a developer who can already see an app should be able to see what
that app can load. The POST was already admin-gated and is unchanged.

The file also declares its `# Bucket: PER-APP` header (plan 29 #9), which is
what the structural sweeps read to know a per-app route file has been thought
about at all.

Found by the repo's own authz sweeps in a full-suite run — four failures, one
cause. Worth noting they fired on a route that has no feature test capable of
catching this: every functional test authenticates as someone entitled to the
data. That is precisely what a structural sweep is for.

Four tests pin the behaviour: denied without access, still visible to the owner,
404 rather than 403 for an app that does not exist (conflating them would make
the endpoint useless to its own caller), and 401 anonymous.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 00:19:46 -04:00
Juan DenisandClaude Opus 5 679082bf16 feat(databases): engine extensions, without the silent failure
The third deferred item. An extension installs INTO a running engine rather than
getting its own container, so it lives in its own namespace:
`backend/templates/extensions/<id>.yaml` with a top-level `extension:` block,
discovered by the same operator-wins two-directory scan the app templates use.
Dropping a YAML in makes it available with no code change, and a test pins that.

Not nested inside the engine template, because pgvector applies to any
PostgreSQL-speaking engine rather than to `postgresql.yaml` — nesting would force
duplication per host. Not a third `kind:` in the app catalog either: an
extension has no compose, port or volume and produces no Application, so every
consumer of `list_all_templates` would have had to learn to skip it.

The hard part is not the SQL. `CREATE EXTENSION vector` is valid and fails on
stock `postgres:16` with "could not open extension control file", because that
image does not ship the library. Two layers, and the statement is never sent
speculatively: an extension declares which images provide it, so a listing marks
an instance unsupported BEFORE anything is clicked; then at install
`pg_available_extensions` is queried first and a negative answer is a 409 with a
remedy, not an attempt. The live probe overrules the image name in both
directions — a private `registry.internal/ops/pg-with-vector` is allowed, and a
`pgvector/pgvector` tag that does not actually carry the library is still
refused.

ServerKit does not recreate a container behind the operator's back. The remedy
instead names an engine template that IS capable, found by matching the
extension's images against each engine template's compose — hence
`postgresql-pgvector.yaml`. TimescaleDB deliberately ships no bundled host and
says so rather than implying one exists.

Compatibility also needed correcting mid-build: matching on wire protocol alone
claimed CockroachDB could host pgvector. It answers on pgwire and cannot load a
single extension. Engines opt in with `engine.extensions: true`, which excludes
it without any Python naming it.

Two fixes fell out. `DatabaseService` had no psql twin of `docker_mysql_execute`
— `managed_db_user_service` carried a private copy with a comment saying as
much; there is now one implementation and it delegates. And that twin sets
`ON_ERROR_STOP=1`, without which psql reports SUCCESS for a statement the server
rejected.

57 tests, covering the zero-code-change property, compatibility, the image
problem in both probe directions, admin gating, and refusals that must not
execute anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:44:34 -04:00
github-actions[bot] 371df4d205 chore: bump version to 1.7.63 [skip ci] 2026-07-26 03:23:37 +00:00
Juan DenisandClaude Opus 5 42db5b8053 feat(databases): build the table builder and dump import that were deferred
Both were listed as out of scope for this round. That was wrong about the
importer in particular — the restore routes already existed and only the UI was
missing, so "deserves its own round" overstated a wiring job.

The builder is family-aware like the design mock, but split by what ServerKit
can actually reach rather than pretending uniformity. MySQL, PostgreSQL, SQLite
and containerised MySQL get a column grid and a live preview that is
DIALECT-CORRECT — an integer primary key is `BIGINT AUTO_INCREMENT`,
`BIGSERIAL`, or `INTEGER PRIMARY KEY AUTOINCREMENT` depending on the target, not
the mock's MySQL-everywhere. Engines with no query endpoint (Mongo, Redis,
Meilisearch, Influx, Neo4j) generate their own client's syntax to copy, and say
why they cannot run it. Generating a statement we cannot execute and pretending
otherwise would be worse than the honest limit.

This runs real DDL, which the mock never did, so: identifiers are validated
against a strict pattern AND quoted through `quoteIdent`; defaults never become
SQL — numbers stay numbers, NULL/TRUE/FALSE/CURRENT_TIMESTAMP/now() are an
allow-list, anything else is emitted as a quoted literal; and the statement is
always shown before it runs. Verified against injection probes
(`x'; DROP TABLE t; --` renders as a literal).

The importer targets the real contract, not the mock's. `POST
/databases/{mysql,postgresql}/<name>/restore` takes a SERVER-SIDE path — there
is no upload endpoint — so the UI offers dumps already in the backup directory
or a typed path, and says so rather than showing a file picker that could not
work. Neither restore route snapshots first, which the mock claimed it did, so
"back up first" is an explicit checkbox that runs the real backup route and
aborts the import if it fails. Overwriting a database needs the database's name
typed to confirm.

The opt-in for writes turned out to already exist: `readonly: false` on the
existing query routes, gated on admin inside each one. No new endpoint.

Three limits found and surfaced rather than papered over: non-relational engines
have no query endpoint at all; a containerised PostgreSQL cannot execute
anything, because the docker query path resolves a mysql/mariadb client; and
neither SQLite nor containerised databases have a restore route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:18:57 -04:00
Juan DenisandClaude Opus 5 5927e44487 fix(databases): run all twelve engines before claiming they work
The engine templates were written from upstream docs and reviewed carefully, but
only ever read. Every one has now been brought up for real, authenticated
against, and torn down.

CouchDB was broken. A fresh node never created its system databases: `/_all_dbs`
answered `[]`, `_users` did not exist so no non-admin account could ever be
created, and it error-looped on "Missing system database _users". The image
reads only COUCHDB_USER/PASSWORD/SECRET/ERLANG_COOKIE — both
COUCHDB_COUCHDB__SINGLE_NODE and ERL_FLAGS are rejected as unknown_config_value
— so `single_node = true` ships as a local.d config file instead.

Meilisearch and Valkey advertised versions they do not install (1.0 vs the 1.12
that boots; 8.0 vs 8.1.9), caught by comparing the catalog header against what
actually reported for itself.

The method mattered more than the count. Probing auth from INSIDE the container
is not a test and would have passed two engines that were never checked:
postgres initdb writes `host all all 127.0.0.1/32 trust`, so a deliberately
wrong password succeeds on loopback, and the ClickHouse client reads
CLICKHOUSE_PASSWORD out of the container's own environment. Re-run from a remote
source address, both correctly refuse. Every auth assertion here was made from
off-host.

Nine tests cover what can be checked without Docker, led by the one that matters
most: the generated admin password must actually REACH the image. Mongo,
ClickHouse and CouchDB all start happily with authentication disabled when the
password is generated but never passed — silent, and indistinguishable from
working. Also: every ${VAR} resolves, no port binds all interfaces, the catalog
port matches the published one, the declared version is the tag installed, and a
config file can never be rendered over the data volume's mount point (which
would delete persistence without a word).

Verified by reverting each fix and confirming the tests fail with the right
message, then restoring. 67 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:51:04 -04:00
Juan Denis b5160e90bf chore: clean root and docs — drop recovery artifacts, relocate stale audits
- Remove patch_localhost.py (one-off patch script, served its purpose)
- Remove RECOVERY.md + docs/RECOVERED_COMMITS.txt (July-6 incident artifacts)
- Move completed audits and Jan-era strategy docs to docs/audits/
- Update .gitignore for relocated UI_CONSISTENCY_AUDIT.md
2026-07-25 22:22:33 -04:00
Juan DenisandClaude Opus 5 86e44e1077 fix(deploy): stop the deploy pill shouting at the page it links to
Installing an engine navigates straight to `/deployments/:jobId`. The global
"1 deploy running" pill then appeared in the corner of that very console —
a shortcut back to the page you were already reading.

The pill itself is right; its timing was wrong. It is route-aware now: on
`/deployments` and `/deployments/*` it renders nothing and stops polling.
Deliberately NOT filtered by job kind — hiding it only for database installs
would be the parallel-surface mistake, and an engine install is an ordinary
template install on the ordinary pipeline.

On success the console now offers the way back: "Open <db> in Databases",
labelled with the initial database when the install seeded one, landing on
`/databases?engine=<app_id>` with that engine selected and expanded. The
auto-redirect is guarded three ways — a visible countdown, a "Stay here" button
that kills it for good, and it only arms when this page WATCHED the run finish.
Opening a completed install from the activity feed never navigates under you;
that is reading history, not waiting on a deploy.

Also fixes an affordance that had never once rendered. `DbBlankStates` read
`instance.deploy_job_id`, `initial_database` and `error_message` — none of which
`_describe_instance()` returns — so "View install log" was dead markup from the
day it was written. Both are now derived from data the API does return: the job
from the deployments feed, the database through the engine's own `database_var`,
so it holds for any engine template rather than ones that happen to spell it
DB_NAME.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:19:12 -04:00
Juan DenisandClaude Opus 5 bccc951d5b feat(databases): make "Not installed" a door instead of a sign
The tree announced `MySQL / MariaDB — Not installed` and then offered nothing to
do about it. We ship engine templates for exactly those now, so the row is
actionable: an Install pill on the row itself, an entry in its context menu, and
the empty-children line reading "Not installed — install MySQL / MariaDB". One
matching template opens the install drawer pre-selected; several open the
catalog pre-filtered.

Candidates are matched on each template's own `engine.protocol`, so MySQL and
MariaDB both satisfy a `mysql` host root without either being named in the UI —
adding a compatible engine template makes it an option here with no code change,
which is the whole point of the template-backed catalog.

A host engine with no installable counterpart now says WHICH kind of nothing it
is — `catalog unavailable` when the backend didn't answer, `no engine template`
when it did and had none — rather than leaving a dead label.

The tree also carries the information the design mock does: child counts per
node, row counts per table, size per database, the engine count on the install
row, and a version badge on installed template engines. Counts come only from
data already loaded, so an unopened node shows nothing rather than a `0`, and
nothing is pre-fetched to fill a badge. The host MySQL/PostgreSQL roots
deliberately keep `Running`/`Stopped`: `/databases/status` returns only
`{installed, running}`, with no version anywhere in that path, so the mock's
`v10.11` is not portable and inventing one would be worse than omitting it.

And an app with no database is no longer an error. `/databases/docker/app/<id>`
answers 400 for an app with no compose to read — a fact about the app, not a
failure to read it — and that surfaced as red "Couldn't load. Right-click to
retry." under a perfectly healthy service. It returns an empty list now, and
each empty state says what is empty: "No databases", "No tables yet",
"No Docker apps".

Also styles `.dbx-tree-node`, which had no rule at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:07:35 -04:00
Juan DenisandClaude Opus 5 aa30c859ed fix(databases): the panel could not read the MariaDB it installs
MariaDB 11 dropped the `mysql` symlink in favour of `mariadb`. Every place that
execs a client inside a database container hardcoded `mysql`, so introspecting
any modern MariaDB image failed outright:

    OCI runtime exec failed: exec: "mysql": executable file not found in $PATH

Which is not a hypothetical image. `backend/templates/mariadb.yaml` — the engine
template shipped earlier in this same plan — pins `mariadb:11.4`. Installing
MariaDB through ServerKit produced a database ServerKit could not open, and the
failure surfaced in the tree as a generic "couldn't load".

The client is now resolved per container by probing `--version`, preferring
`mariadb` and falling back to `mysql`, memoised because the answer cannot change
while a container runs. When neither answers it keeps returning `mysql`, so an
unrelated failure still reports the error operators already recognise.

Found by running a real mariadb:11.4 container against the service rather than
trusting the code path; verified end to end (SHOW DATABASES, list databases,
list tables with row counts) before writing the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:01:15 -04:00
Juan DenisandClaude Opus 5 c3e0c9ebf6 feat(dashboard): bring back hovering a chart to read it
The old Recharts graph let you point at a moment and see what every series was
doing there. The ported chart drew lines and nothing else, so a spike was
something you could see but not identify.

Hovering a time series now tracks the nearest sample: a crosshair at that
moment, a dot on each line, and a readout listing every series with its value,
in its own colour, above the time it was sampled. The tooltip flips to the other
side of the crosshair once it would run past the right edge.

The overlay is HTML rather than SVG on purpose. The chart is drawn with
`preserveAspectRatio="none"` so its viewBox stretches to the container — which
would turn every dot into an ellipse. Percentage-positioned elements land on the
same coordinates without inheriting the distortion.

Series of different lengths clamp to their last sample rather than reading past
the end, and the hovered position is stored as a sample index rather than a
pixel so it survives a resize.

`useMetricSeriesSet` now carries timestamps alongside each series; it was
dropping them, which is why a hovered point could not say when it happened.

Left off the stat sparkline deliberately: a tooltip over a 3×2 tile whose value
is already printed above it would cover the number it was explaining.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:44:12 -04:00
Juan DenisandClaude Opus 5 fc7bb3a0a1 fix(dashboard): one colour dot per series, not the whole palette every time
Rendering all seven swatches inline on every series row meant a four-series
chart put twenty-eight coloured squares in the settings column, competing with
the controls that actually matter — and pushing the delete button onto its own
line, so each series occupied three rows instead of one.

A colour picker only needs to show the colour. Each series now has a single dot
in its row and the palette opens behind it, so the row is one line again:
colour, resource, metric, delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:38:49 -04:00
Juan DenisandClaude Opus 5 5180ad4360 fix(dashboard): line up the control row, and let series pick a colour
The bar was a ragged line of mismatched pills: the segmented control, the
refresh select, the icon buttons and Edit each came from a different place with
its own padding and height. Every control in the row is now pinned to 32px and
centred, asserted in one rule rather than trusted from four sources.

Edit, Discard, Add widget and Done were bare `.btn`s, so they floated as loose
labels beside bordered pills. They share the icon buttons shell now.

The refresh select was rendering as an unstyled native control — it kept the
class name of the header that was deleted, and even with the right name it was
missing `appearance: none`, so the browser painted its own light box and system
chevron over everything. Styled properly, with its own chevron and named option
colours so a dark theme cannot produce white-on-white.

Load average shared --accent-bright with CPU, so a chart of both drew two
identical lines. The six metrics now map 1:1 onto the six themeable semantic
tokens; load takes red, which is also what load spiking usually means.

Beyond that, series colour is now yours to set: a swatch row per series in the
editor, offering the semantic tokens rather than hex, so a chart follows
whatever theme is running instead of pinning a colour a skin cannot repaint.

And charts can be stepped again. A sampled metric IS a series of discrete
readings, so holding each value until the next one is arguably the more honest
shape — the squared-off look from before, back as an option next to Legend and
Area fill rather than as the only choice.

Also styles five classes the markup emitted with no rules behind them (TV-mode
bar, empty-board actions, the error hint), caught by sweeping emitted class
names against the compiled CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:35:32 -04:00
Juan DenisandClaude Opus 5 62449d7f1f fix(dashboard): give percentages a real 0-100 axis
Charts scaled to their own data, which reads badly in both directions. One
spike to 90% compressed the rest of a CPU series into a flat line along the
bottom, and a disk idling between 0.70% and 0.71% got stretched into dramatic
mountains that meant nothing at all.

A percentage already has an absolute frame of reference, so use it: when every
series on a chart is a percentage the y-axis is pinned to [0, 100]. A spike now
reads as a spike, and a flat metric looks flat because it is.

Only when EVERY series is a percentage. A chart mixing CPU% with load average
keeps scaling to its data — clamping that would bury the load in the floor.
Values outside the domain are clamped into the box rather than painting past it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:26:04 -04:00
Juan DenisandClaude Opus 5 500a217618 feat(dashboard): one control row, and the clock becomes a widget
The board carried two headers: a host identity strip (hostname, live dot, IP,
kernel, uptime, clock) and, under it, the board tabs and controls. Two rows of
chrome before any actual dashboard, and the strip was fixed — you could not
move it, size it, or point it at a different machine.

It is one row now: board tabs left, then the server picker, range, refresh,
TV and Edit. The server switcher moved into that row as a labelled `server`
pill, and still collapses to a plain readout when there is only one host.

Nothing was lost, it just became placeable. IP, kernel and uptime are the Host
details widget — which can be resized and pointed at any server, unlike the
strip. And the time is a new Clock widget: add it if you want it, put it on a
second timezone if that is what you need, leave it off if you do not. It ticks
locally rather than polling, because re-fetching every second to print a number
the browser already knows would be silly.

Also fixes a regression from the previous commit: making the spark row flexible
let it compete with the value for height, and centring then pushed the number up
out of the tile. The reading is the point of a stat widget, so it is pinned
first and the chart takes what is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:23:14 -04:00
Juan DenisandClaude Opus 5 9c2901e18e fix(dashboard): make the trend line fill the tile it lives in
`ds/Sparkline` renders a fixed-width SVG — `width={220}` here — so in a tile of
any other width the line stopped short and banked dead space to its right. It
looked like the chart was broken; it was just the wrong primitive.

The stat widget now draws through the same viewBox-based WidgetChart the time
series uses, which stretches to its container, and the spark row takes the
leftover tile height instead of a fixed sliver — so a taller stat widget grows
its trend line rather than parking it under the value.

ds/Sparkline is left alone deliberately: it is shared with MetricCard across
many pages, and making it responsive is a change those pages should opt into
rather than inherit from a dashboard fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:16:17 -04:00
Juan DenisandClaude Opus 5 563e6c980d feat(dashboard): a real editor, and widgets that say what they are
The widget editor was a cramped fixed panel wedged against the right edge, with
inputs that rendered as bare white boxes because the field class sat on a
wrapper div while the stylesheet styled it as though it were the input. It is
now a wide drawer: settings on the right, a LIVE PREVIEW of the actual widget
on the left, sized from its real grid geometry so the size steppers visibly
move it. Stacks below lg. Built on the shared Drawer, which also makes the
floating AI bubble hide itself — it already hides for any open dialog, and the
old bare <aside> was exactly why it did not.

Every tile read "Stat". Seeded widgets carry no title, so the frame fell back
to the widget TYPE and a board of four different stats said "Stat" four times.
Titles are now derived at render time from what the widget points at — CPU
usage, Memory, Applications, "CPU usage & Memory" for a two-series chart —
falling back to the type name only when nothing better is known. Deliberately
derived rather than written into stored config: a title the user never set has
no business appearing in their saved board.

Boards already in the database are repaired on read. Fixing the shipped
defaults only ever helped users who had never opened the dashboard, because
seeding never runs again for anyone else — so `metrics` becomes `series`,
`actions` becomes `items`, dead shortcut keys are aliased or dropped, and an
empty shortcut list falls back to the shipped four instead of rendering "No
actions selected". The repair works on a deep copy: a JSON column mutated in
place looks unchanged to SQLAlchemy, so the commit would expire the instance
and silently reload the originals.

The log widget stopped guessing. With nothing configured it grabbed whatever
the log listing returned first — typically a system file the panel cannot read
— so a fresh dashboard greeted people with "sudo: a password is required". It
now asks you to pick a source, and phrases a permission failure as a setup
fact rather than quoting the shell.

Also deletes ~300 lines of stylesheet for the inspector that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 21:14:50 -04:00
Juan DenisandClaude Opus 5 ac4d8761b2 fix(dashboard): the widgets were speaking a dialect the stylesheet did not
Thirty-five of the fifty-two class names the widget renderers emit had no rule
anywhere. The renderers kept the design mock's flat names (`skw-stat-v`,
`skw-feed-item`, `skw-bar-row`) while the stylesheet was written in BEM
(`skw-stat__value`, `skw-topn__row`). Both files compiled, linted and built
clean the whole time — the styles simply never reached the markup.

So the activity feed rendered as raw text, host details ran their labels into
their values ("HostnameDESKTOP-8ELA366"), stat values wrapped mid-tile, and the
time-series chart lost its height constraint and drew as stray vertical spikes
against an empty box. Renamed the JSX to match, and wrote the blocks that were
missing outright: the feed, quick-action rows, alert body, and distinct
loading-vs-error empty states.

The seeded boards were wrong in the same way, one layer down. `timeseries`
seeded `metrics` while the renderer reads `series`; `actions` seeded `actions`
while the renderer reads `items`, with shortcut keys (`new-app`, `deploy`) that
no route resolves. Both fail silently — the widget just renders empty, which is
what every new user saw on their first dashboard. `logs` and `status` were
seeding keys nothing reads either.

Two tests now pin that coupling: seeded configs must use keys the renderers
actually read, and every seeded shortcut must resolve to a real route. The
table in the test is an explicit mirror of renderers.jsx, and says so.

Also: seeded widgets carry real titles again — "CPU usage", "Applications",
"Hardware specs", following the old dashboard's panel names — instead of every
tile falling back to its widget type and reading "Stat". And "Local (this
server)" is now "This server", which fits a 3-column tile on one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 20:57:32 -04:00
Juan Denis 2d3bb33a34 docs: expand README requirements beyond Ubuntu, note homelab/ARM support
- 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
2026-07-25 20:40:11 -04:00
Juan DenisandClaude Opus 5 c55390e5f4 test(migrate): pin the three states the update screen can be in
`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>
2026-07-25 20:35:19 -04:00
Juan DenisandClaude Opus 5 69b98f7149 fix(migrate): stop burying Continue under every migration ever written
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>
2026-07-25 20:33:34 -04:00
Juan DenisandClaude Opus 5 3070353631 feat(databases): install any engine, because an engine is just a template
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>
2026-07-25 20:09:05 -04:00
Juan DenisandClaude Opus 5 c4878f392f fix(tests): stop the compose-flag test depending on the host having Docker
`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>
2026-07-25 20:04:39 -04:00
Juan Denis 89fa1a7a17 Merge branch 'dev' of https://github.com/jhd3197/ServerKit into dev 2026-07-25 19:56:53 -04:00
Juan DenisandClaude Opus 5 8843b860ae feat(dashboard): a grid you arrange, not a list you toggle
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>
2026-07-25 19:43:54 -04:00
Juan DenisandClaude Opus 5 010b632530 feat(dashboard): boards that survive the browser they were built in
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>
2026-07-25 19:30:51 -04:00
Juan DenisandClaude Opus 5 92e0750262 feat(sdk): a second widget surface — types users place, not just slots
`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>
2026-07-25 19:24:41 -04:00
github-actions[bot] 7f56441552 chore: bump version to 1.7.62 [skip ci] 2026-07-25 23:06:37 +00:00
Juan DenisandClaude Opus 5 8e6b53d77b refactor(servers): one list instead of a rail, and give Add Server its drawer back
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>
2026-07-25 19:03:48 -04:00
Juan DenisandClaude Opus 5 98ea0f6b10 feat(backups): port the design's Overview, Schedules, Snapshots and Storage
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>
2026-07-25 19:03:34 -04:00
Juan DenisandClaude Opus 5 b413148194 feat(monitoring): fold Fleet Monitor in, and scope the page to a server
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>
2026-07-25 19:03:15 -04:00
Juan DenisandClaude Opus 5 d944b695e7 refactor(ui): one place for search, and drop the KPI restatements
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>
2026-07-25 19:02:58 -04:00
Juan DenisandClaude Opus 5 ef3712f97a fix(services): real numbers in Resource Usage, and a Related card that finishes
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>
2026-07-25 17:13:36 -04:00
Juan DenisandClaude Opus 5 a15a78a1cd fix(services): show the logs and usage that were there all along
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>
2026-07-25 17:07:04 -04:00
Juan DenisandClaude Opus 5 658f51dbbd fix(deploy): let a failed install be retried from the panel
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>
2026-07-25 16:51:44 -04:00
Juan DenisandClaude Opus 5 8953481d25 feat(deploy-console): let the log fill the page, and add a focus mode
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>
2026-07-25 16:43:21 -04:00
Juan DenisandClaude Opus 5 64d84c4abd fix(deploy-console): collapse the failure tail so the log has room
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>
2026-07-25 16:38:42 -04:00
Juan DenisandClaude Opus 5 269be3ea42 feat(deploy-console): finish the log console to match the mock
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>
2026-07-25 16:32:34 -04:00
Juan DenisandClaude Opus 5 5b789fbfa0 feat(deploy-console): add the severity map beside the log
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>
2026-07-25 16:24:48 -04:00
Juan DenisandClaude Opus 5 26705cf80f fix(ui): give the deploy console the gutters every other tab-group page has
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>
2026-07-25 16:19:43 -04:00
Juan DenisandClaude Opus 5 cde4595764 fix(docker): stop sending compose v1 a v2-only flag
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>
2026-07-25 16:19:33 -04:00
Juan DenisandClaude Opus 5 6934435ab2 chore(plugins): check in the manifest with its trailing newline
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>
2026-07-25 16:12:27 -04:00
github-actions[bot] b04a5d1aa8 chore: bump version to 1.7.61 [skip ci] 2026-07-25 19:57:04 +00:00
Juan DenisandClaude Opus 5 402c28311e feat(templates): show the fit check in the deploy drawer
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>
2026-07-25 15:56:07 -04:00
Juan DenisandClaude Opus 5 a2c31c27bb fix(ui): restore the compliance band on Server Templates, and lead with Library
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>
2026-07-25 15:48:47 -04:00