Now that every /api/v1/files endpoint enforces the files.read
permission, a user with that permission revoked would only hit 403
toasts. Filter the Files item out of the sidebar and the command
palette (usePaletteAuthz) instead, using the resolved permissions
already returned by /api/v1/auth/me.
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>
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>
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>
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>
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>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
"Create WordPress Site" was a centred modal while services and templates
deploy from a right-hand drawer, and its own styling was broken: an icon
stranded on its own line, an unspaced list running straight into the
first field, and a footer that floated over the form.
Two causes, both structural. It rendered legacy .modal-body and
.modal-footer *inside* the shared Modal, which already supplies a body
and takes a footer prop — the chrome was nested twice. And its
.install-warning callout is styled under `.wordpress-page`, while the
dialog renders in a portal, so it is not a descendant of the page that
opened it and none of those rules ever applied.
So the drawer styling moves out of _templates.scss into the design system
as .sk-formdrawer, shared by both pages. That is the fix for the class of
bug, not just this instance: a portalled surface cannot rely on
page-scoped SCSS, so its styles belong somewhere unscoped. The block also
now covers bare <select> and checkbox rows, which otherwise render with
the browser's own white chrome on a dark panel, and the callout.
Verified in a browser: drawer settles at the right edge at its declared
560px, callout renders with its background and spacing, selects pick up
the surface colour, and no .modal-body/.modal-footer/.install-warning
remain in the tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a repo-kind template like agentsite navigated to
/services/new?template=<id> — a five-step wizard — while every other
template opened a drawer in place. Same catalog, same button, two
completely different experiences depending on a kind the card barely
advertises.
The wizard exists to work out what an unknown repo needs: detect
manifests, pick a build method, guess a port. A curated template already
declares all of that (repo.url, branch, app_type, build_method, port), so
for templates those steps had nothing left to ask. Repo templates now
deploy from the same drawer, submitting createAppFromRepository with the
template's own declarations, plus a Branch field — the one thing a
template cannot decide for you.
The wizard's duplicate "Deploy Template" source mode goes with it: the
gallery, its manifest fetch, the selection state and the submit branch.
It keeps its four real sources (GitHub, other Git remote, local, upload)
for repos that genuinely need detecting — including the quick-start cards
on /templates, which link into it.
Old /services/new?template=<id> links forward to /templates?install=<id>
rather than breaking, and ?install= now covers repo templates too.
Verified in a browser: wizard shows four sources and still advances to
Connect; the legacy link lands on the drawer with the branch field.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ResourceListPage returned its loading state before the wrapper the
loaded page renders into, and several pages early-return theirs the same
way. The skeleton therefore spanned edge to edge with no side or top
padding, and everything jumped inward when data arrived — precisely the
flash a skeleton exists to prevent.
ResourceListPage now wraps its loading branch in the same container as
its content. For the early-return pages, a shaped skeleton reasserts page
padding itself, dropped again when a real container is already supplying
it, so neither case doubles up.
Measured on /services with the request stalled: the skeleton now sits
25px in from both sides of the content area, matching the loaded table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking a template card opened a detail modal while its Deploy button
opened the deploy drawer — two different surfaces for one object, and the
modal was rendering blank above its footer, so the visible result was a
popup cut in half.
The design prototype has no detail view at all: a card click deploys.
That is now what both the card and its button do, so a click anywhere on
a card has one predictable outcome. The modal is removed along with the
state, imports and RepoTemplateDetailBody that only it used;
handleViewTemplate stays, narrowed to the ?install=<id> deep link that
still needs the full template record.
Removed with it: the repo-template manifest preview (detected manifests
and env), which had no other entry point. Repo templates deploy through
the New Service wizard, which does its own repo inspection — say if you
want that preview back as a drawer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
StyleGuide demonstrates the raw elements and legacy classes the
no-restricted-syntax rules exist to discourage — showing a bare <button>
beside the shared <Button> is the whole point of the page. Un-suppressed
it contributed 165 of ~950 such warnings, one file masking the real drift
in every other file behind it.
Suppressing it drops the lint total from 1185 to 1019, so what remains is
drift worth acting on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pages had drifted into three unrelated interactions for the same job —
the shared FilterDrawer on three pages, SegControl on seven, and a raw
<select> on ten more, with several using two of the three at once. The
split was never written down, so each page picked by whoever wrote it.
FilterDrawer now documents the rule: a segmented control for <= 4
mutually-exclusive options in one dimension, this drawer for anything
wider or multi-dimensional, and a plain <select> only for genuinely
unbounded lists (servers, groups, versions). A raw select is never right
for a fixed set of states; a segmented control is never right for two
dimensions at once.
DeliveryLog was the clearest violation — two dimensions, one of them
seven channels wide, as bare selects — and moves to the drawer.
Most remaining raw selects are form fields or unbounded pickers, i.e.
correct as they stand; Telemetry (source/type/severity) is the one real
conversion still outstanding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three places had reimplemented chrome the design system already owns.
Servers' agent-setup wizard and Templates' detail view built their own
.modal-overlay div. Both are now the shared Modal (Radix dialog), which
brings focus trapping, escape handling and scroll locking that every
other dialog in the app already had — an accessibility gap, not just a
visual one. A .sk-modal__subtitle style covers the lead paragraph the
Servers wizard shows under its title.
Workspaces hand-copied ResourceListPage's .wp-list toolbar, bulk bar and
table markup. It had already silently missed the list/cards switch added
with the Services card view, and every future change to the shared list
chrome would have had to be made twice. It now passes columns to
ResourceListPage like Services does, and delegates its loading state
too — so it picks up the table skeleton for free.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture tool shipped with two targets and had evidently never been
run past them — every one of them failed for reasons the tool could not
report.
Three bugs, in the order they bit:
- login filled the first `input[type="text"]` on the page, which is a 2FA
digit box rendered above the real form. The credential form stayed
empty, so every target then timed out waiting for content on a page
that was still /login. Target #email/#password, and fail loudly rather
than silently proceeding unauthenticated.
- The API client clears both tokens from localStorage whenever any
request 401s, and one admin-only endpoint doing so was enough to bounce
every later target back to /login. The captured tokens are now re-seeded
before app code runs on each navigation, with a re-auth-and-retry as
backstop.
- A bare "waitForSelector timeout" hid all of the above. Failures now say
which cause applies: bounced to /login, root selector absent, or the
page rendered its empty state.
Targets grew to services and domains alongside ssl and wordpress-list.
/templates is deliberately excluded: its catalog measures ~975 bones over
5700px — a skeleton nobody scrolls, at a node count costing more to
animate than the page costs to load. A MAX_BONES cap now rejects that
class of capture outright, pointing at the PageSkeleton archetypes, and
the target list documents that a page only qualifies once the dev
database reliably has rows for it — bones measured from an empty page
would predict that nothing is coming.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every one of the ~40 loaders went through EmptyState's single hardcoded
shape — an avatar, three cards, three lines — whether the page was a
table, a console, a form or a chart. A skeleton exists to predict the
layout about to arrive so that arrival is a fill-in rather than a jump;
one shape reused everywhere mispredicts on all but the page it was drawn
for, which is worse than honest, because it promises a layout that never
comes.
PageSkeleton provides ten layout archetypes — panel, table, feed, cards,
detail, console, form, chart, tree, split — driven by SCSS so widths stay
responsive instead of being guessed in pixels. Repeated lines use a
deterministic ragged width, so they read as text and don't reshuffle each
render the way Math.random() would.
EmptyState takes `loadingVariant` (defaulting to the old `panel`, so
nothing regresses) and 41 call sites now name their real shape.
ResourceListPage picks table or cards from its own view state.
All ten are on the style guide under Feedback & Loading, and captured as
one screenshot so a regression in any of them is visible in one shot.
Captured bones (npm run capture:skeletons) remain the pixel-exact option
for individual regions; these archetypes are the broad-coverage answer
for the pages that will never be worth capturing one by one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three inconsistencies the section top bars had drifted into.
Tab groups repeated the section name as a heading beside their tabs. The
title block is gone when no title is passed (what TabGroupLayout, Git,
Email and Terminal now do) and the tabs lead the bar from the left edge
— the active tab already names the section, as does the lit sidebar
item. Entity pages (ServiceDetail, WordPressDetail, ProjectDetail,
CloudflareZoneSettings) keep their title on purpose: there it says
*which* entity you are on, which nothing else in the bar says.
Search position was per-page: Templates, Marketplace and Jobs all put it
before their action buttons. Rather than reorder call sites and hope
future pages comply, the top bar pins it:
.sk-topbar__actions > .sk-searchfield { order: 1 }
The bar also gains its own surface, so it reads as chrome rather than as
the first row of the content.
The shared FilterDrawer grew optional per-option counts and a result
count on its confirm button ("Show 45 templates"). Its active-filter
subtitle can now be driven by the host's own count, which fixes a drawer
reading "1 active" when nothing was filtered — the button excluded a
default sort from its badge while the drawer recounted and included it.
Templates drops its quick category chips: filtering lived in two
competing surfaces, and the drawer is the one that scales.
Finally, the AI bubble is hidden while a modal is open. It is fixed at
bottom-right above Radix's dialog layer, so it floated over every drawer
in the app and covered the primary action in drawer footers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Brings the Services tab group in line with the design prototype.
Deploy Activity is a feed rather than a table: runs group into Queued /
Today / Earlier, each row showing what was deployed (commit, image, or
template), why (push, webhook, manual), a per-step pipeline bar, target,
duration, and the error inline on failures. Step states are derived
client-side from current_step/total_steps/status, so the feed stays one
request. Status filters carry live counts via the shared SegControl.
Deploy Console trades the vertical step rail for a horizontal pipeline
strip above the log, each segment's bar sized to its share of the run —
so where the time went is visible at a glance, and the log pane gets the
full page width. A mono run-identity line sits between them.
Templates leads with the two routes no template covers (import a repo,
drop a ZIP), gains a Featured / A-Z sort, and deploys through a drawer
that shows the subdomain the service will be published at and a server
picker listing each target's CPU and memory — placing a service on a
second box is now a visible choice rather than a hidden default. That
needs capacity in the payload, so get_available_servers() now reports
cpu_cores/total_memory/total_disk (remote from the Server row, local via
a psutil probe).
Services gains an opt-in card view; ResourceListPage grew renderCard /
viewStorageKey props, so table-only pages are untouched.
Not ported: the mock's Resources card (CPU/memory limits, auto-restart,
backups). installTemplate does not carry limits through to
Application.cpu_limit/memory_limit, so the card would have claimed
limits the deploy never applies.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/deployments still reserved a second grid column for the detail pane plan 51
removed — the table sat at half width with its Started column clipped by
overflow:hidden. Single full-width panel now; pruned ~150 lines of dead
detail-pane SCSS; status badge + progress colors move from inline styles to
modifier classes; kinds humanized; skeleton loading rows; removed the topbar
New Service button duplicating the adjacent tab. Console: fills the viewport
(calc-based min-height), wider step rail (248px), flexible log search, denser
toolbar. Adds deploy-console screenshots (4 sections, mock-data captures).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
/deployments gains a topbar 'Test deployment' action (shown only when the
backend demo gate is on) with a scenario-picker modal; the console titles
demo_deploy jobs 'Test deployment — <scenario>' and shows a dashed Simulated
pill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>