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

FinOps Fleet Sweeps Alert History + Investigations Alert Rules Admin Custom 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"/>