The fleet list had a tag filter and nothing else: no search, no sort, and an
unbounded list. Searching hostname/address/OS and sorting by hostname, status
or last seen are all client-side, since the browser already holds the fleet
the page just fetched. Sorting by status orders by how much attention each
state wants rather than alphabetically, which is the only reason to sort by it.
The filtered count is shown beside the total so a search does not read as the
fleet having shrunk, and "no results" is a distinct empty state from "no
servers", with a way back out of the search.
refetchIntervalInBackground defaults to false on the query client. Polling
pages kept refetching in a hidden tab — the fleet list pulls inventory blobs
every 30s — so a console left open in a background tab polled until its
session expired. It belongs in the defaults because the argument is identical
on every polling page.
Four correctness/accessibility defects and the destructive-action flow.
- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
a find/replace of "org" that landed inside a URL. Button also grows an href
form, because <Link><Button> nested a button inside an anchor at nineteen
call sites: invalid markup, two tab stops, and Enter firing only the anchor.
- Fleet status was four meanings carried by hue with the distinction living in
a title attribute, which touch never shows and screen readers need not
announce. It now carries a text label and an accessible name, which is the
one rule the design system states outright.
- Modal had no focus management at all: no trap, no initial focus, no restore,
no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
a stack decides which panel owns Escape and Tab.
- Seven destructive actions went through window.confirm(). ConfirmDialog
replaces them and can say what is about to happen; deleting a secret group,
a shared base step or a workflow now requires typing the name, since those
have no undo and a wide blast radius. adminsite keeps its own inline idiom
rather than importing a dialog system it does not have.
Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.
The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.
The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.
Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
server/internal/services/settings.go SaveSettings takes alerts and
email as required (non-pointer) values and writes them unconditionally
- absent fields would blank stored settings, not just leave them
alone. onLocalLoginChange was building its payload from the stale
loaded settings object instead of the in-progress form state
(thresholdMinutes/logRetentionDays) that handleSubmit uses, so editing
the offline threshold and then flipping the toggle silently reverted
the edit. Both paths now submit the same in-progress values.
Three root causes behind five reported overflows:
- Td below sm is a flex row, and a flex item's default min-width:auto is
its min-content width. A fingerprint or target URL is one unbreakable
token, so the value could not shrink and spilled out of the card. The
value now sits in a min-w-0 break-words wrapper that is display:contents
above sm, leaving the desktop table untouched. Fixes /keys, /monitors
and the installed-keys table on /servers/[id] in one place.
- The ESO read URL and the secrets group endpoint are unbreakable tokens
in prose with the default overflow-wrap:normal. Both paragraphs now
break-words.
- Three page headers (servers/[id], keys/[id], monitors/[id]) held an
unwrapped button row. They stack below sm and the clusters wrap. These
used items-start, which is why the earlier responsive sweep — matching
items-center — skipped all three; only one was reported.
Verified: tsc clean, next build clean, and the compiled stylesheet confirms
max-sm:min-w-0 / max-sm:break-words land in the below-640px query and
sm:contents in the above-640px one.
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.
Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.
Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.
Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.
Docs: CLAUDE.md, spec index and plan 5 preamble updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A cloud instance's licence is issued in HQ and written into the control
plane by admin/internal/inject, straight to the database. The customer has
nothing to paste, so /settings/license offered them a form that could only
ever fail — and on an expired cloud instance, failed at the exact moment
they were looking for a way out.
GET /license now reports `deployment`, and the page swaps the paste form
for a short panel saying where the licence comes from, with a link to the
portal when HQ_URL is set. That is the same treatment hq-managed members
already get in the members table: read-only here, and a pointer to where
it is actually managed.
POST /license refuses with 409 cloud_managed on a cloud deployment. Hiding
a form is a courtesy; this codebase's rule is that the API is the boundary,
and the endpoint was reachable regardless of what the page rendered. It
cannot break injection, which never goes through HTTP at all.
Verified: server builds and vets clean, web builds clean.
This screen was never finished. It had no page padding, so it sat flush
against the sidebar while every other screen has p-8; it used raw button,
textarea and file inputs instead of the design system; and it rendered
state as "State: valid" in a sentence.
It also dropped `reason` entirely. An instance with an invalid licence was
told "State: invalid" and nothing else — not that the signature failed, not
that it was issued against a different instance, not what to do. That was
the real defect, and no layout survives having nowhere to put the most
important thing on the page.
A licence is a document: issued, dated, signed, carrying a reference you
quote to support. The page now reads that way, which is also how adminsite/
presents the same object from the issuing side — state as a label with a
shape, the reference on a mono record line, entitlement as keyed fields.
- a record panel opens the page: the state as a statement, the reason
underneath in this app's own words rather than the API's identifiers,
then Expires / Source / Instance ID as keyed fields. `source` and
`days_remaining` were being returned and never shown
- counted limits become meters, because that is already how the console
shows headroom on a server's disks — same question, same reading. They
turn amber at 80% and red at the cap
- an unlimited allowance gets no bar. A full-width one would read as "at
the limit", which is the opposite of what it means
- features render as included/not with a glyph as well as a colour
- the file input is a styled label over a visually hidden input, and now
reports which file it loaded
Two things found reviewing my own work: Card's p-6 is emitted after p-4, so
`<Card className="p-4">` silently rendered at p-6 — the allowance cards pass
padding={false} instead. And the state had a coloured dot next to a coloured
word on a colour-ruled card, which is one telling too many; the dot is gone.
`Group` moves to components/settings/ so this page and /settings share the
band label rather than growing a second copy.
/settings/instance held two cards — Members and Single sign-on — behind a
sidebar entry called "Instance", next to one called "Settings". Nothing in
either name told you which held what, and the split left both pages thin.
They are now the Access group at the top of /settings, above Monitoring and
Integrations. Six cards on one page needs sorting into groups or it reads as
a pile, so each group carries site/'s keyed-label eyebrow on a hairline.
The cards move to web/components/settings/ rather than into the page, which
would have made it ~600 lines. That is also where the Field and inputClass
pair now lives: the two pages each had a byte-identical copy, and folding
them together is exactly when three copies would have started to drift.
Members and SSO adopt SectionCard, which the rest of the page already used.
A card that kept its own header treatment would read as a different kind of
thing rather than another setting, which is the problem being fixed.
next.config.ts keeps a permanent redirect from the old path, so bookmarks
and any support reply linking it still land somewhere useful.