Skip to content

Web UI smoke check for the Darling pages and Web Admin (#5701, phase 1) - #5732

Merged
erikdarlingdata merged 15 commits into
devfrom
fix/5701-ui-smoke
Oct 13, 2026
Merged

erikdarlingdata merged 15 commits into
devfrom
fix/5701-ui-smoke

Conversation

@erikdarlingdata

@erikdarlingdata erikdarlingdata commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Claude posting for Erik Darling.

Fixes #5701 (phase 1 of 3; phases 2 and 3 get their own issues). Lane A: harness, seed, workflow. Lane B (PG seed and tabs, remaining pages, drift-guard test, nightly wiring, artifacts, README) is the last section.

Why

Nothing loads the Darling web pages in a browser in CI, so a broken panel, a failed /api/* read or a console error reaches dev unseen.

What the check does

tools/ui-smoke/run-ui-smoke.ps1 starts the Darling service (Release) on a throwaway PostgreSQL store, applies seed.sql, and runs smoke.mjs (playwright-core 1.64.0, lockfile, the runner's installed Chrome). Pages in pages.json: Fleet, ui-sql Overview, Wait Stats, CPU, Memory, I/O, Queries, and Web Admin's servers tab. Per page, any failure exits 1:

  1. no console error, page error, failed request or /api/* (or asset) answer >= 400; the allow-list starts empty and each entry needs an issue number
  2. #main fills; the sidebar lists ui-sql; the route's nav link (or the server) is active
  3. no .strip.error or [role=alert] in #main
  4. named panels and grids have the state and rows the seed guarantees (Top Queries >= 12 rows, Wait Stats 4, Admin servers exactly 1)
  5. panels the seed leaves empty must show their empty text (state: empty)
  6. waits are for state (no loading strip, network idle, two identical panel reads in a row), never fixed sleeps

A failing page keeps a screenshot; a page that fails is retried once, and a retry that passes is printed as a FLAKE line, not hidden.

Product change (the only one): data-panel (read name) and data-title on the panel cards (panelShell in server-tabs.js and renderPanel in panels.js, which most panels use), and data-grid on .grid-box (and on the bare table wrapper when a grid has no tools). check-web-js.sh: 68 files, 0 failures, 68 modules linked.

Spike (the 20-minute box, by reading the code)

  • servers: [] does NOT start: DarlingConfig.Validate returns "servers must contain at least one entry" (DarlingConfig.cs:542), fatal. Fallback used: the config registers one SQL Server, ui-sql at 127.0.0.1,1 (integrated auth), an address nothing listens on. The connect fails at once, the worker backs off quietly (a warning in the log), and the service never writes over the seeded rows. Alerts, analysis and MCP are off in the config, so a failed connect sends nothing. I did not disable the server in the store: collect.servers.is_enabled mirrors the config row and every Fleet read filters on it, so a disabled server would vanish from Fleet.
  • Seeded servers show: seed.sql writes the collect.servers row (storage name = host) and the collector rows; Fleet, the sidebar and the tabs show it (see the run below).
  • A loopback web host needs no sign-in (no web.network block), so the harness signs in to nothing and no token exists.

Run (the push run on this branch, windows-latest)

First run: green in 2 m 54 s, all 8 pages clean. Panels seen with data: Overview (Overview, Server Properties, CPU, Memory, File I/O, Daily Summary), Wait Stats (4 rows), CPU and Queries Top Queries by CPU (12 rows), Memory trend, I/O (File I/O Stats 2 rows, Database Sizes 3 rows), Query Duration and Executions per Second. Everything else shows its empty text.

Draft skip: the pull_request run on this draft PR concluded skipped. (gh workflow run ... --ref returns 404 until the workflow file is on the default branch, so the run above came from a TEMPORARY push: trigger for this branch; it is removed in the last commit.)

Red proof: a commit that blanked every grid (gridTable returning the empty strip) failed the job: "panel Wait Stats has 0 rows, wanted >= 4", "panel get_top_queries_by_cpu has 0 rows, wanted >= 12", "grid #0 not found (page has 0)", 5/8 pages clean. Reverted in the next commit; the run after the revert is the final green one.

Finding: a first-load 404 on Fleet

Fixed by lane B, see the lane-B section.

Expected minutes

About 3 minutes measured warm (pg-runtime cache hit, service build, store, 8 page loads); plan estimate 8 to 10 for the full ~40 pages, +3 on a cold pg-runtime cache. Cap 20.

Run it locally

See tools/ui-smoke/README.md (start a throwaway cluster by hand, then run-ui-smoke.ps1 -PgBin ... -PgPort ...).

Not done / for the coordinator

See the lane-B section: the guards lane A could not run were run by lane B.

Lane B: coverage, drift guard, nightly, guards

Pages: 32 (was 8; 34 with the part below), each with the six checks above. Fleet; ui-sql all 12 tabs (overview, waits, cpu, memory, blocking, io, queries, config, changes, activity, events, health); ui-pg all 8 tabs (overview, activity, vacuum, waits, io, replication, storage, config); Alert History, Alert Rules, FinOps, Fleet Sweeps, Custom Views, Mute Rules, Manage Tags, Job History; Admin servers, routes and settings. The sidebar must list both servers. #/ag and #/triage are covered in the "Icons, AG and triage" part below.

Seed coverage (tools/ui-smoke/seed.sql, header lists every count). ui-sql: the lane-A tables plus 2 blocked-process reports, 1 deadlock, 2 analysis findings, 1 alert-history row. ui-pg (registered in darling.ui-smoke.json as a PostgreSQL target at 127.0.0.1 port 1, unreachable like ui-sql): pg_database_stats 2 databases x 360, pg_wait_stats 3 events x 360, pg_statement_stats 6 queries x 36 with pg_statement_text 6, collection_log 3 collectors x 72. (There is no pg_top_queries table: the Top Query Shapes panel reads pg_statement_stats and pg_statement_text.) Row-count assertions: Blocking 2, Collectors 5, Top Query Shapes 6, By Database 2, Wait Events 3, Admin servers exactly 2, Alert History at least 1; the panels the seed leaves empty (Severe Errors, Trace Flag Changes, Sessions, Autovacuum Backlog, I/O Summary, Slots, By Table, Settings and others) must show their empty text.

The 404 (assertion 1). Every response of 400 or more, /api/* or not, is recorded with method and path in the problem text. The first load made one request that answered 404 and was retried green as a FLAKE. index.html had no icon link, so Chrome asked for /favicon.ico. Fix in the product: index.html now has a data: SVG icon link (the CSP already allows img-src data:, and app.css uses the same kind of URL), so the request is not made. The allow-list stays empty. Evidence: both lane-A runs flaked on Fleet; every run since has 0 flakes. I did not capture the 404's URL itself (the fix landed before a run printed it), so "favicon" is the cause by inference, not by a printed path.

Hidden-panel settle. ui-pg-overview first timed out: the Instance CPU panel answers "not collected" on a non-Aurora target and hides its card (hideWhenNotCollected, panels.js) but leaves its loading strip inside. The settle wait now ignores a loading strip inside a [hidden] card. No product change.

Drift guard. Darling.Tests/UiSmokeSeedAppliesLiveTests.cs: migrates a scratch store (ScratchPostgres, #1776 own-store, not the shared live-postgres store, so the seed's server rows cannot leak into other live classes), inserts the two config_monitored_servers rows the service would write from the config, applies seed.sql with the runtime's own psql (the seed uses \gset, which Npgsql cannot run; skips when DARLING_TEST_PGRUNTIME is unset, which CI's darling-pg job sets), and asserts 21 row-count literals plus 4 DarlingAgReader ones. Ran on a throwaway cluster on 55499: 1 passed, not skipped.

Nightly. nightly.yml job darling-ui-smoke (uses: ./.github/workflows/darling-ui-smoke.yml, needs: check, same if as darling-upgrade-shapes), not in publish's needs. The workflow gained workflow_call. Artifact darling-ui-smoke (service log, PostgreSQL log, report.json, failing pages' screenshots and <page>.trace.zip), 7 days. Each page records a Playwright trace and keeps it only when the page fails.

-StartPg removed from run-ui-smoke.ps1 (unproven; CI starts its own cluster). The README describes starting PostgreSQL by hand.

Runs on the branch (windows-latest, push trigger, since removed): 32/32 pages clean, 0 flakes, in about 3.5 minutes; one deliberate single-panel break (the Top Query Shapes rowsKey) failed exactly that page ("panel Top Query Shapes is empty, wanted data" and "has 0 rows, wanted >= 6"), 31/32; reverted, and the run after the revert is green.

Guards run locally (Darling.Tests Debug build, 0 warnings): -trait Stage=Guard 1584 passed, 0 failed; 70 classes (LockedModeRestoreCoverageTests, CrossAppGuardCiGateTests, ProductVersionDeclarationTests, CiClusterWorkerSizingTests, every class that reads index.html, nightly.yml or the ui-smoke files, every Census and Ratchet class, UiSmokeSeedAppliesLiveTests) 828 passed, 0 failed; check-web-js.sh 68 files, 0 failures, 68 modules linked. I changed no line of panels.js or server-tabs.js that stays (the break was reverted), so the classes that read only those files were not separately run. The full suite was not run; CI decides.

For the coordinator. The first-load fix is a user-visible product change (the tab icon). The PR still needs a non-draft run of the workflow once it is ready (a draft skips pull_request). The sign-in page and the two small pages the host builds by hand now carry the icon too (see the part below).

Icons, AG and triage

Icons on the host's own pages. DarlingWebHostService.cs builds three pages with their own <head>: the sign-in page (served when web.network is set), the signed-in landing page and the sign-in error page. Each now carries the same data: SVG icon link as index.html, from one shared constant (DarlingWebHostService.IconLinkHtml), so a signed-out browser no longer asks for /favicon.ico and gets a 404. The default policy already allows img-src data:, so no policy changed (the sign-in page's script and style hashes are unaffected: the link holds neither). WriteSignInErrorAsync went from private to internal so the test can render it. New WebPageIconLinkTests (Guard): every wwwroot/*.html head has an icon link; the sign-in page (plain and SSO), the landing page and the error page each carry the shared link; the shared link is a data: URL and the same href as index.html; a census that the host source holds exactly three <head literals, so a fourth page fails the test until it is added.

#/ag. seed.sql now writes collect.ag_replica_states (2 rows: group SalesAG, replicas UISQL01 primary and local, UISQL02 secondary) and collect.ag_database_replica_states (4 rows: SalesDb and OrdersDb, each on both replicas), newest minute, for ui-sql. These are the tables DarlingAgReader (the web /api/ag and get_ag_health) reads through DarlingAgStatesReader. pages.json page ag: nav ag active, heading "Availability Groups", grid #0 exactly 4 rows. UiSmokeSeedAppliesLiveTests now also asserts the two table counts and reads the seed back through DarlingAgReader.GetAvailabilityGroupCountAsync (1) and GetAgHealthAsync (one group SalesAG, 2 replicas, 4 databases).

#/triage. Page triage, hash #/triage?server=ui-sql&metric=High%20CPU (the route needs server and metric; at is left out so the anchor is now and the seeded alert, an hour old, is inside the 24-hour match window). The seeded alert's metric_name changed from cpu to High CPU, the name the authored/cpu notebook template is keyed on (nothing asserted the old name). Assertions: heading "Triage"; panel get_top_queries_by_cpu data with at least 10 rows; get_top_procedures_by_cpu and get_cpu_scheduler_pressure empty (the seed holds no procedure or scheduler rows).

Checked in a browser, not by the smoke script. I ran the Debug service on a throwaway cluster (port 55505), seeded it, and looked at the two pages with playwright-cli (4 page-level commands, no screenshots). #/ag: heading, nav ag active, one card, 2 replica chips, one grid of 4 rows, 0 error strips, 0 loading strips. #/triage: the alert notebook renders (header, status, two chart cells, three read cells); panels get_top_queries_by_cpu 10 rows with a notice strip, the other two strip empty, none hidden. The smoke script itself was not run locally, as asked. This PR's own pull_request run is the proof for all 34 pages.

Local results (Debug build, 0 warnings, throwaway cluster on 55505, stopped afterwards): UiSmokeSeedAppliesLiveTests and WebPageIconLinkTests 8 passed, 0 skipped; 49 classes that read DarlingWebHostService.cs, index.html or the ui-smoke files plus every Census and Ratchet class: 2157 passed, 0 failed; -trait Stage=Guard: 1591 passed, 0 failed; check-web-js.sh: 68 modules linked, 0 failures. One class (DarlingStoreLoginsTests) failed once because my edit had left DarlingWebHostService.cs with LF endings (the repo's eol=crlf; the test matches CRLF text); fixed by restoring CRLF and rerun green. No push: trigger was added.

Review round (lane D, head ab4ceb4)

Fixes for the automated review (id 5498827903):

  • Worker sizing (1023201). darling-ui-smoke.yml is now in CiClusterWorkerSizingTests.ClusterWorkflows() and is copied beside the test binary by Darling.Tests.csproj, so ClusterWorkflow_SizesWorkersFromTheProductsFormula checks its 74 / 85 against TimescaleSupport.HypertableCount. That test, not the Guard run, is what pins the workflow's cluster. Revert check: with max_background_workers = 73 in the new workflow the class fails (ClusterWorkflow_SizesWorkersFromTheProductsFormula(workflowFileName: "darling-ui-smoke.yml") [FAIL], 5 run, 1 failed); restored, it passes.
  • Path filter (1023201). .github/darling-paths-filter.yml darling filter gains tools/ui-smoke/**/!(*.md) (the extglob form the file requires) and .github/workflows/darling-ui-smoke.yml (Darling.Tests now parses it), each with a comment. A new test, DarlingPathFilterGateTests.TheDarlingGate_CoversTheUiSmokeWorkflowAndHarness, pins both entries, the same way the upgrade-shape entries are pinned.
  • Registration wait (ab4ceb4). run-ui-smoke.ps1: its own 120 s deadline, a do { } while so it probes at least once, and the HasExited check carried in. The ping wait keeps its 90 s.
  • Settle filter (ab4ceb4). smoke.mjs: both copies of the loading-strip predicate now treat a card with style.display === "none" as hidden (the readPanels test), as well as any [hidden] ancestor, so a hide path that sets only display:none cannot hang settle. I kept the [hidden] ancestor test as well as the card test, so a hidden non-card container still counts as hidden.
  • panel: keys (ab4ceb4). One sentence in pages.json _comment and in the README: a panel key is unambiguous only for a read that feeds one card; key a fanout panel by title.

Local results (Debug build, 0 warnings): CiClusterWorkerSizingTests, DarlingPathFilterGateTests, CommentFilterAdoptionTests 20 passed; those three plus every Census and Ratchet class 581 passed, 0 failed, 2 skipped (the live-store classes, no cluster running); -trait Stage=Guard 1591 passed, 0 failed. node --check tools/ui-smoke/smoke.mjs clean. No wwwroot change, so check-web-js.sh was not rerun. The smoke check itself was not run locally and no push: trigger was added; this PR's own run covers it.

SECTION: Fixed
ENTRY:

@erikdarlingdata
erikdarlingdata marked this pull request as ready for review October 13, 2026 04:06
Comment on lines +95 to +96
Add-Content -Path "$dataDir\postgresql.conf" -Value "timescaledb.max_background_workers = 74"
Add-Content -Path "$dataDir\postgresql.conf" -Value "max_worker_processes = 85"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A third hand-written copy of the #1888 cluster sizing, and nothing pins this one.

CiClusterWorkerSizingTests.ClusterWorkflows() is a roster of exactly two files:

public static TheoryData<string> ClusterWorkflows() => new() { "build.yml", "nightly.yml" };

and Darling.Tests.csproj:122-123 copies exactly those two beside the test binary. This workflow is now a third copy of the same Add-Content -Path "$dataDir\postgresql.conf" block with 74 / 85 baked in, and the roster does not see it.

Failure scenario: a collector is added, so TimescaleSupport.HypertableCount goes to 73. ClusterWorkflow_SizesWorkersFromTheProductsFormula reds and names build.yml and nightly.yml; both get bumped to 75/86. This file keeps 74/85 forever, and the UI-smoke store runs one background worker short of what every hypertable policy needs — the "failed to start a background worker" shape #1888 exists to keep out of CI, now with no test that can report it. That is the test's own stated failure mode: "Both, not just build.yml: the PR leg and the nightly leg are separate copies of the same steps, and a fix applied to one of them is the ordinary way these two drift apart."

The comment above at line 82-83 ("worker numbers pinned by CiClusterWorkerSizingTests there; copied here exactly") is accurate about build.yml and is exactly why the copy here needs the same treatment.

Fix is two lines: add "darling-ui-smoke.yml" to ClusterWorkflows() and a <None Include="..\..\.github\workflows\darling-ui-smoke.yml" Link="Fixtures\darling-ui-smoke.yml" .../> to the csproj. The Add-Content regex already matches this file's spelling verbatim, so no parser change is needed. Note that doing so also makes .github/workflows/darling-ui-smoke.yml a file Darling.Tests parses, which is the stated reason nightly.yml and darling-upgrade-shapes.yml are entries in .github/darling-paths-filter.yml — it would need one too.

Comment on lines +103 to +104
Assert.Equal(360, await CountAsync("collect.cpu_utilization_stats", sql));
Assert.Equal(360, await CountAsync("collect.memory_stats", sql));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard cannot run on an edit to the file it guards: tools/ui-smoke/** is not in the Darling path gate.

These 21 literals are the counts in tools/ui-smoke/seed.sql, and the class only runs when DARLING_TEST_PG and DARLING_TEST_PGRUNTIME are both set. The only job that sets them is build.yml's darling-pg job (build.yml:1936), which is gated on steps.filter.outputs.darling — i.e. on .github/darling-paths-filter.yml's darling filter. That filter's entries are Darling/**, the seven shared libraries, build.yml, the filter file, nightly.yml, the two shard scripts and the two darling-upgrade-shapes files. tools/ui-smoke/seed.sql matches none of them.

Failure scenario, and it is the one the README prescribes: a contributor follows "A table: one more section in seed.sql … add the new table's count there too" and touches only tools/ui-smoke/seed.sql (plus pages.json). The darling filter is false, the darling-pg job is skipped, this test never runs, and the new/changed count literal is never checked. The UI-smoke workflow does fire on that path and does apply the seed — but it asserts page expectations, not these counts, so a count literal left stale here reports clean and then surfaces as an unrelated red on the next Darling PR that happens to flip the filter.

The filter file already states this exact requirement for its own entries, twice — "nightly.yml is not a build input, but Darling.Tests PARSES it … so an edit to it has to reach the suite" and the same sentence for darling-upgrade-shapes.yml. Adding

  - 'tools/ui-smoke/**/!(*.md)'

to the darling filter (extglob form, for the dorny v4 quantifier reason the file documents) closes it. The guard's reverse direction — a Darling schema change breaking the seed — is already covered, since that edit matches Darling/**.

Comment thread tools/ui-smoke/run-ui-smoke.ps1 Outdated
Comment on lines +79 to +85
$registered = $false
while ((Get-Date) -lt $deadline) {
$n = & $psql -h 127.0.0.1 -p $PgPort -U darling -d darling -X -q -t -A -c "SELECT count(*) FROM config.config_monitored_servers WHERE name = 'ui-sql'" 2>$null
if ($n -eq '1') { $registered = $true; break }
Start-Sleep -Seconds 1
}
if (-not $registered) { throw "the service did not register ui-sql in the store in time" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The registration wait has no budget of its own — it inherits whatever the ping wait left of the 90 s, which can be nothing.

$deadline is computed once at line 71 and both loops test against it. The second loop's while ((Get-Date) -lt $deadline) is evaluated before its first probe, so if /api/ping answered late the registration check can run zero times and throw "the service did not register ui-sql in the store in time" without ever having asked.

The two waits are for different things and the second is the slower one: /api/ping answers as soon as the web host is up, while config.config_monitored_servers is written after PgMigrations.MigrateAsync has walked the whole ladder against an empty store. A cold runner that spends 70 s on the host coming up leaves 20 s for ~170 migration rungs plus the config seed, and the failure it reports blames registration for a slow start. Even in the ordinary case the budget is whatever is left over rather than a stated one.

Give the second wait its own deadline and make it probe at least once:

Suggested change
$registered = $false
while ((Get-Date) -lt $deadline) {
$n = & $psql -h 127.0.0.1 -p $PgPort -U darling -d darling -X -q -t -A -c "SELECT count(*) FROM config.config_monitored_servers WHERE name = 'ui-sql'" 2>$null
if ($n -eq '1') { $registered = $true; break }
Start-Sleep -Seconds 1
}
if (-not $registered) { throw "the service did not register ui-sql in the store in time" }
$registered = $false
$regDeadline = (Get-Date).AddSeconds(120)
do {
if ($serviceProc.HasExited) { throw "the service exited early (code $($serviceProc.ExitCode)); see service.out.log" }
$n = & $psql -h 127.0.0.1 -p $PgPort -U darling -d darling -X -q -t -A -c "SELECT count(*) FROM config.config_monitored_servers WHERE name = 'ui-sql'" 2>$null
if ($n -eq '1') { $registered = $true; break }
Start-Sleep -Seconds 1
} while ((Get-Date) -lt $regDeadline)
if (-not $registered) { throw "the service did not register ui-sql in the store in 120 s (migrations still running? see service.out.log)" }

The HasExited check is worth carrying into this loop too: the ping loop has it, and a service that dies during the migrations is exactly what this wait sits through today, spending the full remaining budget before reporting the wrong cause.

const body = el("div", { class: "panel-body" }, [loadingStrip()]);
const panel = el("div", { class: "panel card" + (span === 2 ? " span-2" : "") }, [
/* #5701: `data-panel` (the read) and `data-title` are the hooks the web UI smoke check finds a panel by. */
const panel = el("div", { class: "panel card" + (span === 2 ? " span-2" : ""), "data-panel": read || title, "data-title": title }, [

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: data-panel is not unique on a fanout tab. fanout hands the same read to every shell —

const shells = specs.map((s) => panelShell(s.title, s.subtitle, s.span ?? 2, read, s.dbScope));

— so e.g. the Blocking tab draws two cards both carrying data-panel="get_current_waits_trend" (Waiting Tasks, Blocked Sessions) and two more both carrying get_blocking_stats. smoke.mjs's lookup is panels.find((p) => want.panel ? p.panel === want.panel : p.title === want.title), which takes the first match, so a panel:-keyed expectation on a fanout read silently grades whichever card the tab happens to build first.

Nothing in pages.json hits this today (the three panel: entries — get_top_queries_by_cpu, get_top_procedures_by_cpu, get_cpu_scheduler_pressure — are single-panel reads), so this is a latent sharp edge rather than a defect in the current run. The README's advice to "take the panel names from a run's log" will walk someone into it. Worth a sentence in pages.json's _comment saying panel: is only unambiguous for a read that feeds one card, and to key a fanout panel by title.

Comment thread tools/ui-smoke/smoke.mjs Outdated
const main = document.querySelector("#main");
/* A panel that answered "not collected" and asked to be hidden (Instance CPU on a PostgreSQL server that is not Aurora,
panels.js hideWhenNotCollected) keeps its loading strip inside a hidden card: not a panel still loading. */
const loading = [...(main ? main.querySelectorAll(".strip.loading") : [])].filter((e) => !e.closest("[hidden]"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, and this one checks out — but the reason it checks out is narrower than the comment above says, so it is worth writing down.

There are two hide paths for a panel card and they do different things:

  • panels.js:146-149 (hideWhenNotCollected) sets both style.display = "none" and hidden = true, and returns without mounting, so the loading strip stays. That is the case this filter exists for, and closest("[hidden]") sees it because the hidden property reflects to the attribute.
  • server-tabs.js:199 (hidesPanel, for hideWhenNoRows) sets only style.display = "none" — no hidden attribute — but it also calls mount(shell.body, []), which clears the strip. So this filter never has to see it.

So the [hidden]-only predicate is correct today purely because the one path that orphans a strip is also the one that sets the attribute. A third hide path that sets display:none and leaves the body alone would hang settle() for the full SETTLE_MS and report "did not settle" on a page that is in fact fine. Matching what readPanels already does for the same question (card.style.display === "none" || card.hidden) would make it independent of that coincidence:

      const loading = [...(main ? main.querySelectorAll(".strip.loading") : [])].filter((e) => {
        const card = e.closest(".panel.card");
        return !(card && (card.hidden || card.style.display === "none"));
      });

Line 84's copy of the same predicate would want the same treatment.

claude[bot]
claude Bot previously requested changes Oct 13, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: CHANGES REQUESTED

The product diff is genuinely additive — I read every gridTable branch and every hide path that could orphan a loading strip. The two defects are in the CI wiring: a third hand-written copy of the #1888 cluster sizing that no roster sees, and a drift guard that cannot run on edits to the file it guards. See the inline comments.

Claims

  • VERIFIED — servers: [] is refused with that message — DarlingConfig.cs:547 (body cites :542)
  • VERIFIED — the DOM change is data-panel/data-title on panel cards and data-grid on .grid-box plus the bare wrapper when a grid has no tools — panels.js:96, :293-301; server-tabs.js:149; all four gridTable returns keep their prior node shape
  • VERIFIED — pages.json carries 34 pages — counted the "name": entries
  • VERIFIED — the CSP already allows img-src data: — DarlingWebSecurityHeaders.cs:66, precedent at :39
  • VERIFIED — IconLinkHtml's href is byte-identical to index.html's — the extracted data: URL hashes the same out of DarlingWebHostService.cs:2344 and index.html:14
  • VERIFIED — the host source holds exactly three <head literals, so the census is not vacuous — :2326, :2371, :2434
  • VERIFIED — the sign-in page's hashes are unaffected — IconLinkHtml holds no script or style block
  • VERIFIED — hideWhenNotCollected leaves a strip inside a hidden card and the settle wait skips it — panels.js:146-149; smoke.mjs:73, :84
  • VERIFIED — #/ag grid #0 is the 4-row database grid — ag.js:170-171 chips, :223-234 the one VIZ.table
  • VERIFIED — the authored/cpu read cells are exactly the three reads named, and carry data-panel — AlertNotebookTemplateCpuTests.cs:75-87; views.js:813
  • VERIFIED — the seed needs psql for \gset — both \gset blocks in seed.sql
  • VERIFIED — only the darling-pg job sets DARLING_TEST_PGRUNTIME — build.yml:1936
  • VERIFIED — the nightly job is gated like darling-upgrade-shapes and absent from publish's needs — nightly.yml:155-159 vs :1516
  • VERIFIED — the air-gap scan still passes on the new index.html line — the only http literal is the allowlisted SVG namespace and ExtractUrl stops at the '
  • VERIFIED — the artifact collects logs, out/* and the PG log for 7 days — darling-ui-smoke.yml:125-135
  • VERIFIED — the CRLF restoration landed — file(1) reports CRLF on every changed and added file
  • UNVERIFIED — the green runs (8/8, then 32/32 with 0 flakes) — live windows-latest runs
  • UNVERIFIED — the two red proofs — those commits are not in this squashed tree
  • UNVERIFIED — the live seed guard passing on 55499 / 55505 — needs PostgreSQL and a pg-runtime
  • UNVERIFIED — the local guard and check-web-js.sh counts — local runs
  • UNVERIFIED — that the draft run concluded skipped — the push: trigger's absence I confirmed (:20-29); the conclusion needs Actions history
  • UNVERIFIED — that the 404 was /favicon.ico — the body labels this inference, not a printed path
  • UNVERIFIED — #/ag and #/triage checked by hand with playwright-cli — a live session
  • UNVERIFIED — the timing estimates — measurements
  • REFUTED — "asserts 18 row counts" — UiSmokeSeedAppliesLiveTests.cs:103-134 carries 21 count assertions plus four DarlingAgReader ones; stronger than advertised, so no code consequence, but the figure is cited as its coverage
  • REFUTED — the guard run as evidence for this change ("every class that reads … darling-ui-smoke", CiClusterWorkerSizingTests green) — no class reads darling-ui-smoke (a grep of both test projects returns only the new seed guard, which reads seed.sql), and that test's roster names build.yml and nightly.yml only

Riskiest lines

  1. .github/workflows/darling-ui-smoke.yml:95-96 — the 74/85 pair, a third copy of a derivation whose siblings are pinned to TimescaleSupport.HypertableCount — UNPINNED (roster is two files; Darling.Tests.csproj:122-123 copies two)
  2. Darling/Darling.Tests/UiSmokeSeedAppliesLiveTests.cs:103-134 — 21 count literals mirroring seed.sql — pinned by its own test, but unreachable from an edit to seed.sql, the edit the README prescribes
  3. wwwroot/js/panels.js:296-301 — gridTable's return split from two branches to three — UNPINNED (no JS test; verified by reading that nothing in wwwroot reads these attributes and the hook never nests)

Traps near this diff

  • "A roster-based census does not see the file it does not roster (#3699)." — applies twice: ClusterWorkflows() vs darling-ui-smoke.yml:95-96, and the darling path filter vs the new tools/ui-smoke/**
  • "Two copies pinned EQUAL to each other fail only after BOTH have drifted (#3684)." — applies, worse: the new pair is pinned to nothing
  • "dorny/paths-filter v4 evaluates each pattern as an INDEPENDENT predicate." — does not apply: darling-ui-smoke.yml:23-26 is native paths with no negation
  • "A bare $(gh api …) under set -euo pipefail kills the step (#2309)." — does not apply: all pwsh, no gh, $LASTEXITCODE checked at :97, :106, :108
  • "gh api --paginate --jq filters once PER PAGE." — does not apply: no gh api
  • "A summary helper reading only the first positional arg truncates a wrapped echo." — does not apply: the tail at :115-123 is pwsh
  • "claude-code-action refuses when claude-review.yml differs from the default branch (#2229)." — does not apply: not touched
  • "Lite and Darling ship in parity." — does not apply: Lite/ has no wwwroot and no *.html
  • "eol=crlf: a round-trip converts the WHOLE file's endings." — does not apply: small hunks, all files CRLF
  • "A test that reads the clock twice measures the CI scheduler (#3676)." — does not apply to the seed: now() in six statements inside one BEGIN/COMMIT is one anchor. It is the shape of the run-ui-smoke.ps1 comment, where one $deadline serves two waits

Divergence (blind read vs PR body)

The guard run is offered as evidence this change is pinned, and for the new CI it cannot be. No test reads darling-ui-smoke.yml, and CiClusterWorkerSizingTests is green here precisely because its roster stops at two files — the same run would stay green had the new workflow written max_worker_processes = 8. The same gap reaches the seed guard the body leans on: it does not fire on a seed-only edit, since tools/ui-smoke/** is in neither the Darling path gate nor any roster.

Lane A's figures are superseded by lane B and not retracted inline: 8 pages, "Admin servers exactly 1", "Product change (the only one)" vs a tree with 34 pages, exactRows: 2, and a product diff that also carries the icon link and a private-to-internal widening. Lane B states all three correctly, so this is a stale section rather than a wrong account — worth collapsing.

Otherwise the body matches the diff, including where it marks a conclusion as inference.

Reviewer checklist

  • Mark ready and confirm the pull_request leg reports: the green runs came from a temporary push: trigger now removed, and a draft is skipped by the job's own if, so this workflow has never run under the trigger it ships with
  • Watch triage: two authored/cpu cells are composed panel cells (including PostgreSQL CPU against a SQL Server target) rendered through compose.js, which carries no data-panel — invisible to assertions 4 and 5, but able to trip assertion 3 with a .strip.error if /api/compose/run refuses that source
  • Confirm collect.servers.server_name for ui-pg: the seed writes the host, but ServerIdHelper.BuildStorageName yields 127.0.0.1:pg:1 for a PG target with a port. Every read I traced scopes by server_id, so likely cosmetic — but the guard cannot catch it, since it mimics the registration rather than letting the service perform it
  • Time /api/ping answering vs ui-sql appearing in config_monitored_servers on a cold store, against the shared 90 s budget — the migration ladder runs between those two events
  • Verify Chrome is on the windows-latest image: smoke.mjs launches the chrome channel under npm ci --ignore-scripts, so nothing installs a browser if it moves

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: LGTM

Claims

  • VERIFIED — "darling-ui-smoke.yml is now in CiClusterWorkerSizingTests.ClusterWorkflows() and is copied beside the test binary by Darling.Tests.csproj, so ClusterWorkflow_SizesWorkersFromTheProductsFormula checks its 74 / 85 against TimescaleSupport.HypertableCount" — confirmed in the diff (CiClusterWorkerSizingTests.cs:54, Darling.Tests.csproj) and the values satisfy the formula (HypertableCount+2=74 ⇒ HypertableCount=72; 3+74+8=85).
  • VERIFIED — ".github/darling-paths-filter.yml darling filter gains tools/ui-smoke/**/!(*.md) (the extglob form the file requires) and .github/workflows/darling-ui-smoke.yml" — matches the diff; the !(*.md) carve-out sits inside the include, the form DarlingPathFilterGateTests.cs's own doc comment (lines ~36-40) requires, not the bare-!**/*.md shape the traps file flags.
  • VERIFIED — "A new test, DarlingPathFilterGateTests.TheDarlingGate_CoversTheUiSmokeWorkflowAndHarness, pins both entries" — present in the diff, asserting both the workflow path and the tools/ui-smoke/**/!(*.md) pattern.
  • VERIFIED — "run-ui-smoke.ps1: its own 120 s deadline, a do { } while so it probes at least once, and the HasExited check carried in. The ping wait keeps its 90 s." — confirmed; $deadline (90s, ping) and $regDeadline (120s, registration) are now distinct and each used exactly once.
  • VERIFIED — "smoke.mjs: both copies of the loading-strip predicate now treat a card with style.display === "none" as hidden... as well as any [hidden] ancestor" — confirmed in both the waitForFunction and polling-loop copies; traced the real need to server-tabs.js's fanout/hidesPanel (shell.panel.style.display = "none" with no hidden attribute for hideWhenNoRows cards), which the old [hidden]-only filter could not see.
  • VERIFIED — "One sentence in pages.json _comment and in the README: a panel key is unambiguous only for a read that feeds one card; key a fanout panel by title" — matches the diff, and cross-checked against pages.json's actual entries: every multi-card fanout read already keys by title, so this is documentation catching up to existing-correct usage, not a fix to a live bug.
  • UNVERIFIED — the "Revert check" (injecting max_background_workers = 73 fails the test) and all "Local results" test-run counts (1591 passed, etc.) and node --check — these are live run outputs that a read-only tree can't reproduce.

Riskiest lines

  1. tools/ui-smoke/run-ui-smoke.ps1:84-91 — the new registration wait's own deadline and early-exit check — pinned by none directly (no unit test exercises this script; it's only exercised by the darling-ui-smoke.yml CI run itself), but the logic was traced by hand and is sound (do-while probes once before the clock check, $deadline/$regDeadline no longer alias).
  2. tools/ui-smoke/smoke.mjs:73-76,87-90 — the two loading-strip filters must stay in lockstep with each other and with readPanels's hidden = card.style.display === "none" || card.hidden definition — UNPINNED by any unit test (smoke.mjs has none), but hand-verified both copies now match and .panel.card/[data-panel] are confirmed to be the same element via panelShell/renderPanel.
  3. .github/darling-paths-filter.yml:58-63 — new gate entries — pinned by DarlingPathFilterGateTests.TheDarlingGate_CoversTheUiSmokeWorkflowAndHarness.

Traps near this diff

  • "dorny/paths-filter v4 evaluates each pattern as an INDEPENDENT predicate under predicate-quantifier: some: a bare !**/*.md line is not a subtraction... Keep the carve-out INSIDE the include: dir/**/!(*.md)." — does not apply; the new line is tools/ui-smoke/**/!(*.md), the carve-out form, at .github/darling-paths-filter.yml:61, not a standalone exclude.
  • "Lite and Darling ship in parity... a fix to one SKU's reader, tool, alert or description without the counterpart's is a finding." — does not apply; this diff is Darling-only CI/test/harness plumbing (worker sizing, path gate, ui-smoke harness) with no Lite-side analog to keep in parity.

Divergence (blind read vs PR body)

None — the body's "Review round (lane D, head ab4ceb4)" section's description of both commits (worker sizing, path filter, registration wait, settle filter, panel: key docs) matches what the diff actually does, including the detail that the [hidden]-ancestor check was kept alongside the new .panel.card check rather than replaced by it.

Reviewer checklist

  • Let the darling-ui-smoke.yml pull_request run execute once for real (a draft PR skips it) and confirm the registration wait's new 120 s budget and the HasExited early-exit actually fire as intended on a live run, not just by code trace.

@erikdarlingdata
erikdarlingdata dismissed claude[bot]’s stale review October 13, 2026 05:00

Claude posting for Erik Darling. Addressed in 1023201 and ab4ceb4: the UI smoke workflow is in the worker-sizing check's roster, the Darling path filter covers tools/ui-smoke, the registration wait has its own deadline, and the settle filter matches readPanels.

@erikdarlingdata
erikdarlingdata merged commit 5805c0e into dev Oct 13, 2026
27 checks passed
@erikdarlingdata
erikdarlingdata deleted the fix/5701-ui-smoke branch October 13, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant