", 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",