From 9f5d59fce883741c26aadbb6ac4d196d6f681f84 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:16:51 -0400 Subject: [PATCH 1/4] Refs #4222. Client render for the alert notebook (SPA slice) - panels.js renderPanel and compose.js renderComposedPanelCard take an optional onSettled callback, fired once a cell's load reaches a terminal state, so a caller with its own concurrency budget knows when a slot frees. - views.js renderNotebookDoc is refactored to accept either a saved view (mode: saved, unchanged behaviour) or an in-memory definition bound to a firing (mode: alert, read-only): no fleet scope picker, a max-3 in-flight InFlightLimiter gating read-cell loads, header/status cells rendered directly from the endpoint's own facts, Save as notebook and Open live controls. - triage.js tries GET /api/alert-notebook first and renders its definition through renderNotebookDoc in alert mode; on a 404 (endpoint not present on this build) it falls back to the existing /api/triage assembly unchanged. - New pin tests (Darling.Tests/AlertNotebookRenderClientTests.cs) over the shipped JS source text: in-memory render path, no /api/fleet call in alert mode, the max-3 limiter, and the Save-as-notebook provenance string. Built against PR #4366 (feature/4222-alert-notebook-endpoint, head ee6387936) for the /api/alert-notebook response shape. Status refresh on the 60s poll is out of scope (needs slice d's poll guard on dev). Not opening a PR yet per the coordinator's rule: pins exist and build 0/0; a successor lane should verify the shape against #4366 once it lands and open the PR. --- .../AlertNotebookRenderClientTests.cs | 111 ++++++++++ .../wwwroot/js/compose.js | 14 +- .../wwwroot/js/pages/triage.js | 66 ++++++ .../wwwroot/js/pages/views.js | 206 +++++++++++++++--- .../wwwroot/js/panels.js | 18 +- 5 files changed, 378 insertions(+), 37 deletions(-) create mode 100644 Darling/Darling.Tests/AlertNotebookRenderClientTests.cs diff --git a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs new file mode 100644 index 0000000000..75c2bd0fbe --- /dev/null +++ b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Erik Darling, Darling Data LLC + * + * This file is part of the SQL Server Performance Monitor. + * + * Licensed under the MIT License. See LICENSE file in the project root for full license information. + */ + +using System; +using Xunit; +using static Darling.Tests.RepoFile; + +namespace Darling.Tests; + +/// +/// #4222 (client slice c): source-text pins over wwwroot/js/pages/triage.js and +/// wwwroot/js/pages/views.js's alert-notebook render path. There is no JS test runner in this +/// repository, so these pin the SHAPE of the shipped source the way and +/// already do for this same pair of files — a rewrite that drops one of +/// these properties changes visible behaviour without failing the C# build otherwise. +/// +/// Pinned properties (the brief's own list): +/// - the triage alert path renders through the SAME renderNotebookDoc the saved-view page uses, with +/// an in-memory (not saved) definition; +/// - the alert render path never calls /api/fleet (no fleet scope picker \u2014 the server is fixed); +/// - a max-3 in-flight limiter exists and gates each alert-mode read cell's load; +/// - "Save as notebook" POSTs to /api/views carrying the provenance string. +/// +public sealed class AlertNotebookRenderClientTests +{ + private const string TriagePath = "Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js"; + private const string ViewsPath = "Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js"; + + [Fact] + public void Triage_RendersTheAlertNotebook_ThroughTheSharedRenderNotebookDocWithAnInMemoryDefinition() + { + var triage = ReadRepoFile(TriagePath); + + Assert.Contains("import { renderNotebookDoc } from \"./views.js\";", triage, StringComparison.Ordinal); + Assert.Contains("mode: \"alert\"", triage, StringComparison.Ordinal); + Assert.Contains("definition: def", triage, StringComparison.Ordinal); + Assert.Contains("/api/alert-notebook", triage, StringComparison.Ordinal); + + // #2710: /api/triage stays the fallback for one release, on a 404 from the new endpoint. + Assert.Contains("/api/triage", triage, StringComparison.Ordinal); + } + + [Fact] + public void Triage_AlertNotebookPath_NeverReadsApiFleet() + { + var triage = ReadRepoFile(TriagePath); + + Assert.DoesNotContain("/api/fleet", triage, StringComparison.Ordinal); + Assert.DoesNotContain("apiGetFleet", triage, StringComparison.Ordinal); + } + + [Fact] + public void Views_AlertMode_SkipsTheFleetScopeBar() + { + var views = ReadRepoFile(ViewsPath); + + // The alert branch of renderNotebookDoc must not call fleetOptions/apiGetFleet on its own path \u2014 + // only the saved-view (`!isAlert`) branch may. + var alertBranchStart = views.IndexOf("if (!isAlert) {", StringComparison.Ordinal); + Assert.True(alertBranchStart > 0, "Expected renderNotebookDoc's saved-mode scope-bar branch guarded by `if (!isAlert)`."); + } + + [Fact] + public void Views_HasAMaxThreeInFlightLimiter_GatingAlertReadCells() + { + var views = ReadRepoFile(ViewsPath); + + Assert.Contains("class InFlightLimiter", views, StringComparison.Ordinal); + Assert.Contains("new InFlightLimiter(3)", views, StringComparison.Ordinal); + Assert.Contains("limiter.acquire()", views, StringComparison.Ordinal); + Assert.Contains("limiter.release()", views, StringComparison.Ordinal); + } + + [Fact] + public void Views_SaveAsNotebook_PostsToApiViews_WithTheProvenanceString() + { + var views = ReadRepoFile(ViewsPath); + + Assert.Contains("function saveAsNotebookButton(", views, StringComparison.Ordinal); + Assert.Contains("api.createView({ name, description: provenance || null, definition });", views, StringComparison.Ordinal); + } + + [Fact] + public void Triage_ProvenanceString_NamesTemplateMetricServerAndAt() + { + var triage = ReadRepoFile(TriagePath); + + Assert.Contains("\"from alert template \"", triage, StringComparison.Ordinal); + Assert.Contains("t.template && t.template.id", triage, StringComparison.Ordinal); + Assert.Contains("t.template && t.template.version", triage, StringComparison.Ordinal); + } + + /// + /// "Open live" drops each read cell's as_of pin (today's only per-cell absolute-window field in the + /// #4366 shape \u2014 no composed cells, no per-cell `range`, yet) rather than a client-authored recompute. + /// + [Fact] + public void Triage_OpenLive_DropsTheReadCellsAsOfParam() + { + var triage = ReadRepoFile(TriagePath); + + Assert.Contains("function stripAsOf(", triage, StringComparison.Ordinal); + Assert.Contains("as_of", triage, StringComparison.Ordinal); + Assert.Contains("onOpenLive: () => { def = stripAsOf(def); paint(); }", triage, StringComparison.Ordinal); + } +} diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js index 2cb8af1516..50711532fe 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/compose.js @@ -40,13 +40,13 @@ const OTHER_SERIES_LABEL = "(other)"; * shape (title + span-2 + a body that shows a loading strip, then the chart / a state). `scope` is the view-level * run context {server, hours, variables, values}; flipping it and re-rendering re-scopes every panel at once. */ -export function renderComposedPanelCard(panelSpec, scope) { +export function renderComposedPanelCard(panelSpec, scope, onSettled) { const body = el("div", { class: "panel-body" }, [loadingStrip()]); const panel = el("div", { class: "panel card" + (panelSpec.span === 2 ? " span-2" : "") }, [ el("h3", {}, [panelSpec.title || measureLabel(panelSpec), pinBadge(panelSpec)]), body, ]); - driveComposedPanel(body, panelSpec, scope); + driveComposedPanel(body, panelSpec, scope, onSettled); return panel; } @@ -198,9 +198,11 @@ function pinHoursLabel(hours) { * never persisted (the stored definition renders verbatim), and a "clear" chip pops back to the base spec. Each new * selection REPLACES the drill (a simple "you are viewing X" model), so drilling never stacks into a dead end. */ -function driveComposedPanel(body, panelSpec, scope) { +function driveComposedPanel(body, panelSpec, scope, onSettled) { let drill = null; // { keys: [{dimension, value}] } or null let zoom = null; // { startIso, endIso } or null (#1606 brush-zoom — view-state only, like the drill) + let firstRun = true; // onSettled (#4222) fires once, for the initial load only — a later drill/zoom re-run is + // user-driven and already past whatever concurrency budget gated the first load. function onDrill(next) { drill = next && Array.isArray(next.keys) && next.keys.length ? next : null; run(); @@ -211,7 +213,11 @@ function driveComposedPanel(body, panelSpec, scope) { } function run() { const spec = drill ? withDrillFilters(panelSpec, drill) : panelSpec; - renderComposedInto(body, spec, scope, { drill, onDrill, zoom, onZoomChange }); + const done = firstRun ? onSettled : null; + firstRun = false; + renderComposedInto(body, spec, scope, { drill, onDrill, zoom, onZoomChange }).finally(() => { + if (done) done(); + }); } run(); } diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js index 4aaab9d5ca..9fac5edbc5 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js @@ -24,6 +24,8 @@ import { el, mount, apiGet, buildQuery, loadingStrip, errorStrip, emptyStrip, no alertDeliveryState } from "../util.js"; import { VIZ } from "../panels.js"; import { suggestViz, deriveVizConfig } from "../derive.js"; +import { renderNotebookDoc } from "./views.js"; +import { getSession, getCatalog } from "../views-api.js"; /* The get_alert_history wire shape's status collapse, matching the Alert History page's own cell — including its #2781 rule: the "tray" channel (the Lite/Dashboard system-tray toast) is meaningless on the headless web, @@ -96,6 +98,66 @@ function sectionCard(section) { ]); } +/* #4222: the alert notebook render. GET /api/alert-notebook returns {alert, status, notes, template, definition} + bound to this link's server/metric/at/dedup — a per-family template server-side, so the page can render the + forensic reads an investigation needs (v1 read cells) as a document, read-only, no fleet scope picker (the + server is fixed). /api/triage is NOT called first: /api/alert-notebook is tried, and only on a 404 (it isn't on + dev yet, or this build predates it) does the page fall back to the legacy /api/triage assembly below — so the + route works whichever endpoint is live. Status refresh on the 60s poll is OUT of scope here: it needs the poll + guard #4222(d) adds to app.js's refresh(), which is not in this slice. */ +async function renderAlertNotebook(main, box, server, metric, at, dedup) { + const res = await apiGet("/api/alert-notebook" + buildQuery({ server, metric, at, dedup })); + if (res.kind === "error" && res.status === 404) return false; // not on this build — caller falls back + if (res.kind === "error") { + mount(box, errorStrip(res.message)); + return true; + } + const t = res.data || {}; + const notes = Array.isArray(t.notes) ? t.notes : []; + const [session, catalog] = await Promise.all([getSession(), getCatalog()]); + const canEdit = !!session.can_edit; + + let def = t.definition || { kind: "notebook", cells: [] }; + const provenance = + "from alert template " + (t.template && t.template.id) + " v" + (t.template && t.template.version) + + ", " + metric + " on " + server + " at " + at; + + function stripAsOf(d) { + const cells = Array.isArray(d.cells) ? d.cells : []; + return { + ...d, + cells: cells.map((c) => { + if (c && c.type === "read" && c.params && c.params.as_of != null) { + const { as_of, ...rest } = c.params; + return { ...c, params: rest }; + } + return c; + }), + }; + } + + const noteBox = el("div", {}); + const docHolder = el("div", {}); + mount(box, [noteBox, docHolder]); + + function paint() { + mount(noteBox, notes.map((n) => noticeStrip(n))); + renderNotebookDoc(docHolder, { + mode: "alert", + definition: def, + alert: t.alert || null, + status: t.status, + notes, + canEdit, + catalog, + provenance, + onOpenLive: () => { def = stripAsOf(def); paint(); }, + }); + } + paint(); + return true; +} + export async function renderTriage(main, queryString) { const params = new URLSearchParams(queryString || ""); const server = params.get("server") || ""; @@ -113,6 +175,10 @@ export async function renderTriage(main, queryString) { ]); mount(box, loadingStrip("Assembling triage context…")); + + const handled = await renderAlertNotebook(main, box, server, metric, at, dedup); + if (handled) return; + const res = await apiGet("/api/triage" + buildQuery({ server, metric, at, dedup })); if (res.kind === "error") return mount(box, errorStrip(res.message)); diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js index 46bbaa572b..0e32b91e57 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js @@ -25,7 +25,7 @@ * descriptions) reaches the DOM through el()/textContent (R4 — never innerHTML). */ -import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, relTime } from "../util.js"; +import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, relTime, localTime } from "../util.js"; import { renderPanel, setPanelSignal, VIZ } from "../panels.js"; import { renderComposedPanelCard } from "../compose.js"; import { renderMarkdown } from "../markdown.js"; @@ -421,7 +421,7 @@ export async function renderView(main, id) { /* A notebook is the same stored row with a kind:"notebook" definition — render it as a document, not a grid. */ if (isNotebookDefinition(def)) { - renderNotebookDoc(main, view, session, catalog, fleetRes); + renderNotebookDoc(main, { mode: "saved", view, session, catalog, fleetRes }); return; } @@ -495,59 +495,205 @@ export async function renderView(main, id) { * visible and the scope bar can't silently lie for it; server scope still applies (#2788). Export/Delete/Edit * are shared with the dashboard renderer; Edit points at the notebook composer route. */ -async function renderNotebookDoc(main, view, session, catalog, fleetRes) { - const def = view.definition || {}; +/** + * `opts`: + * { mode: "saved", view, session, catalog, fleetRes } — an ordinary saved notebook. + * { mode: "alert", definition, alert, status, notes, canEdit, provenance, onOpenLive } — #4222's read-only + * alert-notebook render: an IN-MEMORY definition bound to one firing (no saved `view` row, no /api/fleet + * read — the server is fixed — and no scope bar, since there is nothing to re-scope). `provenance` is the + * description string "Save as notebook" writes; `onOpenLive` is called (with no args) when the viewer clicks + * "Open live", and is this function's caller's job to turn into dropping the read cells' `as_of` and + * re-rendering — see triage.js. + * Saved-view behaviour (mode:"saved") is UNCHANGED by this refactor: same scope bar, same Export/Edit/Delete, + * same panel-cell rendering. The alert mode is read-only: no scope bar (nothing to re-scope: read cells carry + * their own `as_of`/`hours`; there are no composed cells yet), no Export/Edit/Delete, and a header/status cell + * pair rendered directly from `alert`/`status` rather than through a read. + */ +export async function renderNotebookDoc(main, opts) { + const isAlert = opts.mode === "alert"; + const def = isAlert ? (opts.definition || {}) : (opts.view.definition || {}); const cells = Array.isArray(def.cells) ? def.cells : []; - const canEdit = !!session.can_edit; + const canEdit = isAlert ? !!opts.canEdit : !!opts.session.can_edit; + const catalog = isAlert ? { reads: [], compose: {} } : opts.catalog; const readSet = new Set((catalog.reads || []).map((r) => r.name)); const sourceSet = new Set(((catalog.compose || {}).measures || []).map((m) => m.source)); const status = el("div", { class: "view-status" }); - const head = el("div", { class: "page-head" }, [ - backToViews(), - el("h2", { text: view.name || "Notebook" }), - el("span", { class: "notebook-badge", text: "Notebook" }), - view.description ? el("div", { class: "meta", text: view.description }) : null, - el("div", { class: "spacer" }), - exportButton(view), - canEdit ? el("a", { class: "btn small", href: "#/notebook/" + encodeURIComponent(view.id) + "/edit", text: "Edit" }) : null, - canEdit ? deleteButton(view.id, status) : null, - ]); + const head = isAlert + ? el("div", { class: "page-head" }, [ + el("h2", { text: "Alert notebook" }), + el("span", { class: "notebook-badge", text: "Notebook" }), + el("div", { class: "spacer" }), + canEdit ? saveAsNotebookButton(def, opts.provenance, status) : null, + openLiveButton(opts.onOpenLive), + ]) + : el("div", { class: "page-head" }, [ + backToViews(), + el("h2", { text: opts.view.name || "Notebook" }), + el("span", { class: "notebook-badge", text: "Notebook" }), + opts.view.description ? el("div", { class: "meta", text: opts.view.description }) : null, + el("div", { class: "spacer" }), + exportButton(opts.view), + canEdit ? el("a", { class: "btn small", href: "#/notebook/" + encodeURIComponent(opts.view.id) + "/edit", text: "Edit" }) : null, + canEdit ? deleteButton(opts.view.id, status) : null, + ]); if (!cells.length) { mount(main, [head, status, emptyStrip("This notebook has no cells yet.")]); return; } - /* Scope bar identical to a dashboard's: it only drives the panel cells (markdown is static). Skipped when a - notebook is prose-only (no panel cells to re-scope). */ - const fleet = fleetOptions(fleetRes); - const variables = Array.isArray(def.variables) ? def.variables.filter((v) => v && v.name) : []; - const defaultHours = def.range && typeof def.range.hours === "number" ? def.range.hours : 24; - const hasPanels = cells.some((c) => c && c.type === "panel" && c.source != null); - - const state = seedState(view.id, defaultHours, variables); - - function currentScope() { - return { + /* Alert mode has no scope bar: the server is fixed to the firing, and read cells already carry as_of/hours — + nothing here is re-scopeable yet (no composed cells, no per-cell range pins in this shape). Saved mode keeps + its scope bar exactly as before. */ + let controls = null; + let currentScope = () => ({ server: "All", hours: 24, variables: [], values: {} }); + if (!isAlert) { + const fleet = fleetOptions(opts.fleetRes); + const variables = Array.isArray(def.variables) ? def.variables.filter((v) => v && v.name) : []; + const defaultHours = def.range && typeof def.range.hours === "number" ? def.range.hours : 24; + const hasPanels = cells.some((c) => c && c.type === "panel" && c.source != null); + const state = seedState(opts.view.id, defaultHours, variables); + currentScope = () => ({ server: state.server, hours: state.hours, variables: variables.map((v) => ({ name: v.name, dimension: v.dimension, default: v.default || undefined })), values: { ...state.values }, - }; + }); + const onChange = () => { rememberScope(opts.view.id, state); renderDoc(); }; + controls = hasPanels ? buildViewControls(fleet, variables, state, defaultHours, onChange) : null; } const docBox = el("div", { class: "notebook-doc" }); + const limiter = isAlert ? new InFlightLimiter(3) : null; function renderDoc() { - mount(docBox, cells.map((cell) => renderCell(cell, readSet, sourceSet, currentScope()))); + if (isAlert) { + /* Alert-mode cells load top to bottom, at most 3 in flight (#4222's cost rule) — renderCell/renderPanel + return synchronously with the fetch outstanding, so the limiter gates ACQUIRING a slot before the cell + node is even built, then releases it off renderPanel's onSettled callback. */ + mount(docBox, cells.map((cell, i) => renderAlertCell(cell, i, readSet, opts, limiter))); + } else { + mount(docBox, cells.map((cell) => renderCell(cell, readSet, sourceSet, currentScope()))); + } } - const onChange = () => { rememberScope(view.id, state); renderDoc(); }; - const controls = hasPanels ? buildViewControls(fleet, variables, state, defaultHours, onChange) : null; mount(main, [head, status, controls, docBox]); renderDoc(); } +/* A max-N in-flight gate (#4222 cost rule) for the alert notebook's client-side cell loads. A cell asks for a + slot; if none is free it queues (FIFO) until release() frees one. Kept tiny and dependency-free — this is not + a general scheduler, just the one "at most 3 outstanding reads" rule the cost design calls for. */ +class InFlightLimiter { + constructor(max) { + this.max = max; + this.active = 0; + this.queue = []; + } + acquire() { + if (this.active < this.max) { + this.active++; + return Promise.resolve(); + } + return new Promise((resolve) => this.queue.push(resolve)); + } + release() { + const next = this.queue.shift(); + if (next) { + next(); + } else { + this.active = Math.max(0, this.active - 1); + } + } +} + +/* One alert-notebook cell -> a document block. header/status render directly from the endpoint's own facts (no + read); a read cell queues on the limiter, then renders through the SAME v1 renderPanel dashboards use — + explicit `viz` per cell, no derive.js guessing (#4222). A stale/empty firing (no alert, "Unknown" status, an + empty read) renders honest empty sections, never an error page: header/status always render (their own null + guards below), and renderPanel already degrades a read's own empty/error kinds to a strip, not a throw. */ +function renderAlertCell(cell, index, readSet, opts, limiter) { + if (!cell || typeof cell !== "object") return null; + if (cell.type === "header") { + return renderAlertHeaderCell(cell, opts); + } + if (cell.type === "status") { + return el("div", { class: "notebook-panel panel card" }, [ + el("h3", {}, [cell.title || "Status"]), + el("div", { class: "panel-body" }, [el("span", { class: "fv", text: cell.status || "Unknown" })]), + ]); + } + if (cell.type === "read") { + if (!cell.read || !readSet.has(cell.read) || !cell.viz || !VIZ[cell.viz]) { + return panelErrorCard(cell.title, "Unknown read '" + (cell.read || "") + "' or visualization '" + (cell.viz || "") + "'."); + } + const holder = el("div", { class: "notebook-panel" }, [loadingStrip()]); + /* Cells load top to bottom: acquiring a slot is async, so a later cell's slot request cannot resolve before + an earlier one queued first (the limiter's queue is FIFO) — renderPanel itself is only called once the + slot is granted, at which point it starts the fetch and returns synchronously. */ + limiter.acquire().then(() => { + mount(holder, renderPanel(cell, () => limiter.release())); + }); + return holder; + } + return null; +} + +/* The header cell: the endpoint's own alert facts, rendered directly (no read — they arrived with the envelope). + A null `alert` (stale/no match) renders the honest empty state instead of blank fields. */ +function renderAlertHeaderCell(cell, opts) { + const a = opts.alert; + const rows = []; + if (a) { + rows.push(["Server", a.server_name || "\u2014"]); + rows.push(["Metric", a.metric_name || "\u2014"]); + rows.push(["Fired", a.alert_time ? localTime(a.alert_time) : "\u2014"]); + if (a.incident_since) rows.push(["Incident since", localTime(a.incident_since)]); + if (a.involved_objects) rows.push(["Involved objects", a.involved_objects]); + if (a.database) rows.push(["Database", a.database]); + if (typeof a.total_occurrences === "number") rows.push(["Total occurrences", String(a.total_occurrences)]); + } + const body = a + ? el("div", { class: "detail-fields" }, rows.map(([k, v]) => + el("div", { class: "detail-field" }, [el("span", { class: "fk", text: k }), el("span", { class: "fv", text: v })]) + )) + : emptyStrip("No matching alert history row for this link."); + return el("div", { class: "notebook-panel panel card" }, [ + el("h3", {}, [cell.title || "Alert"]), + el("div", { class: "panel-body" }, [body]), + ]); +} + +/* "Save as notebook" (#4222): POSTs the bound in-memory definition to /api/views, unmodified except for the + description carrying the provenance string ("from alert template v, on at + ", built by the caller — triage.js — since it has the template/metric/server/at facts, not this module). + Only mounted when the seat can edit (canEdit gate above), matching every other edit affordance on this page. */ +function saveAsNotebookButton(definition, provenance, status) { + const btn = el("button", { class: "btn small", type: "button", text: "Save as notebook" }); + btn.addEventListener("click", async () => { + btn.disabled = true; + mount(status, el("div", { class: "strip loading", text: "Saving\u2026" })); + const name = "Alert notebook " + new Date().toISOString(); + const res = await api.createView({ name, description: provenance || null, definition }); + if (res.kind === "data" && res.data && res.data.id != null) { + location.hash = "#/notebook/" + encodeURIComponent(res.data.id); + return; + } + btn.disabled = false; + mount(status, errorStrip(res.message || "Could not save this notebook.")); + }); + return btn; +} + +/* "Open live" (#4222): drops the absolute window pins (today: each read cell's `as_of`) so the notebook falls + back to a relative/live view. `onOpenLive` is the caller's (triage.js's) re-render with as_of stripped — this + module has no state of its own to mutate, since the definition is the caller's in-memory object. */ +function openLiveButton(onOpenLive) { + const btn = el("button", { class: "btn small", type: "button", text: "Open live" }); + btn.addEventListener("click", () => { if (onOpenLive) onOpenLive(); }); + return btn; +} + /* One notebook cell -> a document block: a markdown cell renders through renderMarkdown (XSS-safe); a panel cell through the shared panelOrError (unknown source/viz -> a clean strip, exactly as in the dashboard grid). */ function renderCell(cell, readSet, sourceSet, scope) { diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js index fcbd631b84..f0b514ad78 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js @@ -59,18 +59,30 @@ export function setPanelSignal(signal) { * Build a panel node. It returns immediately with a loading strip and fills itself once the fetch resolves, * mapping the API response kinds (data / empty envelope / error / aborted / auth) to the right UI. */ -export function renderPanel(desc) { +export function renderPanel(desc, onSettled) { const signal = panelSignal; const body = el("div", { class: "panel-body" }, [loadingStrip()]); const panel = el("div", { class: "panel card" + (desc.span === 2 ? " span-2" : "") }, [ el("h3", {}, [desc.title, desc.subtitle ? el("span", { class: "panel-sub", text: " " + desc.subtitle }) : null]), body, ]); - loadPanel(desc, body, signal); + loadPanel(desc, body, signal, onSettled); return panel; } -async function loadPanel(desc, body, signal) { +/* `onSettled` (#4222) is an optional completion callback fired exactly once when this panel's load reaches a + terminal state (data, empty, error, aborted, or auth) — every renderPanel caller today omits it (a no-op), but + it is what lets a caller with its OWN concurrency budget (the alert-notebook doc's max-3 in-flight cell loader, + views.js) know when a slot frees, since renderPanel itself returns synchronously with the fetch still in flight. */ +async function loadPanel(desc, body, signal, onSettled) { + try { + await loadPanelBody(desc, body, signal); + } finally { + if (onSettled) onSettled(); + } +} + +async function loadPanelBody(desc, body, signal) { const res = desc.read ? await readTool(desc.read, desc.params, signal) : await apiGet(desc.path + buildQuery(desc.params), signal); From ab1b107859ab5503043e27a47c6d0cd8b89ed7f7 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:02:16 -0400 Subject: [PATCH 2/4] Render current_value/threshold_value/detail_text; guard alert-mode read cells on route change (#4368) --- .../AlertNotebookRenderClientTests.cs | 39 ++++++++++++++ .../wwwroot/js/pages/triage.js | 10 ++++ .../wwwroot/js/pages/views.js | 52 ++++++++++++++----- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs index 75c2bd0fbe..6ead1cb270 100644 --- a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs +++ b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs @@ -108,4 +108,43 @@ public void Triage_OpenLive_DropsTheReadCellsAsOfParam() Assert.Contains("as_of", triage, StringComparison.Ordinal); Assert.Contains("onOpenLive: () => { def = stripAsOf(def); paint(); }", triage, StringComparison.Ordinal); } + + /// + /// #4368: the header cell must render current_value/threshold_value/detail_text — the endpoint's alert + /// node carries all three (AlertNotebookEndpoint.cs's AlertRowNode), and the header dropped them entirely + /// before this slice. Pinned through el()'s text path only (R4): a rewrite that switches either + /// field to innerHTML or a template string must fail this build, not just an XSS review. + /// + [Fact] + public void Views_AlertHeaderCell_RendersValueThresholdAndDetailText_ThroughElsTextPath() + { + var views = ReadRepoFile(ViewsPath); + + Assert.Contains("a.current_value", views, StringComparison.Ordinal); + Assert.Contains("a.threshold_value", views, StringComparison.Ordinal); + Assert.Contains("a.detail_text", views, StringComparison.Ordinal); + + // The detail_text line must go through el()'s { text: ... } prop (textContent), never innerHTML. + Assert.Contains("el(\"pre\", { class: \"code\", text: a.detail_text })", views, StringComparison.Ordinal); + Assert.DoesNotContain("innerHTML", views, StringComparison.Ordinal); + } + + /// + /// #4368: a route change (hashchange off #/triage, or navigating away entirely) while an alert-mode read + /// cell's slot is queued or its fetch is outstanding must not paint a stale result into a detached holder. + /// The fetch and the limiter's release still have to run either way — only the mount is guarded. + /// + [Fact] + public void Views_AlertModeReadCells_GuardAgainstRenderingAfterARouteChange() + { + var views = ReadRepoFile(ViewsPath); + + Assert.Contains("opts.isLive", views, StringComparison.Ordinal); + Assert.Contains("const stillLive = !opts.isLive || opts.isLive();", views, StringComparison.Ordinal); + Assert.Contains("if (stillLive) mount(holder, rendered);", views, StringComparison.Ordinal); + + var triage = ReadRepoFile(TriagePath); + Assert.Contains("const isLive = () => location.hash === ourHash;", triage, StringComparison.Ordinal); + Assert.Contains("isLive,", triage, StringComparison.Ordinal); + } } diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js index 9fac5edbc5..5aeb69a479 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js @@ -106,16 +106,25 @@ function sectionCard(section) { route works whichever endpoint is live. Status refresh on the 60s poll is OUT of scope here: it needs the poll guard #4222(d) adds to app.js's refresh(), which is not in this slice. */ async function renderAlertNotebook(main, box, server, metric, at, dedup) { + /* #4368: the same route-change guard renderView (views.js) already uses for its own await gap — captured + BEFORE the first await, so a hashchange between now and any later check is visible. Threaded into + renderNotebookDoc as `opts.isLive` so its alert-mode read cells can drop a result that lands after the + viewer has navigated off #/triage, without cancelling the fetch or starving the limiter's next slot. */ + const ourHash = location.hash; + const isLive = () => location.hash === ourHash; const res = await apiGet("/api/alert-notebook" + buildQuery({ server, metric, at, dedup })); if (res.kind === "error" && res.status === 404) return false; // not on this build — caller falls back if (res.kind === "error") { mount(box, errorStrip(res.message)); return true; } + if (!isLive()) return true; // navigated away during the fetch above — nothing left to mount into. + const t = res.data || {}; const notes = Array.isArray(t.notes) ? t.notes : []; const [session, catalog] = await Promise.all([getSession(), getCatalog()]); const canEdit = !!session.can_edit; + if (!isLive()) return true; // ditto, across the session/catalog await gap. let def = t.definition || { kind: "notebook", cells: [] }; const provenance = @@ -151,6 +160,7 @@ async function renderAlertNotebook(main, box, server, metric, at, dedup) { canEdit, catalog, provenance, + isLive, onOpenLive: () => { def = stripAsOf(def); paint(); }, }); } diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js index 0e32b91e57..071b555535 100644 --- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js +++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js @@ -25,7 +25,7 @@ * descriptions) reaches the DOM through el()/textContent (R4 — never innerHTML). */ -import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, relTime, localTime } from "../util.js"; +import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, relTime, localTime, fmtNum } from "../util.js"; import { renderPanel, setPanelSignal, VIZ } from "../panels.js"; import { renderComposedPanelCard } from "../compose.js"; import { renderMarkdown } from "../markdown.js"; @@ -498,12 +498,17 @@ export async function renderView(main, id) { /** * `opts`: * { mode: "saved", view, session, catalog, fleetRes } — an ordinary saved notebook. - * { mode: "alert", definition, alert, status, notes, canEdit, provenance, onOpenLive } — #4222's read-only - * alert-notebook render: an IN-MEMORY definition bound to one firing (no saved `view` row, no /api/fleet - * read — the server is fixed — and no scope bar, since there is nothing to re-scope). `provenance` is the - * description string "Save as notebook" writes; `onOpenLive` is called (with no args) when the viewer clicks - * "Open live", and is this function's caller's job to turn into dropping the read cells' `as_of` and - * re-rendering — see triage.js. + * { mode: "alert", definition, alert, status, notes, canEdit, provenance, isLive, onOpenLive } — #4222's + * read-only alert-notebook render: an IN-MEMORY definition bound to one firing (no saved `view` row, no + * /api/fleet read — the server is fixed — and no scope bar, since there is nothing to re-scope). + * `provenance` is the description string "Save as notebook" writes; `onOpenLive` is called (with no args) + * when the viewer clicks "Open live", and is this function's caller's job to turn into dropping the read + * cells' `as_of` and re-rendering — see triage.js. `isLive` (#4368) is an optional `() => boolean` the + * caller supplies to say whether its route is still the one on screen; each alert-mode read cell checks it + * right before mounting its settled result, so a result that lands after a route change is dropped instead + * of painted into a holder no longer attached to the visible document — the fetch and the limiter's + * release still run either way. Omitted (saved mode, or an alert-mode caller that doesn't pass it) means + * "always live", i.e. today's behaviour. * Saved-view behaviour (mode:"saved") is UNCHANGED by this refactor: same scope bar, same Export/Edit/Delete, * same panel-cell rendering. The alert mode is read-only: no scope bar (nothing to re-scope: read cells carry * their own `as_of`/`hours`; there are no composed cells yet), no Export/Edit/Delete, and a header/status cell @@ -630,9 +635,21 @@ function renderAlertCell(cell, index, readSet, opts, limiter) { const holder = el("div", { class: "notebook-panel" }, [loadingStrip()]); /* Cells load top to bottom: acquiring a slot is async, so a later cell's slot request cannot resolve before an earlier one queued first (the limiter's queue is FIFO) — renderPanel itself is only called once the - slot is granted, at which point it starts the fetch and returns synchronously. */ + slot is granted, at which point it starts the fetch and returns synchronously. + #4368: `opts.isLive` — set by triage.js to a `location.hash === ourHash` check, the same route-change + guard renderView already uses for its own await gap — is checked before mounting the settled result. + A route change after the slot was granted still lets the fetch finish and the limiter release (so the + NEXT page's own reads aren't starved by a request this page no longer owns); it only stops the result + from painting into a holder that is no longer attached to the visible document. */ limiter.acquire().then(() => { - mount(holder, renderPanel(cell, () => limiter.release())); + /* A route change (a fresh #/triage link, or navigating off the page entirely) between queueing and the + slot opening: this cell's holder is no longer attached to the visible document, and its own reads + are for a firing nobody is looking at any more. renderPanel still runs and its own onSettled still + releases the limiter's slot below — the fetch and release always happen, so a superseded batch never + starves the page that replaced it — only the RESULT is dropped instead of painted into a dead node. */ + const stillLive = !opts.isLive || opts.isLive(); + const rendered = renderPanel(cell, () => limiter.release()); + if (stillLive) mount(holder, rendered); }); return holder; } @@ -652,11 +669,22 @@ function renderAlertHeaderCell(cell, opts) { if (a.involved_objects) rows.push(["Involved objects", a.involved_objects]); if (a.database) rows.push(["Database", a.database]); if (typeof a.total_occurrences === "number") rows.push(["Total occurrences", String(a.total_occurrences)]); + /* current_value/threshold_value (#4368): same fmtNum(_, 1) triage.js's alertFields uses for the matched + alert card, so the two pages format the same fields the same way. Omitted only when BOTH are absent — + fmtNum already renders a lone missing side as "\u2014" rather than dropping the whole line. */ + if (typeof a.current_value === "number" || typeof a.threshold_value === "number") { + rows.push(["Value vs threshold", "current " + fmtNum(a.current_value, 1) + " vs threshold " + fmtNum(a.threshold_value, 1)]); + } } const body = a - ? el("div", { class: "detail-fields" }, rows.map(([k, v]) => - el("div", { class: "detail-field" }, [el("span", { class: "fk", text: k }), el("span", { class: "fv", text: v })]) - )) + ? [ + el("div", { class: "detail-fields" }, rows.map(([k, v]) => + el("div", { class: "detail-field" }, [el("span", { class: "fk", text: k }), el("span", { class: "fv", text: v })]) + )), + /* detail_text (#4368): the same
 block triage.js's alertFields uses — through el()'s
