> GetPlanCorrectionsAsync(
reader.IsDBNull(20) ? null : reader.GetString(20),
reader.IsDBNull(21) ? null : reader.GetDateTime(21),
reader.IsDBNull(22) ? null : reader.GetString(22),
- reader.IsDBNull(23) ? null : reader.GetDateTime(23)));
+ reader.IsDBNull(23) ? null : reader.GetDateTime(23),
+ collapse && !reader.IsDBNull(24) ? reader.GetDateTime(24) : null,
+ collapse && !reader.IsDBNull(25) ? reader.GetInt64(25) : null));
}
return rows;
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css
index 35886ef7f8..fce876122f 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/css/app.css
@@ -476,6 +476,11 @@ table.data th {
table.data tbody tr:nth-child(even) { background: var(--row-alt); }
table.data td.num { text-align: right; font-variant-numeric: tabular-nums; }
table.data td.wrap { white-space: normal; max-width: 620px; }
+/* A long text cell on one line, the rest on demand (#5644 W11). The full text stays in the node; the clip is only paint. */
+table.data td.clamp { max-width: 28rem; }
+.clamp-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.clamp-text[role="button"] { cursor: pointer; }
+.clamp-text.open { white-space: pre-wrap; overflow-wrap: anywhere; }
.plan-summary { display: flex; flex-wrap: wrap; gap: 0 1rem; margin: 4px 0; }
table.data td.pre { white-space: pre-wrap; }
table.data td.mono { font-family: var(--mono); font-size: 0.78rem; }
@@ -1313,7 +1318,7 @@ pre.code {
.locking-detail-counters dd { margin: 0; font-variant-numeric: tabular-nums; }
/* Job History (W2): the Message cell keeps a readable width and shows the first 3 lines; the row's detail pane holds the whole message. */
-.jh-message { min-width: 24rem; max-width: 40rem; display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; }
+.jh-message { min-width: 24rem; max-width: 40rem; }
.jh-detail:empty { display: none; }
.jh-detail { background: var(--bg-dark); border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; margin: 8px 0; }
.jh-detail-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js
index d265f2157a..cf3590bead 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/charts.js
@@ -185,6 +185,51 @@ function watchPlotWidth(host, draw, key, drawnW) {
observer.observe(host);
}
+/* Collection gaps (#5644 W7). The service host is off overnight, so a series has a stretch with no samples; a line drawn
+ point to point would run a straight diagonal across it and read as data. A gap is a jump between two neighbouring
+ timestamps of more than GAP_FACTOR times the series' own median interval, never less than GAP_FLOOR_MS (a collector can
+ miss a cycle without that being worth a break). Fewer than three points have no normal interval to compare against, so
+ they never break. Every time-series line, area and stacked chart goes through this, so no caller inserts null points.
+ A break is for a SAMPLED series that went silent, so it needs a cadence to be silent against. A series of events
+ (clusters of rows with hours between them) has none: its spacing runs from seconds to hours, the median is tiny, and
+ the floor would cut it into lone dots. A series has a cadence when its interquartile spacing is tight (the 75th
+ percentile of the intervals is within GAP_FACTOR times the 25th); one with no cadence never breaks (#5644 H4b). */
+const GAP_FACTOR = 3;
+const GAP_FLOOR_MS = 5 * 60 * 1000;
+
+/* The break threshold, in ms, for ascending timestamps (epoch ms); Infinity when there is nothing to compare against or
+ the series has no regular cadence (events). */
+export function gapThresholdMs(times) {
+ const deltas = [];
+ for (let i = 1; i < times.length; i++) {
+ const d = times[i] - times[i - 1];
+ if (d > 0) deltas.push(d);
+ }
+ if (deltas.length < 2) return Infinity;
+ deltas.sort((a, b) => a - b);
+ const mid = deltas.length >> 1;
+ const quantile = (p) => deltas[Math.round((deltas.length - 1) * p)];
+ if (quantile(0.75) > GAP_FACTOR * quantile(0.25)) return Infinity;
+ const median = deltas.length % 2 ? deltas[mid] : (deltas[mid - 1] + deltas[mid]) / 2;
+ return Math.max(GAP_FACTOR * median, GAP_FLOOR_MS);
+}
+
+/* Splits ascending positions (indices into times) into runs of neighbours no further apart than thresholdMs. */
+export function splitAtGaps(indices, times, thresholdMs) {
+ const runs = [];
+ let run = null;
+ let prev = -1;
+ for (const i of indices) {
+ if (run === null || times[i] - times[prev] > thresholdMs) {
+ run = [];
+ runs.push(run);
+ }
+ run.push(i);
+ prev = i;
+ }
+ return runs;
+}
+
/**
* Render a multi-series time chart into a returned `.chart` node.
* spec: { points, xKey, series:[{key,label,color}], formatValue?, clampMax?, unit?, mode?, thresholds? }
@@ -281,6 +326,8 @@ export function renderLineChart(spec) {
const tMin = hasWindow ? windowStart : dataTMin;
const tMax = hasWindow ? windowEnd : dataTMax;
const spanMs = tMax - tMin;
+ const rowTimes = rows.map((p) => p.t.getTime());
+ const gapMs = gapThresholdMs(rowTimes);
/* A numeric reader: null/NaN reads as null for line/area (a gap), or 0 for a stacked mode (a continuous baseline). */
const readVal = (r, key) => {
@@ -485,76 +532,94 @@ export function renderLineChart(spec) {
} else {
/* Filled bands drawn top series first so lower bands paint over the seams; each band is bounded above by its
own cumulative top and below by the previous series' cumulative top (the x-axis for series 0). */
+ /* A collection gap (#5644 W7) ends one run of bands and starts the next, so the bands do not ramp across it;
+ a run of one bucket has no extent, so it is dots at the stack tops, as the single-bucket case above. */
+ const runs = splitAtGaps(rows.map((_, i) => i), rowTimes, gapMs);
for (let k = series.length - 1; k >= 0; k--) {
- const top = [];
- const bottom = [];
- for (let i = 0; i < rows.length; i++) {
- top.push(xs[i] + "," + plotY(stackTops[i][k]));
- bottom.push(xs[i] + "," + plotY(k === 0 ? 0 : stackTops[i][k - 1]));
+ for (const run of runs) {
+ if (run.length === 1) {
+ root.appendChild(svg("circle", { class: "series-dot", cx: xs[run[0]], cy: plotY(stackTops[run[0]][k]), r: 4, fill: normalizeColor(series[k].color) }));
+ continue;
+ }
+ const top = [];
+ const bottom = [];
+ for (const i of run) {
+ top.push(xs[i] + "," + plotY(stackTops[i][k]));
+ bottom.push(xs[i] + "," + plotY(k === 0 ? 0 : stackTops[i][k - 1]));
+ }
+ bottom.reverse();
+ root.appendChild(
+ svg("polygon", {
+ class: "series-area",
+ "clip-path": clipAttr,
+ points: top.concat(bottom).join(" "),
+ fill: normalizeColor(series[k].color),
+ "fill-opacity": "0.72",
+ })
+ );
}
- bottom.reverse();
- root.appendChild(
- svg("polygon", {
- class: "series-area",
- "clip-path": clipAttr,
- points: top.concat(bottom).join(" "),
- fill: normalizeColor(series[k].color),
- "fill-opacity": "0.72",
- })
- );
}
}
} else {
/* Area fill (each series to the baseline) then the line on top; or just the line. Nulls drop the gap. */
for (const s of series) {
- const linePts = [];
+ const plottable = [];
for (let i = 0; i < rows.length; i++) {
const v = readVal(rows[i].r, s.key);
if (v == null) continue;
- linePts.push(xs[i] + "," + plotY(v));
+ plottable.push(i);
}
- /* A lone plottable point has no segment to stroke, so draw it as a dot — one bucket still shows its
- reading. (Its resting marker matches the hover dot; static class so it does not vanish on mouseout.) */
- if (linePts.length === 1) {
- const [cx, cy] = linePts[0].split(",");
- root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(s.color) }));
- continue;
- }
- if (linePts.length < 2) continue;
- if (filled) {
- const first = linePts[0].split(",")[0];
- const last = linePts[linePts.length - 1].split(",")[0];
- root.appendChild(
- svg("polygon", {
- class: "series-area",
- "clip-path": clipAttr,
- points: `${first},${baseY} ${linePts.join(" ")} ${last},${baseY}`,
- fill: normalizeColor(s.color),
- "fill-opacity": "0.15",
- })
- );
+ /* One run per stretch with samples (#5644 W7): a collection gap ends a run, so no line or fill crosses it. The
+ threshold comes from THIS series' own spacing: a 15-minute series on a chart of 1-minute buckets is not a
+ run of gaps. */
+ for (const run of splitAtGaps(plottable, rowTimes, gapThresholdMs(plottable.map((i) => rowTimes[i])))) {
+ const linePts = run.map((i) => xs[i] + "," + plotY(readVal(rows[i].r, s.key)));
+ /* A lone plottable point has no segment to stroke, so draw it as a dot — one bucket still shows its
+ reading. (Its resting marker matches the hover dot; static class so it does not vanish on mouseout.) */
+ if (linePts.length === 1) {
+ const [cx, cy] = linePts[0].split(",");
+ root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(s.color) }));
+ continue;
+ }
+ if (filled) {
+ const first = linePts[0].split(",")[0];
+ const last = linePts[linePts.length - 1].split(",")[0];
+ root.appendChild(
+ svg("polygon", {
+ class: "series-area",
+ "clip-path": clipAttr,
+ points: `${first},${baseY} ${linePts.join(" ")} ${last},${baseY}`,
+ fill: normalizeColor(s.color),
+ "fill-opacity": "0.15",
+ })
+ );
+ }
+ root.appendChild(svg("polyline", { class: "series-line", "clip-path": clipAttr, points: linePts.join(" "), stroke: normalizeColor(s.color) }));
}
- root.appendChild(svg("polyline", { class: "series-line", "clip-path": clipAttr, points: linePts.join(" "), stroke: normalizeColor(s.color) }));
}
}
/* The dual-axis overlay line (#1606): plotted against ITS axis (scaleY2), clamped into the plot box,
nulls dropped as gaps — same discipline as a primary line. Always a plain line (never filled/stacked). */
if (series2 && scaleY2) {
- const pts2 = [];
+ const plottable2 = [];
for (let i = 0; i < rows.length; i++) {
const v = rows[i].r[series2.key];
if (v == null || isNaN(v)) continue;
- const y2 = Math.max(M.t, Math.min(M.t + PLOT_H, scaleY2(Number(v))));
- pts2.push(xs[i] + "," + y2);
+ plottable2.push(i);
}
- if (pts2.length === 1) {
- /* Same single-bucket rule as the primary series: a lone overlay reading draws as a dot, not a dropped
- series, so "a dot per series" holds for the right axis too. */
- const [cx, cy] = pts2[0].split(",");
- root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(series2.color) }));
- } else if (pts2.length >= 2) {
- root.appendChild(svg("polyline", { class: "series-line series-line-overlay", "clip-path": clipAttr, points: pts2.join(" "), stroke: normalizeColor(series2.color) }));
+ /* Broken at collection gaps like a primary line (#5644 W7), by its own spacing (a 15-minute overlay over
+ 1-minute buckets draws one line, not a dot per sample). */
+ for (const run of splitAtGaps(plottable2, rowTimes, gapThresholdMs(plottable2.map((i) => rowTimes[i])))) {
+ const pts2 = run.map((i) => xs[i] + "," + Math.max(M.t, Math.min(M.t + PLOT_H, scaleY2(Number(rows[i].r[series2.key])))));
+ if (pts2.length === 1) {
+ /* Same single-bucket rule as the primary series: a lone overlay reading draws as a dot, not a dropped
+ series, so "a dot per series" holds for the right axis too. */
+ const [cx, cy] = pts2[0].split(",");
+ root.appendChild(svg("circle", { class: "series-dot", cx, cy, r: 4, fill: normalizeColor(series2.color) }));
+ } else {
+ root.appendChild(svg("polyline", { class: "series-line series-line-overlay", "clip-path": clipAttr, points: pts2.join(" "), stroke: normalizeColor(series2.color) }));
+ }
}
}
@@ -1312,7 +1377,7 @@ export async function openServerTabAt(server, tMs, item, { beforeRoute = null, s
if (typeof beforeRoute === "function") beforeRoute();
/* Bring the item's own grid into view once the router has built the tab. The router's hashchange listener was
added first (app.js), and for a server already on screen renderServer paints the tab synchronously, so the new
- panels are in place when this one runs. A chart and a grid share the title "Deadlocks"; the grid is the later one.
+ panels are in place when this one runs. The Blocking tab's chart is "Deadlock trend" (#5644 W16), so the grid is the one "Deadlocks" panel there.
The listener is once-only, and added after the range is taken, so a refused range leaves none behind. */
if (typeof window.addEventListener === "function") {
window.addEventListener(
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
index b34fe298bc..49653539f2 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/alerts.js
@@ -24,7 +24,7 @@
*/
import { el, mount, readTool, apiSend, buildQuery, loadingStrip, errorStrip, emptyStrip, noticeStrip, disclosure,
- ALERT_STATE_LABELS, alertDeliveryState, parseUtc } from "../util.js";
+ ALERT_STATE_LABELS, alertDeliveryState, parseUtc, localizeAlertDetail } from "../util.js";
import { VIZ, reapplyGridSort, gridRowOf } from "../panels.js";
import { copyText } from "../grid-tools.js";
import { pageRangePicker, windowResultOfSpec } from "../page-range.js";
@@ -50,9 +50,12 @@ const ALERT_COLUMNS = [
{ key: "threshold_value", label: "Threshold", format: "num1" },
{ key: "status", label: "Status", render: (a) => statusCell(a) },
{ key: "detail_text", label: "Detail", render: (a) => detailCell(a), copyValue: (a) => detailFullText(a) },
- { key: "triage", label: "Triage", csv: false, render: (a) => triageCell(a) },
];
+/* W14 (#5644): Triage and Mute are the grid's FIRST columns. At 1600 px the 1,791 px grid sat in a 1,298 px box and the
+ two actions were the last things reached by horizontal scroll. The checkbox column (a seat that can edit) stays
+ ahead of them. */
+const TRIAGE_COLUMN = { key: "triage", label: "Triage", csv: false, render: (a) => triageCell(a) };
/* #5654: the External source column - the name of the service that queued an external alert, from the row's
external_source (null for every built-in alert). Shown only while a fetched row carries one, so a store that never
receives external alerts keeps the table it had. drawAlerts flips showExternal and rebuilds the table when that changes. */
@@ -70,7 +73,7 @@ function alertColumns() {
const base = showExternal
? ALERT_COLUMNS.slice(0, 3).concat([EXTERNAL_COLUMN], ALERT_COLUMNS.slice(3))
: ALERT_COLUMNS;
- return canMute ? [SELECT_COLUMN].concat(base, [MUTE_COLUMN]) : base;
+ return canMute ? [SELECT_COLUMN, TRIAGE_COLUMN, MUTE_COLUMN].concat(base) : [TRIAGE_COLUMN].concat(base);
}
/* Dismiss (the web twin of the Viewer's Dismiss Selected / Dismiss All), for a seat that can edit. The checked
@@ -226,7 +229,7 @@ function detailCell(a) {
const hasDetail = a.detail_text != null && String(a.detail_text).trim().length > 0;
if (!hasDetail && !a.send_error) return el("span", { class: "muted", text: "—" });
- const summary = hasDetail ? a.detail_text : "Delivery error: " + a.send_error;
+ const summary = hasDetail ? localizeAlertDetail(a.detail_text, a.newest_sample) : "Delivery error: " + a.send_error;
const placeholder = el("div", {});
const node = disclosure(summary, placeholder, { max: 120 });
@@ -300,7 +303,10 @@ function detailBody(a, details) {
* the fix script. They replace the parsed detail_text, which is the same headings and fields without the prose.
* A row without them (not an Analysis alert, no advice, a failed fetch) keeps the detail_text rendering below. */
const items = Array.isArray(details) && details.length > 0 ? details : null;
- const fields = !items && hasDetail ? parseDetailFields(a.detail_text) : null;
+ /* #5644 W20: the stored text names UTC instants ("Incident Since: ...Z", the sample-age line's time of day); the page shows them
+ * in the grids' local format. The stored text, the copy and the CSV keep the stored form. */
+ const shownDetail = hasDetail ? localizeAlertDetail(a.detail_text, a.newest_sample) : null;
+ const fields = !items && hasDetail ? parseDetailFields(shownDetail) : null;
const body = [];
if (items) {
for (const item of items) body.push(...detailItem(item));
@@ -309,11 +315,11 @@ function detailBody(a, details) {
* engine persisted when the alert fired, never re-measured against this page's clock) - once, and only when the
* carrier is set. The detail_text branches below already carry the line in the text and must not add it again. */
const ageLine = sampleAgeLine(a.newest_sample);
- if (ageLine !== null) body.push(el("div", { class: "detail-sample-age", text: ageLine }));
+ if (ageLine !== null) body.push(el("div", { class: "detail-sample-age", text: localizeAlertDetail(ageLine, a.newest_sample) }));
} else if (fields) {
body.push(el("div", { class: "detail-fields" }, fields.map(fieldRow)));
} else if (hasDetail) {
- body.push(el("div", { class: "detail-raw", text: a.detail_text }));
+ body.push(el("div", { class: "detail-raw", text: shownDetail }));
}
if (a.send_error) {
body.push(el("div", { class: "detail-fields detail-error" }, [fieldRow(["Delivery error", a.send_error])]));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js
index b1917ef698..6d1c255b1e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/index-analysis.js
@@ -20,6 +20,7 @@
import { VIZ } from "../../panels.js";
import { databaseBox, newBoxChoice } from "./database-box.js";
import { el, mount, loadingStrip, noticeStrip, readErrorStrip, errorStrip, readTool, fmtInt, fmtNum } from "../../util.js";
+import { plainText } from "../../plain-text.js";
import { gatedEmptyStrip } from "./gate.js";
// The most recommendations the view lists (#5238). The desktop grid shows every finding, so the tab asks for the ceiling, as the
@@ -163,7 +164,7 @@ export const tab = {
if (more.length) {
parts.push(el("details", {}, [
el("summary", { text: "Analyzer notes (" + more.length + ")" }),
- ...more.map((t) => el("p", { class: "finops-note", text: t })),
+ ...more.map((t) => el("p", { class: "finops-note", text: plainText(t) })),
]));
}
parts.push(el("h3", { text: "Reclaimable Space (overall + per database)" }));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js
index d80641d0e3..cdab8b594b 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/server-inventory.js
@@ -37,7 +37,7 @@ const COLUMNS = [
{ key: "cores_per_socket", label: "Cores/Socket", format: "int" },
{ key: "hardware_note", label: "Hardware note", wrap: true },
{ key: "provisioning_status", label: "Status", sevKey: "provisioning_sev" },
- { key: "avg_cpu_pct", label: "Avg CPU %", format: "num1" },
+ { key: "avg_cpu_pct", label: "Avg SQL Server CPU %", format: "num1" },
{ key: "storage_total_gb", label: "Storage GB", format: "num1" },
{ key: "idle_db_count", label: "Idle DBs", format: "int" },
{ key: "sqlserver_start_time_local", label: "Start time (server clock)" },
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
index 1065489b4a..f0d46f2a98 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/finops/utilization.js
@@ -17,6 +17,7 @@
import { renderPanel, VIZ } from "../../panels.js";
import { READ_FIELDS } from "../../read-fields.js";
import { el, mount, loadingStrip, readErrorStrip, errorStrip, readTool, fmtInt } from "../../util.js";
+import { plainText } from "../../plain-text.js";
import { gatedEmptyStrip, PG_NOT_COLLECTED } from "./gate.js";
const CPU_HOURS = 24;
const TOP_HOURS = 24;
@@ -62,9 +63,9 @@ const SUMMARY_STATS = [
{ key: "health_score", label: "Health", format: "int" },
{ key: "monthly_cost_text", label: "Budget", format: "text" },
{ key: "annual_cost_text", label: "Annual", format: "text" },
- { key: "cpu.avg_cpu_pct", label: "Avg CPU %", format: "num1" },
- { key: "cpu.p95_cpu_pct", label: "P95 CPU %", format: "num1" },
- { key: "cpu.max_cpu_pct", label: "Max CPU %", format: "int" },
+ { key: "cpu.avg_cpu_pct", label: "Avg SQL Server CPU %", format: "num1" },
+ { key: "cpu.p95_cpu_pct", label: "P95 SQL Server CPU %", format: "num1" },
+ { key: "cpu.max_cpu_pct", label: "Max SQL Server CPU %", format: "int" },
{ key: "cpu.cpu_count", label: "vCores", format: "int", nullKey: "cpu.cpu_count_reason", showWhen: { key: "cpu.cpu_count_unit", equals: "vcores" } },
{ key: "cpu.cpu_count", label: "CPUs", format: "int", nullKey: "cpu.cpu_count_reason", hideWhen: { key: "cpu.cpu_count_unit", equals: "vcores" } },
{ key: "cpu.max_workers", label: "Max workers", format: "int" },
@@ -82,9 +83,9 @@ const SUMMARY_STATS = [
const TREND_COLUMNS = [
{ key: "day_label", label: "Day" },
{ key: "verdict_label", label: "Status", sevKey: "verdict_sev" },
- { key: "avg_cpu_pct", label: "Avg CPU %", format: "num1" },
- { key: "p95_cpu_pct", label: "P95 CPU %", format: "num1" },
- { key: "max_cpu_pct", label: "Max CPU %", format: "int" },
+ { key: "avg_cpu_pct", label: "Avg SQL Server CPU %", format: "num1" },
+ { key: "p95_cpu_pct", label: "P95 SQL Server CPU %", format: "num1" },
+ { key: "max_cpu_pct", label: "Max SQL Server CPU %", format: "int" },
{ key: "memory_ratio", label: "Mem Ratio", format: "num2" },
];
/* utilization-keys:end */
@@ -100,7 +101,7 @@ const TOP_TOTAL_COLUMNS = [
const TOP_AVG_COLUMNS = [
{ key: "database_name", label: "Database" },
- { key: "avg_cpu_ms", label: "Avg CPU (ms)", format: "int" },
+ { key: "avg_cpu_ms", label: "Avg CPU (ms)", format: "num2" },
{ key: "avg_io_mb", label: "Avg IO (MB)", format: "num2" },
{ key: "execution_count", label: "Execs", format: "int" },
];
@@ -147,7 +148,7 @@ function loadUtilization(body, server, ctx) {
mount(body, [
el("h3", {}, [SUMMARY_TITLE, el("span", { class: "panel-sub", text: " " + SUMMARY_SUBTITLE })]),
VIZ.stat(view, { stats }),
- data.health_score_note ? el("p", { class: "finops-note", title: data.health_score_note, text: data.health_score_note }) : null,
+ data.health_score_note ? el("p", { class: "finops-note", title: plainText(data.health_score_note), text: plainText(data.health_score_note) }) : null,
el("p", { class: "finops-reason", text: data.verdict_reason }),
el("details", {}, [
el("summary", { text: "7-Day Provisioning Trend" }),
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js
index e20712bcca..eaaf2da142 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/fleet.js
@@ -728,10 +728,15 @@ export function metricBands(c) {
: c.available_threads != null
? fmtInt(c.available_threads) + " free"
: "ok";
- const threadsDetail =
+ /* W19 (#5644): the tile value reads "N free", so the line under it must not repeat N over the total (that read as
+ N in use). It says how many are in use, of the total. */
+ const threadsInUse =
c.total_threads != null
- ? fmtInt(c.available_threads ?? c.total_threads - (c.current_workers ?? 0)) + " / " + fmtInt(c.total_threads) + " threads"
+ ? c.available_threads != null
+ ? c.total_threads - c.available_threads
+ : c.current_workers ?? 0
: null;
+ const threadsDetail = c.total_threads != null ? fmtInt(threadsInUse) + " in use / " + fmtInt(c.total_threads) + " threads" : null;
const cpuValue =
c.total_cpu_percent != null || c.cpu_percent != null ? fmtPct(c.total_cpu_percent ?? c.cpu_percent) : "n/a";
@@ -807,7 +812,9 @@ export function metricBands(c) {
}
return el("div", { class: "metric-bands" }, [
- chip("CPU", cpuValue, c.cpu_severity, cpuDetail),
+ /* W19 (#5644): total_cpu_percent is the whole box (SQL Server plus other processes); FinOps reads the SQL Server
+ process alone, so this chip says which one it shows. The cpu_percent fallback (a PostgreSQL target) stays "CPU". */
+ chip(c.total_cpu_percent != null ? "Total CPU" : "CPU", cpuValue, c.cpu_severity, cpuDetail),
chip("Threads", threadsValue, c.threads_severity, threadsDetail),
chip("Memory", memValue, c.memory_severity, memDetail),
chip("Blocking", fmtInt(c.blocking_count), c.blocking_severity, blockingDetail),
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js
index 0442e03bdb..ffd8342a8e 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/job-history.js
@@ -25,7 +25,7 @@ import { VIZ } from "../panels.js";
import { pageRangePicker, windowOfSpec } from "../page-range.js";
import { relativeSpec } from "../time-range.js";
import { orderServers } from "../server-order.js";
-import { el, mount, clear, loadingStrip, emptyStrip, noticeStrip, errorStrip, readErrorStrip, readTool, readToolWithinKeptHistory, keptWindowStrip, localTime, makeActivatable } from "../util.js";
+import { el, mount, clear, loadingStrip, emptyStrip, noticeStrip, errorStrip, readErrorStrip, readTool, readToolWithinKeptHistory, keptWindowStrip, localTime, makeActivatable, clampText } from "../util.js";
/** The default range, in hours. The range itself is the shared time range picker's (#5562). */
export const DEFAULT_HOURS = 24;
@@ -42,7 +42,9 @@ export const STATUSES = [
/** The row limits on offer (the read takes at most 1000). */
export const LIMITS = [100, 250, 500, 1000];
-/** The desktop grid's columns, in its order: Run Time, Server, Job, Category, Step, Status, Duration, Retries, Last Success, Message. */
+/** The grid's columns, in the desktop grid's order: Run Time, Server, Job, Category, Step, Status, Duration, Retries, Message.
+ * #5644 W21: the desktop grid's Last Success column is not here. The read's `last_success` is the JOB's current last
+ * success, the same on every run row of a job, so an older run showed a time later than its own run. */
export const JOB_HISTORY_COLUMNS = [
{ key: "run_time", label: "Run Time", format: "time" },
{ key: "server", label: "Server" },
@@ -52,16 +54,16 @@ export const JOB_HISTORY_COLUMNS = [
{ key: "status", label: "Status" },
{ key: "duration_formatted", label: "Duration", sortValue: (r) => r.duration_seconds },
{ key: "retries", label: "Retries", format: "int" },
- { key: "last_success", label: "Last Success", format: "time" },
- { key: "message", label: "Message", wrap: true, render: messageCell },
+ { key: "message", label: "Message", clamp: true, render: messageCell },
];
/* The Message column (the morning walk, W2). A failed step's message can run to thousands of characters and sat in a
- column the table squeezed to about 90 px, so each row grew 150-250 px tall. The cell now has a readable width and
- shows the first few lines only; the whole message opens in the detail pane when the row is clicked. */
+ column the table squeezed to about 90 px, so each row grew 150-250 px tall. The cell has a readable width and is
+ clipped to ONE line (#5644 W11, was three), with the full text in the tooltip;
+ the whole message opens in the detail pane when the row is clicked. */
function messageCell(r) {
const text = r && r.message != null ? String(r.message) : "";
- return el("div", { class: "jh-message", title: text || null, text });
+ return el("div", { class: "jh-message" }, [clampText(text, { expandable: false })]);
}
/* A run has no id in the read, so the open row is found again after a rebuild by what identifies it. */
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
index d6ecb3d1ad..244e8badc6 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/server-tabs.js
@@ -39,8 +39,8 @@
* touches innerHTML.
*/
-import { el, makeActivatable, readTool, readToolWithinKeptHistory, keptWindowStrip, windowFloorStrip, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, getPath, fmtMs, fmtRate, localTime, parseUtc, windowFromHours, daysText, dbScopeChip, dbFilteredEmptyText, sourceStrip, getActiveDatabaseFilter, queryWaitFilter, setQueryWaitFilter, waitIsLinked } from "../util.js";
-import { renderPanel, setPanelSignal, getPanelSignal, VIZ, worstFirst, newestFirst } from "../panels.js";
+import { el, makeActivatable, readTool, readToolWithinKeptHistory, keptWindowStrip, windowFloorStrip, markPartialSubtitle, mount, truncate, loadingStrip, errorStrip, readErrorStrip, emptyStrip, disclosure, noticeStrip, metricNote, getPath, fmtMs, fmtRate, localTime, parseUtc, windowFromHours, daysText, dbScopeChip, dbFilteredEmptyText, sourceStrip, clampText, getActiveDatabaseFilter, queryWaitFilter, setQueryWaitFilter, waitIsLinked } from "../util.js";
+import { renderPanel, emptyHeadline, setPanelSignal, getPanelSignal, VIZ, worstFirst, newestFirst } from "../panels.js";
import { zoomableLineChart, chartZoomScope, SERIES_COLORS, CATEGORICAL_COLORS } from "../charts.js";
import { multiPicker, mergeSeriesRows } from "../multi-picker.js";
import { pgPlanColumn } from "./pg-plan-viewer.js";
@@ -218,7 +218,7 @@ function fanout(read, params, specs) {
const body = shells[i].body;
if (hidesPanel(spec, res, shells[i], keys[i])) return;
if (res.kind === "error") return mount(body, readErrorStrip(res.message));
- if (res.kind === "empty") return mount(body, [keptWindowStrip(res), windowFloorStrip(res.data, spec), emptyStrip(res.message)]);
+ if (res.kind === "empty") return mount(body, [keptWindowStrip(res), windowFloorStrip(res.data, spec), emptyStrip(emptyHeadline(spec, res))]);
try {
/* #2802: a fanout spec carries no `params` of its own (the window lives on the shared fetch above), so
hand vizLine the fetch's `hours` as `windowHours` — otherwise a fanout line panel (Current Waits,
@@ -234,6 +234,7 @@ function fanout(read, params, specs) {
opts.atTimeItem; Active Queries when it names none. */
const rendered = VIZ[spec.viz](res.data, { ...spec, read, windowHours: res.keptHours || (params && params.hours), atTime: spec.atTime === false || !params || !params.server ? null : { server: params.server, item: spec.atTimeItem || "queries" } });
+ markPartialSubtitle(shells[i].panel, res.data, spec);
mount(body, [keptWindowStrip(res), windowFloorStrip(res.data, spec), sourceStrip(res.data, spec), typeof note === "string" && note.trim() ? noticeStrip(note) : null, rendered]);
} catch (e) {
mount(body, errorStrip("Could not render this panel: " + (e && e.message ? e.message : String(e))));
@@ -1000,9 +1001,9 @@ function pivot(rows, { xKey, seriesKey, valueKey }, maxSeries = 8) {
* and its process rows follow the database filter.
* `control` (#5226) is a node drawn under the title, before the rows: the ranking selector on the Top Queries and Top Procedures cards.
*/
-function table(title, read, params, rowsKey, columns, subtitle, emptyText, span = 2, noteKey = null, moreNoteKeys = null, columnGroups = null, control = null, dbScope = null, extensionMissingLine = null) {
+function table(title, read, params, rowsKey, columns, subtitle, emptyText, span = 2, noteKey = null, moreNoteKeys = null, columnGroups = null, control = null, dbScope = null, extensionMissingLine = null, emptyWins = false) {
if (!emptyText) throw new Error("table(" + title + "): a table panel must explain its own empty state.");
- const desc = { title, subtitle, read, params, viz: "table", rowsKey, columns, emptyText, moreNoteKeys, span, noteKey, dbScope, extensionMissingLine };
+ const desc = { title, subtitle, read, params, viz: "table", rowsKey, columns, emptyText, moreNoteKeys, span, noteKey, dbScope, extensionMissingLine, emptyWins };
if (columnGroups) Object.assign(desc, { groups: columnGroups.groups, defaultGroups: columnGroups.defaultGroups });
if (control) desc.control = control;
return renderPanel(desc);
@@ -1054,8 +1055,12 @@ function line(title, read, params, rowsKey, xKey, series, opts = {}) {
format: opts.format,
unit: opts.unit,
emptyText: opts.emptyText,
+ /* #5644 W16: the page's one sentence replaces the read's own empty message. */
+ emptyWins: opts.emptyWins === true,
/* #5244: the answer's field naming the collector that answered (get_blocking_trend's `source`); absent on every other line. */
sourceKey: opts.sourceKey,
+ /* #5644 W16: wording that follows the collector that answered (see BLOCKING_BY_SOURCE); absent on every other line. */
+ bySource: opts.bySource,
/* The panel is not drawn when the server answers "not collected" (it does not apply to this kind of server). */
hideWhenNotCollected: opts.hideWhenNotCollected === true,
span: opts.span ?? 1,
@@ -1095,18 +1100,21 @@ export const SERVER_TABS = [
format: "mb",
emptyText: "No memory samples in this window.",
}),
- line("Blocking Events", "get_blocking_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
+ line("Blocked process report trend", "get_blocking_trend", { server, hours: ctx.hours }, "trend", "time", BLOCKING_REPORT_SERIES, {
subtitle: ctx.label,
format: "int",
+ unit: "reports",
atTimeItem: "blocking",
sourceKey: "source",
- emptyText: "No blocking events in this window — an empty trend here means none happened, not that nothing was collected.",
+ bySource: BLOCKING_BY_SOURCE,
+ emptyText: "No blocked process reports in this window — an empty trend here means none were captured, not that nothing was collected.",
}),
line("Deadlocks", "get_deadlock_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
subtitle: ctx.label,
format: "int",
atTimeItem: "deadlocks",
- emptyText: "No deadlocks in this window — an empty trend here means none happened, not that nothing was collected.",
+ emptyText: DEADLOCKS_EMPTY_TEXT,
+ emptyWins: true,
}),
fileIoPanel(server, ctx),
table(
@@ -1316,18 +1324,21 @@ export const SERVER_TABS = [
daysText(widestHours) +
". A Custom View can show more blocking and deadlock history.",
build: (server, ctx) => [
- line("Blocking Events", "get_blocking_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
+ line("Blocked process report trend", "get_blocking_trend", { server, hours: ctx.hours }, "trend", "time", BLOCKING_REPORT_SERIES, {
subtitle: ctx.label,
format: "int",
+ unit: "reports",
atTimeItem: "blocking",
sourceKey: "source",
- emptyText: "No blocking events in this window — an empty trend here means none happened, not that nothing was collected.",
+ bySource: BLOCKING_BY_SOURCE,
+ emptyText: "No blocked process reports in this window — an empty trend here means none were captured, not that nothing was collected.",
}),
- line("Deadlocks", "get_deadlock_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
+ line("Deadlock trend", "get_deadlock_trend", { server, hours: ctx.hours }, "trend", "time", COUNT_SERIES, {
subtitle: ctx.label,
format: "int",
atTimeItem: "deadlocks",
- emptyText: "No deadlocks in this window — an empty trend here means none happened, not that nothing was collected.",
+ emptyText: DEADLOCKS_EMPTY_TEXT,
+ emptyWins: true,
}),
table(
"Blocking",
@@ -1349,9 +1360,15 @@ export const SERVER_TABS = [
"deadlocks",
[...DEADLOCK_COLUMNS, deadlockPlanColumn(server)],
ctx.label,
- "No deadlocks in this window.",
+ DEADLOCKS_EMPTY_TEXT,
2,
- "separately_monitored_note"
+ "separately_monitored_note",
+ null,
+ null,
+ null,
+ null,
+ null,
+ true
),
/* #2484: the Current Waits tab the viewer has and the browser did not. ONE read, two panels --
via fanout, not two line() calls, because the tab must not fetch the same read twice (there is
@@ -1418,7 +1435,8 @@ export const SERVER_TABS = [
rowsKey: "deadlock_severity",
xKey: "time",
series: DEADLOCK_SEVERITY_SERIES,
- emptyText: "No deadlocks in this window.",
+ emptyText: DEADLOCKS_EMPTY_TEXT,
+ emptyWins: true,
},
]),
deadlockGraphsPanel(server, ctx),
@@ -2957,7 +2975,7 @@ const PROPERTY_STATS = [
{ key: "cpu_count", label: "Logical CPUs", format: "int" },
{ key: "socket_count", label: "Sockets", format: "int", hideWhen: AZURE_SQL_DATABASE },
{ key: "cores_per_socket", label: "Cores/socket", format: "int", hideWhen: AZURE_SQL_DATABASE },
- { key: "hyperthread_ratio", label: "HT ratio", format: "int", hideWhen: AZURE_SQL_DATABASE },
+ { key: "hyperthread_ratio", label: "Logical CPUs per socket", format: "int", hideWhen: AZURE_SQL_DATABASE },
{ key: "physical_memory_mb", label: "Physical memory", format: "mb", hideWhen: AZURE_SQL_DATABASE },
{ key: "is_clustered", label: "Clustered", format: "bool" },
{ key: "is_hadr_enabled", label: "Always On", format: "bool" },
@@ -3034,17 +3052,20 @@ const MEMORY_STATS = [
{ key: "total_server_memory_mb", label: "Total server", format: "mb" },
{ key: "target_server_memory_mb", label: "Target server", format: "mb" },
{ key: "buffer_pool_mb", label: "Buffer pool", format: "mb" },
- { key: "plan_cache_mb", label: "Plan cache", format: "mb" },
+ { key: "plan_cache_mb", label: "Plan cache (all cache stores)", format: "mb" },
{ key: "system_memory_state", label: "System state", format: "text", small: true, nullKey: "system_memory_state_note" },
{ key: "sql_memory_model", label: "Memory model", format: "text", small: true },
];
+/* W16 (#5644): the Blocking tab's Deadlock trend chart and Deadlocks table say the same thing when empty. */
+const DEADLOCKS_EMPTY_TEXT = "No deadlocks in this window.";
+
const PLAN_CACHE_STATS = [
{ key: "summary.bloat_level", label: "Bloat", format: "text", small: true },
{ key: "summary.total_plans", label: "Plans", format: "int" },
{ key: "summary.single_use_plans", label: "Single-use", format: "int" },
{ key: "summary.single_use_percent", label: "Single-use %", format: "num1" },
- { key: "summary.total_size_mb", label: "Cache size", format: "mb" },
+ { key: "summary.total_size_mb", label: "Cached plans size", format: "mb" },
{ key: "summary.single_use_size_mb", label: "Single-use size", format: "mb" },
{ key: "summary.wasted_percent", label: "Wasted %", format: "num1" },
{ key: "summary.bloat_recommendation", label: "Recommendation", format: "text", small: true },
@@ -3163,6 +3184,14 @@ const MEMORY_SERIES = [
put their gridlines on whole numbers (charts.js integerTicks) instead of repeating one label down a 0-1 axis. */
const COUNT_SERIES = [{ key: "count", label: "Events" }];
+/* #5644 W16: get_blocking_trend counts one point per blocked-process REPORT (a single wait can produce several), so the
+ chart says "reports" where the tile beside it counts one event per wait. Same read and same count as COUNT_SERIES. */
+const BLOCKING_REPORT_SERIES = [{ key: "count", label: "Reports" }];
+/* When get_blocking_trend has no blocked process reports for the window and falls back to the DMV blocking snapshot (AWS RDS and any
+ server without the blocked process report event), a point counts snapshot rows, not reports: title, legend and axis follow the
+ answer's `source` (panels.js applies it), so the chart never calls snapshot rows reports. */
+const BLOCKING_BY_SOURCE = { "DMV snapshot": { title: "Blocking snapshot trend", label: "Snapshot rows", unit: "snapshot rows" } };
+
/* #2484: the aggregate lock-wait rate. One numeric key, per the same reasoning as the Current Waits series
below — the LCK wait type is the grouping the read applied, not a second axis. */
/* Stays local: this page labels the series "Lock wait (ms/sec)", not the catalog's label. */
@@ -3307,7 +3336,8 @@ const LATCH_COLUMNS = [
{ key: "total_delta_wait_time_ms", label: "Wait", format: "ms" },
{ key: "total_delta_waiting_requests", label: "Requests", format: "int" },
{ key: "avg_wait_ms_per_request", label: "Avg/req", format: "num2" },
- { key: "wait_ms_per_second", label: "ms/s", format: "num2" },
+ /* #5644 W10: the panel reads "last 24 hours" and the totals are, but this rate is the latest interval's only. */
+ { key: "wait_ms_per_second", label: "ms/s, latest interval", format: "num2" },
{ key: "description", label: "What it means", wrap: true },
];
@@ -3317,7 +3347,7 @@ const SPINLOCK_COLUMNS = [
{ key: "total_delta_spins", label: "Spins", format: "int" },
{ key: "total_delta_backoffs", label: "Backoffs", format: "int" },
{ key: "spins_per_collision", label: "Spins/coll", format: "num1" },
- { key: "collisions_per_second", label: "Coll/s", format: "num2" },
+ { key: "collisions_per_second", label: "Coll/s, latest interval", format: "num2" },
{ key: "description", label: "What it means", wrap: true },
];
@@ -3590,8 +3620,12 @@ const LONG_QUERY_COLUMNS = [
returns no implementation script or action flags, and the page does not rebuild them. */
const PLAN_CORRECTION_GROUPS = { groups: ["Plans", "Plan metrics", "Lifecycle"], defaultGroups: [] };
+/* #5644 W21: the web read collapses the collector's per-cycle re-captures to one row per recommendation, so the old
+ "Collected" column is the recommendation's LAST capture, with its first capture and how many captures the window held beside it. */
const PLAN_CORRECTION_COLUMNS = [
- { key: "collection_time", label: "Collected", format: "time" },
+ { key: "first_seen", label: "First seen", format: "time" },
+ { key: "collection_time", label: "Last seen", format: "time" },
+ { key: "capture_count", label: "Captures", format: "int" },
{ key: "query_text", label: "Query", valueList: false, render: (r) => codeDisclosure(r.query_text) },
{ key: "database_name", label: "Database" },
{ key: "recommendation_state", label: "State" },
@@ -3899,8 +3933,9 @@ const GRANT_WINDOW_COLUMNS = [
{ key: "pool_id", label: "Pool", format: "int" },
{ key: "snapshots_in_window", label: "Snapshots", format: "int" },
{ key: "peak_waiter_count", label: "Peak waiters", format: "int" },
- { key: "peak_waiters_at", label: "Peak at", format: "time" },
+ { key: "peak_waiters_at", label: "Peak waiters at", format: "time" },
{ key: "peak_granted_memory_mb", label: "Peak granted", format: "mb" },
+ { key: "peak_granted_at", label: "Peak granted at", format: "time" },
{ key: "min_available_memory_mb", label: "Min available", format: "mb" },
{ key: "timeout_errors_in_window", label: "Timeouts", format: "int" },
{ key: "forced_grants_in_window", label: "Forced grants", format: "int" },
@@ -4054,7 +4089,7 @@ export function serverTrendPanel(server, ctx, kind) {
unit: spec.unit,
...windowFromHours(res.keptHours || ctx.hours),
}, "server-trend|" + server + "|" + spec.metric, chartZoomScope(ctx.hours)),
- typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? el("div", { class: "mp-metric-note", text: res.data.aggregate_note }) : null,
+ typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? metricNote(res.data.aggregate_note) : null,
]);
})();
return panel;
@@ -4158,7 +4193,7 @@ export async function drawClerkTrends(slot, server, ctx, checked) {
unit: "MB",
...windowFromHours(trend.keptHours || ctx.hours),
}, "clerk-trend|" + server, chartZoomScope(ctx.hours)),
- typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? el("div", { class: "mp-metric-note", text: trend.data.aggregate_note }) : null,
+ typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? metricNote(trend.data.aggregate_note) : null,
]);
}
@@ -4177,6 +4212,7 @@ const NAMED_TRENDS = {
seriesKey: "latch_class",
unit: "ms/s",
noun: "latch class",
+ plural: "latch classes",
label: "Latch classes",
note: "Wait ms/s, one line per checked latch class.",
noOptions: "No latch classes accumulated waits in this window.",
@@ -4189,6 +4225,7 @@ const NAMED_TRENDS = {
seriesKey: "spinlock_name",
unit: "collisions/s",
noun: "spinlock",
+ plural: "spinlocks",
label: "Spinlocks",
note: "Collisions/s, one line per checked spinlock.",
noOptions: "No spinlocks recorded collisions in this window.",
@@ -4199,7 +4236,7 @@ const DEFAULT_NAMES_CHECKED = 5;
export function namedTrendPanel(server, ctx, kind) {
const spec = NAMED_TRENDS[kind];
- const { panel, body } = panelShell(spec.title, ctx.label + ", with a trend for the " + spec.noun + "s you check", 2, spec.optionsTool);
+ const { panel, body } = panelShell(spec.title, ctx.label + ", with a trend for the " + spec.plural + " you check", 2, spec.optionsTool);
(async () => {
const res = await readToolWithinKeptHistory(spec.optionsTool, { server, hours: ctx.hours, top: MAX_NAMES_CHARTED }, ctx && ctx.signal);
if (res.kind === "error") return mount(body, readErrorStrip(res.message));
@@ -4214,7 +4251,7 @@ export function namedTrendPanel(server, ctx, kind) {
options,
max: MAX_NAMES_CHARTED,
noun: spec.noun,
- defaultsLabel: "Top " + spec.noun + "s",
+ defaultsLabel: "Top " + spec.plural,
defaults: (o) => o.slice(0, DEFAULT_NAMES_CHECKED),
onChange: (checked) => drawNamedTrends(chartSlot, server, ctx, kind, checked),
});
@@ -4281,7 +4318,7 @@ export async function drawNamedTrends(slot, server, ctx, kind, checked) {
...windowFromHours(trend.keptHours || ctx.hours),
}, kind + "-trend|" + server, chartZoomScope(ctx.hours)),
el("div", { class: "mp-metric-note", text: spec.note }),
- typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? el("div", { class: "mp-metric-note", text: trend.data.aggregate_note }) : null,
+ typeof trend.data.aggregate_note === "string" && trend.data.aggregate_note ? metricNote(trend.data.aggregate_note) : null,
]);
}
@@ -4324,7 +4361,7 @@ export function sessionStatsTrendPanel(server, ctx) {
...windowFromHours(res.keptHours || ctx.hours),
}, "server-trend|" + server + "|session_stats", chartZoomScope(ctx.hours)),
top.length ? el("div", { class: "mp-metric-note", text: "Top in the newest bucket: " + top.join("; ") + "." }) : null,
- typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? el("div", { class: "mp-metric-note", text: res.data.aggregate_note }) : null,
+ typeof res.data.aggregate_note === "string" && res.data.aggregate_note ? metricNote(res.data.aggregate_note) : null,
]);
})();
return panel;
@@ -4549,14 +4586,13 @@ const JOB_HISTORY_COLUMNS = [
{ key: "status", label: "Status" },
{ key: "duration_formatted", label: "Duration", sortValue: (r) => r.duration_seconds },
{ key: "retries", label: "Retries", format: "int" },
- { key: "last_success", label: "Last Success", format: "time" },
- { key: "message", label: "Message", wrap: true, render: jobMessageCell },
+ { key: "message", label: "Message", clamp: true, render: jobMessageCell },
];
-/* The same squeeze as the Job History page's Message column (W2): a readable width, the first 3 lines, and the whole text on hover. */
+/* The same squeeze as the Job History page's Message column (W2): a readable width, ONE line (#5644 W11), and the rest on a click (this tab has no detail pane). */
function jobMessageCell(r) {
const text = r && r.message != null ? String(r.message) : "";
- return el("div", { class: "jh-message", title: text || null, text });
+ return el("div", { class: "jh-message" }, [clampText(text)]);
}
const PERFMON_COLUMNS = [
@@ -4713,8 +4749,8 @@ const MEMORY_BROKER_COLUMNS = [
const SIGNIFICANT_WAIT_COLUMNS = [
{ key: "event_time", label: "Event Time", format: "time" },
{ key: "wait_type", label: "Wait Type" },
- { key: "duration_ms", label: "Duration ms", format: "ms" },
- { key: "signal_duration_ms", label: "Signal ms", format: "ms" },
+ { key: "duration_ms", label: "Duration", format: "ms" },
+ { key: "signal_duration_ms", label: "Signal duration", format: "ms" },
{ key: "wait_resource", label: "Wait Resource", wrap: true },
{ key: "session_id", label: "Session", format: "int" },
{ key: "query_text", label: "Query", valueList: false, render: (r) => codeDisclosure(r.query_text) },
@@ -4797,7 +4833,7 @@ const COLLECTION_LOG_COLUMNS = [
{ key: "sql_store_ms", label: "Store (in SQL)", format: "ms" },
{ key: "store_duration_ms", label: "On Store", format: "ms" },
{ key: "rows_collected", label: "Rows", format: "int" },
- { key: "error_message", label: "Error", wrap: true },
+ { key: "error_message", label: "Error", wrap: true, localizeText: true },
];
/* #5227: the collector picked on the Collectors panel, keyed by server at MODULE scope so the 60 s rebuild of the tab
@@ -4888,8 +4924,8 @@ const COLLECTOR_COLUMNS = [
time. Each column is left out unless a row fills it, so a server with no run times shows none of them. */
{ key: "run_at", label: "Run at", hideWhenEmpty: true },
{ key: "next_run_utc", label: "Next run", format: "time", hideWhenEmpty: true },
- { key: "run_time_note", label: "Run time", wrap: true, hideWhenEmpty: true },
- { key: "last_error", label: "Last Error", wrap: true, plain: "named" },
+ { key: "run_time_note", label: "Run time", clamp: true, localizeText: true, hideWhenEmpty: true },
+ { key: "last_error", label: "Last Error", clamp: true, localizeText: true, plain: "named" },
/* #1837: what a NON-failing run reported (an enumeration that came back with 0 items). Blank for a
plainly healthy collector; the same column the two WPF grids carry, so the web view is not the one
Collection Health surface that still hides it. note_summary, not the raw last_note: it carries the
@@ -4897,24 +4933,24 @@ const COLLECTOR_COLUMNS = [
one, composed server-side from the shared formatter so this table cannot render it a third way.
A collector's label=value counts read as its latest NOTED run's ("latest run:" only when every run
carried them), never a window total. */
- { key: "note_summary", label: "Note", wrap: true, plain: true },
+ { key: "note_summary", label: "Note", clamp: true, localizeText: true, plain: true },
/* #5646: a collector that recovered (its newest 3 runs succeeded after failures) is HEALTHY, and this says so with the
time of the last failure. Partial rows carry no note_summary, so the sentence rides in its own field. */
- { key: "recovered_note", label: "Recovered", wrap: true, plain: true, hideWhenEmpty: true },
+ { key: "recovered_note", label: "Recovered", clamp: true, localizeText: true, plain: true, hideWhenEmpty: true },
/* #3017: which of the two zero-output readings a collector that spent and stored nothing is — read and
found nothing, or could not read. Blank whenever Rows is positive, for the same reason the Note
column is blank on a plainly healthy collector. Composed server-side from the shared formatter, so
this table cannot render the sentence a second way. */
- { key: "output_finding", label: "Output", wrap: true, plain: true },
+ { key: "output_finding", label: "Output", clamp: true, localizeText: true, plain: true },
/* #4620: why a collector that stopped doing what it used to do reads WARNING. The Status column already
showed the floor, but no column showed the sentence behind it, so a regressed row read WARNING with
every other cell blank. Composed server-side from the shared formatter, like the two columns above. */
- { key: "regression_finding", label: "Regression", wrap: true, plain: true },
+ { key: "regression_finding", label: "Regression", clamp: true, localizeText: true, plain: true },
/* #5249: a PostgreSQL collector this server's engine does not collect (a collector this engine kind can never run: an Aurora-only reader on stock PostgreSQL, the sampler on Aurora) has no log row,
so the server lists it with status not_collected and the sentence saying why. The status paints with the
neutral Unknown band, not an error one: a gate is not a fault. Left out unless a row has a message, so a
server with no such collector (and every SQL Server tab) shows no column of dashes. */
- { key: "message", label: "Why not collected", wrap: true, hideWhenEmpty: true, plain: true },
+ { key: "message", label: "Why not collected", clamp: true, localizeText: true, hideWhenEmpty: true, plain: true },
];
const HEAVIEST_COLUMNS = [
@@ -4927,7 +4963,7 @@ const HEAVIEST_COLUMNS = [
row has one, so a server with no run times shows no column of dashes. */
{ key: "run_at", label: "Run at", hideWhenEmpty: true },
{ key: "amortized_ms_per_minute", label: "ms/min", format: "num1" },
- { key: "pct_of_sweep_budget_per_run", label: "% of sweep", format: "num1" },
+ { key: "pct_of_sweep_budget_per_run", label: "% of run budget", format: "num1" },
];
/* ─────────────────────────── PostgreSQL stat descriptors ─────────────────────────── */
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js
index 0a5aa126ae..e0896d57cf 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/sweeps.js
@@ -24,7 +24,7 @@
*/
import { el, mount, apiGet, readTool, loadingStrip, errorStrip, emptyStrip, localTime, relTime, parseUtc, rollupTextId,
- fmtInt, fmtMb, fmtMs, fmtPct, fmtBool, fmtText, bandClass, disclosure } from "../util.js";
+ fmtInt, fmtMb, fmtMs, fmtPct, fmtBool, fmtText, bandClass, disclosure, localizeIsoText } from "../util.js";
import { gridTable } from "../panels.js";
import { pageRangePicker, windowOfSpec } from "../page-range.js";
import { relativeSpec } from "../time-range.js";
@@ -230,7 +230,7 @@ function sweepDocument(d) {
{ key: "server", label: "Server" },
bandColumn("from", "From"),
bandColumn("to", "To"),
- { key: "reason", label: "Reason", wrap: true, display: (t) => t.reason || "—" },
+ { key: "reason", label: "Reason", wrap: true, localizeText: true, display: (t) => t.reason || "—" },
], transitions));
}
for (const [label, list] of [["New servers", changes.new_servers], ["Departed servers", changes.departed_servers]]) {
@@ -262,7 +262,7 @@ function sweepDocument(d) {
{ key: "server", label: "Server" },
bandColumn("previous_band", "Previous", (v) => v.previous_band || "—"),
bandColumn("band", "Band", (v) => v.band, (v) => v.band_changed),
- { key: "reason", label: "Reason", wrap: true, display: (v) => v.reason || "—" },
+ { key: "reason", label: "Reason", wrap: true, localizeText: true, display: (v) => v.reason || "—" },
], verdicts)
: emptyStrip("This sweep carries no per-server verdicts."));
@@ -297,7 +297,7 @@ function livenessBlock(d, lv) {
+ faults.map((f) => f.server + " (" + f.error + ")").join("; ") })
: null,
notes.length
- ? el("ul", { class: "liveness-notes" }, notes.map((n) => el("li", { text: String(n) })))
+ ? el("ul", { class: "liveness-notes" }, notes.map((n) => el("li", { text: localizeIsoText(String(n)) })))
: null,
]);
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js
index 63995b0ffd..70313742b1 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/triage.js
@@ -21,7 +21,7 @@
*/
import { el, mount, apiGet, buildQuery, loadingStrip, errorStrip, emptyStrip, noticeStrip, localTime, fmtNum,
- alertDeliveryState } from "../util.js";
+ alertDeliveryState, localizeAlertDetail } from "../util.js";
import { VIZ } from "../panels.js";
import { suggestViz, deriveVizConfig } from "../derive.js";
import { renderNotebookDoc } from "./views.js";
@@ -61,7 +61,7 @@ function alertFields(a) {
])
));
if (a.detail_text) {
- return [box, el("pre", { class: "code", text: a.detail_text })];
+ return [box, el("pre", { class: "code", text: localizeAlertDetail(a.detail_text, a.newest_sample) })];
}
return [box];
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js
index 0e42a3f836..04f6589d32 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/pages/views.js
@@ -25,7 +25,7 @@
* descriptions) reaches the DOM through el()/textContent (R4 — never innerHTML).
*/
-import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, noticeStrip, relTime, localTime, fmtNum } from "../util.js";
+import { el, mount, apiGetFleet, loadingStrip, errorStrip, emptyStrip, noticeStrip, relTime, localTime, localizeIsoText, localizeAlertDetail, fmtNum } from "../util.js";
import { rollingHoursPicker } from "../page-range.js";
import { renderPanel, setPanelSignal, VIZ } from "../panels.js";
import { orderServers } from "../server-order.js";
@@ -803,7 +803,7 @@ function renderAlertCell(cell, index, readSet, sourceSet, scope, opts, limiter)
if (cell.type === "status") {
return el("div", { class: "notebook-panel panel card" }, [
el("h3", {}, [cell.title || "Status"]),
- el("div", { class: "panel-body" }, [el("span", { class: "fv", text: cell.status || "Unknown" })]),
+ el("div", { class: "panel-body" }, [el("span", { class: "fv", text: localizeIsoText(cell.status || "Unknown") })]),
]);
}
if (cell.type === "read") {
@@ -896,7 +896,7 @@ function renderAlertHeaderCell(cell, opts) {
)),
/* detail_text (#4368): the same block triage.js's alertFields uses — through el()'s
text path (textContent), never innerHTML, since this is alert/query text the server only echoes. */
- a.detail_text ? el("pre", { class: "code", text: a.detail_text }) : null,
+ a.detail_text ? el("pre", { class: "code", text: localizeAlertDetail(a.detail_text, a.newest_sample) }) : null,
]
: emptyStrip("No matching alert history row for this link.");
return el("div", { class: "notebook-panel panel card" }, [
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js
index 9a9fe860af..de76666a97 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/panels.js
@@ -44,6 +44,10 @@ import {
dbScopeChip,
dbFilteredEmptyText,
sourceStrip,
+ clampText,
+ markPartialSubtitle,
+ localizeIsoText,
+ localizeAlertDetail,
} from "./util.js";
import { zoomableLineChart, chartZoomScope, SERIES_COLORS } from "./charts.js";
import { plainText, plainTextNamed } from "./plain-text.js";
@@ -85,6 +89,38 @@ export function getPanelSignal() {
return panelSignal;
}
+/**
+ * The sentence an empty envelope shows (#5644 W16). A panel that sets `emptyWins` has ONE agreed sentence for its
+ * empty state (the Blocking tab's deadlock panels, whose reads each worded "nothing happened" differently), and it
+ * replaces the read's own message; every other panel keeps the server's message.
+ */
+export function emptyHeadline(desc, res) {
+ /* Only a TRUE negative (status "empty") takes the panel's sentence. A "not_collected" (an Azure SQL Database has no system_health
+ session), "unavailable" or "precondition" envelope is a different statement, and "No deadlocks" over it would be a false
+ all-clear that also drops the server's own engine-gate line; those keep the read's message. */
+ return desc && desc.emptyWins && desc.emptyText && res.status === "empty" ? desc.emptyText : res.message;
+}
+
+/**
+ * A panel whose wording depends on which collector answered (#5644 W16, the blocking trend: blocked process reports, or the DMV
+ * snapshot rows it falls back to). `desc.bySource` maps the answer's `desc.sourceKey` value to `{ title, label, unit }`; the
+ * returned descriptor carries the series label and axis unit, and the heading's title text is replaced in place. A descriptor
+ * without `bySource`, or an answer whose source has no entry, comes back as given.
+ */
+export function applySourceWording(desc, data, panel) {
+ const alt = desc.bySource && desc.sourceKey ? desc.bySource[getPath(data, desc.sourceKey)] : null;
+ if (!alt) return desc;
+ const head = panel ? Array.from(panel.children || [])[0] : null;
+ const titleNode = head ? head.firstChild : null;
+ if (titleNode && alt.title) titleNode.textContent = alt.title;
+ return {
+ ...desc,
+ title: alt.title || desc.title,
+ unit: alt.unit ?? desc.unit,
+ series: Array.isArray(desc.series) ? desc.series.map((x) => ({ ...x, label: alt.label || x.label })) : desc.series,
+ };
+}
+
/**
* Build a panel node. It returns immediately with a loading strip and fills itself once the fetch resolves,
* mapping the API response kinds (data / empty envelope / error / aborted / auth) to the right UI.
@@ -166,7 +202,7 @@ async function loadPanelBody(desc, body, signal) {
window's start (the server adds the note to that envelope only, never to an unavailable or not_collected one), so a
new server's empty week does not read as a quiet one. windowFloorStrip is null for a chart and for an envelope
without the note. */
- mount(body, [kept, windowFloorStrip(res.data, desc), emptyStrip(res.message)]);
+ mount(body, [kept, windowFloorStrip(res.data, desc), emptyStrip(emptyHeadline(desc, res))]);
return;
}
@@ -193,9 +229,12 @@ async function loadPanelBody(desc, body, signal) {
const moreNotes = (desc.moreNoteKeys || []).map((k) => getPath(res.data, k));
/* #4966: a grid says where its table's data starts when that is after the window's start (windowFloorStrip). */
const floor = windowFloorStrip(res.data, desc);
+ /* #5644 W12: a row-capped panel stops claiming the window it was asked for in its heading. */
+ markPartialSubtitle(body.parentNode, res.data, desc);
/* A narrowed read draws its chart over the hours it answered for, not the Range it was asked for (#2802).
A copy, so the caller's descriptor keeps the window it asked for. */
if (res.keptHours) desc = { ...desc, windowHours: res.keptHours };
+ desc = applySourceWording(desc, res.data, body.parentNode);
const rendered = render(res.data, desc);
mount(body, [
@@ -234,7 +273,7 @@ export const VIZ = {
*/
const NO_FIELDS_MSG = "No fields configured — edit this view and run Auto-detect fields.";
-/* table: desc = { rowsKey, columns:[{key,label,format,align,wrap,mono,pre,sevKey,statusSev,sortable,sortValue,csv,copy,copyValue}], sortable, sortId, onRow(row, tr), rowClass(row)|string, tools:false }
+/* table: desc = { rowsKey, columns:[{key,label,format,align,wrap,clamp,mono,pre,sevKey,statusSev,sortable,sortValue,csv,copy,copyValue}], sortable, sortId, onRow(row, tr), rowClass(row)|string, tools:false }
Column-header sort (#4843). A header click cycles ascending -> descending -> the server's own order, with a ▲/▼
indicator and aria-sort; Enter and Space on the focused header do the same. The comparison reads the RAW row value
@@ -1219,7 +1258,8 @@ function cell(row, c) {
query-text expander. It owns its own content; wrap/mono classes still apply if the column asks for them. */
if (typeof c.render === "function") {
const rcls = [];
- if (c.wrap) rcls.push("wrap");
+ if (c.clamp) rcls.push("clamp");
+ else if (c.wrap) rcls.push("wrap");
if (c.mono) rcls.push("mono");
if (c.pre) rcls.push("pre");
if (typeof c.cellClass === "function") { const k = c.cellClass(row); if (k) rcls.push(k); }
@@ -1228,7 +1268,8 @@ function cell(row, c) {
const raw = getPath(row, c.key);
const cls = [];
if (isNumericCol(c)) cls.push("num");
- if (c.wrap) cls.push("wrap");
+ if (c.clamp) cls.push("clamp");
+ else if (c.wrap) cls.push("wrap");
if (c.mono) cls.push("mono");
if (c.pre) cls.push("pre");
if (c.sevKey) cls.push(sevClass(getPath(row, c.sevKey)));
@@ -1238,7 +1279,7 @@ function cell(row, c) {
"n/a (log service)" for the log file of a Hyperscale database). The server wrote the sentence; the page only
shows it in place of the bare em dash. */
const why = raw == null && c.nullKey ? getPath(row, c.nullKey) : null;
- const text =
+ const shown =
why != null && why !== ""
? String(why)
: typeof c.display === "function"
@@ -1252,6 +1293,16 @@ function cell(row, c) {
: c.plain
? plainText(String(raw))
: String(raw);
+ /* #5644 W20: a column marked `localizeText` (a message or note: collection-health notes, collector errors) shows any ISO
+ instant in its sentence in local time, like the grids beside it. Opt-in: query text, statement text, details and
+ identifiers are data, and a literal such as '2026-01-01T00:00:00' inside them must show as written. */
+ /* #5644 W20: `localizeAlertDetail` is the same opt-in for an alert's stored body (get_alert_history's detail_text): only the
+ labelled time lines ("Incident Since:", "Started:", "From:", "To:", "Window:", ...) and the sample-age line shift, because the
+ same body carries query and SQL lines that must print as written. Never use `localizeText` on a column that holds SQL. */
+ const text = c.localizeAlertDetail && !c.format
+ ? localizeAlertDetail(shown, getPath(row, "newest_sample"))
+ : c.localizeText && !c.format ? localizeIsoText(shown) : shown;
+ if (c.clamp) return el("td", { class: cls.join(" ") || null }, [clampText(text)]);
return el("td", { class: cls.join(" ") || null, text });
}
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js
index a8733562af..69face0dae 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/plain-text.js
@@ -179,6 +179,43 @@ export function plainText(text) {
t = t.replace(/\banswered_only: true\b/g, "the answered-only ranking");
t = t.replace(/\banswered_only\b/g, "the answered-only ranking");
+ /* #5644 W8: clauses that tell an MCP client which parameter to pass. The page has no such control, so a clause that points
+ at one is dropped (the sentence before it still stands) and a filter name is said as the page names it. hours_back and
+ days_back are already put into words by namedTokenText, which runs first. */
+ /* A trend's width note: "...near 1500 points; pass bucket_minutes (1-1440) for another width, or narrow hours_back for finer
+ points." The page picks the width, so only the narrower range is left to say. */
+ t = t.replace(/pass bucket_minutes \(1-\d+\) for another width, or /g, "");
+ /* #5644 W8: a trend note's closing advice, and the field names it explains. The CPU and Memory pages have no bucket control (the
+ page picks the width), so "narrow the bucket" is said as the control that exists, the time range; the Waits, Latch/Spinlock
+ and Activity notes name their fields by the column or tile the page shows. */
+ t = t.replace(/\bnarrow the bucket to see it\b/g, "narrow the time range to see it");
+ t = t.replace(/\bwait_time_ms_per_second is recomputed\b/g, "Wait ms/s is recomputed");
+ t = t.replace(/\bcollisions_per_second is recomputed\b/g, "Coll/s is recomputed");
+ t = t.replace(
+ /\btop_application_name, top_application_connections, top_host_name and top_host_connections are\b/g,
+ "the top application, its connection count, the top host and its connection count are",
+ );
+ /* "24 hour(s)", "288 collector run(s)": the count is known, so the word agrees with it ("1 hour", "24 hours", "2 queries"). */
+ t = t.replace(/(? n + " " + (n === "1" ? word : /[^aeiou]y$/.test(word) ? word.slice(0, -1) + "ies" : word + "s"));
+
+ /* "raise limit": the pages have no row-limit control. "...filter - raise limit to see more." and "...returned; raise limit
+ (now 50) for more." lose the clause; ", or raise limit," and ", and raise limit if none ... shows one" lose theirs. */
+ t = t.replace(/\s*[;—–-]+\s*raise (?:the )?limit(?: \(now [\d,]+\))?(?: (?:to see|for) (?:more|the rest(?: of the rows)?))?(?=\.)/g, "");
+ t = t.replace(/Scope by server_name(?:, or raise limit,)? (to see the rest|to see them)\./g, "Select a single server $1.");
+ t = t.replace(/, or raise (?:the )?limit(?=[.,])/g, "");
+ t = t.replace(/, and raise limit if none of the databases returned shows one/g, "");
+ t = t.replace(/\bpass database_name to ask about one\b/g, "filter by database to ask about one");
+ t = t.replace(/\bpass database_name to chart one database per file\b/g, "choose a database to chart its files");
+ t = t.replace(/\bomit database_name for every database\b/g, "clear the database filter for every database");
+ /* The filters a "matched ..." sentence names: "matched database_name 'x' with blocking_only with wait_type 'y'". */
+ t = t.replace(/\bdatabase_name '/g, "database '");
+ t = t.replace(/\bwait_type '/g, "wait type '");
+ t = t.replace(/\bcollector_name '/g, "collector '");
+ t = t.replace(/\bmin_duration_ms (?=\d)/g, "minimum duration (ms) ");
+ t = t.replace(/\bblocking_only\b/g, "blocking queries only");
+ /* The system-health "events were captured, none qualified" closing. */
+ t = t.replace(/so this is the healthy answer for this read rather than missing data/g, "so this is a normal result, not missing data");
+
/* A tool name: use get_collection_health -> use Collection Health. Only a real tool name; any other get_ word is the
reader's own text (an object, a server) and stays as written. */
t = t.replace(/(? (TOOL_NAME_SET.has(m) ? toolWords(rest) : m));
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js
index a221785446..4b8b3b7bf5 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/read-fields.js
@@ -89,9 +89,9 @@ export const READ_FIELDS = {
{ key: "errors", label: "Errors", format: "int" },
{ key: "avg_duration_ms", label: "Avg Dur", format: "ms" },
{ key: "last_success", label: "Last Success", format: "time" },
- { key: "note_summary", label: "Note", wrap: true, plain: true },
- { key: "recovered_note", label: "Recovered", wrap: true, plain: true, hideWhenEmpty: true },
- { key: "message", label: "Why not collected", wrap: true, plain: true },
+ { key: "note_summary", label: "Note", wrap: true, localizeText: true, plain: true },
+ { key: "recovered_note", label: "Recovered", wrap: true, localizeText: true, plain: true, hideWhenEmpty: true },
+ { key: "message", label: "Why not collected", wrap: true, localizeText: true, plain: true },
],
},
},
@@ -365,7 +365,7 @@ export const READ_FIELDS = {
{ key: "status", label: "Status", statusSev: true },
{ key: "duration_ms", label: "Total", format: "ms" },
{ key: "rows_collected", label: "Rows", format: "int" },
- { key: "error_message", label: "Error", wrap: true },
+ { key: "error_message", label: "Error", wrap: true, localizeText: true },
],
},
},
@@ -582,7 +582,7 @@ export const READ_FIELDS = {
{ key: "threshold_value", label: "Threshold", format: "num2" },
{ key: "notification_type", label: "Notification" },
{ key: "muted", label: "Muted", format: "bool" },
- { key: "detail_text", label: "Detail", wrap: true },
+ { key: "detail_text", label: "Detail", wrap: true, localizeAlertDetail: true },
],
},
},
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
index dd6c69f97a..290915c978 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/util.js
@@ -105,6 +105,46 @@ export function disclosure(summaryText, detail, opts = {}) {
return el("details", { class: "disclosure" }, [summary, el("div", { class: "disc-body" }, detail)]);
}
+/* Text longer than this many characters (or holding a line break) gets the expand toggle; shorter text fits a cell as is. */
+const CLAMP_MIN_CHARS = 60;
+
+/**
+ * A long text cell shown on ONE line (#5644 W11): the full text stays in the node (so a selection, the tooltip and a
+ * screen reader read all of it) and the stylesheet clips it with an ellipsis (`.clamp-text`); a click, or Enter/Space,
+ * opens the rest in place (`.open`) and closes it again. Long collector notes, errors and job messages made rows
+ * hundreds of pixels tall. Copy and CSV export read the row data, not this node, so they still give the full text.
+ * `expandable: false` is for a cell whose row already opens the whole text elsewhere (the Job History detail pane).
+ * The toggle stops its event so a clickable row (Collectors) is not also activated.
+ */
+export function clampText(text, opts = {}) {
+ const full = String(text == null ? "" : text);
+ const node = el("span", { class: "clamp-text", title: full || null, text: full });
+ const long = full.length > CLAMP_MIN_CHARS || /[\r\n]/.test(full);
+ if (opts.expandable === false || !long) return node;
+ node.setAttribute("role", "button");
+ node.setAttribute("tabindex", "0");
+ node.setAttribute("aria-expanded", "false");
+ const toggle = (e) => {
+ /* A click that ends a text selection inside the cell is the reader copying, not opening it. */
+ if (e && e.type === "click" && typeof window !== "undefined" && window.getSelection) {
+ const sel = window.getSelection();
+ if (sel && !sel.isCollapsed && String(sel).length > 0 && sel.anchorNode && node.contains(sel.anchorNode)) return;
+ }
+ if (e && e.stopPropagation) e.stopPropagation();
+ const open = node.getAttribute("aria-expanded") !== "true";
+ node.setAttribute("aria-expanded", open ? "true" : "false");
+ node.classList.toggle("open", open);
+ };
+ node.addEventListener("click", toggle);
+ node.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ toggle(e);
+ }
+ });
+ return node;
+}
+
/*
* #3031: a stable id for one of a rollup tile's text rows, so the tile's NUMBER can point at it.
*
@@ -135,12 +175,61 @@ export function errorStrip(message) {
return el("div", { class: "strip error", role: "alert" }, [message]);
}
export function emptyStrip(message) {
- return el("div", { class: "strip empty" }, [message]);
+ return el("div", { class: "strip empty" }, [localizeIsoText(plainText(message))]);
}
-/** A non-fatal caveat about otherwise-good data (e.g. the #1665 partial-window notice). */
+/** A non-fatal caveat about otherwise-good data (e.g. the #1665 partial-window notice). The sentence may be one the service
+ wrote for an MCP caller (a width note, a truncation note), so it passes through plainText like every message (#5644 W8). */
export function noticeStrip(message) {
- return el("div", { class: "strip notice", role: "status" }, [message]);
+ return el("div", { class: "strip notice", role: "status" }, [localizeIsoText(plainText(message))]);
+}
+/** A note line under a chart, from a payload's own `aggregate_note`: written for an MCP caller, said in the page's words (#5644 W8). */
+export function metricNote(text) {
+ return el("div", { class: "mp-metric-note", text: plainText(text) });
}
+
+/**
+ * Rewrites every UTC instant inside a sentence (a server message, a status line, a note, an alert body) into the grids'
+ * local-time format (#5644 W20): "stored at 2026-10-09T14:37:35.0158630", "since 2026-10-12T19:18:38Z", "until
+ * 2026-10-11 12:48:52Z" (the alert text's space-separated form) and "2026-10-12 23:13:00 UTC" printed raw UTC beside grids
+ * in local time. The separator is a "T" or a space and the fraction is 0-7 digits. A "T" stamp with no zone is UTC, as
+ * parseUtc reads it everywhere; a space-separated one is rewritten ONLY with a "Z" or " UTC" after it, because the same
+ * shape without one is a monitored server's own wall clock ("2026-09-27 21:32:43 (server clock)") and shifting it would
+ * state the wrong hour. Text with no such stamp, and a non-string, come back as given.
+ */
+export function localizeIsoText(text) {
+ if (typeof text !== "string") return text;
+ return text.replace(/(\d{4}-\d\d-\d\d)([T ])(\d\d:\d\d:\d\d)(?:\.(\d{1,7}))?(Z(?![A-Za-z])| UTC(?![A-Za-z])|[+-]\d\d:?\d\d)?/g, (m, date, sep, time, frac, zone) => {
+ if (sep === " " && !zone) return m;
+ const iso = date + "T" + time + (frac ? "." + frac.slice(0, 3).padEnd(3, "0") : "") + (zone === " UTC" ? "Z" : zone || "");
+ const d = parseUtc(iso);
+ return d ? d.toLocaleString() : m;
+ });
+}
+
+/**
+ * An alert's stored body text in the grids' local-time format (#5644 W20, Alert History and Triage). The instants the alert
+ * builders write ("Incident Since: 2026-10-12 23:13:00Z", "Failed At: 2026-10-12 07:00:00Z", and the Started, Snapshot, From,
+ * To and Window lines) go through localizeIsoText. Only those labelled lines do: the same body carries "Blocked Query", "Query"
+ * and "Victim SQL" lines, and a literal such as '2026-01-01 00:00:00Z' inside a statement is data. The sample-age line ("Newest blocked process sample: 23:12:45 UTC, 5m 20s old") carries a time of day
+ * with no date, which cannot be shifted safely alone, so it takes its date from the row's own `newest_sample.instant_utc`
+ * (the carrier the engine persisted when the alert fired) and only when that instant's UTC time of day IS the one written
+ * on the line. The stored and emailed text is not changed (the same line feeds email, webhooks and Lite's windows); only
+ * what this page shows is. A copy of the text for the clipboard or CSV keeps the stored form.
+ */
+export function localizeAlertDetail(text, sample) {
+ if (typeof text !== "string") return text;
+ let out = text.replace(/^(\s*(?:Incident Since|Started|Failed At|Snapshot|From|To|Window): )(.*)$/gm,
+ (m, lead, value) => lead + localizeIsoText(value));
+ const at = sample && typeof sample.instant_utc === "string" ? parseUtc(sample.instant_utc) : null;
+ if (at) {
+ const two = (n) => String(n).padStart(2, "0");
+ const clock = two(at.getUTCHours()) + ":" + two(at.getUTCMinutes()) + ":" + two(at.getUTCSeconds());
+ out = out.replace(/^(\s*(?:Newest .+? sample|.+? reading)): (\d\d:\d\d:\d\d) UTC(?=, .* old\s*$)/gm,
+ (m, lead, time) => (time === clock ? lead + ": " + at.toLocaleString() : m));
+ }
+ return out;
+}
+
/**
* The two collectors the blocking reads draw from (#5244): the blocked-process-report XE session when it holds a row in the window
* for the chosen databases, and the DMV snapshot only when it holds none. The MCP answers name the one that answered in `source`;
@@ -248,12 +337,83 @@ export function keptWindowStrip(res) {
* `floorKey`, and the fields are read from there instead of from the top level. The note is text, never markup (R4).
*/
export function windowFloorStrip(data, desc) {
- if (!desc || desc.windowNote === false || (desc.viz !== "table" && desc.viz !== "stat")) return null;
+ if (!desc || desc.windowNote === false) return null;
+ const chart = desc.viz === "line";
+ if (desc.viz !== "table" && desc.viz !== "stat" && !chart) return null;
if (desc.noteKey === "truncation_note" || (desc.moreNoteKeys || []).includes("truncation_note")) return null;
const source = desc.floorKey ? getPath(data, desc.floorKey) : data;
- if (!source || source.window_truncated !== true) return null;
- const note = source.truncation_note;
- return typeof note === "string" && note.trim() ? noticeStrip(note) : null;
+ if (source && source.window_truncated === true) {
+ const note = source.truncation_note;
+ if (typeof note !== "string" || !note.trim()) return null;
+ if (!chart) return noticeStrip(note);
+ /* A chart draws only the row-capped note (#5644 W12): its time axis already shows the empty span of a data start that
+ falls after the window's start, but a chart built from the newest N rows plots nothing before its oldest row while
+ the axis and the title claim the whole window. That note names the oldest row shown (oldest_shown_utc). */
+ if (typeof source.oldest_shown_utc !== "string") return null;
+ return noticeStrip(note.replace("this grid shows", "this chart shows").replace("The grid covers", "The chart covers"));
+ }
+ const capped = cappedReadNote(data, desc);
+ return capped ? noticeStrip(capped.text) : null;
+}
+
+/*
+ * The partial-window facts of a read that stopped at its row limit (#5644 W12), or null. Two shapes carry them: the server's
+ * window-floor fields (`window_truncated` with `oldest_shown_utc`: the reads WebDataStartNote lists, rewritten by readTool), and a
+ * newest-first page that says it was cut (`truncated: true` with `oldest_returned_collection_time` and an `*_desc` order: the
+ * active-query page, whose cap of 50 rows covers about an hour of a "last 24 hours" panel). A cut that names no time (a list
+ * ranked by something other than time) is not a partial window, so it gets nothing. `{ text, oldest }`: the sentence for a
+ * strip above the panel, and the page's oldest instant.
+ */
+export function cappedReadNote(data, desc) {
+ if (!data || typeof data !== "object" || Array.isArray(data)) return null;
+ if (desc && (desc.windowNote === false || (desc.viz !== "table" && desc.viz !== "line"))) return null;
+ if (desc && (desc.noteKey === "truncation_note" || (desc.moreNoteKeys || []).includes("truncation_note"))) return null;
+ if (data.window_truncated === true) return null;
+ if (data.truncated !== true) return null;
+ const oldest = data.oldest_returned_collection_time;
+ if (typeof oldest !== "string" || !parseUtc(oldest)) return null;
+ if (!/_desc$|newest/.test(String(data.order == null ? "" : data.order))) return null;
+ const noun = desc && desc.viz === "line" ? "chart" : "grid";
+ const shown = Number(data.snapshots_returned);
+ const total = Number(data.total_snapshots);
+ const counts = shown > 0 && total > shown ? shown.toLocaleString() + " of " + total.toLocaleString() + " snapshots" : "rows";
+ return {
+ text: "partial window: this " + noun + " shows only the newest " + counts + ", back to " + localTime(oldest) + ", because it stops at its row limit.",
+ oldest,
+ };
+}
+
+/**
+ * The subtitle a row-capped panel gets in place of its window label (#5644 W12): "last 24 hours" over a page that reaches back
+ * one hour claimed a window the data does not cover. Null when the panel is not a row-capped one (the label stays).
+ */
+export function partialWindowSubtitle(data, desc) {
+ if (desc && desc.windowNote === false) return null;
+ if (data && typeof data === "object" && data.window_truncated === true) {
+ if (typeof data.oldest_shown_utc !== "string" || !parseUtc(data.oldest_shown_utc)) return null;
+ if (desc && desc.viz !== "table" && desc.viz !== "line") return null;
+ return "newest rows only, back to " + localTime(data.oldest_shown_utc);
+ }
+ const capped = cappedReadNote(data, desc);
+ return capped ? "newest rows only, back to " + localTime(capped.oldest) : null;
+}
+
+/** Replaces the window label in a panel's heading (its `.panel-sub` span) when the read was row-capped. A no-op otherwise. */
+export function markPartialSubtitle(panel, data, desc) {
+ const text = partialWindowSubtitle(data, desc);
+ if (!text || !panel) return;
+ const find = (node) => {
+ for (const c of Array.from(node.children || [])) {
+ const k = c.className || (typeof c.getAttribute === "function" ? c.getAttribute("class") : null);
+ if (k === "panel-sub") return c;
+ const deeper = find(c);
+ if (deeper) return deeper;
+ }
+ return null;
+ };
+ const head = Array.from(panel.children || [])[0];
+ const sub = head ? find(head) : null;
+ if (sub) sub.textContent = " " + text;
}
/**
diff --git a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js
index a383019860..7af79e6036 100644
--- a/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js
+++ b/Darling/PerformanceMonitor.Darling.Service/wwwroot/js/view-templates.js
@@ -183,15 +183,18 @@ export const DASHBOARD_TEMPLATES = [
definition: {
panels: [
{
- title: "Blocking Events",
+ /* #5644 W16: a Custom View panel has no collector tag, and get_blocking_trend falls back from blocked process
+ reports to DMV snapshot rows, so the title, legend and axis caption are true for both. */
+ title: "Blocking trend (reports or snapshot rows)",
read: "get_blocking_trend",
params: { server, hours: 24 },
viz: "line",
rowsKey: "trend",
xKey: "time",
- emptyText: "No blocking events in this window — an empty trend here means none happened, not that nothing was collected.",
- series: [{ key: "count", label: "Events" }],
+ emptyText: "No blocked process reports in this window — an empty trend here means none were captured, not that nothing was collected.",
+ series: [{ key: "count", label: "Reports or snapshot rows" }],
format: "int",
+ unit: "reports or snapshot rows",
},
{
title: "Deadlocks",
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs
index b910e1b09e..89e5efe498 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummaryAzureMasterScope.cs
@@ -49,6 +49,10 @@ public static class DailySummaryAzureMasterScope
private const string DeadlockSelectLine = "SELECT date_trunc('day', deadlock_time) AS d, COUNT(*) AS c";
private const string BprSelectLine = "SELECT date_trunc('day', event_time) AS d, COUNT(*) AS c, MAX(wait_time_ms) AS max_wait_ms";
+ // #5644: the report count the health band reads, the bpr CTE's next select line. It takes the same scope filter
+ // as the wait count beside it, or a scoped day would band on reports of waits it no longer counts.
+ private const string BprReportsLine = "SUM(wait_reports)::bigint AS reports";
+
/// The graphs the every-process rule still has to decide, one row per stored deadlock: the row's
/// database is unknown, is master, or is a listed one. $1 server_id, $2/$3 the half-open window as
/// reads it, $4 the event-window floor for $2, $5 the list.
@@ -74,9 +78,25 @@ public static string Scope(string routedSql)
scoped = ReplaceOnce(scoped, BprSelectLine,
"SELECT date_trunc('day', event_time) AS d, COUNT(*) FILTER (WHERE " + BprKeepPredicate + ") AS c, "
+ "MAX(wait_time_ms) FILTER (WHERE " + BprKeepPredicate + ") AS max_wait_ms");
+ /* The line after it is the bpr CTE's own (the dmv CTE's reports column is the same text, so the bpr select line
+ comes first): replace the FIRST occurrence only, which is the bpr one. */
+ scoped = ReplaceFirst(scoped, BprReportsLine,
+ "SUM(wait_reports) FILTER (WHERE " + BprKeepPredicate + ")::bigint AS reports");
return scoped;
}
+ private static string ReplaceFirst(string sql, string fragment, string replacement)
+ {
+ var at = sql.IndexOf(fragment, StringComparison.Ordinal);
+ if (at < 0)
+ {
+ throw new InvalidOperationException(
+ "Daily-summary master scoping found no '" + fragment + "' to replace: DailySummarySql.RangeSql has drifted from DailySummaryAzureMasterScope (#5644).");
+ }
+
+ return sql.Remove(at, fragment.Length).Insert(at, replacement);
+ }
+
private static string ReplaceOnce(string sql, string fragment, string replacement)
{
var at = sql.IndexOf(fragment, StringComparison.Ordinal);
diff --git a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
index f937785c53..c97b673ebd 100644
--- a/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
+++ b/Darling/PerformanceMonitor.Darling.Storage/DailySummarySql.cs
@@ -8,6 +8,7 @@
using System;
using System.Linq;
+using PerformanceMonitor.Common;
namespace PerformanceMonitor.Darling.Storage;
@@ -38,7 +39,7 @@ public static class DailySummarySql
/// per-stored-name aggregation this read always ran; wait_per_type merges its groups, so the trim runs
/// once per group rather than once per row.
///