From f54c58ee4658d94c458931fa1b7d9d8dc3e683e3 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:34:20 -0400
Subject: [PATCH 1/8] Investigations page: requests, reports, stored brief and
journal in the web viewer (#5904)
---
.../InvestigationToolsLiveTests.cs | 65 +++++
.../Darling.Tests/InvestigationsPageTests.cs | 135 ++++++++++
.../Darling.Tests/investigations-harness.mjs | 222 ++++++++++++++++
.../DarlingWebEndpoints.cs | 8 +-
.../Mcp/DarlingMcpInvestigationTools.cs | 4 +-
.../Mcp/DarlingWebHostService.cs | 5 +
.../wwwroot/index.html | 1 +
.../wwwroot/js/app.js | 4 +
.../wwwroot/js/pages/investigations.js | 249 ++++++++++++++++++
.../wwwroot/js/read-fields.js | 31 +++
10 files changed, 721 insertions(+), 3 deletions(-)
create mode 100644 Darling/Darling.Tests/InvestigationsPageTests.cs
create mode 100644 Darling/Darling.Tests/investigations-harness.mjs
create mode 100644 Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js
diff --git a/Darling/Darling.Tests/InvestigationToolsLiveTests.cs b/Darling/Darling.Tests/InvestigationToolsLiveTests.cs
index f9febc3ad0..ca71e496ec 100644
--- a/Darling/Darling.Tests/InvestigationToolsLiveTests.cs
+++ b/Darling/Darling.Tests/InvestigationToolsLiveTests.cs
@@ -13,6 +13,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service;
@@ -194,6 +195,70 @@ await ExecAsync(connection,
});
}
+ /// #5904: the two reads the Investigations page makes carry the fields it draws, in the MCP order (requests
+ /// opened_at DESC, journal event_time DESC), and the report read the web serves is masked at the level the
+ /// web host registered: the same summary a full-level read returns is withheld at metadataOnly, so a masked report stays
+ /// masked in the page.
+ [Fact]
+ public async Task TheWebReads_CarryTheFieldsThePageDraws_InTheMcpOrder_AndMaskTheReportAtTheRegisteredLevel()
+ {
+ await RunAsync(async (store, dataSource, connection, ct) =>
+ {
+ var older = (await store.OpenOrJoinAsync(Alert("resources"), ct)).RequestId!.Value;
+ _now = _now.AddSeconds(2);
+ var newer = (await store.OpenOrJoinAsync(Alert(), ct)).RequestId!.Value;
+ var composer = new InvestigationBriefComposer(new PgInvestigationBriefSourceReader(dataSource), new McpDataDetailSetting(McpDataDetail.Full), DarlingInvestigationBriefTools.Tools);
+ var id = newer.ToString();
+ var claim = Root(await DarlingMcpInvestigationTools.ClaimAsync(store, composer, id, "agent-a", null, null, ct));
+ await DarlingMcpInvestigationTools.SubmitAsync(store, id, claim.GetProperty("claim_id").GetString()!, 1,
+ "needs_person", "could not tell", Evidence, "see the table SecretOrders for the cause", null, null, ct);
+
+ async Task WebRead(string tool, string query, McpDataDetail? level)
+ {
+ var services = new ServiceCollection();
+ if (level is { } l)
+ services.AddSingleton(new McpDataDetailSetting(l));
+ var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
+ context.Request.QueryString = new QueryString(query);
+ return Root(await DarlingWebEndpoints.BuildReadDispatch()[tool](context, dataSource, null!));
+ }
+
+ var list = await WebRead("list_investigation_requests", "?limit=100", null);
+ var requests = list.GetProperty("requests").EnumerateArray().ToArray();
+ Assert.Equal(new[] { newer, older }, requests.Select(r => r.GetProperty("request_id").GetGuid()));
+ foreach (var key in new[] { "request_id", "opened_at", "state", "server_name", "first_metric", "family", "alert_count",
+ "claimed_by", "lease_until", "reported_at", "route_id" })
+ Assert.True(requests[0].TryGetProperty(key, out _), "the list row carries " + key);
+ Assert.Equal("reported", requests[0].GetProperty("state").GetString());
+ Assert.Equal("agent-a", requests[0].GetProperty("claimed_by").GetString());
+ Assert.Equal("open", requests[1].GetProperty("state").GetString());
+
+ var full = await WebRead("get_investigation_reports", "?request_id=" + id, McpDataDetail.Full);
+ var report = Assert.Single(full.GetProperty("reports").EnumerateArray().ToArray());
+ foreach (var key in new[] { "agent_id", "submitted_at", "verdict", "root_cause_found", "root_cause", "summary", "evidence" })
+ Assert.True(report.TryGetProperty(key, out _), "the report carries " + key);
+ Assert.Equal("see the table SecretOrders for the cause", report.GetProperty("summary").GetString());
+ Assert.True(full.GetProperty("brief").ValueKind == JsonValueKind.Object);
+ var journal = full.GetProperty("journal").EnumerateArray().ToArray();
+ Assert.Contains(journal, j => j.GetProperty("event").GetString() == "claimed");
+ Assert.Contains(journal, j => j.GetProperty("event").GetString() == "reported");
+ var times = journal.Select(j => j.GetProperty("event_time").GetDateTime()).ToArray();
+ Assert.Equal(times.OrderByDescending(t => t), times);
+ foreach (var key in new[] { "event_time", "event", "actor", "reason", "request_id" })
+ Assert.True(journal[0].TryGetProperty(key, out _), "the journal row carries " + key);
+
+ var masked = await WebRead("get_investigation_reports", "?request_id=" + id, McpDataDetail.MetadataOnly);
+ var maskedText = masked.GetRawText();
+ Assert.DoesNotContain("SecretOrders", maskedText, StringComparison.Ordinal);
+ Assert.NotEqual(full.GetProperty("reports")[0].GetProperty("summary").GetString(),
+ masked.GetProperty("reports")[0].GetProperty("summary").GetString());
+ Assert.Equal("metadataOnly", masked.GetProperty("brief").GetProperty("data_detail").GetString());
+ /* The MCP tool at the same level answers the same report: the web read goes through the same masking. */
+ var tool = Root(await DarlingMcpInvestigationTools.ReportsAsync(dataSource, McpDataDetail.MetadataOnly, null, id, 24, ct));
+ Assert.Equal(tool.GetProperty("reports").GetRawText(), masked.GetProperty("reports").GetRawText());
+ });
+ }
+
[Fact]
public async Task Submit_AProposedNote_LandsPending_AndTheReportIsStored()
{
diff --git a/Darling/Darling.Tests/InvestigationsPageTests.cs b/Darling/Darling.Tests/InvestigationsPageTests.cs
new file mode 100644
index 0000000000..8cfd2259d3
--- /dev/null
+++ b/Darling/Darling.Tests/InvestigationsPageTests.cs
@@ -0,0 +1,135 @@
+/*
+ * 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 System.ComponentModel;
+using System.Diagnostics;
+using System.Linq;
+using System.Text.Json;
+using Xunit;
+using static Darling.Tests.RepoFile;
+
+namespace Darling.Tests;
+
+///
+/// The web Investigations page (#5904), run in Node against a recording fetch: what it asks the two reads for, the list in
+/// the order the read gave, a picked request's report, stored brief and journal, markup in the data drawn as text, the
+/// poll's rebuild keeping the open request, and the empty, error and truncation lines. Behavior only; the spelling of
+/// the page's source is not pinned.
+///
+public sealed class InvestigationsPageTests
+{
+ private static JsonElement Run()
+ {
+ var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
+ psi.ArgumentList.Add(PathTo("Darling", "Darling.Tests", "investigations-harness.mjs"));
+ psi.ArgumentList.Add(PathTo("Darling", "PerformanceMonitor.Darling.Service", "wwwroot", "js"));
+ Process proc;
+ try
+ {
+ proc = Process.Start(psi)!;
+ }
+ catch (Win32Exception)
+ {
+ Assert.Skip("Node is not installed, so the shipped page script cannot be run.");
+ return default;
+ }
+
+ using (proc)
+ {
+ var error = proc.StandardError.ReadToEndAsync();
+ var output = proc.StandardOutput.ReadToEnd().Trim();
+ if (!proc.WaitForExit(30000))
+ {
+ proc.Kill(entireProcessTree: true);
+ Assert.Fail("the investigations harness did not finish in 30 s");
+ }
+
+ Assert.True(proc.ExitCode == 0, "the investigations harness failed: " + error.Result);
+ using var doc = JsonDocument.Parse(output);
+ return doc.RootElement.Clone();
+ }
+ }
+
+ private static string[] Strings(JsonElement e) => e.EnumerateArray().Select(x => x.GetString()!).ToArray();
+
+ [Fact]
+ public void TheListReadsTheRequestsInTheOrderTheReadGave_WithTheViewerColumns()
+ {
+ var first = Run().GetProperty("first");
+ var call = Assert.Single(first.GetProperty("listCalls").EnumerateArray());
+ Assert.Equal("100", call.GetProperty("limit").GetString());
+ Assert.False(call.TryGetProperty("state", out _));
+ Assert.Empty(first.GetProperty("reportCalls").EnumerateArray());
+ Assert.Equal("Opened,State,Server,Metric,Family,Alerts,Agent,Lease until,Reported,Route", string.Join(",", Strings(first.GetProperty("headers"))));
+ var rows = first.GetProperty("rows").EnumerateArray().Select(Strings).ToArray();
+ Assert.Equal(new[] { "srv-new", "srv-old" }, rows.Select(r => r[2]));
+ Assert.Equal(new[] { "reported", "open" }, rows.Select(r => r[1]));
+ Assert.Equal("agent-a", rows[0][6]);
+ Assert.Equal(",open,claimed,reported,closed,expired", string.Join(",", Strings(first.GetProperty("stateChoices"))));
+ }
+
+ [Fact]
+ public void PickingARequest_ReadsThatOneRequest_AndDrawsItsReportBriefAndJournal_AsText()
+ {
+ var picked = Run().GetProperty("picked");
+ var read = Assert.Single(picked.GetProperty("reportCalls").EnumerateArray());
+ Assert.Equal("11111111-1111-1111-1111-111111111111", read.GetProperty("request_id").GetString());
+ var text = picked.GetProperty("text").GetString()!;
+ Assert.Contains("Verdict: needs_person", text);
+ Assert.Contains("agent-a", text);
+ Assert.Contains("unreviewed and self-attributed", text);
+ Assert.Contains("Stored brief (data detail: maskLiterals)", text);
+ // Markup in the stored data is drawn as its characters, never built into elements.
+ Assert.Contains("SUMMARY ", text);
+ Assert.Contains("", text);
+ Assert.Equal(0, picked.GetProperty("createdImgOrScript").GetInt32());
+ // The journal keeps the read's order (newest first) and the claim time comes from its claimed event.
+ var journal = picked.GetProperty("journalRows").EnumerateArray().Select(Strings).ToArray();
+ Assert.Equal(new[] { "reported", "claimed", "opened" }, journal.Select(r => r[1]));
+ Assert.Matches("claimed .*6:05", text);
+ }
+
+ [Fact]
+ public void TheRebuild_KeepsTheOpenRequest_AndCloseEmptiesThePane()
+ {
+ var run = Run();
+ var rebuilt = run.GetProperty("rebuilt");
+ Assert.True(rebuilt.GetProperty("hasDetail").GetBoolean());
+ Assert.Single(rebuilt.GetProperty("reportCalls").EnumerateArray());
+ Assert.Equal("", run.GetProperty("closed").GetString());
+ }
+
+ [Fact]
+ public void AStateAndFamilyPick_AreSentToTheRead_AndDropTheOpenRequest()
+ {
+ var filtered = Run().GetProperty("filtered");
+ var calls = filtered.GetProperty("listCalls").EnumerateArray().ToArray();
+ Assert.Equal("claimed", calls[0].GetProperty("state").GetString());
+ Assert.Equal("claimed", calls[1].GetProperty("state").GetString());
+ Assert.Equal("blocking", calls[1].GetProperty("family").GetString());
+ Assert.Empty(filtered.GetProperty("reportCalls").EnumerateArray());
+ Assert.Equal("", filtered.GetProperty("pane").GetString());
+ }
+
+ [Fact]
+ public void ARequestWithNoReport_SaysSo_AndTheEmptyErrorAndTruncationLinesAreDrawn()
+ {
+ var run = Run();
+ var none = run.GetProperty("noReport").GetString()!;
+ Assert.Contains("No agent has reported on this request.", none);
+ Assert.Contains("never claimed", none);
+ Assert.DoesNotContain("unreviewed", none);
+ Assert.True(run.GetProperty("truncated").GetBoolean());
+ Assert.True(run.GetProperty("empty").GetBoolean());
+ Assert.True(run.GetProperty("error").GetBoolean());
+ Assert.Equal("2026-10-14T10:05:00Z", run.GetProperty("claimedAt").GetString());
+ Assert.Equal(JsonValueKind.Null, run.GetProperty("claimedAtNever").ValueKind);
+ Assert.False(run.GetProperty("params").GetProperty("none").TryGetProperty("state", out _));
+ }
+}
diff --git a/Darling/Darling.Tests/investigations-harness.mjs b/Darling/Darling.Tests/investigations-harness.mjs
new file mode 100644
index 0000000000..5ba4b63537
--- /dev/null
+++ b/Darling/Darling.Tests/investigations-harness.mjs
@@ -0,0 +1,222 @@
+/* Runs the shipped Investigations page (wwwroot/js/pages/investigations.js) against a recording fetch and a node-tree DOM,
+ and prints as one line of JSON what it asked for and drew: the first list, a state pick, a picked request's report, brief
+ and journal (markup in the data stays text), a rebuild (the 60 s poll) that keeps the open request, the empty and error
+ lines, and the truncation notices.
+ node investigations-harness.mjs */
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { withDocumentListeners } from "./web-picker-driver.mjs";
+
+const jsDir = process.argv[2];
+
+class FakeNode {
+ constructor(tag, text) {
+ this.tag = tag;
+ this.children = [];
+ this.attrs = {};
+ this.dataset = {};
+ this.style = {};
+ this.className = "";
+ this.value = "";
+ this.handlers = {};
+ this.text = text == null ? null : String(text);
+ this.classList = { add() {}, remove() {}, toggle() {}, contains: () => false };
+ }
+ get firstChild() {
+ return this.children[0] || null;
+ }
+ appendChild(child) {
+ child.parent = this;
+ this.children.push(child);
+ return child;
+ }
+ removeChild(child) {
+ const i = this.children.indexOf(child);
+ if (i >= 0) this.children.splice(i, 1);
+ child.parent = null;
+ return child;
+ }
+ get isConnected() {
+ let n = this;
+ while (n.parent) n = n.parent;
+ return n.isRoot === true;
+ }
+ setAttribute(name, value) {
+ this.attrs[name] = String(value);
+ }
+ getAttribute(name) {
+ return name in this.attrs ? this.attrs[name] : null;
+ }
+ addEventListener(type, fn) {
+ this.handlers[type] = fn;
+ }
+ focus() {}
+ closest() {
+ return null;
+ }
+ set textContent(value) {
+ this.children = [];
+ this.text = String(value);
+ }
+ get textContent() {
+ return (this.text || "") + this.children.map((c) => c.textContent).join("");
+ }
+}
+
+globalThis.Node = FakeNode;
+const created = [];
+globalThis.document = withDocumentListeners({
+ createElement: (tag) => {
+ created.push(tag);
+ return new FakeNode(tag);
+ },
+ createElementNS: (ns, tag) => new FakeNode(tag),
+ createTextNode: (text) => new FakeNode("#text", text),
+});
+
+const fetches = [];
+const bodies = {};
+globalThis.fetch = async (url, init) => {
+ const u = String(url);
+ fetches.push(u);
+ const tool = u.split("/api/read/")[1].split("?")[0];
+ const b = bodies[tool];
+ if (b && b.fail) return { status: 500, ok: false, text: async () => JSON.stringify({ error: "the store is down" }) };
+ return { status: 200, ok: true, text: async () => JSON.stringify(b) };
+};
+
+const all = (node, tag, out = []) => {
+ if (node.tag === tag) out.push(node);
+ node.children.forEach((c) => all(c, tag, out));
+ return out;
+};
+const byLabel = (root, label) => all(root, "select").find((n) => n.attrs["aria-label"] === label);
+const settle = () => new Promise((r) => setTimeout(r, 30));
+const calls = (tool) =>
+ fetches.filter((f) => f.includes("/api/read/" + tool)).map((f) => Object.fromEntries(new URL(f, "http://viewer.test").searchParams));
+const rowsOf = (root, id) => all(root, "tbody").flatMap((t) => t.children);
+const cells = (tr) => tr.children.map((c) => c.textContent);
+
+const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "investigations-"));
+try {
+ fs.cpSync(jsDir, scratch, { recursive: true });
+ fs.writeFileSync(path.join(scratch, "package.json"), '{ "type": "module" }');
+ fs.writeFileSync(
+ path.join(scratch, "charts.js"),
+ 'import { el } from "./util.js";\nexport const SERIES_COLORS = [];\nexport function normalizeColor(c) { return c; }\nexport function renderLineChart() { return el("div", {}); }\n' +
+ 'export function zoomableLineChart() { return el("div", {}); }\nexport function chartZoomScope() { return ""; }\n'
+ );
+ const mod = await import(pathToFileURL(path.join(scratch, "pages", "investigations.js")).href);
+ const { renderInvestigations, REQUEST_COLUMNS, JOURNAL_COLUMNS, claimedAt, listParams } = mod;
+
+ const A = "11111111-1111-1111-1111-111111111111";
+ const B = "22222222-2222-2222-2222-222222222222";
+ const list = (truncated) => ({
+ status: "ok",
+ requests_returned: 2,
+ truncated,
+ requests: [
+ { request_id: A, route_id: 3, server_name: "srv-new", family: "blocking", first_metric: "Blocking", alert_count: 4, state: "reported",
+ opened_at: "2026-10-14T10:00:00Z", expires_at: "2026-10-14T14:00:00Z", claimed_by: "agent-a", lease_until: "2026-10-14T10:20:00Z",
+ claim_count: 1, reported_at: "2026-10-14T10:12:00Z" },
+ { request_id: B, route_id: 3, server_name: "srv-old", family: "resources", first_metric: "High CPU", alert_count: 1, state: "open",
+ opened_at: "2026-10-14T09:00:00Z", expires_at: "2026-10-14T13:00:00Z", claimed_by: null, lease_until: null, claim_count: 0, reported_at: null },
+ ],
+ });
+ const reportsA = {
+ status: "ok", requests_returned: 1, truncated: false, journal_truncated: false,
+ requests: [],
+ reports: [
+ { request_id: A, report_format: 1, agent_id: "agent-a", submitted_at: "2026-10-14T10:12:00Z", verdict: "needs_person", root_cause: "ROOTCAUSE bold",
+ root_cause_found: false, summary: "SUMMARY ", evidence: [{ tool: "get_blocking", key_numbers: "head blocker 412 s" }],
+ labels: ["unreviewed", "self_attributed"] },
+ ],
+ journal: [
+ { event_time: "2026-10-14T10:12:00Z", request_id: A, server_id: 7, event: "reported", actor: "agent:agent-a", reason: null, detail: null },
+ { event_time: "2026-10-14T10:05:00Z", request_id: A, server_id: 7, event: "claimed", actor: "agent:agent-a", reason: null, detail: { claim: 1 } },
+ { event_time: "2026-10-14T10:00:00Z", request_id: A, server_id: 7, event: "opened", actor: "service", reason: "", detail: null },
+ ],
+ brief: { brief_format: 1, data_detail: "maskLiterals", request: { id: A, server: "srv-new" }, alerts: [{ metric: "Blocking" }] },
+ };
+ bodies.list_investigation_requests = list(false);
+ bodies.get_investigation_reports = reportsA;
+
+ const main = new FakeNode("div");
+ main.isRoot = true;
+ const out = {};
+
+ fetches.length = 0;
+ renderInvestigations(main);
+ await settle();
+ out.first = {
+ listCalls: calls("list_investigation_requests"),
+ reportCalls: calls("get_investigation_reports"),
+ headers: all(main, "th").map((t) => t.textContent.replace(/[ ▲▼]+$/, "")),
+ rows: rowsOf(main).map(cells),
+ selects: { State: byLabel(main, "State").value, Family: byLabel(main, "Family").value },
+ stateChoices: byLabel(main, "State").children.map((o) => o.attrs.value),
+ };
+
+ // Picking a row reads that one request and draws its report, brief and journal.
+ fetches.length = 0;
+ rowsOf(main)[0].handlers.click({ type: "click" });
+ await settle();
+ const slotText = () => all(main, "div").filter((d) => d.className === "inv-detail").map((d) => d.textContent).join("");
+ out.picked = {
+ reportCalls: calls("get_investigation_reports"),
+ text: slotText(),
+ pre: all(main, "pre").map((p) => p.textContent),
+ journalRows: all(main, "tbody").pop().children.map(cells),
+ journalHeaders: JOURNAL_COLUMNS.map((c) => c.label),
+ createdImgOrScript: created.filter((t) => t === "img" || t === "script").length,
+ };
+
+ // A poll rebuild keeps the open request.
+ fetches.length = 0;
+ renderInvestigations(main);
+ await settle();
+ out.rebuilt = { reportCalls: calls("get_investigation_reports"), hasDetail: slotText().includes("Request on srv-new") };
+
+ // Close empties the pane.
+ all(main, "button").find((b) => b.textContent === "Close").handlers.click();
+ out.closed = slotText();
+
+ // A state pick sends the state to the read and drops the open request.
+ fetches.length = 0;
+ byLabel(main, "State").value = "claimed";
+ byLabel(main, "State").handlers.change();
+ byLabel(main, "Family").value = "blocking";
+ byLabel(main, "Family").handlers.change();
+ await settle();
+ out.filtered = { listCalls: calls("list_investigation_requests"), reportCalls: calls("get_investigation_reports"), pane: slotText() };
+
+ // A request with no report says so, and the unreviewed notice is not shown for it.
+ bodies.get_investigation_reports = { status: "ok", requests: [], reports: [], journal: [], journal_truncated: true, brief: null };
+ rowsOf(main)[1].handlers.click({ type: "click" });
+ await settle();
+ out.noReport = slotText();
+
+ // The notices and the empty and error lines.
+ bodies.list_investigation_requests = list(true);
+ renderInvestigations(main);
+ await settle();
+ out.truncated = main.textContent.includes("Showing the newest 2 requests");
+ bodies.list_investigation_requests = { status: "empty", message: "No investigation request matches these filters." };
+ renderInvestigations(main);
+ await settle();
+ out.empty = main.textContent.includes("No investigation request matches these filters.");
+ bodies.list_investigation_requests = { fail: true };
+ renderInvestigations(main);
+ await settle();
+ out.error = main.textContent.includes("the store is down");
+
+ out.columns = REQUEST_COLUMNS.map((c) => c.key);
+ out.claimedAt = claimedAt(reportsA.journal, A);
+ out.claimedAtNever = claimedAt([{ event: "opened", event_time: "2026-10-14T10:00:00Z" }], A);
+ out.params = { none: listParams({ state: "", family: "" }), both: listParams({ state: "open", family: "jobs" }) };
+ console.log(JSON.stringify(out));
+} finally {
+ fs.rmSync(scratch, { recursive: true, force: true });
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index 6507cacd19..5b412a3cf9 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -6158,7 +6158,7 @@ internal static IReadOnlyDictionary BuildReadDispatch(I
["get_mute_rules"] = (c, pg, an) => DarlingMcpAlertTools.GetMuteRules(pg, QueryBool(c, "enabled_only", true), c.RequestAborted),
["get_notification_routes"] = (c, pg, an) => DarlingMcpAlertTools.GetNotificationRoutes(pg, c.RequestAborted),
["list_investigation_requests"] = (c, pg, an) => DarlingMcpInvestigationTools.ListInvestigationRequests(pg, Str(c, "state"), Server(c), Str(c, "family"), QueryInt(c, "limit", null, 20), c.RequestAborted),
- ["get_investigation_reports"] = (c, pg, an) => DarlingMcpInvestigationTools.ReportsAsync(pg, McpDataDetail.Full, Server(c), Str(c, "request_id"), Hours(c, 24), c.RequestAborted),
+ ["get_investigation_reports"] = (c, pg, an) => DarlingMcpInvestigationTools.ReportsAsync(pg, WebDataDetail(c), Server(c), Str(c, "request_id"), Hours(c, 24), c.RequestAborted),
/* ── fleet sweep reports (#3466 lane 4) ── the tool mirror beside the dedicated /api/sweeps
routes, the /api/fleet + /api/read/get_fleet_overview coexistence: the page reads its own
@@ -6718,6 +6718,12 @@ internal static IResult ServerErrorResult(string sentence, string route, ILogger
/// The server name from ?server= (or the tool's own ?server_name=); null when absent,
/// which lets a tool auto-select a sole configured server exactly as the MCP surface does.
+ /// #5904: the mcp.dataDetail level the investigation report read is masked at, from the setting the web host
+ /// registers once per start (the same value the MCP host serves). A host with none registered (a test host that maps the
+ /// routes directly) serves .
+ private static McpDataDetail WebDataDetail(HttpContext context) =>
+ context.RequestServices.GetService()?.Mode ?? McpDataDetail.Full;
+
private static string? Server(HttpContext context) => First(context, "server") ?? First(context, "server_name");
/* ── the Locking page's index detail selector (#5311): web-only, never an MCP parameter ── */
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs
index 46c1be3684..4374d03d8c 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs
@@ -551,8 +551,8 @@ public static Task GetInvestigationReports(
CancellationToken cancellationToken = default) =>
ReportsAsync(postgres, composer.Mode, server_name, request_id, hours_back, cancellationToken);
- /// The report read at . The web panel passes : its role
- /// can SELECT these tables itself, so the setting guards the MCP side (#5632 review round 2, M1).
+ /// The report read at . The web read passes the level the web host registered from
+ /// mcp.dataDetail (#5904), so the viewer shows reports and briefs masked exactly as the MCP tool serves them.
internal static async Task ReportsAsync(NpgsqlDataSource postgres, McpDataDetail mode, string? server_name,
string? request_id, int hours_back, CancellationToken cancellationToken)
{
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
index 0bbfc363a4..b3d060529d 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
@@ -862,6 +862,11 @@ tool parameters through DI. */
/* The VIEWER-role store pool the read endpoints query (registered for DI + passed to MapAll). */
builder.Services.AddSingleton(postgres);
+ /* #5904: mcp.dataDetail, read once per start, so the investigation report read the viewer serves is masked at the
+ level the MCP tools serve. An unrecognized value runs as metadataOnly (the strictest), as on the MCP host. */
+ builder.Services.AddSingleton(new McpDataDetailSetting(
+ McpDataDetailModes.Resolve(config.Mcp.DataDetail)));
+
/* #4188: Brotli/gzip for the JSON API and the static JS/CSS/HTML. A services-collection step, so it
has to run on the BUILDER, ahead of Build() - see ConfigureResponseCompression's own doc for why
this is a method the live-HTTP test calls too rather than a block copied into it. */
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html b/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html
index 4b9186491f..a889aff1f1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/index.html
@@ -36,6 +36,7 @@
Darling Web
FinOpsFleet SweepsAlert History
+ InvestigationsAlert RulesAdminCustom Views
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
index 14fdb65498..27b18f10b8 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/app.js
@@ -15,6 +15,7 @@
* #/server/{name} — one server's detail (Overview)
* #/server/{name}/{tab} — one server's detail, opened on a named sub-tab (pages/server-tabs.js)
* #/alerts — fleet-wide Alert History
+ * #/investigations — agent investigation requests, their reports and journal (#5904)
* #/alert-rules — Custom Alert Rules list (#3285)
* #/alert-rule/new — the alert-rule editor creating a new rule (optionally /new/{template})
* #/alert-rule/{id} — the alert-rule editor editing a saved rule (#3285)
@@ -44,6 +45,7 @@ import { renderAdmin } from "./pages/admin.js";
import { renderServer } from "./pages/server.js";
import { renderFinops } from "./pages/finops.js";
import { renderAlerts } from "./pages/alerts.js";
+import { renderInvestigations } from "./pages/investigations.js";
import { renderAlertRuleList } from "./pages/alert-rules.js";
import { renderViewList, renderView, currentViewRefresh, onViewRefreshChange, clearViewRefresh } from "./pages/views.js";
import { REFRESH_CHOICES, PAGE_REFRESH_CHOICES, nextRefreshDelayMs, isBackedOff, defaultRefreshChoice, refreshLabel, loadPageRefreshChoice, savePageRefreshChoice, PAGE_REFRESH_KEY } from "./refresh-policy.js";
@@ -86,6 +88,7 @@ function currentRoute() {
return { name: "triage", query: q >= 0 ? h.slice(q + 1) : "" };
}
if (h.startsWith("#/alerts")) return { name: "alerts" };
+ if (h === "#/investigations" || h === "#/investigations/") return { name: "investigations" };
/* Alert-rule routes (#3285): the list, the new form (optionally /new/{template}), then the bare /{id} edit form,
tested most-specific first. Distinct from #/alerts above (which cannot collide: #/alert- never starts #/alerts). */
if (h === "#/alert-rules" || h === "#/alert-rules/") return { name: "alertRules" };
@@ -180,6 +183,7 @@ function route(opts) {
else if (r.name === "ag") renderAg(main);
else if (r.name === "sweeps") renderSweeps(main, opts);
else if (r.name === "alerts") renderAlerts(main);
+ else if (r.name === "investigations") renderInvestigations(main);
else if (r.name === "alertRules") renderAlertRuleList(main);
else if (r.name === "alertEditor") renderAlertEditor(main, r.id, r.template);
else if (r.name === "admin") renderAdmin(main, r.tab);
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js
new file mode 100644
index 0000000000..ac6d8cb0e3
--- /dev/null
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js
@@ -0,0 +1,249 @@
+/*
+ * 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.
+ */
+
+/*
+ * The Investigations page (#5904): the agent investigation requests the service opened, newest first, with the state
+ * each one is in (open, claimed, reported, closed, expired). Two reads over /api/read, the answers of the MCP tools:
+ * list_investigation_requests for the list (filtered by state and family by the read itself), and
+ * get_investigation_reports for ONE request when a row is picked: its stored brief, its reports and its journal.
+ *
+ * Read only. Nothing here claims, releases, reports or decides anything about a request. The brief and the reports
+ * arrive already masked at the mcp.dataDetail level the service serves, and the page draws exactly what the read
+ * answered. A report is an agent's own claim, unreviewed and self-attributed, and the page says so.
+ *
+ * The list keeps the order the read gave (opened_at DESC, request_id) and the journal keeps its own (event_time
+ * DESC, journal_id DESC). The filters and the picked request live at MODULE scope, so the poll's rebuild of the page shows the
+ * same choices and the same open request.
+ *
+ * R4 (XSS): every value, the brief and the report text included, reaches the DOM as text through el()'s text path
+ * (a
or a cell), never as HTML.
+ */
+
+import { VIZ } from "../panels.js";
+import { READ_FIELDS } from "../read-fields.js";
+import { el, mount, loadingStrip, emptyStrip, noticeStrip, errorStrip, readErrorStrip, readTool, localTime, makeActivatable } from "../util.js";
+
+/** The states the list read takes; an empty value is every state. */
+export const STATES = [
+ { value: "", label: "All states" },
+ { value: "open", label: "Open" },
+ { value: "claimed", label: "Claimed" },
+ { value: "reported", label: "Reported" },
+ { value: "closed", label: "Closed" },
+ { value: "expired", label: "Expired" },
+];
+
+/** The alert families the list read takes; an empty value is every family. */
+export const FAMILIES = [
+ { value: "", label: "All families" },
+ { value: "blocking", label: "Blocking" },
+ { value: "query_performance", label: "Query performance" },
+ { value: "resources", label: "Resources" },
+ { value: "availability", label: "Availability" },
+ { value: "jobs", label: "Jobs" },
+ { value: "self_monitor", label: "Self monitor" },
+ { value: "other", label: "Other" },
+];
+
+/** The most requests one list read returns (the read takes 1 to 100). */
+export const LIST_LIMIT = 100;
+
+/** The list's columns, from READ_FIELDS (once, for the page and for a notebook cell). claimed_by is the agent id; lease_until is when its live lease ends (the claim time itself is in the journal). */
+export const REQUEST_COLUMNS = READ_FIELDS.list_investigation_requests.table.columns;
+
+/** The journal's columns, in the order the read gave them (newest first): the read's own, then the detail the page draws as text. */
+export const JOURNAL_COLUMNS = [...READ_FIELDS.get_investigation_reports.table.columns, { key: "detail_text", label: "Detail", wrap: true }];
+
+/* The rows of a reply under `key`; [] for anything else. */
+function rowsUnder(data, key) {
+ return data && Array.isArray(data[key]) ? data[key] : [];
+}
+
+/** The text of one value: a string as it is, nothing as "", anything else as indented JSON (a masked field is an object). */
+export function valueText(v) {
+ if (v == null) return "";
+ if (typeof v === "string") return v;
+ try {
+ return JSON.stringify(v, null, 2);
+ } catch {
+ return String(v);
+ }
+}
+
+/** When the request was claimed: the newest "claimed" event of its journal, or null when it was never claimed. */
+export function claimedAt(journal, requestId) {
+ let best = null;
+ for (const j of journal || []) {
+ if (!j || j.event !== "claimed") continue;
+ if (j.request_id != null && String(j.request_id) !== String(requestId)) continue;
+ if (best == null || String(j.event_time || "") > String(best)) best = j.event_time;
+ }
+ return best;
+}
+
+/** The journal rows of one request, each with its detail as text, in the order the read gave them. */
+export function journalRows(data, requestId) {
+ return rowsUnder(data, "journal")
+ .filter((j) => j && (j.request_id == null || String(j.request_id) === String(requestId)))
+ .map((j) => ({ ...j, detail_text: j.detail == null ? "" : valueText(j.detail) }));
+}
+
+/** The reports of one request, as the read gave them. */
+export function reportRows(data, requestId) {
+ return rowsUnder(data, "reports").filter((r) => r && String(r.request_id) === String(requestId));
+}
+
+/** The list read's parameters for the chosen filters. A blank choice is not sent. */
+export function listParams(choices) {
+ const p = { limit: LIST_LIMIT };
+ if (choices.state) p.state = choices.state;
+ if (choices.family) p.family = choices.family;
+ return p;
+}
+
+const choices = { state: "", family: "", requestId: null };
+let pageSeq = 0;
+let detailSeq = 0;
+let pageAbort = null;
+let detailAbort = null;
+
+function select(label, options, value, onPick) {
+ const sel = el("select", { "aria-label": label, class: "range-select-inline" }, options.map((o) => el("option", { value: String(o.value), text: o.label })));
+ sel.value = value;
+ sel.addEventListener("change", () => onPick(sel.value));
+ return el("label", { class: "range-control" }, [el("span", { text: label }), sel]);
+}
+
+function section(title, children) {
+ return el("div", { class: "inv-section" }, [el("strong", { text: title }), ...children]);
+}
+
+function reportBlock(rep) {
+ const found = rep.root_cause_found === true ? "Root cause found" : rep.root_cause_found === false ? "Root cause not found" : null;
+ const head = [rep.agent_id ? "Agent " + rep.agent_id : null, rep.submitted_at ? "reported " + localTime(rep.submitted_at) : null, found]
+ .filter(Boolean).join(" · ");
+ const parts = [el("div", { class: "muted", text: head })];
+ if (rep.verdict != null) parts.push(el("div", {}, [el("strong", { text: "Verdict: " }), el("span", { text: valueText(rep.verdict) })]));
+ if (rep.summary != null) parts.push(el("div", {}, [el("strong", { text: "Summary: " }), el("pre", { class: "inv-text", text: valueText(rep.summary) })]));
+ if (rep.root_cause != null) parts.push(el("div", {}, [el("strong", { text: "Root cause: " }), el("pre", { class: "inv-text", text: valueText(rep.root_cause) })]));
+ const evidence = Array.isArray(rep.evidence) ? rep.evidence : [];
+ if (evidence.length) {
+ parts.push(el("div", {}, [el("strong", { text: "Evidence (" + evidence.length + "): " }), ...evidence.map((e) => el("pre", { class: "inv-text", text: valueText(e) }))]));
+ }
+ return el("div", { class: "inv-report" }, parts);
+}
+
+/* The pane under the list for one request, from its get_investigation_reports answer. */
+function detailPane(row, data) {
+ const reports = reportRows(data, row.request_id);
+ const journal = journalRows(data, row.request_id);
+ const claimed = claimedAt(journal, row.request_id);
+ const facts = [
+ "State " + row.state,
+ row.claimed_by ? "agent " + row.claimed_by : "no agent",
+ claimed ? "claimed " + localTime(claimed) : "never claimed",
+ row.reported_at ? "reported " + localTime(row.reported_at) : "not reported",
+ ].join(" · ");
+ const parts = [
+ el("div", { class: "inv-detail-head" }, [
+ el("strong", { text: "Request on " + (row.server_name || "?") + " (" + (row.family || "?") + ")" }),
+ el("button", { type: "button", class: "jh-detail-close", text: "Close", onClick: () => { choices.requestId = null; mount(slot, []); } }),
+ ]),
+ el("div", { class: "muted", text: facts }),
+ ];
+
+ if (reports.length) {
+ parts.push(noticeStrip("A report is an agent's own claim: unreviewed and self-attributed, not a finding."));
+ parts.push(section("Report" + (reports.length > 1 ? "s" : ""), reports.map(reportBlock)));
+ } else {
+ parts.push(section("Report", [emptyStrip("No agent has reported on this request.")]));
+ }
+
+ if (data && data.brief != null) {
+ const level = data.brief && data.brief.data_detail ? " (data detail: " + data.brief.data_detail + ")" : "";
+ parts.push(section("Stored brief" + level, [el("pre", { class: "inv-text inv-brief", text: valueText(data.brief) })]));
+ }
+
+ const jparts = [];
+ if (data && data.journal_truncated && journal.length) jparts.push(noticeStrip("Showing the newest " + journal.length + " journal rows; older ones are not listed."));
+ jparts.push(VIZ.table({ journal }, { id: "investigation-journal", rowsKey: "journal", columns: JOURNAL_COLUMNS, emptyText: "No journal rows for this request." }));
+ parts.push(section("Journal", jparts));
+ return parts;
+}
+
+let slot = null;
+
+async function loadDetail(row) {
+ const ticket = ++detailSeq;
+ if (detailAbort) detailAbort.abort();
+ const signal = (detailAbort = new AbortController()).signal;
+ mount(slot, [loadingStrip("Loading this request…")]);
+ const res = await readTool("get_investigation_reports", { request_id: row.request_id }, signal);
+ if (ticket !== detailSeq) return;
+ if (res.kind === "aborted" || res.kind === "auth") return;
+ if (res.kind === "error") return mount(slot, [readErrorStrip(res.message)]);
+ if (res.kind === "empty") return mount(slot, [emptyStrip(res.message || "This request has no stored report or journal.")]);
+ try {
+ mount(slot, detailPane(row, res.data || {}));
+ } catch (e) {
+ mount(slot, [errorStrip("Could not draw this request: " + (e && e.message ? e.message : String(e)))]);
+ }
+}
+
+export function renderInvestigations(main) {
+ if (pageAbort) pageAbort.abort();
+ const signal = (pageAbort = new AbortController()).signal;
+ ++pageSeq;
+ const body = el("div", {}, [loadingStrip("Loading investigation requests…")]);
+ slot = el("div", { class: "inv-detail" });
+
+ const reload = () => {
+ choices.requestId = null;
+ mount(slot, []);
+ load();
+ };
+ const state = select("State", STATES, choices.state, (v) => { choices.state = v; reload(); });
+ const family = select("Family", FAMILIES, choices.family, (v) => { choices.family = v; reload(); });
+ mount(main, [
+ el("div", { class: "page-head" }, [el("h2", { text: "Investigations" }), el("div", { class: "spacer" }), state, family]),
+ el("div", { class: "muted", text: "Requests the service opened for agents to look into, newest first. Read only." }),
+ body,
+ slot,
+ ]);
+
+ async function load() {
+ const mine = ++pageSeq;
+ mount(body, loadingStrip("Loading investigation requests…"));
+ const res = await readTool("list_investigation_requests", listParams(choices), signal);
+ if (mine !== pageSeq || res.kind === "aborted" || res.kind === "auth") return;
+ if (res.kind === "error") return mount(body, readErrorStrip(res.message));
+ if (res.kind === "empty") return mount(body, emptyStrip(res.message || "No investigation requests."));
+ const data = res.data || {};
+ const rows = rowsUnder(data, "requests");
+ if (!rows.length) return mount(body, emptyStrip("No investigation requests."));
+ const onRow = (r, tr) => {
+ if (!r) return;
+ tr.style.cursor = "pointer";
+ tr.setAttribute("title", "Show this request's report and journal");
+ makeActivatable(tr, () => {
+ choices.requestId = r.request_id;
+ loadDetail(r);
+ });
+ };
+ const parts = [];
+ if (data.truncated) parts.push(noticeStrip("Showing the newest " + rows.length + " requests; older ones are not listed. Narrow the state or family."));
+ parts.push(el("div", { class: "muted", text: rows.length + " requests shown" }));
+ parts.push(VIZ.table(data, { id: "investigations", rowsKey: "requests", columns: REQUEST_COLUMNS, onRow, emptyText: "No investigation requests." }));
+ mount(body, parts);
+ const keep = choices.requestId ? rows.find((r) => String(r.request_id) === String(choices.requestId)) : null;
+ if (keep) loadDetail(keep);
+ else choices.requestId = null;
+ }
+
+ load();
+}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js
index 5e5ab27d73..831ce3b7fd 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js
@@ -782,6 +782,37 @@ export const READ_FIELDS = {
],
},
},
+ /* #5904: the Investigations page's two reads. The page reads the requests' columns from here, so they live once. */
+ list_investigation_requests: {
+ table: {
+ rowsKey: "requests",
+ emptyText: "No investigation requests.",
+ columns: [
+ { key: "opened_at", label: "Opened", format: "time" },
+ { key: "state", label: "State" },
+ { key: "server_name", label: "Server" },
+ { key: "first_metric", label: "Metric" },
+ { key: "family", label: "Family" },
+ { key: "alert_count", label: "Alerts", format: "int" },
+ { key: "claimed_by", label: "Agent" },
+ { key: "lease_until", label: "Lease until", format: "time" },
+ { key: "reported_at", label: "Reported", format: "time" },
+ { key: "route_id", label: "Route", format: "int" },
+ ],
+ },
+ },
+ get_investigation_reports: {
+ table: {
+ rowsKey: "journal",
+ emptyText: "No journal rows for this request.",
+ columns: [
+ { key: "event_time", label: "Time", format: "time" },
+ { key: "event", label: "Event" },
+ { key: "actor", label: "Actor" },
+ { key: "reason", label: "Reason", wrap: true },
+ ],
+ },
+ },
};
/**
From 1b9fd2bbd8d6670d644393ccf89a876f3e9bfaf5 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:40:33 -0400
Subject: [PATCH 2/8] Investigations page: the notebook field catalog finds the
shared payload builder (#5904)
---
.../Darling.Tests/AlertNotebookTableCatalogTests.cs | 13 +++++++++++++
.../DarlingWebEndpoints.cs | 2 +-
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs b/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs
index b9e44fd2bd..84213e2f92 100644
--- a/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs
+++ b/Darling/Darling.Tests/AlertNotebookTableCatalogTests.cs
@@ -256,6 +256,14 @@ public void ACollectorCostCell_ResolvesToTheFleetPartWithoutACollectorName_AndTh
["get_ag_health"] = "DarlingAgReader.cs",
};
+ /// #5904: a read whose payload is built in the shared library rather than in the Mcp folder (the investigation
+ /// tools' core, shared with Lite): the file in PerformanceMonitor.Common/Investigations that builds it.
+ private static readonly Dictionary s_commonPayloadFiles = new(StringComparer.Ordinal)
+ {
+ ["list_investigation_requests"] = "InvestigationToolCore.cs",
+ ["get_investigation_reports"] = "InvestigationToolCore.cs",
+ };
+
/// A key the payload code spells through a shared constant rather than a literal: the constant it uses.
private static readonly Dictionary s_keyConstants = new(StringComparer.Ordinal)
{
@@ -295,6 +303,11 @@ private static string PayloadSourceFor(string read, Dictionary t
source += File.ReadAllText(PathTo(s_serviceDir.Append("Mcp").Append(typeFile).ToArray()));
}
+ if (s_commonPayloadFiles.TryGetValue(read, out var commonFile))
+ {
+ source += File.ReadAllText(PathTo("PerformanceMonitor.Common", "Investigations", commonFile));
+ }
+
return source;
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index 5b412a3cf9..1e6f030dff 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -6722,7 +6722,7 @@ internal static IResult ServerErrorResult(string sentence, string route, ILogger
/// registers once per start (the same value the MCP host serves). A host with none registered (a test host that maps the
/// routes directly) serves .
private static McpDataDetail WebDataDetail(HttpContext context) =>
- context.RequestServices.GetService()?.Mode ?? McpDataDetail.Full;
+ (context.RequestServices.GetService(typeof(McpDataDetailSetting)) as McpDataDetailSetting)?.Mode ?? McpDataDetail.Full;
private static string? Server(HttpContext context) => First(context, "server") ?? First(context, "server_name");
From ed0bd0bae9f1a036cc7fadbd2614537f8fcd6428 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:46:15 -0400
Subject: [PATCH 3/8] The web report read serves the full level when the
request has no service provider (#5904)
---
.../PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index 1e6f030dff..3d8c32938f 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -6720,9 +6720,9 @@ internal static IResult ServerErrorResult(string sentence, string route, ILogger
/// which lets a tool auto-select a sole configured server exactly as the MCP surface does.
/// #5904: the mcp.dataDetail level the investigation report read is masked at, from the setting the web host
/// registers once per start (the same value the MCP host serves). A host with none registered (a test host that maps the
- /// routes directly) serves .
+ /// routes directly, whose request carries no service provider) serves .
private static McpDataDetail WebDataDetail(HttpContext context) =>
- (context.RequestServices.GetService(typeof(McpDataDetailSetting)) as McpDataDetailSetting)?.Mode ?? McpDataDetail.Full;
+ (context.RequestServices?.GetService(typeof(McpDataDetailSetting)) as McpDataDetailSetting)?.Mode ?? McpDataDetail.Full;
private static string? Server(HttpContext context) => First(context, "server") ?? First(context, "server_name");
From edf9821f9c4248fabf3aab086b012452805a6715 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:57:37 -0400
Subject: [PATCH 4/8] The web report read serves the full report and brief
again, as on dev (#5904)
---
.../InvestigationToolsLiveTests.cs | 32 +++++++------------
.../DarlingWebEndpoints.cs | 8 +----
.../Mcp/DarlingMcpInvestigationTools.cs | 4 +--
.../Mcp/DarlingWebHostService.cs | 5 ---
4 files changed, 14 insertions(+), 35 deletions(-)
diff --git a/Darling/Darling.Tests/InvestigationToolsLiveTests.cs b/Darling/Darling.Tests/InvestigationToolsLiveTests.cs
index ca71e496ec..a45671df11 100644
--- a/Darling/Darling.Tests/InvestigationToolsLiveTests.cs
+++ b/Darling/Darling.Tests/InvestigationToolsLiveTests.cs
@@ -13,7 +13,6 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using PerformanceMonitor.Common;
using PerformanceMonitor.Darling.Service;
@@ -196,11 +195,10 @@ await ExecAsync(connection,
}
/// #5904: the two reads the Investigations page makes carry the fields it draws, in the MCP order (requests
- /// opened_at DESC, journal event_time DESC), and the report read the web serves is masked at the level the
- /// web host registered: the same summary a full-level read returns is withheld at metadataOnly, so a masked report stays
- /// masked in the page.
+ /// opened_at DESC, journal event_time DESC), and the report read the web serves is the full report and
+ /// brief an operator sees (the same answer the tool gives at the full level).
[Fact]
- public async Task TheWebReads_CarryTheFieldsThePageDraws_InTheMcpOrder_AndMaskTheReportAtTheRegisteredLevel()
+ public async Task TheWebReads_CarryTheFieldsThePageDraws_InTheMcpOrder_AndTheReportIsServedInFull()
{
await RunAsync(async (store, dataSource, connection, ct) =>
{
@@ -213,17 +211,14 @@ await RunAsync(async (store, dataSource, connection, ct) =>
await DarlingMcpInvestigationTools.SubmitAsync(store, id, claim.GetProperty("claim_id").GetString()!, 1,
"needs_person", "could not tell", Evidence, "see the table SecretOrders for the cause", null, null, ct);
- async Task WebRead(string tool, string query, McpDataDetail? level)
+ async Task WebRead(string tool, string query)
{
- var services = new ServiceCollection();
- if (level is { } l)
- services.AddSingleton(new McpDataDetailSetting(l));
- var context = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
+ var context = new DefaultHttpContext();
context.Request.QueryString = new QueryString(query);
return Root(await DarlingWebEndpoints.BuildReadDispatch()[tool](context, dataSource, null!));
}
- var list = await WebRead("list_investigation_requests", "?limit=100", null);
+ var list = await WebRead("list_investigation_requests", "?limit=100");
var requests = list.GetProperty("requests").EnumerateArray().ToArray();
Assert.Equal(new[] { newer, older }, requests.Select(r => r.GetProperty("request_id").GetGuid()));
foreach (var key in new[] { "request_id", "opened_at", "state", "server_name", "first_metric", "family", "alert_count",
@@ -233,7 +228,7 @@ async Task WebRead(string tool, string query, McpDataDetail? level)
Assert.Equal("agent-a", requests[0].GetProperty("claimed_by").GetString());
Assert.Equal("open", requests[1].GetProperty("state").GetString());
- var full = await WebRead("get_investigation_reports", "?request_id=" + id, McpDataDetail.Full);
+ var full = await WebRead("get_investigation_reports", "?request_id=" + id);
var report = Assert.Single(full.GetProperty("reports").EnumerateArray().ToArray());
foreach (var key in new[] { "agent_id", "submitted_at", "verdict", "root_cause_found", "root_cause", "summary", "evidence" })
Assert.True(report.TryGetProperty(key, out _), "the report carries " + key);
@@ -247,15 +242,10 @@ async Task WebRead(string tool, string query, McpDataDetail? level)
foreach (var key in new[] { "event_time", "event", "actor", "reason", "request_id" })
Assert.True(journal[0].TryGetProperty(key, out _), "the journal row carries " + key);
- var masked = await WebRead("get_investigation_reports", "?request_id=" + id, McpDataDetail.MetadataOnly);
- var maskedText = masked.GetRawText();
- Assert.DoesNotContain("SecretOrders", maskedText, StringComparison.Ordinal);
- Assert.NotEqual(full.GetProperty("reports")[0].GetProperty("summary").GetString(),
- masked.GetProperty("reports")[0].GetProperty("summary").GetString());
- Assert.Equal("metadataOnly", masked.GetProperty("brief").GetProperty("data_detail").GetString());
- /* The MCP tool at the same level answers the same report: the web read goes through the same masking. */
- var tool = Root(await DarlingMcpInvestigationTools.ReportsAsync(dataSource, McpDataDetail.MetadataOnly, null, id, 24, ct));
- Assert.Equal(tool.GetProperty("reports").GetRawText(), masked.GetProperty("reports").GetRawText());
+ /* The web read is the tool's full-level answer: an operator sees the whole report and brief. */
+ var tool = Root(await DarlingMcpInvestigationTools.ReportsAsync(dataSource, McpDataDetail.Full, null, id, 24, ct));
+ Assert.Equal(tool.GetProperty("reports").GetRawText(), full.GetProperty("reports").GetRawText());
+ Assert.Equal(tool.GetProperty("brief").GetRawText(), full.GetProperty("brief").GetRawText());
});
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
index 3d8c32938f..6507cacd19 100644
--- a/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs
@@ -6158,7 +6158,7 @@ internal static IReadOnlyDictionary BuildReadDispatch(I
["get_mute_rules"] = (c, pg, an) => DarlingMcpAlertTools.GetMuteRules(pg, QueryBool(c, "enabled_only", true), c.RequestAborted),
["get_notification_routes"] = (c, pg, an) => DarlingMcpAlertTools.GetNotificationRoutes(pg, c.RequestAborted),
["list_investigation_requests"] = (c, pg, an) => DarlingMcpInvestigationTools.ListInvestigationRequests(pg, Str(c, "state"), Server(c), Str(c, "family"), QueryInt(c, "limit", null, 20), c.RequestAborted),
- ["get_investigation_reports"] = (c, pg, an) => DarlingMcpInvestigationTools.ReportsAsync(pg, WebDataDetail(c), Server(c), Str(c, "request_id"), Hours(c, 24), c.RequestAborted),
+ ["get_investigation_reports"] = (c, pg, an) => DarlingMcpInvestigationTools.ReportsAsync(pg, McpDataDetail.Full, Server(c), Str(c, "request_id"), Hours(c, 24), c.RequestAborted),
/* ── fleet sweep reports (#3466 lane 4) ── the tool mirror beside the dedicated /api/sweeps
routes, the /api/fleet + /api/read/get_fleet_overview coexistence: the page reads its own
@@ -6718,12 +6718,6 @@ internal static IResult ServerErrorResult(string sentence, string route, ILogger
/// The server name from ?server= (or the tool's own ?server_name=); null when absent,
/// which lets a tool auto-select a sole configured server exactly as the MCP surface does.
- /// #5904: the mcp.dataDetail level the investigation report read is masked at, from the setting the web host
- /// registers once per start (the same value the MCP host serves). A host with none registered (a test host that maps the
- /// routes directly, whose request carries no service provider) serves .
- private static McpDataDetail WebDataDetail(HttpContext context) =>
- (context.RequestServices?.GetService(typeof(McpDataDetailSetting)) as McpDataDetailSetting)?.Mode ?? McpDataDetail.Full;
-
private static string? Server(HttpContext context) => First(context, "server") ?? First(context, "server_name");
/* ── the Locking page's index detail selector (#5311): web-only, never an MCP parameter ── */
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs
index 4374d03d8c..46c1be3684 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingMcpInvestigationTools.cs
@@ -551,8 +551,8 @@ public static Task GetInvestigationReports(
CancellationToken cancellationToken = default) =>
ReportsAsync(postgres, composer.Mode, server_name, request_id, hours_back, cancellationToken);
- /// The report read at . The web read passes the level the web host registered from
- /// mcp.dataDetail (#5904), so the viewer shows reports and briefs masked exactly as the MCP tool serves them.
+ /// The report read at . The web panel passes : its role
+ /// can SELECT these tables itself, so the setting guards the MCP side (#5632 review round 2, M1).
internal static async Task ReportsAsync(NpgsqlDataSource postgres, McpDataDetail mode, string? server_name,
string? request_id, int hours_back, CancellationToken cancellationToken)
{
diff --git a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
index b3d060529d..0bbfc363a4 100644
--- a/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
+++ b/Darling/PerformanceMonitor.Darling.Service/Mcp/DarlingWebHostService.cs
@@ -862,11 +862,6 @@ tool parameters through DI. */
/* The VIEWER-role store pool the read endpoints query (registered for DI + passed to MapAll). */
builder.Services.AddSingleton(postgres);
- /* #5904: mcp.dataDetail, read once per start, so the investigation report read the viewer serves is masked at the
- level the MCP tools serve. An unrecognized value runs as metadataOnly (the strictest), as on the MCP host. */
- builder.Services.AddSingleton(new McpDataDetailSetting(
- McpDataDetailModes.Resolve(config.Mcp.DataDetail)));
-
/* #4188: Brotli/gzip for the JSON API and the static JS/CSS/HTML. A services-collection step, so it
has to run on the BUILDER, ahead of Build() - see ConfigureResponseCompression's own doc for why
this is a method the live-HTTP test calls too rather than a block copied into it. */
From 31f239154ba35c90c0b54f32040c14bad112d9b8 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 15 Oct 2026 20:57:59 -0400
Subject: [PATCH 5/8] Investigations page: the header comment no longer says
the read is masked (#5904)
---
.../wwwroot/js/pages/investigations.js | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js
index ac6d8cb0e3..d84d8ceb95 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/investigations.js
@@ -12,9 +12,9 @@
* list_investigation_requests for the list (filtered by state and family by the read itself), and
* get_investigation_reports for ONE request when a row is picked: its stored brief, its reports and its journal.
*
- * Read only. Nothing here claims, releases, reports or decides anything about a request. The brief and the reports
- * arrive already masked at the mcp.dataDetail level the service serves, and the page draws exactly what the read
- * answered. A report is an agent's own claim, unreviewed and self-attributed, and the page says so.
+ * Read only. Nothing here claims, releases, reports or decides anything about a request. The page draws exactly what
+ * the read answered: the operator sees the full report and brief, as in the desktop viewer (mcp.dataDetail masks
+ * agents over MCP only). A report is an agent's own claim, unreviewed and self-attributed, and the page says so.
*
* The list keeps the order the read gave (opened_at DESC, request_id) and the journal keeps its own (event_time
* DESC, journal_id DESC). The filters and the picked request live at MODULE scope, so the poll's rebuild of the page shows the
From 862e7570ea55a6ca2d886d65f5f11f1c1f488216 Mon Sep 17 00:00:00 2001
From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com>
Date: Thu, 15 Oct 2026 23:20:37 -0400
Subject: [PATCH 6/8] Lite: read-only investigations panel (#5904)
---
.../InvestigationPanelReader.cs | 149 ++++++++++++++++
Lite/MainWindow.xaml | 2 +
Lite/MainWindow.xaml.cs | 8 +
Lite/Windows/InvestigationsWindow.xaml | 127 ++++++++++++++
Lite/Windows/InvestigationsWindow.xaml.cs | 166 ++++++++++++++++++
5 files changed, 452 insertions(+)
create mode 100644 Lite/Investigations/InvestigationPanelReader.cs
create mode 100644 Lite/Windows/InvestigationsWindow.xaml
create mode 100644 Lite/Windows/InvestigationsWindow.xaml.cs
diff --git a/Lite/Investigations/InvestigationPanelReader.cs b/Lite/Investigations/InvestigationPanelReader.cs
new file mode 100644
index 0000000000..c30f571ba4
--- /dev/null
+++ b/Lite/Investigations/InvestigationPanelReader.cs
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor Lite.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using PerformanceMonitor.Common;
+
+namespace PerformanceMonitorLite.Investigations;
+
+/// One line of the investigations panel's list (#5904): what list_investigation_requests answers, with the
+/// times as local text. The claim time itself is in the journal, so the list carries the agent and the lease end and the
+/// detail carries the claim time, as the Darling page does.
+internal sealed record InvestigationPanelRequest(InvestigationRequestRow Row)
+{
+ public Guid RequestId => Row.RequestId;
+ public string State => Row.State;
+ public string ServerName => Row.ServerName;
+ public string FirstMetric => Row.FirstMetric;
+ public string Family => Row.Family;
+ public int RouteId => Row.RouteId;
+ public string Agent => Row.ClaimedBy ?? "";
+ public int ClaimCount => Row.ClaimCount;
+ public string Opened => InvestigationPanelReader.LocalText(Row.OpenedAtUtc);
+ public string LeaseUntil => InvestigationPanelReader.LocalText(Row.LeaseUntilUtc);
+ public string Reported => InvestigationPanelReader.LocalText(Row.ReportedAtUtc);
+}
+
+/// One report under a request, as the data-detail masker left it. The fields are text for a read-only box: a
+/// masked field is its JSON.
+internal sealed record InvestigationPanelReport(
+ string AgentId, string Submitted, string Verdict, bool RootCauseFound, string Summary, string RootCause, string Evidence);
+
+/// One journal row under a request (claims, releases, lapses, limits, refusals), newest first.
+internal sealed record InvestigationPanelJournalRow(string Time, string Event, string Actor, string Reason, string Detail);
+
+/// The detail pane of one request: its reports, journal and stored brief, all masked at the level the MCP
+/// investigation tools serve. is the newest claimed journal time, or empty when never claimed.
+internal sealed record InvestigationPanelDetail(
+ InvestigationRequestRow Request,
+ string Claimed,
+ IReadOnlyList Reports,
+ IReadOnlyList Journal,
+ string? Brief,
+ bool JournalTruncated);
+
+///
+/// The read side of Lite's investigations panel (#5904), read-only over : the list
+/// in the MCP list order (opened_at DESC, request_id, which the store's list read already gives) and, per request,
+/// the same ReportsAnswer the get_investigation_reports tool builds. Running that one masker over the read
+/// means a brief, report or journal reason the tool would mask is masked here, at the level the host serves now
+/// (mcp_data_detail); a brief stored at a weaker level is re-masked. Each call is its own store connection under
+/// the store's read rule. Nothing here claims, releases or reports.
+///
+internal sealed class InvestigationPanelReader
+{
+ /// The most requests one list shows (the MCP list tool's cap).
+ internal const int ListLimit = InvestigationToolCore.MaxLimit;
+
+ private readonly IInvestigationRequestStore _store;
+ private readonly Func _mode;
+ private readonly Func _utcNow;
+
+ internal InvestigationPanelReader(IInvestigationRequestStore store, Func mode, Func? utcNow = null)
+ {
+ _store = store;
+ _mode = mode;
+ _utcNow = utcNow ?? (() => DateTime.UtcNow);
+ }
+
+ /// The requests, newest opened first, optionally by state and family; Truncated says more than
+ /// matched.
+ internal async Task<(IReadOnlyList Requests, bool Truncated)> ListAsync(
+ string? state, string? family, CancellationToken cancellationToken = default)
+ {
+ var rows = await _store.ListAsync(
+ string.IsNullOrEmpty(state) ? null : state, null, string.IsNullOrEmpty(family) ? null : family,
+ ListLimit + 1, cancellationToken);
+ var truncated = rows.Count > ListLimit;
+ return (rows.Take(ListLimit).Select(r => new InvestigationPanelRequest(r)).ToList(), truncated);
+ }
+
+ /// One request's detail, or null when it is gone (purged between the list and the pick).
+ internal async Task ReadDetailAsync(Guid requestId, CancellationToken cancellationToken = default)
+ {
+ var read = await _store.ReadReportsAsync(
+ requestId, null, _utcNow(), 1, InvestigationToolCore.JournalLimit, cancellationToken);
+ return read.Requests.Count == 0 ? null : Detail(read, _mode());
+ }
+
+ /// The detail of the first request of at . The masked text comes
+ /// from ; the times and event names come from the read itself, row for row.
+ internal static InvestigationPanelDetail Detail(InvestigationReportsRead read, McpDataDetail mode)
+ {
+ var answer = JsonNode.Parse(InvestigationToolCore.ReportsAnswer(read, null, mode))!.AsObject();
+ var reportNodes = answer["reports"]!.AsArray();
+ var journalNodes = answer["journal"]!.AsArray();
+
+ var reports = new List();
+ for (var i = 0; i < read.Reports.Count; i++)
+ {
+ var r = read.Reports[i];
+ var node = reportNodes[i]!.AsObject();
+ reports.Add(new InvestigationPanelReport(
+ r.AgentId, LocalText(r.SubmittedAtUtc), Text(node["verdict"]), r.RootCauseFound,
+ Withheld(node, Text(node["summary"])), Text(node["root_cause"]), Text(node["evidence"])));
+ }
+
+ var journal = new List();
+ for (var i = 0; i < read.Journal.Count; i++)
+ {
+ var j = read.Journal[i];
+ var node = journalNodes[i]!.AsObject();
+ journal.Add(new InvestigationPanelJournalRow(
+ LocalText(j.EventTimeUtc), j.Event, Text(node["actor"]), Withheld(node, Text(node["reason"])), Text(node["detail"])));
+ }
+
+ var claimed = read.Journal.Where(j => j.Event == "claimed").Select(j => (DateTime?)j.EventTimeUtc).Max();
+ var brief = answer["brief"];
+ return new InvestigationPanelDetail(
+ read.Requests[0], LocalText(claimed), reports, journal, brief is null ? null : Text(brief), read.JournalTruncated);
+ }
+
+ /// A node as text: a string as it is, nothing as empty, anything else (a masked field is an object) as indented JSON.
+ internal static string Text(JsonNode? node) => node switch
+ {
+ null => "",
+ JsonValue v when v.TryGetValue(out string? s) => s ?? "",
+ _ => node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }),
+ };
+
+ /// A node the masker could not read comes back as {"withheld": reason}; the panel says so in the free-text field.
+ private static string Withheld(JsonObject node, string text) =>
+ node["withheld"] is { } w ? "(withheld) " + Text(w) : text;
+
+ /// A UTC time as local text, empty when null.
+ internal static string LocalText(DateTime? utc) =>
+ utc is null ? "" : DateTime.SpecifyKind(utc.Value, DateTimeKind.Utc).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture);
+}
diff --git a/Lite/MainWindow.xaml b/Lite/MainWindow.xaml
index 981d8376da..4a8401db74 100644
--- a/Lite/MainWindow.xaml
+++ b/Lite/MainWindow.xaml
@@ -296,6 +296,8 @@
ToolTip="Paste a list of servers and add them all at once with shared credentials"/>
+