Skip to content

Darling Web: a browser viewer served by the service (#1562) - #1574

Merged
erikdarlingdata merged 10 commits into
devfrom
feature/1562-darling-web
Jul 18, 2026
Merged

erikdarlingdata merged 10 commits into
devfrom
feature/1562-darling-web

Conversation

@erikdarlingdata

Copy link
Copy Markdown
Owner

Darling gains an optional, read-only web dashboard so an operator can watch the fleet from any browser — a remote Mac included — without installing the WPF viewer. This is phase 1 (Erik-approved "yes to the web with defaults"): a fleet page, per-server drill-down, and fleet-wide alert history, all served by the Darling service. Built as a sequential three-builder train on this branch.

Closes #1562.

What ships

Hosting (Builder 1)

  • A second, independent Kestrel host (DarlingWebHostService : BackgroundService) on its own port (default 5153), deliberately separate from the MCP host: MCP owns root on 5152, and the two need independent kill switches (web must not require mcp_enabled) and gate different blast radii (the MCP token guards analyze_server's live outbound SQL connections; the web surface is read-only over the store).
  • Live toggle with no service restart: a web_enabled / web_port control-plane pair (the twin of MCP's), added by store migration V30. The viewer's Settings writes it, the worker publishes it via WebRuntimeState, and the supervisor (5s poll, DecideWebAction table, 30s failed-start backoff) starts / stops / rebinds the host within seconds. Until the first publish, the darling.json file values seed it.
  • Shared bind/auth helpers extracted to Hosting/DarlingHostBinding (the ResolveBind reason ladder, in-app CIDR check, loopback-listener guard, constant-time token compare, firewall reconcile); the MCP host was refactored onto it and its existing tests stayed green (the anti-drift proof).
  • Network exposure (managed mode): loopback is tokenless (Erik-ratified — the surface is read-only), LAN exposure rides a ?token= -> HMAC-signed HttpOnly SameSite=Strict session-cookie handshake behind an in-app CIDR gate, out-of-CIDR is 403, fail-closed. Content/web root pinned to AppContext.BaseDirectory (the Windows-service-CWD-is-System32 static-file footgun). No new packages.

Data / API (Builder 2)

  • GET /api/read/{tool} — 69 endpoints, 1:1 with the read-only MCP tool catalog, calling the SAME public static tool methods the MCP server exposes so there is zero SQL / projection drift. An explicit dispatch table binds query-string params to each tool; a reflection test pins the endpoint set == the catalog MINUS exactly the six documented exclusions (analyze_server, mute_analysis_finding, and the analyze_*_plan compute family).
  • Response mapping: a leading {/[ passes through as application/json (200); an "Error during ..." string maps to 500; any other bare string (validation / resolution) maps to 400 — both wrapped as {"error": ...}. The {status, message[, hints]} empty envelope passes through.
  • GET /api/fleet — a pre-banded per-server card set + cross-server rollup from a new service-side DarlingFleetReader that runs a bounded set of cross-server aggregates (a fixed ~9 round-trips regardless of fleet size, not the WPF viewer's N-servers x per-metric fan-out), plus a matching get_fleet_overview MCP tool on the same reader.
  • Banding unified ONCE in PerformanceMonitor.Common (ServerHealthClassifier + ServerHealthThresholds): collection freshness, the six per-metric bands, and the fleet band / worst-first score. The WPF viewer's ServerSummaryItem / FleetRollup now delegate to it, so thresholds that used to live twice can no longer drift (the viewer keeps its own brushes).

Frontend (Builder 3)

  • Vanilla ES modules, no framework, no build step, and zero off-origin references (air-gapped). Theme tokens are the exact values lifted from the WPF DarkTheme.xaml (dark-only in phase 1).
  • Fleet Overview (/api/fleet): a rollup summary, a worst-first "Needs attention" list (or an all-healthy line), and a card per server with a status dot, six per-metric health bands, and its last-collection time — the amber "Awaiting first collection" state rendered EXACTLY as the API reports it (the browser only renders; it never re-computes a threshold).
  • Per-server drill-down: Overview stats, Wait Stats + a trend for the heaviest wait, Active Queries, a 4-series CPU chart, Memory and File-I/O trends, and Collection Health — all charted with dependency-free inline SVG (multi-series, CSS-var theming, mousemove tooltip).
  • Alert History: fleet-wide, with a client-side server filter box.
  • A hash router (#/fleet, #/server/{name}, #/alerts), a sidebar server list with band dots, and a 60s refresh loop that pauses while the tab is hidden.
  • Discipline: all untrusted text (query text, object / wait / server names) renders through textContent (never innerHTML), and every naive-UTC timestamp is localized in the browser. Everything renders through a renderPanel({read, params, viz, span}) seam (viz registry: table / line / stat / bandlist; pages are hardcoded descriptor arrays) so Darling: custom views/notebooks (Datadog-style composition) on the web surface #1563 can later drive the pages from stored JSON — none of Darling: custom views/notebooks (Datadog-style composition) on the web surface #1563 is built here.

Migration

  • V30 — config_service.web_enabled (default FALSE) + web_port (default 5153). Additive, schema-qualified config.* ALTERs with IF NOT EXISTS; the self-bump trigger covers the new columns and the table's grants are not column-carved, so no grant change is needed. (V29, taken by the long-query-completions collector after the dev rebase, is now documented alongside it in the store-schema table.)

Testing

  • Full dotnet test Darling/Darling.Tests/Darling.Tests.csproj -c Release (no filter): 2375 passed, 0 failed, 137 skipped (the gated live-Postgres suite). The service builds clean in Release (0 errors).
  • New frontend tests: an air-gap self-containment scan that walks wwwroot and fails CI, naming the file + line, on any absolute http(s):// or a protocol-relative //host in a fetchable context (the SVG XML namespace is the lone allowlisted literal), plus a pure line-classifier theory; the assets test was extended to pin the CSS/JS entry files in the build output. All 7 JS modules also pass node --check.

Not in phase 1 (by design)

Zero write paths and zero live-monitored-server hits (analyze_server and actual-plan re-run are explicitly out). The WPF viewer's Plan Viewer / FinOps / Recommendations / Index / deep Blocking / System Events / Config tabs are deferred. No TLS on this surface (the same reverse-proxy story as MCP). The live render is validated by the orchestrator on DARLING01; no browser was opened here.

🤖 Generated with Claude Code

erikdarlingdata and others added 9 commits July 18, 2026 10:57
…), shared bind/auth helpers (#1562)

Section 2 (HOSTING SURFACE) of the Darling Web plan. Adds a SECOND, independent
Kestrel host (DarlingWebHostService) serving a read-only browser dashboard on its
own port (default 5153), with its own kill switch, LAN-exposure block, firewall
rule, and least-privilege VIEWER-role store pool — separate from the MCP host.

- Hosting/DarlingHostBinding.cs: shared surface-agnostic helpers (ResolveBind
  ladder, IsRemoteAddressAllowed, ShouldAddLoopbackListeners, bind enums +
  severity map, constant-time token compare, firewall reconcile). Refactored the
  MCP host onto it; its existing tests stay green unmodified (the refactor proof).
- Mcp/WebRuntimeState.cs + Mcp/DarlingWebHostService.cs: supervisor mirroring the
  MCP host (5s poll, DecideWebAction table, 30s backoff, StartAsync). Browser auth
  in network mode: loopback tokenless (ratified), in-CIDR ?token= -> HMAC session
  cookie + 302 strip, out-of-CIDR 403, inline login page. Content/web root pinned
  to AppContext.BaseDirectory (the Windows-service CWD footgun).
- DarlingWebEndpoints.cs + wwwroot/index.html: seam stubs (Builders 2/3 own them).
- WebConfig/WebNetworkConfig + darling.sample.json web section.
- V29 migration (config_service += web_enabled/web_port); StoreConfigProvider,
  worker publish, Program.cs registration, viewer Settings toggle + schema gate.
- Tests: DecideWebAction, WebRuntimeState, cadences, shared ResolveBind ladder +
  enum-parity pin, route auth matrix, cookie sign/verify/tamper/expiry, wwwroot
  copy assert. Rippled the schema-gate, migrations-count, and service-config SQL pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ified banding (#1562)

Builder 2 of 3. Adds the read-only HTTP API surface behind the seam DarlingWebEndpoints.MapAll:
- GET /api/read/{tool_name} 1:1 with the read-only MCP tool catalog (68 endpoints), calling the
  public static tool methods directly (zero SQL/projection drift). Explicit dispatch table; a
  reflection test pins the endpoint set == catalog minus the 6 documented exclusions
  (analyze_server, mute_analysis_finding, the analyze_*_plan compute family).
- Response mapping: leading {/[ passes through as application/json (200); "Error during ..." -> 500;
  other bare strings (validation/resolution) -> 400.
- GET /api/fleet: pre-banded per-server cards + cross-server rollup from a new service-side reader
  (DarlingFleetReader) that runs bounded cross-server aggregates (GROUP BY / DISTINCT ON per server)
  instead of N-servers x 7 fan-out; naive-UTC instants.
- New MCP tool get_fleet_overview on the same reader (one reader -> MCP, web, WPF).
- Unifies the per-server health banding ONCE in PerformanceMonitor.Common (ServerHealthClassifier +
  ServerHealthThresholds): freshness + the six per-metric bands + fleet band/score. The WPF viewer's
  ServerSummaryItem/FleetRollup now delegate to it (thresholds live in one place; WPF keeps brushes).
- Tests: endpoint<->catalog parity pin, response-kind + query-parse units, banding decision table,
  new-reader SQL dialect pins, DTO JSON-shape pins, fleet tool surface pin, and a gated
  (DARLING_TEST_PG) own-scoped live round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebase onto dev brought #1572's new read tool get_long_query_completions; the endpoint<->catalog
parity test caught it as unwired (exactly its purpose). It is a stored data-read, so add its
/api/read dispatch entry (server, hours_back=24, limit=30). Also carries the web migration renumber
V29->V30 (dev took V29 = long_query_completions): PgMigrations V30Sql + Migration(30), StorageVersion
30, the 14-column store schema probe (long_query->29, web->30), and the migration/probe test pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nforcement (#1562)

Section 3 (FRONTEND) of the Darling Web plan. Replaces the wwwroot seam stub with the real
read-only browser dashboard served by the web host on 5153. Vanilla ES modules, no framework,
no build step, zero off-origin references (air-gapped).

- wwwroot/index.html: app shell (sidebar + main); css/theme.css (dark palette from the WPF
  DarkTheme.xaml, exact values) + css/app.css.
- js/util.js: DOM builders (textContent-only, R4/XSS), UTC->local time (R5), formatters, and the
  API fetch helper that classifies data / {status,message} empty envelope / {error} responses.
- js/panels.js: renderPanel({read,params,viz,span}) + viz registry {table,line,stat,bandlist} —
  the #1563 seam; pages are hardcoded descriptor arrays (none of #1563 built).
- js/charts.js: dependency-free inline SVG line charts (multi-series, CSS-var theming, mousemove
  tooltip). The lone SVG XML namespace is allowlisted by the air-gap scan.
- js/app.js: hash router (#/fleet, #/server/{name}, #/alerts), sidebar server list, 60s refresh
  loop that pauses while the tab is hidden.
- js/pages/{fleet,server,alerts}.js: Fleet Overview (pre-banded cards + six metric bands + amber
  "Awaiting first collection" verbatim + worst-first Needs-Attention/all-healthy), per-server
  drill (Overview, Waits stats+trend, Active Queries, CPU 4-series, Memory/File-IO trends,
  Collection Health), fleet-wide filterable Alert History.
- Tests: DarlingWebSelfContainmentTests scans wwwroot and fails CI on any off-origin
  http(s)://, protocol-relative //host in src/href/url()/import (SVG namespace allowlisted);
  DarlingWebAssetsTests extended to pin the css/js entry files in build output.
- CHANGELOG: the ONE combined [Unreleased] Added entry for the whole #1562 feature; README:
  Darling web section gains a "what you see" paragraph, and the store-schema table is corrected
  (web config is V30; V29 is the long-query collector after the dev rebase).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ro (#1562)

Builder 3 follow-up: the operator guide (Darling/README.md) documents the web dashboard,
but the top-level README's Darling intro didn't mention it. One sentence for discoverability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… detail expanders, nice axes, card richness, a11y (#1562)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rs chip wording (#1562)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… Lite port) (#1562)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d, row clamp)

Applies the three actionable minors from the web security review (#1562):
- M1 DNS-rebinding: IsAllowedHost rejects a Host header that isn't a
  loopback name/IP or the configured listen IP, BEFORE the tokenless
  loopback allow — a browser on the host can't be rebound to
  127.0.0.1:5153 and read the surface same-origin.
- M2 open redirect: the token-strip 302 target is forced single-slash
  site-relative (SanitizeRedirectPath), so a crafted //evil.com path
  can't bounce the operator off-site.
- M3 unbounded reads: ?limit=/?top= clamp to [1,1000] at the dispatch
  layer (ClampRows), matching /api/fleet's existing window clamp, so a
  caller can't request an unbounded result set from a LIMIT $N reader.

All three guards extracted as pure internals and pinned:
IsAllowedHost, SanitizeRedirectPath, ClampRows. Full Darling.Tests
green (2396 passed / 137 gated-skip). M4 (no TLS) and M5 (error echo)
are the reviewer's documented conscious-acceptance items, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@erikdarlingdata
erikdarlingdata merged commit 0a5836a into dev Jul 18, 2026
5 checks passed
@erikdarlingdata
erikdarlingdata deleted the feature/1562-darling-web branch July 18, 2026 17:29
erikdarlingdata added a commit that referenced this pull request Jul 18, 2026
…efect)

The test tampered the LAST base64url char of the HMAC signature, but a
32-byte HMAC encodes to 43 base64url chars where the final char carries
only 4 data bits + 2 padding bits. Flipping 'A'->'B' there changes only
a padding bit, so the tampered cookie decodes to identical bytes and
validation rightly accepts it -> Assert.False fails ~1 run in 16 (when
the HMAC's last char is 'A'). It slipped through #1562/#1574's runs and
surfaced on this branch's gate by unlucky draw; it will intermittently
red ANY branch's gate now that #1562 is on dev.

Fix: tamper the FIRST signature char (right after the '.'), which is
fully data-bearing (6 bits), so any flip always changes the decoded MAC
-> deterministic. Test-only; the cookie validator is correct (accepting
byte-identical input is right behavior). Folded here because it blocked
this PR's gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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