Centralize backup schedule calculations and shared request/form flows, encode query values, and move repository finalization into its service. Add browser CI coverage, repair stale test fixtures, and provide opt-in request and build measurement tools.
Expose MFA and passkey enrollment, use shared confirmations and locale formatting, and guard pending user and invitation actions. Add Chromium regressions for both themes and failure states to frontend CI.
builtin-extensions/serverkit-git + the tracked frontend live copy removed;
the bundled registry entry flips to bundled:false at v1.1.0 (the version
that carries the Gitea backend) with the local zip's sha256; the gated
auto-install (_gitea_was_used) moves to REGISTRY_GATED_SLUGS, leaving
GATED_BUILTIN_SLUGS empty with the machinery intact.
i18n: en.json regenerated (the app.git/app.email/app.cloudflare* namespaces
ride with their extensions, which carry en+es bundles; other languages are
recoverable from git history), all locales pruned, intl-door ratchet
tightens 123 -> 115.
Expected red on this branch: the two registry-mirror tests, until the four
repos are published + signed + re-vendored (see docs/plans/83).
OPERATOR: publish jhd3197/serverkit-git, sign, sync-registry-index.sh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same relocation pattern as email/cloudflare-ops: pages/Git.jsx (1920 lines),
its SCSS partial (with the %sk-state placeholder inlined - @extend cannot
cross compile units) and a new Gitea-only API client move into
builtin-extensions/serverkit-git/frontend/. The page's two halves now show
their seam: Gitea self-host calls go through the extension's own client;
webhook + deployment calls stay on the core api object (they ARE the deploy
pipeline and exist on every panel).
Core files.js sheds the Gitea-half methods but keeps the three fail-soft
getGitea* aliases the app-creation repo pickers probe (they 404 and degrade
to 'not installed' when the extension is absent). serverkit-sdk adds the
ds/status helpers (statusKind & co). pages/ ratchets tighten: raw api calls
431->405, toast extractions 264->218.
Frontend build green, lint fully green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same WP-Phase-4 pattern as the email relocation: the CloudflareZoneSettings
page, the WAF/Workers/Tunnels/Storage panels, the /cloudflare API client and
the SCSS partial move into builtin-extensions/serverkit-cloudflare-ops/
frontend/; the extension index.jsx imports locally and side-effect-imports
the SCSS. Core drops the cloudflareMethods spread from the api object and
the main.scss partial - DNS records and the Cloudflare connection stay core.
serverkit-sdk (still the unreleased 1.4.0 bump) adds Tabs kit, PageLoader
and copyToClipboard; vendor shim regenerated. The pages/ raw-api ratchet
tightens 460 -> 431 (the two relocated pages left pages/).
Frontend build green, lint fully green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`--locales <dir>` points the four assertions at another tree's bundles, so an
extension repo verifies its own translations with the panel's check instead of
copying it -- copying is how the vendor shims drifted. The languages.json
cross-check is skipped there, since an extension ships bundles rather than a
language list.
It also now reports keys present in en.json but absent from a translation.
Those render English rather than failing, so nothing was catching a bundle that
was quietly half-finished -- which is exactly the state a translation pass
starts in and needs to be able to see.
Used to verify all 13 extension Spanish bundles (2,073 keys) at full parity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Built to convert the WordPress extension (1,099 strings across 44 files) and
kept because the same job remains for 12 more extension repos and the panel's
own 8,034-literal tail.
i18n-codemod.mjs keys the same four shapes the census counts, using the same
isCopy rule, so what it converts is exactly what the ratchet measures. It
REFUSES rather than guesses: a hit outside a component needs `t` threaded
through a helper's arguments, which is a design decision, not a rewrite.
Five bugs it had, all caught by building the result rather than by reading it:
- multi-line JSX text became a string literal containing raw newlines. JSX
collapses that whitespace at render, so the default must too.
- a literal already containing \n (an SSH key placeholder) was emitted raw and
produced an unterminated string.
- `confirm({ title: 'x' })` matched BOTH the data-object rule and the copy-call
rule; applying both corrupted the file. Innermost edit wins -- for a call,
t() is right because nothing resolves a `titleKey` there.
- copy hides in `err.message || 'Failed…'` and `x ? 'A' : 'B'` as often as it
sits directly in a call, so argument recursion matches the census.
- that recursion then had to stop at nested JSX, or it "translated" a
`rel="noopener noreferrer"`.
Two census corrections, both of which were inflating the numbers:
- check-i18n-literals recursed into nested JSX inside a copy prop and counted
the nested element's own attributes. `rel` is not copy; the ratchet was
unreachable on correct code.
- check-intl-door walked dist/, so an extension repo's bundle double-counted
every call in it. WordPress reported 56 where the source had 28.
Both censuses and the extractor now take --root (and --out) so an extension
repo runs the panel's own checks instead of copying them -- copying is how the
vendor shims drifted. --update refuses a foreign root: those ceilings are the
panel's.
The format door takes `parts`, passing Intl.DateTimeFormat component options
through for what a named style cannot express -- chart ticks, month labels.
That was the actual reason callers reached for toLocaleDateString([], {...}),
which silently follows the browser; now they keep the format and the locale.
Ceilings re-seeded to the corrected numbers: literals 8034, intl door 123.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up audit of the extension surface, across all of them rather than one.
THE COMPATIBILITY HOLE. Two places documented an install-time `sdk_version`
check as existing -- app/utils/sdk.py's docstring ("checked at install (manifest
lint) and at load") and the runtime loader's comment, which named
`_assert_manifest_sdk_compatible` -- but `sdk_version_satisfies` had ZERO
production callers; only its own unit test. Consequences split by extension
kind: a runtime ESM extension installed cleanly and failed later at the load
gate, while a builtin/in-repo extension never passes that gate at all, so its
range went unchecked everywhere. The function now exists and runs at install,
beside the min/max_panel_version check, and refuses with what the operator
needs to act: what the extension wants, what this panel offers. Fail-open on a
missing range, matching the load gate's 'grace'. Proved both directions.
This matters now because SDK 1.3.0 added the localization surface and made
i18next/react-i18next shared singletons: an extension that externalizes them
needs a panel at 1.3.0+. Pinning `^1.3.0` is what turns a white screen on an
older panel into a readable message. The bump is additive, so anything pinned
to ^1.2.0 keeps installing and loading. Documented as a version table.
THE AUDIT. Measured, not assumed -- both censuses now take `--root` so they can
run against any extension repo (and refuse `--update` there, since the ceilings
belong to the panel):
2,105 untranslated literals across 13 extension repos (WordPress alone 1,099
in 44 files), plus 78 direct toLocale*/Intl calls that follow the BROWSER's
locale (WordPress 56). None of it was visible to any check before, because
every census stopped at frontend/src.
serverkit-gui -- the one builtin with real UI -- is converted as the worked
example, importing useTranslation/useFormat from 'serverkit-sdk' exactly as a
third-party extension would. That also closes its own toLocaleTimeString.
Ceiling 8055 -> 8034, intl door 124 -> 123.
The probe now asserts the claim the SDK surface rests on: an extension
resolving i18next through the shared vendor gets the HOST's INITIALISED
instance, and an extension-style t() returns Spanish. A second instance would
be uninitialised and every t() in that extension would render English forever
with nothing reporting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SDK 1.2.0 -> 1.3.0 (additive).
Auditing the extension surface for plan 79 found the localization gap I
expected and two pre-existing bugs I did not.
The gap: extensions had no way to translate anything. The SDK now re-exports
the HOST's translation surface -- useTranslation, Trans, t, useLocale,
useLabel, useFormat and the intl door -- and i18next/react-i18next join the
shared vendor singletons. That sharing is the load-bearing part: an extension
bundling its own i18next would get a separate, uninitialised instance, so
every t() in it would render its English default forever and nothing would
report it. Exactly the "two React copies" hazard vendorShare already documents,
and exactly the failure mode this plan hit twice in core. They also join
dedupe/optimizeDeps for the same reason React is there.
The bugs, both caused by the same duplication:
- The import map listed `react` and `react-router-dom`, but no shim existed
for either. A runtime extension importing bare `react` 404'd at load.
- The serverkit-sdk shim re-exported 26 of the SDK's 71 exports. An extension
importing Button, Modal, EmptyState, formatBytes or timeAgo from the SDK got
`undefined`. The shim's own comment claimed parity was "asserted by the
frontend build + backend test_sdk_contract"; nothing asserted it.
Three files independently restated the same module list -- the import map, the
namespace capture, and one hand-written shim per module -- so they drifted.
Now vendorManifest.js is the list, generate-vendor-shims.mjs derives every
shim's exports (from the real package's namespace, or from the SDK entry's own
export statements -- never typed by hand), the import map is built from the
same manifest, and `--check` fails the build on drift. 26 -> 85 exports on the
SDK shim, 6 -> 8 shims, all present.
Manifest contributions gain an optional `labelKey` beside `label` for nav, tabs
and command-palette entries, resolved at render by the same translateLabel core
uses -- data cannot call t(), and a label resolved at import would translate
once and never follow a switch. Omitting it renders `label` verbatim, which is
what every existing extension does, so nothing breaks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §H + ratchet 7.
<html dir> already follows the language manifest and the locale probe asserts
it. What was missing is a stylesheet that could follow it: 468
direction-anchored declarations (margin/padding/border-left|right, bare
left:/right:, text-align and float) against 5 logical properties. An RTL
locale today would render a mirrored language inside an unmirrored layout, so
the plan ships plumbing and a ratchet, not a locale. The count is 468 rather
than the plan's grepped 412 because float and text-align are equally anchored.
Also splits `src/i18n/t.js` out of the i18n barrel. The API client's error
mapper imports the bare `t`, and the client is reachable from a node test --
which then loaded i18n/index.js and its `import.meta.glob`, a Vite
compile-time transform that throws under plain node. The obvious fix was the
runtime guard that had just been removed for silently discarding every locale,
so the module is split instead: t.js touches only the i18next singleton, and
index.js keeps initialisation and the glob for browser-only callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §E wave 2 + F1.
Login, Register and Setup go to zero untranslated literals. These screens run
before anyone is signed in, so they are what proves the precedence chain: with
no user row to read, the locale comes from localStorage, then the panel
default from /auth/setup-status, then the browser.
F1 turned out smaller than the plan assumed, and in a good way. The plan
proposed inventing an error-code contract; the repo already has one -- plan 76
milestone B's typed-error door emits { error, status, code, request_id }. So
the work was to USE it: fourteen auth returns become typed raises carrying
stable codes, and the API client maps the code to a translation key with the
server's own English as the default. The 257 `toast.error(err.message)` sites
keep working untouched and gain translations as codes land. Error-shape
ratchet 1159 -> 1145.
The first cut mapped `t('errors.' + code)`, which is wrong in a way worth
recording: a computed key is invisible to the extractor, so those strings
would never reach en.json and a translator would have no way to learn they
exist. The registry is an explicit literal table instead -- it doubles as the
reviewable list of which server errors are actually translated, and anything
absent falls through to English, which is today's behaviour.
Only auth carries codes so far. Plan 76 §C measured 23 sites that choose an
HTTP status by grepping their own error prose; translating that prose would
turn those 403s into 400s, so the rest waits on milestone C rather than
shipping a silent status regression.
test_login_links asserted the bare {'error': ...} body by equality. Updated to
assert the typed contract including `code` -- asserted rather than tolerated,
since dropping the code would silently make the message untranslatable again.
Ceiling 8087 -> 8055.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 G1/G2.
Modal gains `onSubmit`, which wraps body AND footer in one <form>. This is the
unblock plan 76 identified for form adoption: a submit button must live inside
the form it submits, so 28 files had to bypass the `footer` prop and hand-roll
a footer inside the body. Form mode also brings Enter-to-submit, which those
hand-rolled footers did not have. The form element has to BE the flex column
or the body's `flex: 1` resolves against it and the footer stops pinning.
The four Git repo pickers -- GitHub, GitLab, Gitea, Bitbucket, 693 lines --
were near-identical copies differing in which API they called, one URL shape,
and the provider name in six strings. They collapse to one RepoPicker plus a
provider table: four bindings of 11 lines each. The duplication that mattered
was the copy: the same search box, "Loading repositories...", empty state and
branch selector existed four times, so translating them meant translating each
four times, and drift between them was invisible without reading all four
files side by side.
The contract asks providers for `isReady(status)` rather than assuming a
connection object, which is what lets Gitea -- local, no OAuth,
installed+running instead of configured+connected, a two-argument branch API --
use the same component instead of being the fourth copy.
Converting it surfaced the same anti-pattern this plan rejected in
ConfirmDialog: the provider hints had become prefix + <Link> + suffix, three
fragments per sentence. A link lands in a different place in other languages
and split keys cannot be reordered by a translator, so they are single <Trans>
keys with the link as a component.
Ceiling 8132 -> 8087.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §E wave 1. The shell is first because it is on every screen: a
translated page inside an English frame reads as broken, while a translated
frame around an English page reads as in-progress.
Converted: Sidebar (31 literals -> 1, the brand name, which must not be
translated), sidebarItems, CommandPalette, PageTopbar, MobileTopBar,
NotificationBell, EmptyState, ErrorState, ErrorBoundary, ConfirmDialog,
AppLoader. Ceiling 8225 -> 8132, and es.json now covers all 98 keys, so the
shell is provably bilingual rather than merely keyed.
Three shapes needed real tooling rather than a t() call, and each was found by
converting a file rather than by planning:
- Labels in data files (sidebarItems, palette docs entries) cannot call t():
a table resolved at module load translates once, at import, and never
follows a locale switch. They now declare `labelKey` beside `label`; the
extractor collects the pair at its declaration site and useLabel resolves it
at render. The ratchet understands the pairing, so converting a data file
visibly moves the number (sidebarItems 27 -> 0).
- Sentences with an element inside them (ConfirmDialog's "Type <x> to
confirm") go through <Trans>, which the extractor now reads. Splitting them
into prefix/suffix keys would have been untranslatable: word order around
the value differs by language and a split sentence cannot be reordered.
- Class components and module scope (ErrorBoundary, AppLoader) import a bare
`t` from the i18n module. Reaching for `i18next.t` at the call site would
have left that copy out of en.json, since the extractor matches bare calls.
The declarative-pair heuristic initially swept up `archKey: 'amd64'` next to
`arch: 'x64 (amd64)'` -- "Key" already means "identifier" in places -- so a
pair now only counts when the value looks like a dotted translation key.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 D1/D2.
extract-i18n.mjs walks the AST and regenerates en.json from the inline English
defaults, so en.json is output rather than a file anyone edits. That is what
makes a migration this size survivable: an unextracted key still renders
correct English, so a half-migrated page is never broken and never shows a raw
key path to a user. --check fails the build when it drifts.
check-i18n.mjs asserts what only a user in that language would otherwise
discover: no orphan keys, no dropped or invented {{placeholders}}, same leaf-
vs-namespace shape, and manifest/bundle agreement -- a language offered in the
selector with no bundle silently renders English, and the user cannot tell
that apart from "not translated yet".
The extractor immediately caught a real problem in its own author's code: the
format door wrapped i18next behind a helper called `translate()`, which made
its nine literal keys invisible to extraction -- copy that would have quietly
stopped being translatable. The wrapper is now named `t`, and the extractor
matches bare `t(...)` only, since `i18next.t(key)` re-dispatches a variable by
construction.
es.json is seeded now rather than at the end of the plan. A locale you never
switch to is a locale that does not work, and seeding it here means every
subsequent wave is provably bilingual as it lands instead of being validated
once at the finish.
All four guards mutation-checked: dropped placeholder, orphan key, missing
inline default, and stale en.json each fail as intended.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §C + ratchet 5.
utils/intl.js owns dates, times, relative time, durations, numbers, percents
and lists. Three things the scattered calls could not do:
- A module-level locale published by LocaleContext, not read from a hook, so
the API error mapper, log formatter and CSV export format in the panel's
language too. useFormat() is a subscription over the same module, not a
second door.
- Formatter instances cached per (locale, kind, options). The
toLocaleDateString() calls this replaces built a fresh formatter per cell
per render, and Intl construction is the expensive half.
- Relative time via Intl.RelativeTimeFormat rather than translation keys --
the browser already carries every language's plural wording, and keying
"{{count}} days ago" would mean maintaining that table by hand.
This fixes a locale bug that predates any translation work: 124 call sites
use toLocale*() with no argument, which follows the BROWSER's locale, so a
panel set to Spanish already rendered English chrome around Spanish dates.
formatNumber.js was worse -- it built Intl.NumberFormat('en') at module load,
hardcoding English AND making it impossible to follow a switch.
The three human-facing utils now delegate instead of reimplementing, so their
existing callers become locale-aware without being touched. formatBytes gains
the locale decimal separator ("1,5 KB" in es) by replacing a toFixed + regex
trim with maximumFractionDigits, which trims identically in en -- parity
proved case by case against the pre-migration examples.
utils/timeAgo.js is deleted rather than left as a shim; its 6 importers move
to utils/time. A door nobody is forced through is a door nobody uses.
One bug found by the tests: formatNumber(null) returned "0", because
Number(null) is 0 -- a missing metric would have rendered as a real zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 D3. Plan 76 rule 1: every row gets a ratchet before its migration
starts, or it regrows.
Counts user-visible string literals not addressed by a translation key, in
the four shapes copy actually escapes through: JSX text nodes, JSX copy
attributes, toast/confirm arguments, and copy keys in data objects. The
fourth shape is the one an extraction pass usually misses -- a label in a
data file is still copy, and if it resolves at module load it also never
re-renders on a locale switch.
Seeded at 8225 across 347 files. The plan estimated ~4088 from greps; the
AST census finds twice that, because greps miss multi-line JSX text,
template-literal segments, and data-object labels (77 in routeManifest.js
alone). Plan corrected to the measured number.
Uses espree (eslint's own parser) for JSX -- added explicitly rather than
leaned on transitively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hook has zero importers yet was counted in the LEGACY_POLLERS baseline,
holding a phantom slot in the ratchet (a place a new raw setInterval could
hide). Poller debt shrinks 11 sites/10 files -> 10 sites/9 files; the
migration inventory is regenerated to match.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mixin status-variant($color, $bg, $border) in _mixins.scss is the one
shape for coloring status-bearing elements; every mechanical
background/color token pair across 40+ page/component partials now
includes it (pass-through args -> identical compiled CSS values, build
green). check-status-scss.mjs holds the raw-pair count at zero and runs
inside npm run lint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
statusKind()/statusVariant()/statusDotClass()/statusLabel() drive both
Pill kinds and Badge variants from ONE table; ~40 per-page maps across
35+ files migrate to it. Scoped overrides stay in the same file:
serviceStatusKind (running IS the healthy steady state for services/
units/previews -> green), processStateVariant, alertStatusKind,
envActionKind, impactTone. Unifications: run-shaped 'running' is cyan
everywhere (Git.jsx's amber gone), QueueDetail/QueueOperations' invalid
blue/yellow kinds fixed, severity tones (critical/high/medium/low)
agree across security surfaces. Ratchet: npm run check:status fails on
new local maps outside ds/status.js (frozen allowlist for domain maps
and D4-territory hex/css-class renderers). Lint + build green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/MIGRATION_INVENTORY.md is generated from the live censuses and the
frontend boundary checker's new --inventory JSON mode — no hand-typed
numbers, so the plan cannot claim completion while a door is bypassed. A
freshness test fails whenever a ratchet number moves without regenerating
the document, making the regeneration part of every migration commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend: hand-shaped {'error': ...} bodies in app/api get an AST census
and ceiling (1159) — milestone B's migrate-when-touched policy made
enforceable. Frontend: check-frontend-boundaries.mjs gains adoption
ceilings for raw api.* calls in pages (460, E1), per-page toast.error
extraction (264, E1), form-group blocks (343, F2), and unencoded query
interpolations in services/api (97, C4-interim) — totals that may only go
down, with a slack guard so a ceiling cannot drift above reality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Plan 76 milestone G, last item. 151 hex literals live outside the token files
across 35 partials. A theme is a JSON map of CSS custom properties (plan 60), so
only values written as var(--token) change when someone switches skin — every
literal is a spot that stays the default palette on every theme, which is a
functional hole in the themes feature rather than untidiness.
Per-file exact counts rather than a ban, for the reason milestone G gives
explicitly: legitimate brand and data-visualisation colours stay. They just have
to be listed, so the number is honest and can only go down.
Not migrated: picking the right token per site is a semantic judgement (is this
`--green` or `--success`?) with visual consequences, not a substitution — the
same reason .empty-state was ratcheted rather than merged.
Verified non-vacuous: adding one literal to _terminal.scss fails it (20 vs 19),
and it passes again on revert.
Gate: boundary check green, npm run lint 0 errors / 953 warnings, build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone G. 114 top-level class definitions share 50 names across
partials, so those classes have no owner: every definition applies, merged
per-property in main.scss import order, and editing one file changes only the
properties that file happens to win.
`.empty-state` is the worked example the audit named. Three partials define it —
components/_empty-state.scss (flex column, min-height 200px), _cards.scss (card
background, solid border, radius), _users.scss (padding, colour) — and the
compiled sheet carries five competing top-level rules. What renders takes its
background and *dashed* border from one, its padding and display from another,
and its colour from a third. No author wrote that element.
Ratcheted rather than merged, on purpose. Reproducing the cascade's computed
result has no byte-identical-output proof available (unlike the @keyframes
sweep, where deleting seven dead copies left the CSS unchanged to the byte), so
it needs eyes on the affected pages — plan invariant 9, no repo-wide mechanical
rewrite without behavioural tests.
The ratchet counts DEFINITIONS, not names: counting names would let an
already-shared class spread to a fourth and fifth partial without moving the
number, which is the failure mode worth stopping. Mutation-checked both ways —
adding a definition of an existing shared class and introducing a brand-new
shared name each fail it, and both pass on revert.
Wired into `npm run lint`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone G. A @keyframes name is global and last-definition-wins across
every partial main.scss pulls in, so a duplicate name is not wasted bytes — one
copy is silently dead, and which one depends on import order.
`spin` was defined 8 times. The build already emitted exactly one, so seven were
dead: deleting them left the compiled CSS byte-for-byte identical (same content
hash, 958878 bytes), which is the proof rather than an argument. Three of the
seven also differed — `to`-only rather than `from`/`to` — so anyone editing
those was changing nothing.
`pulse-dot` was the one that actually mattered: components/_uptime defined it
with a scale (1 -> 1.2) and pages/_services defined it opacity-only, and
_services is imported later, so the uptime dot has been running the services
animation. Renamed the uptime one to `uptime-pulse-dot`; service-detail keeps
the shared `pulse-dot` it already got.
**Visual change**: the uptime status dot now scales as its own stylesheet always
said it should. Nothing else moves.
`pulse` (identical twice) and `drawer-fade`/`drawer-slide` (identical in
_docker and _file-manager) are consolidated into base/_utilities.scss, so
neither page owns the other page's animation.
Ratchet: the boundary check now scans every .scss partial and fails on any
@keyframes name defined more than once. 66 unique names, zero duplicates.
Verified non-vacuous: an injected second `spin` fails it, revert passes.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, boundary check
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 0bfcf69c. Ratchet 16 sites / 12 files -> 11 / 10.
Dashboard's remote-metrics poll and its socket-down HTTP fallback, ImportWizard's
active-import poll, and TestSandbox's run poll plus its open-log fan-out.
TestSandbox's log refresher fanned out one request per open log per tick and
returned nothing, so a tick reported done before any of them had come back. It
now returns a single Promise.all, which is what the in-flight guard needs to
mean anything for a fan-out.
That leaves 11 raw setInterval sites, and they are deliberate:
- 5 are clock ticks with nothing to overlap and nothing to fetch — an elapsed
timer, a countdown, a board refresh pulse, two wall clocks.
- 3 are the socket-fallback pollers in useMetrics, useContainerStatus and
useDeployJobStream, driven by imperative startPolling()/stopPolling() from
socket handlers with a 3s grace period. Expressing that as a declarative
`enabled` is a real restructure of each hook and they only poll when the
socket is already down.
- 2 belong to the serverkit-gui extension, whose source is the sibling repo;
its frame loop already carries its own in-flight guard.
- 1 is ServerGui's user-pausable capture interval.
The ratchet keeps all of them at exact counts, so the reasoning above has to be
revisited rather than forgotten if one changes.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 89c3896e. Ratchet 22 sites / 15 files -> 16 / 12.
DoctorPanel's fleet sweep and ScannerTab's malware scan were imperative
pollers: a function started an interval, and the interval cleared itself once
the job reached a terminal status, with a second clearInterval in an unmount
cleanup to catch the case where it never did. Both now hold the job id in state
and let usePolling own the schedule —
const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []);
usePolling(tick, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId) });
which is what makes the visibility pause and the in-flight guard apply to them
too. A job poller is the shape that most wants both: it runs while someone
watches a spinner, and its endpoint is doing real work.
Also ContainersTab's stats poll and log tail, and OnboardingWizard's status
poll (its 1s elapsed-time clock stays a plain interval — nothing to overlap).
Two `useRef`s and one `clearInterval` in an unmount cleanup went with them.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing f9657d38. Ratchet 27 sites / 18 files -> 22 / 15.
Terminal's three tab components (file logs, journal, processes), ServicesTab's
unit log tail, and the GUI extension's synthetic desktop. All were the
auto-refresh tail shape: poll only while the toggle is on, never fetch on mount
from the interval effect.
These are the sites where a hidden tab mattered most — a log tail at 3s left
open in a background tab is the single most wasteful poller in the panel, and
every one of them also re-ran on `lineCount`/`priority`/`sort` changes via a
dependency array with `// eslint-disable-line` on it. That silent restart is now
explicit: the tail's own load effects still handle those, the poll handles the
repeat.
Four `intervalRef`s and one `logIntervalRef` went with them.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 3ac566cb. Ratchet 31 sites / 22 files -> 27 / 18.
QueueDetail and QueueOperations each fired two requests per tick. Their ticks
now return a single awaited Promise.all, because the in-flight guard only knows
a tick has finished when the promise it was handed settles — a bare `.then()`
inside the callback reports done immediately and lets the next tick start on top
of a request still in flight, which is the exact overlap the hook exists to
prevent.
ServerDetail and LinkPanelForm keep their mount-time load as its own effect;
the poll is `immediate: false` so nothing fetches twice.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing f21abe7a. Poller ratchet 41 sites / 31 files
-> 31 / 22.
Migrated: Jobs, Monitors, MonitorDetail, DeliveryLog, Deployments, Databases,
VulnerabilityTab, and the service-detail Logs/Metrics/Overview tabs.
The trap flagged in the plan bit on four of them, so it is worth restating: the
interval effect's dependency array was doing double duty as the "reload because
the query changed" trigger —
useEffect(() => {
load(); // <- reload on filter change
pollRef.current = setInterval(load, POLL_MS);
return () => clearInterval(pollRef.current);
}, [isAdmin, load]); // <- load() is a useCallback of the filters
usePolling holds the callback in a ref precisely so it does not restart on every
render, which means a naive swap silently drops that reload and nothing fails to
compile. Each of those four keeps an explicit `useEffect(() => load(), [load])`
alongside the poll, with `immediate: false` so mount does not fetch twice.
Six magic interval literals became named constants while their lines were being
touched (LOG_REFRESH_MS, METRICS_REFRESH_MS, SCAN_POLL_MS, DEPLOY_POLL_MS,
ENGINE_POLL_MS).
Not migrated, and not mechanical: useMetrics, useContainerStatus and
useDeployJobStream are socket-fallback pollers driven by imperative
startPolling()/stopPolling() calls from socket event handlers, with a 3s grace
period before falling back. Expressing that as a declarative `enabled` flag is a
real restructure of each hook, and they only poll when the socket is already
down. Left for when they are next touched.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings (one below the
954 baseline — four orphaned useRef imports removed), npm test 72 passed,
boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone F3 (the blob half) plus the C4-interim dead-file deletion the
audit called out.
The six-line "save this to disk" anchor ritual was pasted into 14 components,
and the copies had drifted in two ways that decide whether the download
actually happens:
- only 3 of 14 appended the anchor to the document. Chrome dispatches a click
on a detached anchor; Firefox has not always done so, which makes "the
download button does nothing" a browser-specific bug that never reproduces
for whoever wrote it.
- most revoked the object URL on the very next line after click(). The download
starts asynchronously, so revoking immediately can cancel it — showing up as
a flaky empty-or-missing file rather than a clean failure.
utils/downloadBlob.js does both correctly (append, click, remove, revoke on a
later task) and accepts either a string or an existing Blob, which is what
FleetCapacityPanel needs since its CSV arrives from the API already blobbed.
Also deletes src/services/wordpress.js: 494 lines, zero importers, and a stale
duplicate of the live src/services/api/wordpress.js that the ApiService barrel
actually wires in. Verified by import graph and a clean production build.
Ratchet: the boundary check now fails on any `.download =` assignment outside
the helper — a zero baseline, not a shrinking one, since every site is
migrated. Verified non-vacuous: an injected anchor fails it, revert passes.
Gate: npm run build clean, npm run lint 0 errors / 954 warnings (unchanged),
npm test 72 passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, drift/bug row 4. 45 hand-rolled setInterval sites across
35 files, with `visibilitychange` handled in exactly one of them and an
in-flight guard in exactly one of them — the documented poller-stampede shape.
Two failures every hand-rolled `setInterval(load, 5000)` shares:
- it starts a request every 5s whether or not the previous one returned. A slow
endpoint does not slow the poller down, it makes it stack; each extra request
makes the server slower, which makes the next overlap more likely. This is
what a DevTools capture of /domains showed as a wall of pending rows.
- it keeps polling a tab nobody is looking at, where browsers throttle timers
unevenly anyway, so it is not even a reliable refresh.
utils/pollScheduler.js holds the rules as a plain object so they are testable
without a DOM (the repo has no jsdom — this follows formState.js/useForm).
hooks/usePolling.js is the React binding, and useServerQuery gains
`refetchInterval` built on the same scheduler, so there is one implementation
rather than a second one for query users.
Two design points worth stating, since both differ from what the sites did:
- the next tick is armed by the previous run *finishing*, not by setInterval.
With setInterval the gap is measured tick-to-tick regardless of how long the
work took, so slow work silently turns a 5s gap into no gap. The in-flight
guard therefore protects the out-of-band paths — refresh() and the visibility
catch-up — rather than the schedule, and the test says so instead of implying
a guard the schedule can no longer exercise.
- the callback is held in a ref, so an inline arrow re-created each render does
not restart the timer. That is the usual reason a hand-rolled poller runs far
faster than its stated interval.
Migrated four as reference implementations, per the plan's "migrate a
representative flow before broader adoption": DeployPill (which had worked all
of this out by hand and loses ~20 lines), MonitorsSummary, appdetail/LogsTab,
and ProcessListPanel. MonitorsSummary keeps its `refreshKey` prop as an explicit
out-of-band refresh rather than folding it into the schedule.
Ratchet: check-frontend-boundaries.mjs gains LEGACY_POLLERS with exact per-file
counts, now 41 sites in 31 files, so every future cleanup must shrink it and a
new bare setInterval fails lint. Genuine non-fetching timers (clock ticks,
countdowns) are listed too rather than pattern-matched away — they are harmless
but the exact count is what makes the ratchet trustworthy.
Gate: npm test (72 passed, 11 of them new), npm run lint (0 errors; 954
warnings, one fewer than the 955 baseline), npm run build clean, boundary check
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone F3, drift/bug row 5. 33 call sites in 25 files reached for
navigator.clipboard.writeText directly, against 4 files using the shared
copyToClipboard/useClipboard helpers.
This is a fix rather than a tidy-up: navigator.clipboard is undefined in an
insecure context, and ServerKit's SSL is optional by policy, so on an
HTTP-served panel every one of those copy buttons did nothing. The failure was
silent at most sites — a bare fire-and-forget writeText, or a `.catch` that
swallowed. utils/clipboard.js already carries the execCommand fallback that
makes them work; it just had almost no callers.
Two of the bypasses were local functions *named* copyToClipboard
(ServerSettingsTab, Downloads) — the same shadowing shape as the backend's
private get_current_user() helpers, and the reason the call sites read as
though they were already correct. ServerSettingsTab's was a hand-rolled
useClipboard (copy + toast) and now uses the hook; Downloads' is renamed
copyCommand since it also owns per-command copied state.
Behaviour preserved per site, with two deliberate improvements the helper's
contract allows:
- state that was set unconditionally (`copied` flags, keyed copy markers) is
now set only when the copy actually succeeded
- CreateTableModal's "this browser will not give the page clipboard access"
notice is gone; the fallback is what it was warning about
`.then(onOk, onErr)` chains became `.then(ok => ...)` because copyToClipboard
resolves false instead of rejecting — the second callback would never have run.
The existing frontend boundary ratchet held an exact per-file legacy baseline;
it is now empty and documented as needing to stay that way, so a new direct
call fails `npm run lint`.
Gate: node scripts/check-frontend-boundaries.mjs (0 clipboard files ratcheted),
npm run lint (0 errors, 955 pre-existing warnings — unchanged), npm test
(61 passed), npm run build (clean).
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>