+           text path (textContent), never innerHTML, since this is alert/query text the server only echoes. */
+        a.detail_text ? el("pre", { class: "code", text: a.detail_text }) : null,
+      ]
     : emptyStrip("No matching alert history row for this link.");
   return el("div", { class: "notebook-panel panel card" }, [
     el("h3", {}, [cell.title || "Alert"]),

From 646d9871fab5110d0ea18242d7e37cdf07225f31 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 25 Sep 2026 21:16:57 -0400
Subject: [PATCH 3/4] Fix #4368 CI: scope innerHTML pin to
 renderAlertHeaderCell, update panels.js loadPanel pin for onSettled

---
 .../Darling.Tests/AlertNotebookRenderClientTests.cs   | 11 ++++++++++-
 Darling/Darling.Tests/WebFetchLayerTests.cs           | 10 ++++++++--
 2 files changed, 18 insertions(+), 3 deletions(-)

diff --git a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
index 6ead1cb270..7ace20e7fb 100644
--- a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
+++ b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
@@ -126,7 +126,16 @@ public void Views_AlertHeaderCell_RendersValueThresholdAndDetailText_ThroughElsT
 
         // The detail_text line must go through el()'s { text: ... } prop (textContent), never innerHTML.
         Assert.Contains("el(\"pre\", { class: \"code\", text: a.detail_text })", views, StringComparison.Ordinal);
-        Assert.DoesNotContain("innerHTML", views, StringComparison.Ordinal);
+
+        // Scope the innerHTML ban to renderAlertHeaderCell's own body — the file's header comment (R4)
+        // says "never innerHTML" in prose, which the whole-file assertion this replaces was tripping on.
+        var lf = ReadRepoFileLf(ViewsPath);
+        var start = lf.IndexOf("function renderAlertHeaderCell(", StringComparison.Ordinal);
+        Assert.True(start >= 0, "renderAlertHeaderCell not found in views.js");
+        var nextFn = lf.IndexOf("\nfunction ", start + 1, StringComparison.Ordinal);
+        Assert.True(nextFn > start, "could not find the next top-level function after renderAlertHeaderCell");
+        var body = lf.Substring(start, nextFn - start);
+        Assert.DoesNotContain("innerHTML", body, StringComparison.Ordinal);
     }
 
     /// 
diff --git a/Darling/Darling.Tests/WebFetchLayerTests.cs b/Darling/Darling.Tests/WebFetchLayerTests.cs
index 3a2f2d0fc6..50d6f9b337 100644
--- a/Darling/Darling.Tests/WebFetchLayerTests.cs
+++ b/Darling/Darling.Tests/WebFetchLayerTests.cs
@@ -165,8 +165,14 @@ public void PanelsJs_CapturesAndAppliesTheRenderSignal()
         var panels = PanelsJs;
 
         Assert.Contains("export function setPanelSignal(signal) {", panels, StringComparison.Ordinal);
-        Assert.Contains("async function loadPanel(desc, body, signal) {", panels, StringComparison.Ordinal);
-        Assert.Contains("loadPanel(desc, body, signal);", panels, StringComparison.Ordinal);
+
+        // #4368: loadPanel grew an `onSettled` param — the alert notebook's max-3 in-flight limiter
+        // (views.js's renderAlertCell) needs to know when a panel's load reaches a terminal state so it can
+        // free the slot. The render-signal capture/apply behaviour this pin protects is unchanged: renderPanel
+        // still captures panelSignal synchronously and threads it straight through to the fetch.
+        Assert.Contains("export function renderPanel(desc, onSettled) {", panels, StringComparison.Ordinal);
+        Assert.Contains("async function loadPanel(desc, body, signal, onSettled) {", panels, StringComparison.Ordinal);
+        Assert.Contains("loadPanel(desc, body, signal, onSettled);", panels, StringComparison.Ordinal);
         Assert.Contains("if (res.kind === \"aborted\" || res.kind === \"auth\") {", panels, StringComparison.Ordinal);
     }
 

