HOMELAB-1682: feat(cockpit-web): LD3.2b — the three panes (gates queue, fleet overview, director chat) + STOP #578
No reviewers
Labels
No labels
operator-review
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Homelab/infra-core!578
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "plane/HOMELAB-1682-three-panes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Slice LD3.2b (HOMELAB-1682) of epic LD3 (Lab Director Cockpit): the three panes. Class D (ui-craft bar + visual-verifier at landing), frontend-only:
lab-director/cockpit/web/**.Design authority:
.rpiv/artifacts/designs/ld3/cockpit-design-brief.md(R4 honest state, R5 gates, R8 STOP, R11 class D) and.rpiv/artifacts/designs/homelab-1654/project-deployments-and-cockpit.md§4.2 (Fleet Overview + Gates Queue mocks), §5.2 (derived-state visuals), §5.4 (STOP). Quality bar: theui-craftskill.Base note (read this first). This branch is based on
origin/epic/LD3= the 3.1a merge. The 3.2a backend (PR #572) — the/api/v1/gates,/api/v1/overviewand/api/v1/fleet/stopendpoints this frontend codes against — is not yet onepic/LD3. Consequence: the new e2e specs need those routes, so thecockpit-e2eCI job goes green only once #572 lands. My local e2e run used the suite's own documentedE2E_COCKPIT_BINhook with a binary built fromplane/HOMELAB-1682-gates-backend@b3c1bec(the tip whose CI is green). I will mergeepic/LD3into this branch and re-run as soon as #572 lands; no Go file is touched by this PR.What
1 · Gates Queue — the centerpiece (§4.2, R5). Rows carry title/ticket, class + state chips, the recommendation, the exposure bound split into blast and recovery, the options offered, the expiry as a countdown (
23h 47m left, orno expiry— never an invented deadline), the owner and the gate nonce. ratify / defer / veto open a confirmation dialog that restates the exposure and requires a reason (the backend refuses a reasonless disposition under P-GATE-002, so the confirm is disabled until it is written); the POST is a real request and its outcome lands on the row it was about.decisionsFor()intersects the payload with['ratify','defer','veto']and dropsvetofor class E, so a payload that claimed otherwise cannot produce a control. A class-E ticket with no gate record renders no controls at all — just the sentence explaining that a decision package has to be raised first.PLANE_PROJECT_ID). The header shows "count not measured", never0 need you.2 · Fleet Overview — the daily read (§4.2, R4). Three card groups (homelab / fleet / updates) fed by
/api/v1/overview+ the read model, one scroll:?when the backend did not measure it — no fabricated zeros anywhere (P-COCKPIT-005). The homelab group carries the measured ticket census (state-group counts) once Plane is wired, and a hatched sentence when it is not.unknown(hatched,data-tone="idle"— never rendered as healthy) with the dependency named. The table is a skeleton on purpose: LD3.4 (HOMELAB-1684) fills it when the sandbox census exists.3 · Director chat. Text-first: message log + composer, the transport seam typed as
ChatTransport.send(message), and the shipped implementation refuses — a typed message is recorded locally as not sent with the reason and the ticket that fills it (LD4.3). No fake bot, no simulated reply; the empty state says so in words.4 · Header STOP (§5.4, R8). ⏹ always visible → confirmation dialog (fleet scope, what a stop actually does: claim minting stops, process groups SIGSTOP, bus steer/ask suspended; plus a preview of the ledger entry) →
POST /api/v1/fleet/stop→ the 503 "sentinel not armed" rendered as an amber banner (not error-red: nothing broke, the arm is not fitted), with the audit entry echoed back — schema, outcome, policy lines, principal, verified: no — dev identity, request id, and whether the ledger actually recorded it. The button is real; the arm is not yet.5 · Pane navigation. A tablist under the header; the fleet overview is the default (the operator's daily read). All three panes stay mounted with the inactive two
hidden, so the gates list and the chat draft survive a glance elsewhere and the shell's structure stays stable across routes (the 3.1a offline spec's three[data-testid=pane]still holds). Arrow/Home/End keyboard navigation,aria-selected/aria-controlswired. The 3.1a identity chip, connection dot, dev badge, PWA and service worker are untouched.Why
The cockpit's whole justification is that an operator can trust it at a glance: "no row ever shows a state the telemetry can't back". This slice is where that rule stops being a sentence in a design document and becomes the thing the code does — every unwired source names its key or ticket, every unmeasured count is absent rather than zero, unknown is a first-class hatched state, and the two irreversible actions (a disposition, a fleet stop) each stop at a confirmation that restates the consequence. It remains a reader: no store, no local mutation of the queue, and no path by which the cockpit ratifies anything on its own.
Test plan (all run locally; evidence pasted)
npm ci && npx svelte-kit sync && npx svelte-check --tsconfig ./tsconfig.jsonnpm run testnpm run buildbuild/npm run e2e(real binary + real build, dev mode)Local e2e was run against the 3.2a backend binary (
E2E_PREBUILT=1 E2E_COCKPIT_BIN=/tmp/ld-cockpit-3.2a, built fromplane/HOMELAB-1682-gates-backend@b3c1bec), becauseepic/LD3does not carry those routes yet. Everything else (types, unit, build) runs against the branch as-is.Manually verified in a browser at 1440 and 390px (evidence below): all three panes, both the unwired and the configured gates queue, the ratify dialog, the refused disposition on its row, the chat send refusal, the STOP dialog and the not-armed banner.
scrollWidth ≤ clientWidthat 390px on every pane; every control clears the 24px target floor; the responsive spec (overflow + targets + 4.5:1 contrast at 390 and 1440) stays green.Screenshots
Attached to this PR (see the evidence comment below):
dashboard-unwired,dashboard-measured,gates-unwired,gates-configured,gate-ratify-dialog,gates-refused,chat,chat-not-sent,stop-dialog,stop-not-armed— for each of the three panes at 1440 and 390px.Slice boundary (hard)
In:
lab-director/cockpit/web/**only — new stores (gates,overview,stop,chat,panes), new components (gates/,dashboard/,chat/,shell/), the confirm dialog primitive, the LD3.2 wire types + client methods,honest-state.ts, one new status tone, the page/layout/header wiring, 65 unit tests and 18 e2e specs.Out (untouched): no Go file (not one line), no CI workflow, no
prod/**, no dispatcher, no manifests, no secrets. No API shape was invented — every field read here is a field 3.2a emits.Retired:
src/lib/components/shell/pane.svelte+ its test. The 3.1a placeholder frame ("no data in this slice" + an owner line) is superseded by the three real panes, and the discipline it enforced — name the missing seam — now lives inside each pane, where the seam is specific (LD3.1b for the queue's Plane credential, LD4.3 for the chat backend, the dispatcher read API for the sandbox census, LD1.5b for the ledger).Deviations from the brief, with reasons
gates-configured.spec.tsfulfils/api/v1/gates+/api/v1/overviewwith the exact payloads the Go tests assert, imported fromsrc/lib/tests/fixtures.ts— the same fixtures the unit suite uses, so a shape change breaks both. The unwired states (the states this environment really has) are asserted against the real server inpanes.spec.ts. If the reviewer would rather have a fake Plane service in the Playwright harness, that is a larger change I did not make unilaterally.gates unwired/LD3.1b andLD4.3/HOMELAB-1687 instead ofpane-empty. The PWA/offline spec, the responsive spec and the auth spec are untouched and green.b3c1bec, "assert the four named read-model source rows"), taken verbatim so the two branches cannot conflict. My dashboard renders all four named rows because it is the same census.Open items for the landing reviewer (and where the visual-verifier should look hardest)
gates-configured.spec.tsis a frontend contract test (deviation 1). Worth a reviewer's eye on whether the fixture shapes are the ones 3.2a emits — they were copied from the Go structs and I read the Go tests to confirm; a second pair of eyes onsrc/lib/tests/fixtures.tsvsgates/store.gois the cheapest check.hiddennavigation choice (all three panes mounted) is a deliberate trade: state survives and the structure is stable, at the cost of one mounted-offscreen fetch. If a reviewer prefers lazy panels, the change is small but it would break the offline spec's pane count.data-decisionwas added to the shared Button so the "never an automatic path" assertion reads the DOM rather than a label string. Purely additive; no existing variant changed.Progress log:
/home/aaron/repos/homelab/.rpiv/artifacts/build/ld3-1682b-builder.mdThe shell's three panes become the real surfaces, and the 3.1a placeholder frame (Pane + its empty-state contract) is superseded by them — each pane now names its own missing dependencies in place, which is what the placeholder was standing in for. - Gates Queue (the centerpiece): rows with recommendation, exposure bound (blast + recovery), options, expiry countdown, owner and nonce, plus the ratify/defer/veto controls from the closed vocabulary. Class-E rows render ratify/defer; a class-E ticket with no gate record renders NO controls and the sentence explaining why. The unwired state says "gates unwired" and names LD3.1b; the write posture is stated before it is discovered via a 503. - Fleet Overview: the three card groups (homelab / fleet / updates), every count through StatCell — a hatched ? when the backend did not measure it, never a zero — the wiring census as named rows, the project switcher, the sandbox table skeleton whose only row is the honest unknown (hatched, never green), and the four-state vocabulary legend. - Director chat: text-first, with the transport seam wired to the refusing implementation, so a typed message is recorded as NOT SENT with the reason (LD4.3 / HOMELAB-1687) and no reply is invented. - Header STOP: always visible, one confirmation dialog naming fleet scope and the ledger entry, then the real fail-closed request. The 503 renders amber with the audit entry echoed back (schema, outcome, policy lines, principal, verified, request id). - Pane navigation: a tablist under the header. All three panes stay mounted (inactive ones hidden) so their state survives a glance and the shell's structure is stable across routes; the gates badge renders only a measured count. The emoji-block glyphs the design names (⏸ ✋ ⏹) have no font coverage in this stack — verified in a browser, they paint as tofu — so the derived-state marks are lucide strokes and the STOP mark is a drawn square. Intent preserved, tofu avoided.Visual evidence — the three panes, 1440px and 390px
Captured from the real build against the real 3.2a backend in dev mode (
X-Cockpit-Dev-User: e2e-operator), with the configured-queue states rendered from the payloads the Go tests assert (the live server has no Plane credential by design — LD3.1b ships it).Gates Queue — the centerpiece
The unwired state (what this environment honestly has): "gates unwired", the backend's reason verbatim,
PLANE_PROJECT_IDas the key that would wire it, the dependency line naming LD3.1b, and no count in the header — never "0 need you".gates unwired @1440
gates unwired @390
The configured queue: exposure bound split into blast/recovery, recommendation, options, expiry countdown, owner, nonce, and the write-posture banner (LD3.1b). Class E (
SEND-478) offers ratify/defer only; the needs-gate row (HOMELAB-1661) renders no controls, just the sentence saying a decision package must be raised.gates configured @1440
gates configured @390
The confirmation restates the bound and requires a reason (P-GATE-002), and the refusal lands on the row it was about with its kind, the credential that would wire it, and the fact that the row is still undecided.
ratify confirmation @1440
refused disposition on its row @1440
Fleet overview — the daily read
Unwired census: every count that was not measured is the hatched
?— no zeros. Wired census: the measured ticket census (state-group counts), class-E depth and the gates count, everything else still honest.fleet overview, unwired census @1440
fleet overview, unwired census @390
fleet overview, measured census @1440
fleet overview, measured census @390
Director chat
No conversation plane: LD4.3 (HOMELAB-1687) is named, and a typed message is recorded as not sent — no invented reply.
director chat @1440
director chat @390
a message that was not sent @1440
Header STOP
One confirmation naming fleet scope and the ledger entry, then the fail-closed 503 rendered amber (not error-red) with the audit entry echoed back: schema, outcome, policy lines P-SENT-001/P-SENT-002, principal, verified: no — dev identity, request id, recorded.
STOP confirmation @1440
sentinel not armed @1440
Measured layout facts (390px)
scrollWidthclientWidthCommitted as specs in
e2e/panes.spec.ts(no pane scrolls horizontally, at 390px and 1440pxandevery control in the new panes clears the 24px target floor at 390px), alongside the untouched 3.1aresponsive.spec.ts(overflow + targets + 4.5:1 contrast at 390 and 1440) which stays green.CI ordering note —
cockpit-e2eis red because LD3.2a is not onepic/LD3yetcockpit-test(Go) is green;cockpit-e2efails 4 specs, and all four are the specs that call the 3.2a routes (/api/v1/gates,/api/v1/overview,/api/v1/fleet/stop) which do not exist in this branch's Go code:e2e/panes.spec.ts→ the Gates Queue pane, unwired (nogates-unwired: the route 404s)e2e/panes.spec.ts→ the STOP button (no/api/v1/fleet/stop)e2e/shell.spec.ts→ the panes 3.1a reserved state their dependencies (same two routes)e2e/shell.spec.ts→ the wiring census reports every source honestly (the 4th,gates, source row comes from the 3.2a read model)Evidence that this is the ordering and nothing else:
go build ./cmd/cockpitE2E_COCKPIT_BIN=binary built fromplane/HOMELAB-1682-gates-backend@b3c1becgit merge origin/plane/HOMELAB-1682-gates-backendshell.spec.tsauto-merges, no conflicts)So: #578 must land after #572. Once #572 is on
epic/LD3I will mergeepic/LD3into this branch (a merge commit, no force-push) and re-run — or the landing worker may take both in sequence; the merge is clean either way.One more thing worth recording honestly: in CI,
e2e/shell.spec.ts→ ships the PWA shell also failed (0.8s), while the same spec passes locally against the same 3.1a binary (re-verified minutes ago). That one is a flake in the service-worker install path under the runner, not a regression from this slice — it will be re-run with the rest after the merge. If it fails again post-merge, it is a 3.1a/CI issue worth its own ticket.Sequencer: epic/LD3 merged — two integration breaks found and fixed
New head
aba6709(mergeb72d365+ fixaba6709). The merge oforigin/epic/LD3(now carrying LD3.2a, PR #572) was conflict-free —
git merge-tree --write-treeexit 0, and the
shell.spec.tssource-row area auto-resolved keeping both theb3c1bec4-name contract (plane/gates/dispatcher/bus) and the 3.2b pane edits.Merged-head scope vs
origin/epic/LD3isweb/**only.Running the suite on the merged head surfaced two breaks. Neither is a defect in the
builder's panes; one is caused by the merge, one was latent and time-triggered.
1. MERGE-INDUCED — the 3.2a audit schema shape (
panes.spec.ts:204)c11c725(the 3.2a review-blocker fix) changedaudit.Entry.Principalfromstringto the sentinel's
Principalobject{kind,id,display}, andEntry.ScopefromstringtoStopScope{kind,...}. The 3.2b frontend still typed both as strings, sothe ledger echo interpolated the object straight into the DOM:
Fixed type-first, following the authoritative Go shape:
types/api.tsgainsAuditPrincipal/AuditScope;AuditEntryuses them.stop-banner.svelterendersprincipal.id— the identity the ledger is grouped by(Go: "the field the ledger is grouped by") — with the schema's own
kind(
unknownfor an unverified dev identity, consistent withverified: no).displayis deliberately not used: it is the raw dev header, not the attribution.fixtures.tsupdated to the real payloads ({kind:'unknown', id:'dev:e2e-operator', display:'e2e-operator'},scope:{kind:'all'}).[object Object]cannotreturn silently.
2. TIME-TRIGGERED LATENT DEFECT — a rotting countdown assertion
gates-configured.spec.ts:68asserted the SEND-472 countdown contained23h, but thefixture pinned
expires_atto a fixed instant (2026-10-03T04:00:00Z) whileformatExpirycomputes a live countdown. It passed in CI run #1699 (~04:30Z) andwent red at 05:00Z, the moment the countdown crossed into
22h.Fixture expiries are now offsets from one process-wide anchor: deterministic within a
run (
gateItem()is called twice and the results are compared — a per-callDate.now()drifts by milliseconds and fails that comparison) and still a real future deadline,
which is what a server emits.
Verification on the merged head
svelte-checknpm run testnpm run buildbuild/npm run e2e(real binary built from this tree)go test ./...go testCI is running on
aba6709; reviews follow once it is green.Review verdict 1/2 — UI-contract lens → BLOCK
Independent reviewer, fresh context, primary sources only: the diff at head
aba6709, design §4.2/§5.2/§5.4, and the merged Go wire (server/*.go,readmodel/*.go,audit/audit.go,gates/store.go). Baseline re-run by the reviewer:npm test137/137,svelte-check0/0.BLOCKER — the Fleet Overview prints a falsehood about its own session
dashboard-pane.svelte:104renderssnapshot?.identity, but every SSEreadmodeltick isprovider.Snapshot(nil)by design —cmd/cockpit/main.go:209: "No identity: this one payload goes to every subscriber, and the SPA already holds its own identity from /auth/me. The fetch form of the read model DOES carry one." OnlyGET /api/v1/readmodel(server.go:197,mapIdentity(id)) carries it.readmodel.svelte.ts:44-48exists to keep the fetched identity in a separateidentityfield; its own doc says "without it, the chip would blink empty on every tick." The 3.2b page passes only{snapshot}(+page.svelte:73), and nothing consumesreadmodel.identity(grep: the store and its unit test only). The pre-PR page read it correctly (origin/epic/LD3:+page.svelte:22,57-60).Net effect: ~1s after load the process strip flips from
as e2e-operator · dev:e2eto "no identity resolved" while the operator is authenticated — an affirmative falsehood, on the screen whose entire thesis is honest state.Fix: pass
identity={readmodel.identity}from+page.svelte:73and render that (with the loading/absent branches the old code had). No test coversdata-testid="readmodel-identity", which is why a green suite missed it.CONCERN
stop.svelte.ts:61-66). A gateway 503 carries no stop body, sorefusalis a truthy non-StopRefusaland the banner prints its hardcoded "fleet stop refused — sentinel not armed" with no reason and no ledger block. The cockpit invents the exact sentence it exists to quote, on a safety surface. Requirestate === 'unwired'and areason/auditbody, else take the failure path.gates.svelte.ts:135-157,overview.svelte.ts:52-70): error banner plus3 need you,configuredchip, badge 3, and census numbers — while the dashboard says "no counts are rendered from this read". The test covers only the no-prior-queue case.gates-pane.svelte:43,gate-row.svelte:42):nowdefaults once, no timer, no SSE input — a gate still reads "3m left" after expiry, while ratify returns410 expired_gate.SUGGESTION
audit.scope(the schema's value,{kind:"all"}); it shows the endpoint's informalscope: "fleet"instead, whichserver/stop.go:33-37explicitly says is not the ledger's scope.gates-pane.svelte:50re-implements absence-not-zero without the store'stypeof count === 'number'guard.client.ts:122spreads...optionsafterheaders, sogatesApi.decide's custom headers replace the merged object.Explicitly clean (falsification attempts failed)
{@html},innerHTMLordocument.writein the app; every backend-controlled string reaches the DOM via Svelte escaping. Only interpolatedhrefs are a constant andidentity-chip.svelte:30(me?.login_url); a scheme guard there is defensible.decisionsFor()(gates.svelte.ts:59-64) intersects the payload with the closed ratify/defer/veto vocabulary and dropsvetofor class E, anddecide()refuses locally before any request. (The test that guards this is a different story — see verdict 2.)unknownis hatched with toneidlein the only row, andok/green is structurally unreachable fromDERIVED_STATE(honest-state.ts:73-78).hidden: safe — no subscribe-after-event race, no cross-principal residue, tablist roles/roving tabindex correct.VERDICT: BLOCK
Review verdict 2/2 — Test-adequacy lens → BLOCK
Independent reviewer, fresh context. Read the specs and the Go wire, then mutation-tested the two claims the slice is built on (mutations reverted; tree verified clean at
aba6709).Baseline:
svelte-check0/0 · vitest 137/137 · Playwright 43/43 against a real Go binary built from this tree.BLOCKER
B1 — "no fabricated zeros" is a false green. The reviewer patched
readmodel.UnwiredCensusto emittotal:0, class_e_open:0, gates_needing_decision:0— the exact regression P-COCKPIT-005 exists to prevent. The dashboard renderedclass-E queue 0 · gates needing you 0 · 0 tickets seenwithdata-measured="true", andpanes.spec.tspassed 12/12.Mechanism:
panes.spec.ts:100-107branches on the component's owndata-measuredflag, whichstat-cell.svelte:49derives fromtypeof value === 'number'— so the else-branchtoContainText(/\d/)accepts"0"as measured. That is circular: the spec cannot see a fabricated zero because it asks the view whether it thought the value was measured. Anddashboard-pane.sveltegates only the chip oncensusWired(line 156); the counts (185, 265, 202) never consultcensus.state, unlike the gates store (gates.svelte.ts:133).Fix: (a) gate the values,
value={censusWired ? measured(...) : null}; (b) assert absence directly in the live spec (census-totalcontains not measured,stat-gates-needingdata-measured="false"). (b) alone makes a fabricated-zero backend fail.B2 — "no automatic path anywhere" is not a structural guard.
gates-configured.spec.ts:88-91greps onlypage.locator('button')'stextContent. Two mutations each left the suite fully green: a<button>proceed</button>that ratifies without the confirmation dialog (e2e 5/5 andgates-pane.test.ts12/12), and an icon-only<button aria-label="auto-land the recommended decision">(emptytextContent).button.svelte:47renders an<a>whenhrefis set — invisible to the grep — andtitle/aria-label/value, non-button controls, selects and keyboard handlers are all unexamined.Fix: assert the complete affordance set inside
[data-testid="gate-list"]— every interactive element is either[data-decision](∈ ratify|defer|veto) or on a short explicit whitelist. The label grep is not a guard.B3 — the census fixture contradicts the wire.
fixtures.tscensus()emits 3 state rows summing to 81 withtotal: 128, but the Go handler always emits all five groups (plane.Groups()→ backlog/unstarted/started/completed/cancelled, iterated atreadmodel/census.go:138) andtotalcounts every issue into both (census.go:120-125).dashboard-pane.test.ts:54then pinstoHaveLength(3), so dropping zero-count rows — a plausible "reduce noise" edit — would pass.Every other fixture field was key-diffed against the live binary's
/api/v1/gates,/overview,/fleet/stop,/readmodel: exact match, including theaudit.principal/audit.scopeobjects reconciled inaba6709and the deliberate absence ofcounton the unwired queue. This is the only drift.CONCERN
queue.state === 'configured'guard (gates.svelte.ts:133) leaves 137/137 green — no fixture carries{state:'unwired', count:0}. The defence works; it has no test.gates-configured.spec.ts:34-43hard-codes the 503). Agate_nonce→gateNoncedrift yields 400invalid_decisionand nothing fails. The only new write path in the slice.size="sm"buttons, i.e. where a 390px overflow would live, are untested.gates.svelte.ts:17-22claims "The Go payload carriescount: 0on an unwired or failed read" — false (Count *int+omitempty; confirmed absent live) and contradicted by the same file at:120-123.What is genuinely good
The class-E decision-vocabulary half is protected (removing the veto filter in
decisionsForfailsgates.svelte.test.ts:96; class-E rows growing a veto fail three specs). The refusal-lands-on-its-row assertions, the store-level intersection tests and theaba6709de-rot fix are real work: reverting the banner to{audit.principal}failsstop.svelte.test.ts:90by mutation, and the fixture's countdown is now deterministic with no wall-clock rot.VERDICT: BLOCK
Landing attempt 2 — HALTED at check-first: required context
cockpit-testis RED ona884e78Not a review verdict (no delta review was run) — a CI gate report. Verified directly via
GET /api/v1/repos/Homelab/infra-core/commits/a884e78506.../status:state: failure, both red contexts being the latest results on that sha.#1704 is a different workflow on the same sha — not a green re-run of the red test workflow.
forgejo_ci_failuresshows a single attempt per red job, so no green attempt of the test workflow exists fora884e78.cockpit-e2e— transient44 passed, 1 failed:shell.spec.ts:123(PWA service worker)Expected "activated" / Received "activating", identical on retry — the race already flagged at 04:47 in this PR. Untouched 3.1a spec. Would clear on re-run.cockpit-test— REAL and deterministic, introduced bya884e78The new fixture↔wire parity guard (review blocker 4's fix) reads the Go vocabulary through
resolve(dirname(fileURLToPath(import.meta.url)), '../../../../plane/plane.go')which is correct in a repo checkout but wrong in CI:
ci.yaml:121-137mounts onlylab-director/cockpit/webandcp -r /web/. /tmp/work/, then runs vitest with root/tmp/work. The expression therefore resolves to/tmp/plane/plane.go(verified:.../tmp/work/src/lib/tests/fixtures.test.ts− 4 levels =/tmp), and/tmp/planeexists on the runner as an unrelated directory, so it surfaces as ENOENT onplane.go.Locally it resolves to
lab-director/cockpit/plane/plane.go(exists,Groups()at :64), which is why the fix worker's local 143/143 could not see it.A path correction alone cannot fix this — the Go tree is not mounted in that container. Options, both crossing this PR's declared "no CI workflow" boundary:
ci.yamlcockpit-test step:-v "$PWD/lab-director/cockpit/plane:/go-plane:ro" -e PLANE_GO_PATH=/go-plane/plane.go, test readsprocess.env.PLANE_GO_PATH ?? <repo-relative fallback>— the guard stays load-bearing in CI, which is the only place backend drift arrives.No product-code defect is implied: the failing artefact is the new test's environment assumption. Delta review and the class-D visual pass were not run and must happen on the head that actually lands. Fix worker on
a884e78→ new head → fresh landing run.Delta review —
a884e78..694e8a7→ 0 blockers (2 concerns)Independent reviewer, fresh context, primary sources only: the diff, the post-diff files, the installed vitest 4.1.11 and Playwright 1.63 — not the fix worker's summary. Verdict posted after
694e8a7(694e8a735589b907d71be686e8b7440752ddb49e), so it supersedes the two verdicts ataba6709.Diff verified to be exactly 5 files, no Go product code:
ci.yaml,package.json,scripts/run-unit-tests.mjs(new),src/lib/tests/fixtures.test.ts,e2e/shell.spec.ts. The reviewer rebuilt the CI layout under/tmpand ran the suite in it.ci.yamlscoped to the cockpit-test frontend step; read-only mount; copy-inside; depth arithmetic; other jobs byte-identical(a) One hunk only (
@@ -127,14 +127,25 @@), entirely inside the "Frontend check, unit tests and build" step. Mechanically checked, not eyeballed: the file with that hunk removed isdiff-identical between the two heads. Mount stays:ro; copy still goes to/tmp/work, never into the checkout. Depth arithmetic computed and executed:web/src/lib/tests/ + ../../../../=<module>/plane/plane.golocally, and/tmp/work/web/src/lib/tests/ + ../../../../=/tmp/work/plane/plane.goin the copied layout — the reviewer rebuilt that tree from the module and got143 passed (143),fixture parity guard: 4 tests collected. Falsified the old shape the same way (tree minus../plane):139 passed, guard(0 test), exit 1 — so the comment's "139 of 143" is accurate.(b) The false-green class is real and genuinely closed: with the guard renamed out of the config glob,
vitest runalone is green at 139 while the new script exits 1. JSON-reporter contract verified against the installed vitest rather than assumed (assertionResults,testResults[].name = file.filepath,--outputFile.jsonresolving where read, non-zero exit propagated,vitest.mjsis literally what the bin shim runs, cwd holds in the container's/tmp/work/web). Concern: the floor counts skipped/todo assertion entries — the reviewer added.skipto the guard'sdescribeand the script printed4 tests collectedand exited 0. Requiringstatus === 'passed'closes it.(c) The poll is not weaker. Falsified the "could it pass on a dead worker" hypothesis with Playwright's own
expect: a permanentlynullvalue rejects, a permanently'activating'value rejects after 8 samples, a value settling at sample 3 passes. Thereadyself-timeout, the originscopeassertion, thegetRegistrations()origin check and thecontroller !== nullcheck all remain. The removedinstallingState/waitingStatefields were dead data ata884e78(written, never read). The race is on record (CI #1703:activating, twice with retry). Not independently reproducible locally — the reviewer's 12/12 probe sampledactivatedimmediately — so the "one run in five" rate stays anecdotal.(d) Every changed line maps to (a)-(c); no unrelated edit, no mode/rename surprises.
Non-blocking findings carried forward
[concern]scripts/run-unit-tests.mjsfloor counts skipped tests (demonstrated, above).[concern]the runner's docblock (and the matchingci.yamlcomment) misattributes the #1703 ENOENT as a silent drop — that shape already exits 1; the script's real value is the vanish case.[suggestion]npm test -- <file>no longer forwards args to vitest; themkdtempreport dir is never cleaned;PLANE_GO_PATHis honoured unvalidated (and is referenced nowhere else in the repo).The four product fixes in
a884e78are accepted by this review as landed-delta-clean. The visual gate is reported separately — it found a blocker, so this PR does not land yet.Class-D visual check — 1 BLOCKER: the STOP confirmation is clipped into the header and opens off-screen
Independent pass on
694e8a7. Scratch copy (git archive 694e8a7→/tmp/ld3-578-visual), real Go binary built from the same tree, port 8177 (unique), real build. 14 screenshots (7 surfaces × 1440/390) + 8 independent DOM probes. No tracked file in the worktree was touched.BLOCKER — pressing ⏹ renders an undimmed ~48 px sliver instead of the confirmation
Reproduced geometry (
getBoundingClientRect+elementFromPoint):Source-verified mechanism, not inferred.
app-header.svelte:36isclass="sticky top-0 z-30 … backdrop-blur"andapp-header.svelte:104mounts<StopControl/>inside it;stop-control.svelte:44mounts<ConfirmDialog>there;confirm-dialog.svelte's backdrop isposition: fixed. Abackdrop-filterancestor becomes the containing block forposition: fixeddescendants (CSS filter-effects), so the backdrop resolves against the 49 px sticky header rather than the viewport. Itsoverflow: autothen clips the dialog to a 48 px strip.At 1440 an operator who presses STOP sees a bright, undimmed 48 px scroll strip containing only the tail of the confirmation — "refusal is shown back to you." and "WHAT IS WRITTEN DOWN BEFORE THE ANSWER". The title Stop the fleet?, the R8 fleet-scope sentence (whole fleet; SIGSTOP ≤2 s; bus steer/ask suspended) and both buttons are not on screen; at 390 the accept button happens to land inside the strip and the title is off-screen. This is the confirmation R8 and §5.4 mandate for the most dangerous control in the app, and it is unreadable.
gate-decision-dialog.svelteis unaffected — it is mounted in the pane, not the header — which is why the ratify dialog renders correctly and this one does not.Why CI is green anyway: Playwright scrolls the clipping container to bring
confirm-acceptinto view before clicking, so the suite exercises a path a human cannot see.panes.spec.tsassertstoBeVisible, which only requires a non-empty box. The visual gate is the only leg that can catch this.Fix direction (reporting, not applied): render the dialog into a portal/host outside the header — or drop
backdrop-blurfrom the header's containing-block chain — then re-verifydialog.top >= 0andbackdrop.height == innerHeightat both widths.Clean — the four
a884e78fixes are visually verified"as e2e-operator · dev:e2e-operator""not measured", unwired chip present, 10[data-measured="false"]hatched cellscount not measured— never03 need you; class-E row offersratify deferonly; needs-gate row renders 0 controlsconfirm-acceptdisabled until a reason is writtenunknown dev:e2e-operator, no[object Object]data-tone=warnamber banner, audit echoedscrollWidth == clientWidth; 0 overflowing elements outside a designed scroller; 0 targets below 24 pxConcern — the configured queue evidence is not reproducible from this branch
The baseline
gates-configured-{1440,390}.pngshow 4 rows, including a class-BHOMELAB-1700/ nonceg-1700-1.src/lib/tests/fixtures.tshas contained exactlyitems: [gateItem(), classEGateItem(), needsGateItem()]= 3 items in every commit that ever touched it, including the builder's own3f1fcc9;HOMELAB-1700andg-1700-1appear nowhere in the repo history; andgates-configured.spec.tsassertedtoHaveCount(3)ataba6709as well. That screenshot was taken from a payload the branch cannot produce. Not a product defect — the product renders whatever the backend returns — but the attached evidence does not match the code it is evidence for.Not merged
Per the landing rule, a class-D slice does not land on a red visual pass, whatever CI says.
forgejo_merge_prwas not called and no GitOps continuation ran. Next: one fix worker on694e8a7to portal the STOP dialog, then a delta review + visual re-check on the new head. Full log:.rpiv/artifacts/build/ld3-1682b-landing.md.Fix worker report — class-D visual blocker (STOP dialog off-viewport)
Head
609d25c(on694e8a7). Territory:lab-director/cockpit/web/**only.Fix.
ConfirmDialognow portals its backdrop to<body>via a documenteduse:action (src/lib/actions/portal.ts); the header keeps itsbackdrop-blurtoken and the STOP trigger stays in the header. Abackdrop-filterancestor is the containing block forposition: fixeddescendants, so the portal is the structural fix — it removes the modal from every ancestor's containing block/overflow chain, for both dialogs.Guard — the assertion that would have caught it.
e2e/dialog-viewport.tsmeasures the dialog in the page before anything is scrolled and asserts the box is inside the viewport on all four edges and the backdrop covers it exactly (top 0,bottom == innerHeight). Applied per-width for STOP (1440, 390) and at 390 for ratify.Pre-fix (failing, committed assertions):
Post-fix:
Screenshots (pre-fix sliver vs post-fix full dialog) in
.rpiv/artifacts/build/ld3-1682b-fix/.Suite counts. svelte-check 0/0 · unit 145 passed / 20 files (+2:
portal.test.ts; floor guard 4 collected) · build OK · full e2e 48 passed (auth 8, gates-configured 6, panes 16, responsive 9, shell 9; was 45, +3) · 390pxscrollWidth 390 == clientWidthunchanged (same with the dialog open).No Go, CI workflow, or manifest touched.
CI green on
609d25c: run #1713 — cockpit-e2e ✔ cockpit-test ✔ dispatcher-test ✔ helm-validate ✔ receiver-test ✔ alert-receiver-test ✔; run #1714 — build-and-push ✔ preflight ✔. (Both runs are on this sha, no re-run involved.)Delta review —
694e8a7..609d25c→ 0 blockers (7 concerns)Independent reviewer, fresh context (
pi -p --no-session --model deepseek/deepseek-flash), primary sources only: the diff, the post-diff files, the repo's own suite. Verdict posted after609d25c(609d25c55b0a8319dbe51b7ce98788001fe443f7), so it supersedes every earlier verdict on this PR.Diff is exactly 7 files / 245 insertions / 0 deletions —
e2e/dialog-viewport.ts,e2e/panes.spec.ts,e2e/gates-configured.spec.ts,src/lib/actions/portal.ts,src/lib/actions/portal.test.ts,src/lib/components/shell/stop.svelte.test.ts,src/lib/components/ui/dialog/confirm-dialog.svelte. Zero deleted lines means no pre-existing assertion was modified or weakened.Independent baseline re-run by the reviewer:
npm test145 passed / 20 files;npm run check0 errors / 0 warnings;npx playwright test e2e/panes.spec.ts e2e/gates-configured.spec.ts22/22.(1)
portal.ts:32-37is one function with no module-scopedocumentaccess (thedocument.bodydefault is evaluated per call, inside the action), so no SSR guard is needed and none is missing;npm run buildruns the SvelteKit SSR pass clean. Relocation isappendChild(:33), notouterHTML/innerHTML, so node identity, listeners and Svelte bindings survive — demonstrated by the portalledconfirm-cancel/confirm-acceptclicks in the suite. Teardown isnode.remove()(:35-37);portal.test.ts:34,36pins parent-is-body and removed-after-destroy. No host wrapper exists, so no stale-host bookkeeping and no collision path between two dialogs. Svelte 5's delegation listener is attached todocumentas well as the mount target explicitly for manual portals (svelte/src/internal/client/render.js:235-243), and the Escape handler is awindowlistener (confirm-dialog.svelte:90-104) — verified live.body.childNodesmeasured across three open/cancel cycles + Escape: 124 → 125 → 124, no growth.(2)
app-header.svelteis absent from the diff; it still carriesbackdrop-blur(:36) and still mounts the trigger (:104). The structural fix was taken, not the blur removal.(3) The reviewer re-parented the portalled backdrop back into its pre-fix mount site in real Chromium (reproducing the
backdrop-filtercontaining block exactly) and measured:dialog-viewport.ts:83-93compares exactly (top < 0,backdropBottom !== viewportHeight), rounded to 0.1 only for sub-pixel noise, and collects every violation into onetoEqual([])— the pre-fix geometry fails on two independent edges at each width, with no tolerance that could save it. Measurement is taken atscrollY === 0before any click-driven scrolling, and the tests are generated per viewport (panes.spec.ts:295-296: 1440 and 390), each arming the dialog viastop-button.Blockers
None. The single class-D blocker (STOP confirmation off-viewport, clipped into the header) is fixed, and its guard provably fails on the pre-fix geometry.
Concerns (non-blocking, all documentation/coverage)
portal.ts:9-10/dialog-viewport.ts:13-14attribute the clip to "the header'soverflow" —app-header.svelte:36has no overflow utility; the clip is the backdrop's ownoverflow-y-auto(confirm-dialog.svelte:105) on a child overflowing upward. Nothing in the fix or the assertion depends on it.panes.spec.ts:288-290) is not the observed mechanism — measuredscrollY = 0pre- and post-fix; the old suite was green becausetoBeVisible()only needs a non-empty box. The assertion's validity is unaffected.gates-configured.spec.ts:200-202still says the ratify dialog is "mounted in the pane"; post-fix it is portalled too. Its geometry test is coverage of a different mount site, not a false green — and the comment says so.stop.svelte.test.ts:45(closest('header')is null) is tautological in that harness (no<header>in the testing-library tree either way); the load-bearing sibling is:44.scrollYis collected but never asserted (dialog-viewport.ts:40,64) while both new test names claim "with nothing scrolled first". Oneexpect(g.scrollY).toBe(0)would make the name true.portal.ts:32targetsdocument.body, outside the app root; harmless (all tokens are:root-scoped, computed styles verified) but a future root-scoped wrapper would silently stop applying to dialogs.bodydirect children grow 3 → ~124 within seconds of load with no dialog opened (SSE-tick text growth); the portal's own contribution is exactly symmetric.Class-D visual spot-check —
609d25c→ clean (targeted re-check of the halted surface)Scratch copy (
git archive 609d25c), real Go binary built from the same tree, realnpm run build, port 8191 (unique). Nothing in the branch worktree was touched. Four viewport probes in real Chromium; the three prior landings' findings were all on the STOP dialog, so this pass targets exactly that plus its sibling.getBoundingClientRect)top 252.4 / bottom 647.6 / left 464 / right 976, backdrop0..900(= viewport),scrollY 0, title Stop the fleet? on screen, both buttons hit-testabletop -173.6, backdrop0..48top 365 / bottom 832 / left 12 / right 378, backdrop0..844,scrollY 0, title visibletop -351, backdrop0..128top 118.3 / bottom 832, backdrop0..844; reason textarea present,ratifydisabled until a reason is written (P-GATE-002)as e2e-operator · dev:e2e-operatorbefore and after; never "no identity resolved"The dimming is real at both widths — in the pre-fix captures the header strip was undimmed because the backdrop was the 48 px header. Screenshots:
/tmp/ld3-visual/{stop-1440,stop-390,ratify-390,identity-after-5s-1440}.png. Note the ratify probe hadscrollY 30(the click scrolled the pane); the dialog isposition: fixedand the geometry is viewport-relative, so this does not weaken the STOP numbers, which were taken atscrollY 0.No new visual finding. The blocker from the previous class-D pass is closed.
Review —
609d25cverdict: pass
Independent delta review of
694e8a7..609d25c(fresh context,pi -p --no-session --model deepseek/deepseek-flash, primary sources only: the diff, the post-diff files, the repo's own suite). Verdict posted after head609d25c55b0a8319dbe51b7ce98788001fe443f7; it supersedes the verdicts ataba6709and694e8a7.0 blockers. Diff is exactly 7 files / 245 insertions / 0 deletions — no pre-existing assertion was modified or weakened. No Go, CI workflow, manifest or header change.
Reviewer re-ran the suite itself: unit 145 passed / 20 files,
svelte-check0/0,playwright e2e/panes.spec.ts e2e/gates-configured.spec.ts22/22. It then reproduced the defect and the fix in real Chromium by re-parenting the portalled backdrop into its pre-fix mount site:appendChildrelocation,destroyremoves, no event leakbackdrop-blurtoken untouched, trigger still mounted in the headerportal.ts:32-37has no module-scopedocumentaccess (the default is evaluated per call inside the action), so no SSR guard is missing; the SvelteKit SSR build runs clean. Relocation isappendChild(:33), so node identity, listeners and bindings survive; teardown isnode.remove()(:35-37), pinned byportal.test.ts:34,36. No host wrapper, so no collision path. Svelte 5 delegates todocumentas well as the mount target for exactly this case (svelte/src/internal/client/render.js:235-243); Escape is awindowlistener (confirm-dialog.svelte:90-104). Measuredbody.childNodesover three open/cancel cycles plus Escape: 124 → 125 → 124.app-header.svelteis absent from the diff and still carriesbackdrop-blur(:36) with the trigger at:104.dialog-viewport.ts:83-93assertstop >= 0,bottom <= innerHeight,left >= 0,right <= innerWidth,backdropTop === 0,backdropBottom === innerHeight, collected into onetoEqual([]). Pre-fix the backdrop box was the header (48 px / 128 px), sobackdropBottom !== innerHeightandtop < 0both fire at each width — exact comparison, rounded only to 0.1 for sub-pixel noise, measured atscrollY 0before any click scrolling, with one generated test per viewport (panes.spec.ts:295-296).No blockers. 7 non-blocking concerns (all documentation/coverage) are recorded in the detailed comment above: the mis-attributed clip mechanism in the
portal.ts/dialog-viewport.tsprose, the "Playwright auto-scrolled the clipping ancestor" explanation (measuredscrollY 0; the old suite was green becausetoBeVisible()needs only a non-empty box), a now-stale comment ingates-configured.spec.ts, a tautological sibling assertion atstop.svelte.test.ts:45,scrollYcollected but not asserted, thedocument.bodytarget coupling, and an unrelatedbody-child-growth observation.