From d48bf52b62f6d2a9c5142ffceba68b39e8c2c339 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Fri, 25 Sep 2026 21:26:43 -0400
Subject: [PATCH 4/4] Fix #4368 pin false positives: strip JS comments before
 innerHTML check, add census entry

---
 .../AlertNotebookRenderClientTests.cs         | 19 ++++++++++++++++++-
 .../Darling.Tests/RepoFileAdoptionTests.cs    |  4 ++++
 2 files changed, 22 insertions(+), 1 deletion(-)

diff --git a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
index 7ace20e7fb..c8ee5be3ee 100644
--- a/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
+++ b/Darling/Darling.Tests/AlertNotebookRenderClientTests.cs
@@ -135,7 +135,24 @@ public void Views_AlertHeaderCell_RendersValueThresholdAndDetailText_ThroughElsT
         var nextFn = lf.IndexOf("\nfunction ", start + 1, StringComparison.Ordinal);
         Assert.True(nextFn > start, "could not find the next top-level function after renderAlertHeaderCell");
         var body = lf.Substring(start, nextFn - start);
-        Assert.DoesNotContain("innerHTML", body, StringComparison.Ordinal);
+        var bodyWithoutComments = StripJsComments(body);
+        Assert.DoesNotContain(".innerHTML", bodyWithoutComments, StringComparison.Ordinal);
+        Assert.DoesNotContain("innerHTML =", bodyWithoutComments, StringComparison.Ordinal);
+        Assert.DoesNotContain("insertAdjacentHTML", bodyWithoutComments, StringComparison.Ordinal);
+    }
+
+    /// 
+    /// Strips `//` line comments and `/* ... */` block comments so a pin can check for real code use of a
+    /// pattern without tripping on prose that happens to mention it (e.g. "never innerHTML" in a comment).
+    /// Not a full JS parser — good enough for this file, which has no such pattern inside a string literal.
+    /// 
+    private static string StripJsComments(string source)
+    {
+        var withoutBlockComments = System.Text.RegularExpressions.Regex.Replace(
+            source, @"/\*.*?\*/", string.Empty, System.Text.RegularExpressions.RegexOptions.Singleline);
+        var withoutLineComments = System.Text.RegularExpressions.Regex.Replace(
+            withoutBlockComments, @"//[^\n]*", string.Empty);
+        return withoutLineComments;
     }
 
     /// 
diff --git a/Darling/Darling.Tests/RepoFileAdoptionTests.cs b/Darling/Darling.Tests/RepoFileAdoptionTests.cs
index b4a5398267..f8c6ad0951 100644
--- a/Darling/Darling.Tests/RepoFileAdoptionTests.cs
+++ b/Darling/Darling.Tests/RepoFileAdoptionTests.cs
@@ -114,6 +114,10 @@ public sealed class RepoFileAdoptionTests
            its four source lines as one literal, so the guard's return is told apart from just those words
            appearing somewhere else in the file. */
         "AlertHistoryLazyRenderTests.cs",
+        /* #4368: its innerHTML-ban pin anchors renderAlertHeaderCell's own function body across the lines
+           between its declaration and the next top-level function, so the ban is scoped to real code and
+           not tripped by the file's own prose comment saying "never innerHTML". */
+        "AlertNotebookRenderClientTests.cs",
         "BuiltinAlertPersistenceRungTests.cs",
         "ChartWindowDomainTests.cs",
         "DarlingPathFilterGateTests.cs",