<!DOCTYPE html>
<html lang="en" data-theme="">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="{{CONTENT_SECURITY_POLICY}}" />
<title>{{PAGE_TITLE}}</title>
<script>try{var d=localStorage.getItem('perf-sentinel:density');document.documentElement.setAttribute('data-density',d==='compact'?'compact':'comfort')}catch(e){document.documentElement.setAttribute('data-density','comfort')}</script>
<style>
{{FONT_FACES}}
/* ============================================================
perf-sentinel HTML report, app-shell redesign (release/0.9.0)
Design tokens ported verbatim from the hi-fi handoff prototype.
@font-face blocks for embedded DM Sans / JetBrains Mono are
injected in the font-embedding step; the stacks below fall back
to system fonts until then.
============================================================ */
:root {
--font-ui: 'DM Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, Consolas, monospace;
/* Legacy aliases kept so any un-migrated rule still resolves. */
--font-sans: var(--font-ui);
--r: 16px; --r-sm: 8px;
}
:root[data-theme="dark"] {
--bg: #141a17; --surface: #1c241f; --surface-2: #252e28; --surface-3: #2d3730;
--border: #2b352e; --border-2: #3b473f;
--text: #e8ede9; --text-2: #a3b5ab; --text-3: #7d8c83;
--brand: #0ba671; --brand-2: #2fd49a; --brand-text: #3ecf9d; --accent-bg: rgba(11,166,113,.13);
--crit-fg: #ffa79e; --crit-bg: #3a1f1c; --crit-bd: #573029;
--warn-fg: #f2c08a; --warn-bg: #392a19; --warn-bd: #53381f;
--info-fg: #8fc0f5; --info-bg: #1a2836; --info-bd: #26394e;
--ok-fg: #8fe6b8; --ok-bg: #17342a; --ok-bd: #235c41;
--pill: #1e3b30; --pill-fg: #9fe9c6; --btn-bd: #2e4d41; --btn-fg: #5ecfa6;
--fix-bg: #16382b; --fix-fg: #a5e8c9;
--glow: none;
}
:root[data-theme="light"] {
--bg: #ffffff; --surface: #f7f7f7; --surface-2: #ffffff; --surface-3: #eef1ef;
--border: #ebebeb; --border-2: #dcdcdc;
/* --text-3 #6a6a6a is the AA-contrast floor on the grey surfaces, do not
lighten it back to #767676 (handoff a11y polish). */
--text: #20261f; --text-2: #556b5c; --text-3: #6a6a6a;
--brand: #0ba671; --brand-2: #0b523a; --brand-text: #11845d; --accent-bg: #e0f4ed;
--crit-fg: #bf2a22; --crit-bg: #ffe4e1; --crit-bd: #f6d2cd;
--warn-fg: #754618; --warn-bg: #ffe0c2; --warn-bd: #f0d2b2;
--info-fg: #1f5e9e; --info-bg: #e9f0f8; --info-bd: #d5e4f3;
--ok-fg: #1b530d; --ok-bg: #ddf3dc; --ok-bd: #c6e6c4;
--pill: #d6e9e3; --pill-fg: #0b523a; --btn-bd: #a7c7bc; --btn-fg: #186b4f;
--fix-bg: #d6e9e3; --fix-fg: #0b523a;
--glow: none;
}
/* -------- base -------- */
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--text); font-family: var(--font-ui); font-size: 13.5px; line-height: 1.5; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; }
::-webkit-scrollbar-track { background: transparent; }
input { font-family: inherit; }
/* A bare <code> (pg_stat block) must not fall back to the browser monospace. */
code { font-family: var(--font-mono); }
a { color: var(--brand-text); }
/* Green highlight on hover, same pattern as the site/docs pages. */
a:not(.ps-brand) { text-decoration: underline; text-underline-offset: 2px; transition: background .15s ease; }
a:not(.ps-brand):hover { background: color-mix(in srgb, var(--brand) 16%, transparent); border-radius: 3px; }
/* -------- severity + tone (attribute-driven, drives every pill/tag) -------- */
/* Borderless pastel pills: the 1px transparent border keeps layout stable. */
[data-sev] { font-family: var(--font-ui); font-weight: 600; letter-spacing: 0; }
[data-sev="crit"] { color: var(--crit-fg); background: var(--crit-bg); border: 1px solid transparent; }
[data-sev="warn"] { color: var(--warn-fg); background: var(--warn-bg); border: 1px solid transparent; }
[data-sev="info"] { color: var(--info-fg); background: var(--info-bg); border: 1px solid transparent; }
[data-sev="ok"] { color: var(--ok-fg); background: var(--ok-bg); border: 1px solid transparent; }
[data-tone="crit"] { color: var(--crit-fg); }
[data-tone="ok"] { color: var(--ok-fg); }
[data-tone="warn"] { color: var(--warn-fg); }
[data-tone="info"] { color: var(--info-fg); }
[data-tone="neutral"] { color: var(--text); }
[data-tone="brand"] { color: var(--brand); }
[data-tone="accent"] { color: var(--brand-text); }
[data-d="0"] { margin-left: 0; } [data-d="1"] { margin-left: 22px; }
[data-d="2"] { margin-left: 44px; } [data-d="3"] { margin-left: 66px; }
/* -------- app shell -------- */
.ps-shell { display: grid; grid-template-columns: 252px 1fr; min-height: 100vh; background-image: var(--glow); }
.ps-sidebar { border-right: 0; background: var(--surface); }
/* Inner wrapper carries the sticky 100vh column so the sidebar element
itself stretches to the full grid-cell height (no empty gap below the
nav on tabs taller than the viewport). */
.ps-sidebar-sticky { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; }
.ps-brand-block { padding: 20px 18px 14px; display: flex; flex-direction: column; gap: 7px; align-items: flex-start; }
.ps-brand { display: block; text-decoration: none; }
.ps-logo { display: none; }
.ps-logo svg, .ps-logo img { height: 34px; width: auto; display: block; }
/* Theme-swapped wordmark. data-theme is always resolved to a concrete
light/dark by applyTheme, so exactly one variant shows. */
:root[data-theme="dark"] .ps-logo-dark { display: block; }
:root[data-theme="light"] .ps-logo-light { display: block; }
.ps-brand-eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: .6px; color: var(--text-3); padding-left: 2px; }
.ps-nav { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; flex: 1; overflow: auto; }
.ps-nav-eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-3); padding: 10px 9px 6px; }
/* Pill nav: no separators, soft pill hover, solid pill + bold when selected. */
[data-nav] { appearance: none; font-family: inherit; width: 100%; text-align: left; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 10px; font-size: 13.5px; font-weight: 500; color: var(--text-2); background: transparent; border: 0; cursor: pointer; transition: background .13s, color .13s; }
[data-nav]:hover { color: var(--text); background: color-mix(in srgb, var(--pill) 50%, transparent); }
[data-nav]:hover > .ps-nav-dot { background: var(--brand); opacity: 1; }
[data-nav][aria-selected="true"] { color: var(--pill-fg); background: var(--pill); font-weight: 600; }
[data-nav][aria-selected="true"] > .ps-nav-dot { background: var(--brand); opacity: 1; }
.ps-nav-dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .85; flex: none; }
.ps-nav-label { flex: 1; }
.ps-nav-badge { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); min-width: 16px; display: inline-flex; justify-content: center; flex: none; }
/* A feature this report cannot show: greyed entry, never removed.
Same [data-nav] base look, hover affordances neutralized. */
[data-nav].ps-tab-disabled { cursor: default; }
[data-nav].ps-tab-disabled:hover { color: var(--text-2); background: transparent; }
[data-nav].ps-tab-disabled:hover > .ps-nav-dot { background: currentColor; opacity: .45; }
.ps-tab-disabled .ps-nav-dot, .ps-tab-disabled .ps-nav-label { opacity: .45; }
.ps-nav-help { flex: none; width: 16px; height: 16px; border-radius: 50%; border: 1px solid var(--border-2); color: var(--text-3); font-family: var(--font-mono); font-size: 10px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; cursor: help; padding-top: 1px; box-sizing: border-box; }
/* The help icon sits beside a heading, never inside it: it carries an
aria-label and would otherwise be read as part of the heading. */
.ps-title-row { display: flex; align-items: center; gap: 9px; min-width: 0; }
.ps-metric-label .ps-nav-help { margin-left: 6px; width: 14px; height: 14px; font-size: 9px; vertical-align: 2px; }
/* Floating tip box appended to <body>: .ps-nav clips with overflow auto,
a CSS-only ::after tooltip cannot escape the sidebar. */
/* min-width beats max-width, so a nowrap code snippet widens the box
instead of spilling out of it. */
.ps-tipbox { position: fixed; z-index: 60; width: max-content; max-width: 320px; min-width: min-content; background: var(--bg); color: var(--text); border: 1px solid var(--border-2); border-radius: 8px; padding: 10px 12px; font-family: var(--font-ui); font-size: 11.5px; line-height: 1.9; pointer-events: none; }
.ps-tipbox::before { content: ""; position: absolute; left: -5px; top: 50%; width: 8px; height: 8px; transform: translateY(-50%) rotate(45deg); background: inherit; border-left: 1px solid var(--border-2); border-bottom: 1px solid var(--border-2); }
.ps-tipbox-more { margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--border-2); font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }
.ps-tipbox-below::before { left: var(--tip-caret-x, 20px); top: -5px; transform: rotate(45deg); border-bottom: 0; border-top: 1px solid var(--border-2); }
.ps-tipbox .ps-code-inline { font-size: 10.5px; white-space: nowrap; }
.ps-metric-sub .ps-code-inline { font-size: 10px; white-space: nowrap; }
/* The run-context line (batch/CI, daemon staging/prod) and the version
line show in every mode. The dot only pulses in live/daemon mode. */
.ps-sidebar-foot { display: flex; height: 62px; padding: 0 16px; border-top: 1px solid var(--border); flex-direction: column; justify-content: center; gap: 6px; }
.ps-sidebar-foot-status { display: flex; align-items: center; gap: 8px; }
.ps-sidebar-foot-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-3); flex: none; }
.ps-sidebar-foot-dot[data-ctx="ok"] { background: var(--ok-fg); }
.ps-sidebar-foot-dot[data-ctx="warn"] { background: var(--warn-fg); }
body.ps-live .ps-sidebar-foot-dot { animation: ps-pulse 2.4s ease-in-out infinite; }
.ps-sidebar-foot-text { font-family: var(--font-mono); font-size: 11px; color: var(--text-2); }
.ps-sidebar-foot-version { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
.ps-main { display: flex; flex-direction: column; min-width: 0; }
.ps-topbar { position: sticky; top: 0; z-index: 5; display: flex; align-items: center; gap: 12px; row-gap: 10px; flex-wrap: wrap; padding: 18px 28px 8px; border-bottom: 0; background: color-mix(in srgb, var(--bg) 90%, transparent); backdrop-filter: blur(8px); }
.ps-topbar-title { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1 1 auto; }
.ps-topbar-eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text-3); }
.ps-topbar-h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -.3px; color: var(--text); }
.ps-gate-pass, .ps-gate-fail { display: inline-flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 20px; font-size: 11.5px; font-family: var(--font-ui); font-weight: 700; white-space: nowrap; }
.ps-gate-pass { color: var(--ok-fg); background: var(--ok-bg); border: 0; }
.ps-gate-fail { color: var(--crit-fg); background: var(--crit-bg); border: 0; }
.ps-gate-pass::before, .ps-gate-fail::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.ps-content { padding: 24px 28px 0; overflow: auto; flex: 1; display: flex; flex-direction: column; }
.ps-panel { display: flex; flex-direction: column; gap: 14px; }
.ps-panel-narrow { max-width: 1100px; }
.ps-panel-wide { max-width: 1240px; }
/* -------- generic controls -------- */
/* Soft-green pill buttons, unified 34px height across the topbar. */
[data-ghost] { cursor: pointer; transition: border-color .12s, color .12s, background .12s; border: 1px solid var(--btn-bd); border-radius: 999px; padding: 7px 11px; font-size: 12px; color: var(--btn-fg); background: var(--surface-2); font-family: var(--font-ui); font-weight: 600; }
[data-ghost]:hover { border-color: var(--brand); color: var(--brand-2); background: var(--accent-bg); }
.ps-theme-toggle { display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: 96px; height: 34px; box-sizing: border-box; border: 1px solid var(--btn-bd); border-radius: 999px; padding: 0 12px; font-family: var(--font-ui); font-size: 12px; font-weight: 600; line-height: normal; color: var(--btn-fg); background: var(--surface-2); cursor: pointer; transition: border-color .25s ease, color .25s ease, background .25s ease; }
.ps-theme-toggle:hover { border-color: var(--brand); color: var(--brand-2); background: var(--accent-bg); }
.ps-theme-toggle .ps-th-svg { flex: none; color: var(--brand-text); }
.ps-theme-toggle.ps-anim .ps-th-svg { animation: ps-spin-in .44s cubic-bezier(.34,1.5,.5,1); }
.ps-theme-toggle.ps-anim .ps-th-lbl { animation: ps-fade .3s ease; }
/* One search box, in the top bar, and it is the only search input in the
report. The focus ring lives on the box (see .ps-search-box:focus-within),
the input itself is borderless and transparent inside it. */
.ps-search-box { display: flex; align-items: center; gap: 8px; border: 1px solid var(--border-2); background: var(--surface-2); border-radius: 999px; padding: 0 11px; height: 34px; flex: 0 0 390px; min-width: 0; }
/* Real magnifier icon (CSS mask) instead of the text glyph. */
.ps-search-box .ps-search-glyph { font-size: 0; flex: none; width: 14px; height: 14px; background-color: var(--text-3);
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.4" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>') center / contain no-repeat;
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.4" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.35-4.35"/></svg>') center / contain no-repeat; }
/* 1px inset so the first letter is not clipped by the rounded box. */
.ps-search-box .ps-search { display: flex; width: auto; flex: 1; min-width: 0; border: 0; background: transparent; padding: 0 0 0 1px; font-size: 12.5px; font-family: var(--font-ui); color: var(--text); outline: none; }
.ps-search-box .ps-search-kbd { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); border: 1px solid var(--border-2); border-radius: 6px; padding: 1px 5px; }
/* -------- cards / metrics -------- */
/* overflow:hidden clips edge-to-edge rows (and the selected row's inset
brand bar) to the card's rounded corners, so the green selection bar
never spills past the corner and the border stays unbroken. */
.ps-card { border: 1px solid transparent; border-radius: var(--r); background: var(--surface); overflow: hidden; }
/* A wide table (acks signatures, mysql/green columns) must scroll inside its
card on a narrow viewport rather than be clipped by overflow:hidden. The
project rule: wide content scrolls in place, the page body never does. */
.ps-card:has(> .ps-table) { overflow-x: auto; overflow-y: hidden; }
.ps-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.ps-metrics-3 { grid-template-columns: repeat(3, 1fr); }
.ps-metric { border: 1px solid transparent; border-radius: var(--r); background: var(--surface); padding: 18px 20px; display: flex; flex-direction: column; gap: 7px; }
/* Flat pastel fills (no gradients). A plain CO2 amount is not a verdict:
"brand" cards stay neutral, "accent" marks the actionable Avoidable card. */
.ps-metric[data-grad="crit"] { background-color: var(--crit-bg); }
.ps-metric[data-grad="warn"] { background-color: var(--warn-bg); }
.ps-metric[data-grad="ok"] { background-color: var(--ok-bg); }
.ps-metric[data-grad="info"] { background-color: var(--info-bg); }
/* A figure that could not be produced: greyed, never hidden. */
.ps-metric[data-grad="muted"] { opacity: .55; border: 1px dashed var(--border-2); }
[data-tone="muted"] { color: var(--text-3); }
.ps-metric[data-grad="brand"] { background-color: var(--surface); }
.ps-metric [data-tone="brand"] { color: var(--text); }
.ps-metric[data-grad="accent"] { background-color: var(--accent-bg); }
/* Overview KPI cards are clickable/keyboard shortcuts to their tab. */
#findings-metrics .ps-metric[data-kpi-link] { cursor: pointer; transition: box-shadow .12s; }
#findings-metrics .ps-metric[data-kpi-link]:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
#findings-metrics .ps-metric[data-kpi-link]:hover { box-shadow: 0 0 0 1.5px var(--btn-bd); }
/* Saturated semantic flat for the Findings KPI (white text). */
#findings-metrics .ps-metric[data-kpi] .ps-metric-label { color: rgba(255,255,255,.88); }
#findings-metrics .ps-metric[data-kpi] .ps-metric-value { color: #fff; }
#findings-metrics .ps-metric[data-kpi] .ps-metric-sub { color: rgba(255,255,255,.78); }
#findings-metrics .ps-metric[data-kpi] [data-tone] { color: #fff; }
#findings-metrics .ps-metric[data-kpi="ok"] { background-color: #0ba671; }
#findings-metrics .ps-metric[data-kpi="info"] { background-color: #2e6ca3; }
#findings-metrics .ps-metric[data-kpi="warn"] { background-color: #d97c24; }
#findings-metrics .ps-metric[data-kpi="crit"] { background-color: #bf4136; }
:root[data-theme="dark"] #findings-metrics .ps-metric[data-kpi="ok"] { background-color: #0d8a60; }
:root[data-theme="dark"] #findings-metrics .ps-metric[data-kpi="info"] { background-color: #2f6494; }
:root[data-theme="dark"] #findings-metrics .ps-metric[data-kpi="warn"] { background-color: #b06a24; }
:root[data-theme="dark"] #findings-metrics .ps-metric[data-kpi="crit"] { background-color: #a8443b; }
.ps-metric-label { font-family: var(--font-ui); font-size: 13px; letter-spacing: 0; text-transform: none; font-weight: 500; color: var(--text-2); }
.ps-metric-value { font-size: 28px; font-weight: 600; letter-spacing: -.4px; word-break: break-word; }
.ps-metric-value-sm { font-size: 16px; line-height: 1.4; }
.ps-metric-sub { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
/* -------- overview landing -------- */
.ps-hero { display: grid; grid-template-columns: 300px 1fr; overflow: hidden; }
.ps-hero-left { padding: 24px 26px; border-right: 0; background: var(--accent-bg); }
/* Gate FAIL: flat red pastel instead of the PASS green. */
.ps-hero-left[data-tone="crit"] { background: var(--crit-bg); }
.ps-hero-verdict { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.ps-hero-dot { width: 13px; height: 13px; border-radius: 50%; background: currentColor; flex: none; }
/* .02em: the original -1px was calibrated for Geist and crushes DM Sans caps. */
.ps-hero-big { font-size: 44px; font-weight: 700; letter-spacing: .02em; line-height: 1; }
.ps-hero-line { margin: 14px 0 0; font-size: 13px; color: var(--text-2); max-width: 230px; }
.ps-hero-right { padding: 20px 24px; display: flex; flex-direction: column; justify-content: center; gap: 16px; }
.ps-hero-rules { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 22px; }
.ps-hero-rule { display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
.ps-hero-rule-chip { width: 16px; height: 16px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-size: 11px; flex: none; margin-top: 1px; }
.ps-hero-rule-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ps-hero-rule-name { font-size: 12.5px; font-weight: 500; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-hero-rule-detail { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
.ps-hero-meta { display: flex; flex-wrap: wrap; gap: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
.ps-hero-ingest { margin-top: 8px; font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
.ps-hero-ingest[data-gap="true"] { color: var(--warn-fg); }
/* correlations cards */
#correlations-list { display: flex; flex-direction: column; gap: 10px; }
/* The meta column cannot shrink (nowrap), so on a narrow viewport it used to
push the card past the panel. Wrapping drops it to its own line instead. */
.ps-corr-card { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; border: 1px solid transparent; border-radius: var(--r); background: var(--surface); padding: 15px 18px; }
.ps-corr-tag { padding: 3px 8px; border-radius: 5px; font-size: 11.5px; }
.ps-corr-path { font-family: var(--font-mono); font-size: 12px; color: var(--text); }
.ps-corr-meta { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); margin-left: auto; white-space: nowrap; }
/* carbon formula banner (class name mirrors the existing .ps-scoring-bandeau) */
.ps-formula-bandeau { display: flex; align-items: center; gap: 22px; flex-wrap: wrap; border: 1px solid transparent; border-radius: var(--r); background: var(--accent-bg); padding: 18px 22px; }
.ps-formula-code { font-family: var(--font-mono); font-size: 17px; color: var(--text); font-weight: 500; background: var(--surface-2); border: 0; border-radius: 10px; padding: 8px 14px; }
.ps-formula-text { font-size: 12.5px; color: var(--text-2); max-width: 560px; }
/* diff two-column cards + horizontal rows */
.ps-diff-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
.ps-row.ps-diff-row { flex-direction: row; align-items: center; gap: 10px; padding: 12px 18px; }
.ps-diff-type { font-size: 12.5px; color: var(--text); }
.ps-diff-endpoint { font-family: var(--font-mono); font-size: 12px; color: var(--text-3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-card .ps-table th:first-child, .ps-card .ps-table td:first-child { padding-left: 18px; }
.ps-card .ps-table th:last-child, .ps-card .ps-table td:last-child { padding-right: 18px; }
.ps-overview-cols { display: grid; grid-template-columns: 1.55fr 1fr; gap: 14px; align-items: start; }
.ps-overview-card-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 4px; border-bottom: 0; }
.ps-overview-card-title { font-size: 14px; font-weight: 600; color: var(--text); }
.ps-overview-link { font-family: var(--font-ui); font-weight: 600; font-size: 11.5px; color: var(--btn-fg); background: var(--surface-2); border: 1px solid var(--btn-bd); border-radius: 999px; padding: 5px 11px; cursor: pointer; }
.ps-overview-link:hover { color: var(--brand-2); background: var(--accent-bg); border-color: var(--brand); }
.ps-overview-rail { display: flex; flex-direction: column; gap: 14px; }
.ps-row.ps-overview-finding { flex-direction: row; align-items: center; justify-content: space-between; gap: 12px; }
.ps-of-left { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.ps-of-right { display: flex; flex-direction: column; align-items: flex-end; gap: 3px; flex: none; }
.ps-of-primary { font-family: var(--font-mono); font-size: 12px; color: var(--text-2); }
.ps-of-secondary { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
.ps-mini-card { border: 1px solid transparent; border-radius: var(--r); background: var(--surface); padding: 16px 18px; display: flex; flex-direction: column; gap: 13px; }
.ps-mini-head { display: flex; align-items: baseline; justify-content: space-between; }
.ps-mini-title { font-size: 14px; font-weight: 600; color: var(--text); }
.ps-mini-total { font-family: var(--font-mono); font-size: 12px; color: var(--brand-text); }
.ps-mini-pills { display: flex; gap: 9px; flex-wrap: wrap; }
.ps-mini-pill { padding: 4px 10px; border-radius: 20px; font-size: 11.5px; }
.ps-mini-link { font-family: var(--font-ui); font-weight: 600; font-size: 11.5px; color: var(--btn-fg); background: var(--surface-2); border: 1px solid var(--btn-bd); border-radius: 999px; padding: 5px 11px; cursor: pointer; align-self: flex-start; }
.ps-mini-link:hover { color: var(--brand-2); background: var(--accent-bg); border-color: var(--brand); }
/* Underline the link label but not the trailing arrow. */
.ps-link-txt { text-decoration: underline; }
.ps-mini-row { display: flex; flex-direction: column; gap: 5px; }
.ps-mini-row-head { display: flex; justify-content: space-between; font-family: var(--font-mono); font-size: 11.5px; }
.ps-bar-track { height: 6px; border-radius: 4px; background: var(--surface-3); overflow: hidden; }
.ps-bar-fill { height: 100%; border-radius: 4px; background: var(--brand); }
/* -------- chips / filters -------- */
.ps-filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.ps-chip-group { display: contents; }
.ps-chip { padding: 5px 13px; font-size: 11.5px; border-radius: 20px; border: 1px solid var(--border-2); background: var(--surface-2); cursor: pointer; color: var(--text-2); font-family: var(--font-ui); font-weight: 500; }
.ps-chip:hover { background: var(--accent-bg); border-color: var(--btn-bd); }
.ps-chip.active, .ps-chip.ps-sort-on { background: var(--brand); color: #fff; border-color: var(--brand); }
.ps-chip[data-sev] { font-weight: 600; }
.ps-chip[data-sev="crit"] { color: var(--crit-fg); background: var(--crit-bg); border-color: transparent; }
.ps-chip[data-sev="warn"] { color: var(--warn-fg); background: var(--warn-bg); border-color: transparent; }
.ps-chip[data-sev="info"] { color: var(--info-fg); background: var(--info-bg); border-color: transparent; }
.ps-chip[data-sev].active { box-shadow: inset 0 0 0 1px currentColor; }
.ps-findings-count { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); margin-left: 6px; }
/* margin-left: auto parks the sort at the row's right edge, visually
apart from the filters it does not belong to. */
.ps-sort-group { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
.ps-sort-label { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); }
/* -------- lists / rows -------- */
/* Floating white rows inside the grey card, hover ring / selected inset ring. */
.ps-list { display: flex; flex-direction: column; padding: 10px; gap: 8px; }
.ps-row { display: flex; flex-direction: column; gap: 7px; padding: 13px 16px; background: var(--surface-2); border-radius: 10px; cursor: pointer; transition: background .12s, box-shadow .12s; }
.ps-row:hover { background: var(--surface-2); box-shadow: 0 0 0 1.5px var(--btn-bd); }
.ps-row.selected { background: var(--surface-2); box-shadow: inset 0 0 0 1.5px var(--brand); }
.ps-sev { padding: 3px 9px; border-radius: 8px; font-size: 11.5px; }
.ps-badge-estimated, .ps-badge-measured { display: inline-block; padding: 2px 7px; border-radius: 8px; font-size: 11.5px; font-family: var(--font-ui); font-weight: 600; }
.ps-badge-estimated { color: var(--warn-fg); background: var(--warn-bg); border: 0; }
.ps-badge-measured { color: var(--ok-fg); background: var(--ok-bg); border: 0; }
.ps-scoring-bandeau { display: flex; align-items: center; gap: 8px; font-size: 12px; flex-wrap: wrap; }
.ps-scoring-label { color: var(--text-2); font-weight: 500; }
.ps-scoring-chip { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 11.5px; font-weight: 500; white-space: nowrap; font-family: var(--font-mono); }
.ps-scoring-chip-neutral { background: var(--surface-2); color: var(--text-2); }
.ps-scoring-chip-warning { background: var(--warn-bg); color: var(--warn-fg); }
.ps-scoring-chip-accent { background: var(--accent-bg); color: var(--brand-text); }
.ps-fin-main { min-width: 0; }
.ps-fin-type { font-weight: 600; font-size: 12.5px; color: var(--text); }
.ps-fin-service { color: var(--text-3); font-weight: 400; font-size: 12px; font-family: var(--font-mono); }
/* The endpoint ellipsizes, the grouping never does: it is what tells two
deployments of one service apart. */
.ps-fin-sub:has(.ps-fin-grouping) { display: flex; align-items: baseline; gap: 7px; }
.ps-fin-sub > .ps-fin-endpoint-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-fin-grouping { flex: 0 0 auto; padding: 1px 6px; border-radius: 4px; background: var(--surface-1); border: 1px solid var(--border-2); font-size: 10.5px; color: var(--text-2); }
.ps-fin-detail { font-size: 12px; color: var(--text-2); font-family: var(--font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-fin-right { font-size: 12px; color: var(--text-2); font-family: var(--font-mono); text-align: right; white-space: nowrap; }
.ps-fin-right-sub { color: var(--text-3); font-size: 11.5px; }
.ps-fin-topline { display: flex; align-items: center; gap: 8px; }
.ps-fin-metric { margin-left: auto; }
.ps-fin-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.ps-detail-titlewrap { flex: 1; min-width: 0; }
/* -------- findings master / detail -------- */
.ps-triage { display: grid; grid-template-columns: 340px 1fr; gap: 14px; align-items: start; }
/* min-width: 0, same reason as .ps-span-text: a grid child sizes to its
content by default, so one long template widened the whole page. */
.ps-detail { min-width: 0; border: 1px solid transparent; border-radius: var(--r); background: var(--surface); padding: 20px 22px; display: flex; flex-direction: column; gap: 17px; }
/* Space the detail sections evenly (title -> meta -> template, breadcrumb -> tree -> pg_stat -> fix). */
#explain-detail-head, #explain-content { display: flex; flex-direction: column; gap: 18px; }
.ps-detail-empty { color: var(--text-3); font-size: 13px; }
.ps-copy-trace { margin-left: 10px; padding: 2px 9px; font-size: 10.5px; vertical-align: middle; }
.ps-detail-head { display: flex; align-items: flex-start; gap: 12px; }
.ps-detail-h2 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: -.3px; color: var(--text); }
.ps-detail-sub { margin: 4px 0 0; font-size: 12.5px; color: var(--text-2); }
.ps-detail-suggestion { margin: 0; font-size: 12.5px; line-height: 1.55; color: var(--text); }
/* auto-fit, not repeat(4): in a squeezed detail column (two-column
triage between the 920px stack point and ~1180px, e.g. the site's
embed iframe) four fixed tracks overflow; auto-fit reflows 2x2. */
.ps-meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.ps-meta-cell { min-width: 0; border: 0; border-radius: 10px; background: var(--surface-2); padding: 11px 12px; }
.ps-meta-cell-label { font-family: var(--font-mono); font-size: 11px; letter-spacing: .6px; text-transform: uppercase; color: var(--text-3); }
.ps-meta-cell-value { font-size: 12.5px; font-weight: 500; color: var(--text); margin-top: 5px; overflow-wrap: anywhere; }
.ps-meta-cell-value.mono { font-family: var(--font-mono); font-size: 11.5px; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ps-meta-cell-value.brand { color: var(--brand-text); }
.ps-eyebrow { font-family: var(--font-mono); font-size: 11px; letter-spacing: 1.1px; text-transform: uppercase; color: var(--text-3); }
.ps-eyebrow.brand { color: var(--brand-text); }
/* pre-wrap, not pre: long templates wrap at the <wbr> addCommaBreaks
inserts. overflow stays for a single unbreakable token. */
.ps-codeblock { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text); background: var(--surface-2); border: 0; border-radius: 10px; padding: 14px 16px; overflow: auto; white-space: pre-wrap; }
.ps-section { display: flex; flex-direction: column; gap: 8px; }
/* -------- explain tree -------- */
.ps-tree-header { font-size: 11.5px; color: var(--text-2); padding: 10px 12px; background: var(--surface-2); border: 0; border-radius: 10px; font-family: var(--font-mono); word-break: break-all; }
.ps-tree { border: 0; border-radius: 12px; background: var(--surface-2); padding: 10px; display: flex; flex-direction: column; gap: 2px; }
.ps-span { display: flex; align-items: center; gap: 9px; padding: 5px 10px; border-radius: 8px; }
.ps-span.dim { color: var(--text-3); }
/* The highlight follows the finding severity carried on the tree host. */
.ps-span.hilite { background: var(--crit-bg); }
.ps-span.hilite .ps-span-text { color: var(--crit-fg); font-weight: 700; }
.ps-span.hilite .ps-span-dur,
.ps-span.hilite .ps-span-time,
.ps-span.hilite .ps-span-flag { color: var(--crit-fg); }
.ps-span.hilite .ps-span-count { background: var(--surface-2); color: var(--crit-fg); }
[data-fsev="warning"] .ps-span.hilite { background: var(--warn-bg); }
[data-fsev="warning"] .ps-span.hilite .ps-span-text,
[data-fsev="warning"] .ps-span.hilite .ps-span-dur,
[data-fsev="warning"] .ps-span.hilite .ps-span-time,
[data-fsev="warning"] .ps-span.hilite .ps-span-flag,
[data-fsev="warning"] .ps-span.hilite .ps-span-count { color: var(--warn-fg); }
[data-fsev="info"] .ps-span.hilite { background: var(--info-bg); }
[data-fsev="info"] .ps-span.hilite .ps-span-text,
[data-fsev="info"] .ps-span.hilite .ps-span-dur,
[data-fsev="info"] .ps-span.hilite .ps-span-time,
[data-fsev="info"] .ps-span.hilite .ps-span-flag,
[data-fsev="info"] .ps-span.hilite .ps-span-count { color: var(--info-fg); }
/* min-width: 0 is what lets the ellipsis fire: a flex child defaults to
min-width: auto, so a long template pushed the whole panel wider. */
.ps-span-text { font-family: var(--font-mono); font-size: 12px; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ps-span-flag { padding: 1px 6px; border-radius: 4px; font-size: 11.5px; }
.ps-span-time { flex: none; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); white-space: nowrap; }
.ps-span-dur { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); margin-left: auto; white-space: nowrap; }
.ps-span-count { flex: none; font-family: var(--font-mono); font-size: 11.5px; padding: 0 6px; border-radius: 5px; background: var(--surface-3); color: var(--text-2); }
.ps-span-find { color: var(--crit-fg); font-weight: 600; }
.ps-span.ps-span-pgstat-link { cursor: pointer; }
.ps-span.ps-span-pgstat-link:hover .ps-span-text { text-decoration: underline; }
/* -------- suggested fix (mint callout, white content cartridges) -------- */
.ps-fix { border: 0; border-radius: 12px; background: var(--fix-bg); padding: 14px 16px; display: flex; flex-direction: column; gap: 9px; }
.ps-fix .ps-eyebrow { color: var(--fix-fg); }
/* pg_stat cross-ref (left) + suggested fix (right), side by side like the model; collapses to one column when only one is present. */
.ps-detail-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; align-items: start; }
.ps-detail-cols.single { grid-template-columns: 1fr; }
/* overflow-wrap: a source path is one long unbreakable token and the
recurrence window lengthened this line. */
.ps-detail-foot { font-family: var(--font-mono); font-size: 11px; color: var(--text-3); border-top: 1px solid var(--border); padding-top: 13px; overflow-wrap: anywhere; }
.ps-pgstat-xref { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 9px 12px; background: var(--surface-2); border: 0; border-radius: 10px; font-family: var(--font-mono); font-size: 12px; }
.ps-pgstat-xref-stat { color: var(--text-3); white-space: nowrap; }
.ps-fix-prose { font-size: 12.5px; color: var(--text); line-height: 1.55; background: var(--surface-2); border-radius: 8px; padding: 10px 13px; }
.ps-fix-code { font-family: var(--font-mono); font-size: 12px; color: var(--text); background: var(--surface-2); border: 0; border-radius: 6px; padding: 9px 11px; overflow: auto; white-space: pre; }
.ps-fix-alt { display: block; font-size: 12px; color: var(--text); line-height: 1.55; padding-top: 9px; border-top: 1px solid color-mix(in srgb, var(--fix-fg) 25%, transparent); }
/* No border-bottom: the generic link underline already draws one line. */
.ps-fix-link { align-self: flex-start; font-family: var(--font-mono); font-size: 11px; color: var(--fix-fg); }
.ps-source-link { color: var(--brand-text); text-decoration: underline; }
/* prose inline-code chip emitted by proseHl (white cartridge WITH thin border) */
.ps-code-inline { font-family: var(--font-mono); font-size: 11.5px; background: var(--surface-2); border: 1px solid var(--border-2); border-radius: 5px; padding: 1px 6px; color: var(--text); }
/* syntax-highlight token weight (color set inline by the highlighter via data-hl) */
.ps-hl-kw { font-weight: 600; }
/* -------- banner / empty / drill -------- */
.ps-banner { background: var(--info-bg); border: 0; border-radius: 10px; padding: 8px 12px; font-size: 11.5px; color: var(--info-fg); }
/* Informational by default (tuning hints, cold start): orange would claim
something is broken. Only a kind that reports lost data takes the warn tone. */
.ps-report-warnings { margin-bottom: 14px; }
.ps-report-warnings[data-tone="warn"] { background: var(--warn-bg); color: var(--warn-fg); }
/* The warning kind is a label, not something the operator types: it must
not read as a code chip sitting next to a real config section. */
.ps-warn-kind { font-family: var(--font-ui); font-size: 9.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.45; margin-right: 9px; }
.ps-empty { padding: 30px; text-align: center; color: var(--text-2); font-size: 13px; background: var(--surface); border: 1px solid transparent; border-radius: var(--r); }
.ps-drill { background: var(--info-bg); border: 0; border-radius: 10px; padding: 10px 12px; font-size: 11.5px; color: var(--info-fg); }
.ps-drill-label { font-size: 11.5px; color: var(--info-fg); margin-bottom: 4px; opacity: .8; }
.ps-drill-link { color: var(--info-fg); }
.ps-drill-clear { color: var(--info-fg); text-decoration: underline; cursor: pointer; margin-left: 8px; }
/* -------- tables -------- */
.ps-table { width: 100%; font-size: 12px; font-family: var(--font-mono); border-collapse: collapse; }
.ps-table th { text-align: left; font-weight: 600; padding: 11px 12px; border-bottom: 1px solid var(--border); color: var(--text-3); font-size: 11px; letter-spacing: .2px; text-transform: none; font-family: var(--font-ui); }
/* Sortable headers (wired by the table-sort module). */
.ps-table th[data-sortable] { cursor: pointer; user-select: none; white-space: nowrap; transition: color .12s; }
.ps-table th[data-sortable]:hover { color: var(--text); }
.ps-table th .ps-sort-ico { display: inline-block; margin-left: 5px; opacity: .4; font-family: var(--font-mono); font-size: 10px; }
.ps-table th[aria-sort] { color: var(--brand-text); }
.ps-table th[aria-sort] .ps-sort-ico { opacity: 1; color: var(--brand-text); }
.ps-table td { padding: 12px; border-bottom: 1px solid var(--border); vertical-align: top; color: var(--text-2); }
/* Diff delta tone (override the base td color) + emphasized "after" value. */
.ps-table td[data-tone="crit"] { color: var(--crit-fg); }
.ps-table td[data-tone="ok"] { color: var(--ok-fg); }
.ps-table td.ps-diff-after { color: var(--text); font-weight: 600; }
.ps-table tr:last-child td { border-bottom-color: transparent; }
/* Diff · Severity changes: Before/After pills and headers centered. */
#diff-sev-table th:nth-child(3), #diff-sev-table th:nth-child(4),
#diff-sev-table td:nth-child(3), #diff-sev-table td:nth-child(4) { text-align: center; }
#pgstat-body tr.hilite { background: var(--crit-bg); }
#pgstat-body tr.hilite td:first-child { color: var(--crit-fg); font-weight: 600; }
/* pg_stat controls row: "rank by" label + ranking chips, then the actions.
The auto margin sits on the action group, not on a button: the row wraps,
and a margin on the first button alone lets the second one drop to the
next line left-aligned. Not on the chips either, since they get
display:none on a single ranking, taking the margin down with them. */
.ps-pgstat-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ps-rankby-label { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); margin-right: 2px; }
.ps-pgstat-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
/* -------- footer -------- */
/* Sticky like the topbar: the shortcut hints stay readable mid-scroll.
The rule is a ::before inset to the content padding, a border-top
would span the full column now the footer sits outside .ps-content. */
.ps-footer { --footer-inset: 28px; position: sticky; bottom: 0; z-index: 5; background: color-mix(in srgb, var(--bg) 92%, transparent); backdrop-filter: blur(8px); margin-top: auto; min-height: 62px; padding: 0 var(--footer-inset); display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-family: var(--font-mono); font-size: 11.5px; color: var(--text-3); }
.ps-footer::before { content: ""; position: absolute; top: 0; left: var(--footer-inset); right: var(--footer-inset); height: 1px; background: var(--border); }
:root[data-density="compact"] .ps-footer { --footer-inset: 22px; }
.ps-credit { margin-left: auto; }
/* Single thin underline (a border-bottom would double the generic link line). */
.ps-credit a, .ps-footer a { color: var(--brand-text); text-decoration: underline; text-decoration-thickness: .5px; text-decoration-color: color-mix(in srgb, var(--brand-text) 55%, transparent); }
.ps-credit a:hover, .ps-footer a:hover { color: var(--text); }
.ps-kbd { font-family: var(--font-mono); font-size: 11.5px; border: 1px solid var(--border-2); border-radius: 6px; padding: 1px 6px; color: var(--text-3); }
/* -------- diff / section headers -------- */
.ps-diff-section { display: flex; flex-direction: column; }
.ps-diff-section-header { font-size: 13px; font-weight: 600; padding: 15px 18px 4px; border-bottom: 0; color: var(--text); }
/* When a section has no table below it, match the bottom padding to the top
so the grey card is symmetric around the header text. */
.ps-diff-section-header.empty { padding-bottom: 15px; }
.ps-diff-section-header.red { color: var(--crit-fg); }
.ps-diff-section-header.green { color: var(--ok-fg); }
/* Regression left, progress right: the tint guides the eye before the
text does. Same pastel pairs as the Overview severity cards, and the
New zone wears its worst severity (neutral when empty). */
.ps-diff-section-new[data-sev="critical"] { background-color: var(--crit-bg); }
.ps-diff-section-new[data-sev="warning"] { background-color: var(--warn-bg); }
.ps-diff-section-new[data-sev="info"] { background-color: var(--info-bg); }
.ps-diff-section-new[data-sev="critical"] .ps-diff-section-header { color: var(--crit-fg); }
.ps-diff-section-new[data-sev="warning"] .ps-diff-section-header { color: var(--warn-fg); }
.ps-diff-section-new[data-sev="info"] .ps-diff-section-header { color: var(--info-fg); }
.ps-diff-section-resolved { background-color: var(--ok-bg); }
/* The diff zones tint a whole column, not a badge, so the shared severity
backgrounds read too bright on dark. Light mode needs no correction. */
:root[data-theme="dark"] .ps-diff-section-new[data-sev="critical"] { background-color: color-mix(in srgb, var(--crit-bg) 72%, #000); }
:root[data-theme="dark"] .ps-diff-section-new[data-sev="warning"] { background-color: color-mix(in srgb, var(--warn-bg) 72%, #000); }
:root[data-theme="dark"] .ps-diff-section-new[data-sev="info"] { background-color: color-mix(in srgb, var(--info-bg) 72%, #000); }
:root[data-theme="dark"] .ps-diff-section-resolved { background-color: color-mix(in srgb, var(--ok-bg) 72%, #000); }
.ps-quality-gate { display: flex; flex-direction: column; gap: 8px; padding: 10px; }
.ps-section-title { font-size: 14px; font-weight: 600; margin: 0; color: var(--text); }
.ps-panel-toolbar { display: flex; justify-content: flex-end; align-items: center; gap: 8px; }
.ps-export-btn, .ps-copy-link-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; font-size: 11.5px; border-radius: 999px; border: 1px solid var(--btn-bd); background: var(--surface-2); cursor: pointer; color: var(--btn-fg); font-family: var(--font-ui); font-weight: 600; }
.ps-export-btn:hover, .ps-copy-link-btn:hover { border-color: var(--brand); color: var(--brand-2); background: var(--accent-bg); }
/* Button icons as CSS masks: they survive textContent swaps ("Copied"). */
.ps-export-btn::before { content: ""; flex: none; width: 12px; height: 12px; background-color: currentColor;
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h8M12 13v4"/></svg>') center / contain no-repeat;
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8M8 17h8M12 13v4"/></svg>') center / contain no-repeat; }
.ps-copy-link-btn::before { content: ""; flex: none; width: 12px; height: 12px; background-color: currentColor;
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>') center / contain no-repeat;
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>') center / contain no-repeat; }
.ps-copy-link-btn.ps-copied::before {
-webkit-mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>') center / contain no-repeat;
mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>') center / contain no-repeat; }
.ps-corr-side { display: inline-flex; align-items: center; gap: 10px; padding: 5px 9px; margin: -5px -9px; border-radius: 9px; }
.ps-corr-side-link { cursor: pointer; transition: background .12s, box-shadow .12s; }
.ps-corr-side-link:hover { background: var(--accent-bg); box-shadow: 0 0 0 1.5px var(--btn-bd); }
.ps-show-more { display: block; padding: 12px 14px; font-size: 12px; border: 0; border-radius: 8px; margin: 0 10px 10px; background: var(--surface-2); color: var(--brand-text); cursor: pointer; font-family: var(--font-mono); width: calc(100% - 20px); text-align: center; transition: box-shadow .12s; }
.ps-show-more:hover { background: var(--surface-2); box-shadow: 0 0 0 1.5px var(--btn-bd); }
@keyframes ps-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .55; } }
@keyframes ps-spin-in { from { transform: rotate(-120deg) scale(.3); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes ps-fade { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } }
@keyframes ps-refresh-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
/* -------- modals (native <dialog>) -------- */
dialog.ps-modal { position: fixed; inset: 0; margin: auto; max-width: 460px; width: calc(100% - 40px); height: fit-content; max-height: calc(100vh - 40px); overflow-y: auto; background: var(--surface); color: var(--text); border-radius: 16px; border: 0; padding: 22px 24px; box-shadow: 0 24px 70px rgba(0,0,0,.25); }
dialog.ps-modal::backdrop { background: rgba(0,0,0,.55); }
dialog.ps-modal-wide { max-width: 560px; }
#topic-help-body h3 { margin: 14px 0 4px; font-size: 12px; font-weight: 600; letter-spacing: .6px; text-transform: uppercase; color: var(--text-3); font-family: var(--font-mono); }
#topic-help-body p { margin: 0; font-size: 12.5px; line-height: 1.75; color: var(--text); }
#topic-help-link { display: inline-block; margin-top: 16px; }
dialog.ps-modal h2 { margin: 0 0 16px 0; font-size: 15px; font-weight: 600; }
.ps-modal-close { position: absolute; top: 14px; right: 16px; background: transparent; border: none; color: var(--text-2); font-family: var(--font-mono); font-size: 12px; cursor: pointer; line-height: 1; padding: 4px 8px; }
.ps-modal-close:hover { color: var(--text); }
dialog.ps-modal table { width: 100%; border-collapse: collapse; }
dialog.ps-modal th { text-align: left; padding: 4px 6px; font-size: 11.5px; font-weight: 600; color: var(--text-3); border-bottom: 1px solid var(--border); font-family: var(--font-mono); }
dialog.ps-modal th:first-child { width: 110px; }
dialog.ps-modal td { padding: 6px 6px; font-size: 12.5px; vertical-align: top; color: var(--text-2); }
dialog.ps-modal td:first-child { width: 110px; white-space: nowrap; font-family: var(--font-mono); color: var(--text); }
dialog.ps-modal label { display: block; font-size: 11.5px; color: var(--text-3); margin: 12px 0 4px 0; font-family: var(--font-ui); font-weight: 600; text-transform: none; letter-spacing: .2px; }
dialog.ps-modal input[type="password"], dialog.ps-modal input[type="text"], dialog.ps-modal textarea, dialog.ps-modal select { width: 100%; box-sizing: border-box; padding: 8px 11px; font-size: 12.5px; font-family: inherit; background: var(--surface-3); border: 0; border-radius: 8px; color: var(--text); }
dialog.ps-modal textarea { min-height: 70px; resize: vertical; }
dialog.ps-modal .ps-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 16px; }
dialog.ps-modal .ps-modal-btn { padding: 8px 15px; font-size: 12.5px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-2); cursor: pointer; font-family: inherit; }
dialog.ps-modal .ps-modal-btn:hover { border-color: var(--border-2); color: var(--text); }
dialog.ps-modal .ps-modal-btn.primary { background: var(--brand); color: #fff; border: 0; font-weight: 600; }
dialog.ps-modal .ps-modal-scope { display: none; margin: 8px 0 0; padding: 8px 10px; font-size: 11px; background: var(--surface-2); color: var(--text-2); border-radius: var(--r-sm); }
dialog.ps-modal .ps-modal-error { display: none; margin-top: 10px; padding: 8px 10px; font-size: 11px; background: var(--crit-bg); color: var(--crit-fg); border-radius: var(--r-sm); }
/* -------- live mode (since 0.5.23) -------- */
.ps-daemon-status { display: none; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-2); }
body.ps-live .ps-daemon-status { display: inline-flex; }
.ps-daemon-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-3); display: inline-block; }
.ps-daemon-dot.connected { background: var(--brand); }
.ps-daemon-dot.disconnected { background: var(--crit-fg); }
.ps-daemon-dot.unauthorized { background: #d98324; }
.ps-refresh-btn { display: none; padding: 0 14px; height: 34px; box-sizing: border-box; font-size: 12px; border-radius: 999px; border: 1px solid var(--btn-bd); background: var(--surface-2); color: var(--btn-fg); cursor: pointer; font-family: var(--font-ui); font-weight: 600; }
body.ps-live .ps-refresh-btn { display: inline-flex; align-items: center; gap: 6px; }
body.ps-live .ps-refresh-btn[hidden] { display: none; }
.ps-refresh-btn:hover { border-color: var(--brand); color: var(--brand-2); background: var(--accent-bg); }
.ps-refresh-btn:disabled { cursor: not-allowed; opacity: 0.6; }
.ps-refresh-ico { flex: none; }
#ps-refresh-btn.ps-spinning .ps-refresh-ico { animation: ps-refresh-spin .6s cubic-bezier(.45,.05,.35,1); }
/* flex-start so the Ack row does not stretch into a full-width dead zone. */
.ps-fin-actions { display: none; margin-left: 8px; align-self: flex-start; }
body.ps-live .ps-fin-actions { display: inline-flex; gap: 6px; }
.ps-fin-action-btn { padding: 5px 11px; font-size: 12px; border-radius: 999px; border: 1px solid var(--btn-bd); background: var(--surface-2); color: var(--ok-fg); cursor: pointer; font-family: inherit; }
.ps-fin-action-btn:hover { background: var(--ok-bg); }
.ps-fin-action-btn.revoke { border-color: var(--border-2); color: var(--text-2); }
.ps-fin-action-btn.revoke:hover { background: var(--surface-3); }
.ps-fin-action-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.ps-include-acked-wrap { display: none; align-items: center; gap: 6px; font-size: 11.5px; color: var(--text-2); margin-left: auto; cursor: pointer; }
/* Same 13px box for input and label text, +1px optical nudge. */
.ps-include-acked-wrap input { margin: 0; width: 13px; height: 13px; }
.ps-include-acked-wrap span { height: 13px; line-height: 13px; position: relative; top: 1px; }
body.ps-live .ps-include-acked-wrap { display: inline-flex; }
.ps-acks-footer { margin-top: 10px; font-size: 11.5px; color: var(--text-3); }
/* -------- toast -------- */
.ps-toast { position: fixed; bottom: 20px; right: 20px; padding: 10px 16px; font-size: 12px; background: var(--surface); color: var(--text); border: 0; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.18); z-index: 9999; max-width: 380px; }
.ps-toast.error { background: var(--crit-bg); color: var(--crit-fg); }
.ps-toast.success { background: var(--ok-bg); color: var(--ok-fg); }
.ps-toast-undo { margin-left: 10px; border: 1px solid var(--ok-bd); background: var(--surface-2); color: var(--ok-fg); border-radius: 999px; padding: 3px 11px; font: 600 11.5px var(--font-ui); cursor: pointer; }
.ps-toast-undo:hover { background: var(--ok-bg); }
/* Search-match highlight injected by the highlighter module. */
mark.ps-mark { background: color-mix(in srgb, var(--brand) 24%, transparent); color: inherit; border-radius: 3px; padding: 0 1px; }
/* -------- density (compact by default, toggled from the topbar) -------- */
:root[data-density="compact"] { --r: 13px; --r-sm: 7px; }
:root[data-density="compact"] body { font-size: 12.5px; }
:root[data-density="compact"] .ps-content { padding: 16px 22px 0; }
:root[data-density="compact"] .ps-topbar { padding: 12px 22px 6px; }
:root[data-density="compact"] .ps-topbar-h1 { font-size: 16px; }
:root[data-density="compact"] .ps-nav { padding: 6px 10px; gap: 2px; }
:root[data-density="compact"] [data-nav] { padding: 7px 10px; font-size: 12.5px; border-radius: 8px; }
:root[data-density="compact"] .ps-metric { padding: 12px 14px; gap: 5px; }
:root[data-density="compact"] .ps-metric-value { font-size: 22px; }
:root[data-density="compact"] .ps-list, :root[data-density="compact"] .ps-quality-gate { padding: 7px; gap: 6px; }
:root[data-density="compact"] .ps-row { padding: 9px 12px; border-radius: 8px; }
/* Show-more follows the row inset of each density. */
:root[data-density="compact"] .ps-show-more { margin: 0 7px 7px; width: calc(100% - 14px); }
:root[data-density="compact"] .ps-detail { padding: 15px 16px; gap: 13px; }
:root[data-density="compact"] .ps-panel { gap: 10px; }
:root[data-density="compact"] .ps-metrics { gap: 8px; }
:root[data-density="compact"] .ps-brand-block { padding: 14px 14px 8px; }
:root[data-density="compact"] .ps-logo svg, :root[data-density="compact"] .ps-logo img { height: 28px; }
:root[data-density="compact"] .ps-hero-big { font-size: 36px; }
:root[data-density="compact"] .ps-hero-left { padding: 18px 20px; }
:root[data-density="compact"] .ps-hero-right { padding: 14px 18px; }
:root[data-density="compact"] .ps-table th { padding: 8px 10px; }
:root[data-density="compact"] .ps-table td { padding: 8px 10px; }
/* Fixed width fits either label; outer bars preview the other mode on hover. */
#density-toggle { width: 103px; padding: 0 14px; gap: 6px; }
#density-toggle svg { flex: none; color: var(--brand-text); }
#density-toggle .b1, #density-toggle .b3 { transition: transform .28s cubic-bezier(.34,1.4,.5,1); }
:root[data-density="compact"] #density-toggle .b1 { transform: translateY(2.4px); }
:root[data-density="compact"] #density-toggle .b3 { transform: translateY(-2.4px); }
:root[data-density="compact"] #density-toggle:hover .b1,
:root[data-density="compact"] #density-toggle:hover .b3 { transform: translateY(0); }
:root[data-density="comfort"] #density-toggle:hover .b1 { transform: translateY(2.4px); }
:root[data-density="comfort"] #density-toggle:hover .b3 { transform: translateY(-2.4px); }
/* -------- accessibility -------- */
button:focus-visible, [data-nav]:focus-visible, .ps-chip:focus-visible, a:focus-visible,
.ps-row:focus-visible, .ps-table th[data-sortable]:focus-visible,
.ps-nav-help:focus-visible {
outline: 2px solid var(--brand); outline-offset: 2px; }
input.ps-search:focus-visible { outline: none; }
.ps-search-box:focus-within { border-color: var(--brand); }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}
/* Tight topbar: icon-only buttons (title keeps the label), stretchy search. */
@media (max-width: 1180px) {
.ps-theme-toggle { width: 40px; padding: 0; gap: 0; justify-content: center; }
.ps-theme-toggle .ps-th-lbl { display: none; }
#density-toggle { width: 40px; padding: 0; }
#density-label { display: none; }
/* Scoped to Refresh by id: it is the only one with an icon to collapse
to. "Forget key" shares the class but is a bare label. */
body.ps-live #ps-refresh-btn { width: 34px; padding: 0; justify-content: center; gap: 0; }
#ps-refresh-btn span { display: none; }
.ps-search-box { flex: 1 1 180px; min-width: 140px; }
}
/* Narrow viewports (small windows, embedded iframes): give the content room
by narrowing the sidebar, stacking the two-column blocks, and letting the
search box shrink. */
@media (max-width: 920px) {
.ps-shell { grid-template-columns: 200px 1fr; }
.ps-hero { grid-template-columns: 1fr; }
.ps-hero-left { border-right: 0; border-bottom: 1px solid var(--border); }
.ps-hero-rules { grid-template-columns: 1fr; }
.ps-metrics { grid-template-columns: repeat(2, 1fr); }
.ps-overview-cols, .ps-diff-cols, .ps-triage { grid-template-columns: 1fr; }
.ps-meta-grid { grid-template-columns: repeat(2, 1fr); }
.ps-search-box { flex: 1 1 auto; min-width: 0; }
.ps-footer { --footer-inset: 22px; min-height: 0; padding-top: 18px; padding-bottom: 14px; }
}
/* On short viewports a pinned footer costs too much reading space. */
@media (max-height: 700px) {
.ps-footer { position: static; background: transparent; backdrop-filter: none; }
}
</style>
</head>
<body>
<!-- Visible until the script below removes it, which happens during
parsing so a normal load never paints it. What is left when the
script cannot run is a page that says why instead of a blank one. -->
<div id="ps-no-js" style="padding:24px;max-width:60em;font:16px/1.6 system-ui,sans-serif">
<strong>This report did not build.</strong>
Its content is rendered by the JavaScript embedded in this file, and the page
loaded without running it. Either the browser has JavaScript turned off, or
the server sent a Content-Security-Policy that blocks it, which a Jenkins
controller does by default while GitHub Pages and GitLab Pages do not.
Remedies per CI provider:
<a href="https://perf-sentinel.dev/docs/CI" target="_blank" rel="noopener noreferrer">perf-sentinel.dev/docs/CI</a>.
Opening this file straight from disk always works.
</div>
<script>var n=document.getElementById("ps-no-js");if(n){n.remove()}</script>
<div class="ps-shell" id="ps-shell">
<aside class="ps-sidebar">
<div class="ps-sidebar-sticky">
<div class="ps-brand-block">
<a class="ps-brand" href="https://perf-sentinel.dev/" target="_blank" rel="noopener noreferrer" aria-label="perf-sentinel, perf-sentinel.dev">{{BRAND_LOGO}}</a>
<span class="ps-brand-eyebrow">HTML REPORT</span>
</div>
<div class="ps-nav" id="tabs" role="tablist" aria-label="Dashboard sections" aria-orientation="vertical">
<div class="ps-nav-eyebrow">// sections</div>
</div>
<div class="ps-sidebar-foot">
<div class="ps-sidebar-foot-status">
<span class="ps-sidebar-foot-dot" id="ps-sidebar-dot"></span>
<span class="ps-sidebar-foot-text" id="ps-sidebar-context"></span>
</div>
<div class="ps-sidebar-foot-version" id="ps-sidebar-version"></div>
</div>
</div>
</aside>
<main class="ps-main">
<header class="ps-topbar">
<div class="ps-topbar-title">
<span class="ps-topbar-eyebrow" id="topbar-eyebrow"></span>
<div class="ps-title-row"><h1 class="ps-topbar-h1" id="topbar-h1">Overview</h1></div>
</div>
<div class="ps-topbar-actions" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end;">
<div class="ps-search-box" id="topbar-search-box">
<span class="ps-search-glyph">⌕</span>
<input type="search" class="ps-search" id="topbar-search" placeholder="Search findings, templates, endpoints…" aria-label="Search findings, templates, endpoints" />
<span class="ps-search-kbd">⌘K</span>
</div>
<span id="topbar-gate"></span>
<output class="ps-daemon-status" id="ps-daemon-status" aria-live="polite">
<span class="ps-daemon-dot" id="ps-daemon-dot"></span>
<span id="ps-daemon-status-text">Connecting...</span>
</output>
<button class="ps-refresh-btn" id="ps-refresh-btn" type="button" aria-label="Refresh daemon data"><svg class="ps-refresh-ico" width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true"><path d="M13.65 5.35A6 6 0 1 0 14 8" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" fill="none"></path><path d="M14 2v3.5h-3.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"></path></svg><span>Refresh</span></button>
<button class="ps-refresh-btn" id="ps-logout-btn" type="button" aria-label="Forget key for this tab (clears the daemon API key from session storage)" hidden>Forget key</button>
<button class="ps-theme-toggle" id="density-toggle" type="button" aria-label="Toggle UI density"><svg width="13" height="13" viewBox="0 0 16 16" aria-hidden="true"><rect class="b1" x="2" y="2.4" width="12" height="1.9" rx="0.95" fill="currentColor"></rect><rect class="b2" x="2" y="7.05" width="12" height="1.9" rx="0.95" fill="currentColor"></rect><rect class="b3" x="2" y="11.7" width="12" height="1.9" rx="0.95" fill="currentColor"></rect></svg><span id="density-label">Comfort</span></button>
<button class="ps-theme-toggle" id="theme-toggle" type="button">System</button>
</div>
</header>
<div class="ps-content">
<div class="ps-banner ps-report-warnings" id="report-warnings" style="display: none;"></div>
<div id="panel-overview" role="tabpanel" aria-labelledby="tab-overview" class="ps-panel ps-panel-wide" style="display: none;">
<div id="overview-hero"></div>
<div class="ps-metrics" id="findings-metrics"></div>
<div id="overview-body"></div>
</div>
<div id="panel-findings" role="tabpanel" aria-labelledby="tab-findings" class="ps-panel">
<div class="ps-banner" id="trim-banner" style="display: none;"></div>
<div class="ps-filters" id="findings-filters"></div>
<div class="ps-panel-toolbar">
<label class="ps-include-acked-wrap" id="findings-include-acked-wrap">
<input type="checkbox" id="findings-include-acked" />
<span>Show acknowledged</span>
</label>
<button type="button" class="ps-export-btn" id="findings-export" data-export-tab="findings">Export CSV</button>
<button type="button" class="ps-copy-link-btn" id="findings-copy-link" data-copy-link-tab="findings">Copy link</button>
</div>
<div class="ps-triage">
<div class="ps-card">
<div class="ps-list" id="findings-list"></div>
<button type="button" class="ps-show-more" id="findings-show-more" style="display: none;"></button>
<div class="ps-empty" id="findings-empty" style="display: none;">No findings in this trace set.</div>
</div>
<div class="ps-detail" id="findings-detail">
<div class="ps-detail-empty" id="explain-empty">Select a finding to view its trace tree and suggested fix.</div>
<div id="explain-detail-head"></div>
<div id="explain-content" style="display: none;">
<div class="ps-tree-header" id="explain-breadcrumb"></div>
<div class="ps-section">
<span class="ps-eyebrow">// explain · trace tree</span>
<div class="ps-tree" id="explain-tree"></div>
<div class="ps-detail-foot" id="explain-evidence-note" style="display: none;"></div>
<div class="ps-detail-empty" id="explain-not-embedded" style="display: none;"></div>
</div>
<div class="ps-detail-cols" id="explain-cols">
<div class="ps-section" id="explain-pgstat" style="display: none;"></div>
<div class="ps-fix" id="explain-fix" style="display: none;"></div>
</div>
<div class="ps-detail-foot" id="explain-foot" style="display: none;"></div>
</div>
</div>
</div>
</div>
<div id="panel-pgstat" role="tabpanel" aria-labelledby="tab-pgstat" class="ps-panel ps-panel-narrow" style="display: none;">
<div class="ps-drill" id="pgstat-drill" style="display: none;">
<div class="ps-drill-label">Filtered from Explain</div>
<span id="pgstat-drill-text"></span>
<span class="ps-drill-clear" id="pgstat-drill-clear">clear</span>
</div>
<div class="ps-pgstat-controls">
<span class="ps-rankby-label" id="pgstat-rankby-label">Rank by</span>
<div class="ps-filters" id="pgstat-rankings"></div>
<div class="ps-pgstat-actions">
<button type="button" class="ps-export-btn" id="pgstat-export" data-export-tab="pgstat">Export CSV</button>
<button type="button" class="ps-copy-link-btn" id="pgstat-copy-link" data-copy-link-tab="pgstat">Copy link</button>
</div>
</div>
<div class="ps-card">
<table class="ps-table" id="pgstat-table">
<thead><tr><th>Template</th><th style="width: 80px;">Calls</th><th style="width: 100px;">Total ms</th><th style="width: 90px;">Mean ms</th></tr></thead>
<tbody id="pgstat-body"></tbody>
</table>
</div>
<div class="ps-empty" id="pgstat-empty" style="display: none;">No pg_stat entries.</div>
</div>
<div id="panel-mysqlstat" role="tabpanel" aria-labelledby="tab-mysqlstat" class="ps-panel ps-panel-narrow" style="display: none;">
<div class="ps-pgstat-controls">
<span class="ps-rankby-label" id="mysqlstat-rankby-label">Rank by</span>
<div class="ps-filters" id="mysqlstat-rankings"></div>
<div class="ps-pgstat-actions">
<button type="button" class="ps-export-btn" id="mysqlstat-export" data-export-tab="mysqlstat">Export CSV</button>
<button type="button" class="ps-copy-link-btn" id="mysqlstat-copy-link" data-copy-link-tab="mysqlstat">Copy link</button>
</div>
</div>
<div class="ps-card">
<table class="ps-table" id="mysqlstat-table">
<thead><tr><th>Template</th><th style="width: 90px;">Schema</th><th style="width: 80px;">Calls</th><th style="width: 100px;">Total ms</th><th style="width: 90px;">Mean ms</th><th style="width: 110px;">Rows examined</th></tr></thead>
<tbody id="mysqlstat-body"></tbody>
</table>
</div>
<div class="ps-empty" id="mysqlstat-empty" style="display: none;">No mysql_stat entries.</div>
</div>
<div id="panel-diff" role="tabpanel" aria-labelledby="tab-diff" class="ps-panel ps-panel-narrow" style="display: none;">
<div class="ps-panel-toolbar">
<button type="button" class="ps-export-btn" id="diff-export" data-export-tab="diff">Export CSV</button>
<button type="button" class="ps-copy-link-btn" id="diff-copy-link" data-copy-link-tab="diff">Copy link</button>
</div>
<div class="ps-diff-cols">
<div class="ps-card ps-diff-section ps-diff-section-new">
<div class="ps-diff-section-header red" id="diff-new-header">New findings (0)</div>
<div class="ps-list" id="diff-new-list"></div>
<div class="ps-empty" id="diff-new-empty" style="display: none;"></div>
</div>
<div class="ps-card ps-diff-section ps-diff-section-resolved">
<div class="ps-diff-section-header green" id="diff-resolved-header">Resolved findings (0)</div>
<div class="ps-list" id="diff-resolved-list"></div>
<div class="ps-empty" id="diff-resolved-empty" style="display: none;"></div>
</div>
</div>
<div class="ps-card ps-diff-section">
<div class="ps-diff-section-header" id="diff-sev-header">Severity changes (0)</div>
<table class="ps-table" id="diff-sev-table" style="display: none;">
<thead><tr><th>Type</th><th>Endpoint</th><th>Before</th><th>After</th></tr></thead>
<tbody id="diff-sev-body"></tbody>
</table>
</div>
<div class="ps-card ps-diff-section">
<div class="ps-diff-section-header" id="diff-endp-header">Endpoint metric deltas (0)</div>
<table class="ps-table" id="diff-endp-table" style="display: none;">
<thead><tr><th>Service</th><th>Endpoint</th><th>Before</th><th>After</th><th>Δ</th></tr></thead>
<tbody id="diff-endp-body"></tbody>
</table>
</div>
</div>
<div id="panel-correlations" role="tabpanel" aria-labelledby="tab-correlations" class="ps-panel ps-panel-narrow" style="display: none;">
<div class="ps-panel-toolbar">
<button type="button" class="ps-export-btn" id="correlations-export" data-export-tab="correlations">Export CSV</button>
<button type="button" class="ps-copy-link-btn" id="correlations-copy-link" data-copy-link-tab="correlations">Copy link</button>
</div>
<div class="ps-list" id="correlations-list"></div>
<div class="ps-empty" id="correlations-empty" style="display: none;"></div>
</div>
<div id="panel-green" role="tabpanel" aria-labelledby="tab-green" class="ps-panel ps-panel-narrow" style="display: none;">
<div class="ps-metrics ps-metrics-3" id="green-metrics"></div>
<div id="green-formula" class="ps-formula-bandeau" style="display: none;"></div>
<div id="green-scoring-config" class="ps-scoring-bandeau" style="display: none;" aria-label="Carbon scoring configuration"></div>
<div class="ps-tree-header" id="green-regions-header"></div>
<div class="ps-card" id="green-regions-card" style="display: none;">
<table class="ps-table" id="green-regions-table">
<thead><tr><th>Region</th><th>Intensity (gCO2/kWh)</th><th>I/O ops</th><th>CO2</th><th>Source</th><th>Estimated</th></tr></thead>
<tbody id="green-regions-body"></tbody>
</table>
</div>
<div class="ps-empty" id="green-regions-empty" style="display: none;">No region breakdown available.</div>
</div>
<div id="panel-acknowledgments" role="tabpanel" aria-labelledby="tab-acknowledgments" class="ps-panel ps-panel-narrow" style="display: none;">
<div class="ps-card" id="acks-card" style="display: none;">
<table class="ps-table" id="acks-table">
<thead><tr><th>Signature</th><th style="width: 140px;">By</th><th>Reason</th><th style="width: 120px;">Expires</th><th style="width: 90px;"></th></tr></thead>
<tbody id="acks-body"></tbody>
</table>
</div>
<div class="ps-empty" id="acks-empty" style="display: none;">No daemon acknowledgments active.</div>
<div class="ps-acks-footer" id="acks-footer"></div>
</div>
<div style="height: 28px; flex: none;"></div>
</div>
<footer class="ps-footer">
<span class="ps-kbd">j/k</span> navigate
<span class="ps-kbd">enter</span> open
<span class="ps-kbd">/</span> search
<span class="ps-kbd">esc</span> back
<button type="button" class="ps-kbd" id="footer-shortcuts" style="cursor: pointer; background: transparent;">?</button> shortcuts
<span class="ps-credit">Powered by <a href="https://perf-sentinel.dev/" target="_blank" rel="noopener noreferrer">perf-sentinel</a> · design & logo by <a href="https://www.linkedin.com/in/gwendoline-meignen-b0224873/" target="_blank" rel="noopener noreferrer">Gwendoline Meignen</a></span>
</footer>
</main>
</div>
<dialog id="cheatsheet" class="ps-modal" aria-labelledby="cheatsheet-title">
<button type="button" class="ps-modal-close" id="cheatsheet-close" aria-label="Close">x</button>
<h2 id="cheatsheet-title">Keyboard shortcuts</h2>
<table id="cheatsheet-table">
<thead><tr><th scope="col">Key</th><th scope="col">Action</th></tr></thead>
<tbody id="cheatsheet-body"></tbody>
</table>
</dialog>
<dialog id="topic-help" class="ps-modal ps-modal-wide" aria-labelledby="topic-help-title">
<button type="button" class="ps-modal-close" id="topic-help-close" aria-label="Close">x</button>
<!-- Placeholder text, replaced per topic by openTopicHelp. An empty
heading has nothing for a screen reader to announce. -->
<h2 id="topic-help-title">Method sheet</h2>
<div id="topic-help-body"></div>
<a id="topic-help-link" class="ps-fix-link" target="_blank" rel="noopener noreferrer">Full methodology ↗</a>
</dialog>
<dialog id="auth-modal" class="ps-modal" aria-labelledby="auth-modal-title">
<button type="button" class="ps-modal-close" id="auth-modal-close" aria-label="Close">x</button>
<h2 id="auth-modal-title">Daemon authentication required</h2>
<p id="auth-modal-hint" style="margin: 0 0 8px 0; font-size: 11px; color: var(--color-text-warning);">The daemon rejected the request with 401. Enter the API key to retry. The key is held in browser memory only for this tab session and cleared when the tab closes.</p>
<form id="auth-modal-form">
<label for="auth-modal-key">API key</label>
<input type="password" id="auth-modal-key" autocomplete="off" />
<div class="ps-modal-error" id="auth-modal-error" role="alert"></div>
<div class="ps-modal-actions">
<button type="button" class="ps-modal-btn" id="auth-modal-cancel">Cancel</button>
<button type="submit" class="ps-modal-btn primary" id="auth-modal-submit">Submit</button>
</div>
</form>
</dialog>
<dialog id="ack-modal" class="ps-modal" aria-labelledby="ack-modal-title">
<button type="button" class="ps-modal-close" id="ack-modal-close" aria-label="Close">x</button>
<h2 id="ack-modal-title">Acknowledge finding</h2>
<form id="ack-modal-form">
<label for="ack-modal-sig">Signature</label>
<input type="text" id="ack-modal-sig" readonly />
<p class="ps-modal-scope" id="ack-modal-scope"></p>
<label for="ack-modal-reason">Reason</label>
<textarea id="ack-modal-reason" required></textarea>
<label for="ack-modal-expires">Expires</label>
<select id="ack-modal-expires">
<option value="">Never (permanent)</option>
<option value="24h">24 hours</option>
<option value="7d" selected>7 days</option>
<option value="30d">30 days</option>
</select>
<label for="ack-modal-by">Acknowledged by</label>
<input type="text" id="ack-modal-by" placeholder="optional" />
<div class="ps-modal-error" id="ack-modal-error" role="alert"></div>
<div class="ps-modal-actions">
<button type="button" class="ps-modal-btn" id="ack-modal-cancel">Cancel</button>
<button type="submit" class="ps-modal-btn primary" id="ack-modal-submit">Acknowledge</button>
</div>
</form>
</dialog>
<script id="report-data" type="application/json">
{{REPORT_JSON}}
</script>
<script>
(function () {
"use strict";
// -------- persistence (sessionStorage, scoped to this browser tab) --------
// sessionStorage throws in Safari private mode and some enterprise
// policies. Every access is wrapped so a failure degrades gracefully
// to in-memory defaults rather than surfacing a runtime error.
var STORAGE_KEYS = {
theme: "perf-sentinel:theme",
pgstatRanking: "perf-sentinel:pgstat-ranking"
};
function sessionGet(key) {
try {
return globalThis.sessionStorage.getItem(key);
} catch {
// Safari private mode or a locked-down enterprise policy:
// fall back to the in-memory default rather than surfacing
// a runtime error to the user.
return null;
}
}
function sessionSet(key, value) {
try {
globalThis.sessionStorage.setItem(key, value);
} catch {
// See sessionGet: silent fallback is the intended posture.
return;
}
}
// -------- theme (tri-state: auto | dark | light) --------
// "auto" follows prefers-color-scheme live. Legacy "dark"/"light"
// sessions keep their forced modes. Applied before DOM render so
// the body background does not flash.
var THEME_MODES = ["auto", "light", "dark"];
var THEME_LABELS = { auto: "System", light: "Light", dark: "Dark" };
var prefersDarkMQ = null;
try {
prefersDarkMQ = globalThis.matchMedia
? globalThis.matchMedia("(prefers-color-scheme: dark)")
: null;
} catch {
prefersDarkMQ = null;
}
function currentThemeMode() {
var stored = sessionGet(STORAGE_KEYS.theme);
return THEME_MODES.includes(stored) ? stored : "auto";
}
function resolveThemeColor(mode) {
if (mode === "dark" || mode === "light") return mode;
return prefersDarkMQ && prefersDarkMQ.matches ? "dark" : "light";
}
// Build an inline SVG (CSP-safe, no innerHTML) from a list of child
// {tag, attrs}. Stroke follows `currentColor` so CSS colors the icon.
function themeSvg(children) {
var NS = "http://www.w3.org/2000/svg";
var svg = document.createElementNS(NS, "svg");
var base = { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none",
stroke: "currentColor", "stroke-width": "1.9", "stroke-linecap": "round",
"stroke-linejoin": "round", "class": "ps-th-svg" };
setAttrs(svg, base);
children.forEach(function (c) {
var node = document.createElementNS(NS, c.tag);
setAttrs(node, c.attrs);
svg.appendChild(node);
});
return svg;
}
// Sun (light), moon (dark), monitor (auto = follow the OS).
function themeIcon(mode) {
if (mode === "light") {
return themeSvg([
{ tag: "circle", attrs: { cx: "12", cy: "12", r: "4" } },
{ tag: "path", attrs: { d: "M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" } }
]);
}
if (mode === "dark") {
return themeSvg([{ tag: "path", attrs: { d: "M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" } }]);
}
return themeSvg([
{ tag: "rect", attrs: { x: "3", y: "4", width: "18", height: "13", rx: "2" } },
{ tag: "path", attrs: { d: "M8 21h8M12 17v4" } }
]);
}
// The .ps-anim class gates the spin/fade keyframes so OS-driven
// re-resolutions in auto mode never replay the click animation.
function applyTheme(mode, animate) {
document.documentElement.dataset.theme = resolveThemeColor(mode);
var btn = document.getElementById("theme-toggle");
if (btn) {
btn.classList.toggle("ps-anim", !!animate);
btn.textContent = "";
btn.appendChild(themeIcon(mode));
btn.appendChild(el("span", "ps-th-lbl", THEME_LABELS[mode]));
var aria = "Switch theme (current: " + THEME_LABELS[mode] + ")";
btn.title = aria;
btn.setAttribute("aria-label", aria);
}
}
applyTheme(currentThemeMode());
if (prefersDarkMQ && typeof prefersDarkMQ.addEventListener === "function") {
try {
prefersDarkMQ.addEventListener("change", function () {
if (currentThemeMode() === "auto") applyTheme("auto");
});
} catch {
// Older browsers expose addListener instead, not worth wiring.
}
}
// -------- load payload --------
function loadPayload() {
var raw = document.getElementById("report-data").textContent || "";
try {
return JSON.parse(raw);
} catch (e) {
document.body.textContent = "Failed to parse embedded report data: " + e.message;
return null;
}
}
function indexTracesById(list) {
// Null-prototype map so a hostile trace_id like "__proto__" or
// "toString" cannot corrupt the lookup by reparenting Object's
// prototype chain. Not currently exploitable to code execution
// (downstream consumption is textContent-only), but the fix is
// a one-liner and removes the family of defects.
var map = Object.create(null);
list.forEach(function (trace) { map[trace.trace_id] = trace; });
return map;
}
var payload = loadPayload();
if (!payload) return;
var report = payload.report || {};
var findings = report.findings || [];
var greenSummary = report.green_summary || null;
var hasGreen = !!(greenSummary && greenSummary.co2);
var embeddedTraces = payload.embedded_traces || [];
var tracesById = indexTracesById(embeddedTraces);
// Spans the sink named for us. See CulpritIndex and culprit_key in
// report/html/mod.rs for the key.
var culpritSpans = payload.culprit_spans || {};
// Shared severity ranking: the Overview impact list, the findings list,
// the diff columns and the correlations all order by it.
var SEV_RANK = { critical: 0, warning: 1, info: 2 };
function bySeverityDesc(a, b) {
return (SEV_RANK[a.severity] ?? 3) - (SEV_RANK[b.severity] ?? 3);
}
// -------- small DOM helpers (textContent only) --------
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = String(text);
return n;
}
function setAttr(node, name, value) { node.setAttribute(name, String(value)); }
function setAttrs(node, attrs) { Object.keys(attrs).forEach(function (k) { node.setAttribute(k, String(attrs[k])); }); }
function safeHttpsHref(url) {
if (typeof url !== "string") return null;
if (!url.startsWith("https://")) return null;
if (/[\x00-\x1f\x7f-\x9f]/.test(url)) return null;
return url;
}
/// Reusable metric card builder. Hoisted to the outer scope so it is
/// not re-created on every render of `renderFindingsMetrics` and
/// `renderGreenPanel` (Sonar S7721).
function metricCard(label, value, sub, smallValue) {
var wrap = el("div", "ps-metric");
wrap.appendChild(el("div", "ps-metric-label", label));
var v = el("div", "ps-metric-value", value);
if (smallValue) v.classList.add("ps-metric-value-sm");
wrap.appendChild(v);
wrap.appendChild(el("div", "ps-metric-sub", sub));
return wrap;
}
// -------- formatters --------
function formatGco2(grams) {
if (!Number.isFinite(grams)) return "-";
var abs = Math.abs(grams);
if (abs >= 1) return grams.toFixed(3) + " g";
if (abs >= 0.001) return (grams * 1000).toFixed(3) + " mg";
if (abs >= 0.000001) return (grams * 1000000).toFixed(3) + " ug";
if (abs === 0) return "0 g";
return grams.toExponential(2) + " g";
}
function formatKwh(kwh) {
if (!Number.isFinite(kwh)) return "-";
var abs = Math.abs(kwh);
if (abs >= 0.001 || abs === 0) return kwh.toFixed(3) + " kWh";
return kwh.toExponential(2) + " kWh";
}
// Thousands separators (comma, en-US) on the integer part, decimals kept:
// 55500 -> "55,500", 1240.5 -> "1,240.5", 320 -> "320".
function fmtNum(value) {
var parts = String(value).split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
}
function formatDurationUs(us) {
if (!Number.isFinite(us)) return "-";
var ms = us / 1000;
if (ms < 1) return ms.toFixed(2) + " ms";
if (ms < 10) return ms.toFixed(2) + " ms";
return Math.round(ms) + " ms";
}
function truncate(s, max) {
if (typeof s !== "string") return "";
if (s.length <= max) return s;
return s.slice(0, max - 1) + "...";
}
function percent(num, denom) {
if (!denom) return "0%";
return Math.round((num * 100) / denom) + "%";
}
// -------- syntax highlighting (textContent-only span builders) --------
// Ported from the design prototype's sqlHl/proseHl. Builds colored
// <span> nodes via el() (textContent each), never innerHTML, so the
// CSP + no_forbidden_apis_in_template invariants hold. Token colors map
// to the severity/info/ok/brand CSS variables. The tokenizers are pure
// regex scans (first-match-wins) and cannot throw on odd input; the
// outer guard falls back to a plain text node just in case.
function buildKwSet(words) {
var set = Object.create(null);
words.forEach(function (w) { set[w] = true; });
return set;
}
var SQL_KW = buildKwSet([
"SELECT","FROM","WHERE","INSERT","INTO","VALUES","UPDATE","SET","DELETE","AND","OR","ON",
"JOIN","LEFT","RIGHT","INNER","OUTER","GROUP","BY","ORDER","HAVING","LIMIT","OFFSET","LIKE",
"ILIKE","ANY","ALL","AS","NULL","NOT","IN","IS","DISTINCT","EXISTS","BETWEEN","ASC","DESC",
"RETURNING","CREATE","INDEX","USING","TABLE","ALTER","ADD","CONSTRAINT"
]);
var GENERIC_KW = buildKwSet([
"fn","let","mut","pub","use","impl","struct","enum","trait","match","if","else","for","while",
"loop","return","async","await","move","self","Self","as","where","const","static","ref","dyn",
"in","break","continue","unsafe","crate","mod","type",
"func","package","import","var","interface","map","chan","go","defer","range","select","case",
"default","switch","nil",
"function","new","class","extends","implements","export","from","of","typeof","instanceof",
"yield","try","catch","finally","throw","throws","this","null","true","false","void","undefined",
"def","elif","with","except","raise","lambda","not","and","or","is","None","True","False","pass",
"global","del","assert","print",
"public","private","protected","final","abstract","sealed","record","super","synchronized",
"readonly","internal","base","volatile","transient","namespace","using","override","virtual",
"out","params","foreach",
"GET","POST","PUT","PATCH","DELETE","HEAD","OPTIONS","TRACE","CONNECT"
]);
function makeTokenSpan(text, color, bold) {
var span = el("span", null, text);
if (color) span.style.color = color;
if (bold) span.style.fontWeight = "600";
return span;
}
// Small inline-SVG icon (stroke = currentColor, so the parent's color
// applies). Used for the gate-rule check/cross so they center exactly,
// rather than relying on text-glyph metrics.
function iconSvg(size, d) {
var NS = "http://www.w3.org/2000/svg";
var svg = document.createElementNS(NS, "svg");
var attrs = { width: String(size), height: String(size), viewBox: "0 0 24 24",
fill: "none", stroke: "currentColor", "stroke-width": "3",
"stroke-linecap": "round", "stroke-linejoin": "round", display: "block" };
setAttrs(svg, attrs);
var path = document.createElementNS(NS, "path");
path.setAttribute("d", d);
svg.appendChild(path);
return svg;
}
function sqlHlFragment(frag, sql) {
var re = /(\s+)|('(?:[^']|'')*')|(\$\d+)|(\b\d+\b)|([A-Za-z_][A-Za-z0-9_]*)|([(),.*=<>!|+\/-]+)|(\S)/g;
var toks = [], m;
while ((m = re.exec(sql))) {
if (m[1]) toks.push({ t: "ws", v: m[1] });
else if (m[2]) toks.push({ t: "str", v: m[2] });
else if (m[3]) toks.push({ t: "param", v: m[3] });
else if (m[4]) toks.push({ t: "num", v: m[4] });
else if (m[5]) toks.push({ t: "ident", v: m[5] });
else if (m[6]) toks.push({ t: "op", v: m[6] });
else toks.push({ t: "other", v: m[0] });
}
toks.forEach(function (tk, i) {
var color = null, bold = false;
if (tk.t === "ident") {
if (SQL_KW[tk.v.toUpperCase()]) { color = "var(--info-fg)"; bold = true; }
else {
var j = i + 1;
while (toks[j] && toks[j].t === "ws") j++;
if (toks[j] && toks[j].t === "op" && toks[j].v[0] === "(") color = "var(--warn-fg)";
}
} else if (tk.t === "str") color = "var(--ok-fg)";
else if (tk.t === "param") color = "var(--warn-fg)";
else if (tk.t === "num") color = "var(--ok-fg)";
else if (tk.t === "op") color = "var(--text-3)";
if (color) frag.appendChild(makeTokenSpan(tk.v, color, bold));
else frag.appendChild(document.createTextNode(tk.v));
});
}
function genericHlFragment(frag, src) {
// `(\$[A-Za-z_]...)` captures a PHP variable ($ids, $this) as one token
// before the catch-all, so it renders cohesively. It matches `$` + a letter
// only, so SQL params ($1, $2) stay digit-led and are unaffected. A rare
// `$letter` token in another language (Prisma `$transaction`, jQuery `$el`)
// also renders as a variable, which is acceptable.
var gre = /(\s+)|(\/\/[^\n]*|#[^\n]*)|(\/\*[\s\S]*?\*\/)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(@[A-Za-z_][A-Za-z0-9_.]*)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_][A-Za-z0-9_]*!?)|(::|->|=>|[(){}\[\].,:;=<>!&|+\-*\/%?@]+)|(\$[A-Za-z_][A-Za-z0-9_]*)|(\S)/g;
var gtk = [], gm;
while ((gm = gre.exec(src))) {
if (gm[1]) gtk.push({ t: "ws", v: gm[1] });
else if (gm[2] || gm[3]) gtk.push({ t: "cmt", v: gm[2] || gm[3] });
else if (gm[4]) gtk.push({ t: "str", v: gm[4] });
else if (gm[5]) gtk.push({ t: "ann", v: gm[5] });
else if (gm[6]) gtk.push({ t: "num", v: gm[6] });
else if (gm[7]) gtk.push({ t: "id", v: gm[7] });
else if (gm[8]) gtk.push({ t: "op", v: gm[8] });
else if (gm[9]) gtk.push({ t: "var", v: gm[9] });
else gtk.push({ t: "o", v: gm[0] });
}
gtk.forEach(function (x, i) {
var color = null, bold = false;
if (x.t === "cmt") color = "var(--text-3)";
else if (x.t === "ann") color = "var(--warn-fg)";
else if (x.t === "str") color = "var(--ok-fg)";
else if (x.t === "num") color = "var(--ok-fg)";
else if (x.t === "var") color = "var(--warn-fg)"; // PHP $variable
else if (x.t === "id") {
if (GENERIC_KW[x.v]) { color = "var(--info-fg)"; bold = true; } // keyword
else {
var j = i + 1;
while (gtk[j] && gtk[j].t === "ws") j++;
var nx = gtk[j] && gtk[j].t === "op" ? gtk[j].v : "";
// IDE-style: functions, macros and snake_case method/property names
// are amber; PascalCase types are teal; plain identifiers and module
// path segments keep the default text color.
if (x.v.slice(-1) === "!" || nx[0] === "(" || /_/.test(x.v)) color = "var(--warn-fg)";
else if (/^[A-Z].*[a-z]/.test(x.v)) color = "var(--brand-text)";
}
} else if (x.t === "op") color = "var(--text-3)";
if (color) frag.appendChild(makeTokenSpan(x.v, color, bold));
else frag.appendChild(document.createTextNode(x.v));
});
}
// Highlight `text` (SQL or generic code / HTTP) into `target`. SQL is
// detected by a leading statement keyword; everything else goes through
// the generic multi-language branch.
// Endpoint / metadata highlighter: colors only the HTTP method, {path
// params} and numbers, leaving service names and path segments plain. A
// URL path is not code, so a segment that happens to match a code keyword
// ("export", "class", ...) must NOT be highlighted as one.
var HTTP_METHODS = { GET: 1, POST: 1, PUT: 1, PATCH: 1, DELETE: 1, HEAD: 1, OPTIONS: 1, TRACE: 1, CONNECT: 1 };
// Derived from HTTP_METHODS so the dispatch gate cannot drift from the set.
var HTTP_DISPATCH_RE = new RegExp("^\\s*(" + Object.keys(HTTP_METHODS).join("|") + ")\\s+/", "i");
function endpointHlFragment(frag, src) {
var re = /(\s+)|(\{[^}]*\})|(\d+)|([A-Za-z][A-Za-z0-9_-]*)|(\S)/g;
var m;
while ((m = re.exec(src))) {
var color = null, bold = false, v = m[0];
if (m[1]) { frag.appendChild(document.createTextNode(v)); continue; }
if (m[2]) color = "var(--warn-fg)";
else if (m[3]) color = "var(--ok-fg)";
else if (m[4] && HTTP_METHODS[m[4].toUpperCase()]) { color = "var(--info-fg)"; bold = true; }
if (color) frag.appendChild(makeTokenSpan(v, color, bold));
else frag.appendChild(document.createTextNode(v));
}
}
function highlightEndpoint(target, text) {
if (text == null) return;
var frag = document.createDocumentFragment();
endpointHlFragment(frag, String(text));
target.appendChild(frag);
}
function highlightInto(target, text) {
if (text == null) return;
var src = String(text);
var frag = document.createDocumentFragment();
try {
// Endpoint ("METHOD /path") first, so "DELETE /x" is not mistaken for
// a SQL DELETE statement.
if (HTTP_DISPATCH_RE.test(src)) {
endpointHlFragment(frag, src);
} else if (/^\s*(SELECT|INSERT|UPDATE|DELETE|WITH|CREATE|ALTER)\b/i.test(src)) {
sqlHlFragment(frag, src);
} else {
genericHlFragment(frag, src);
}
} catch (e) {
target.appendChild(document.createTextNode(src));
return;
}
target.appendChild(frag);
}
// Break opportunity after each comma. <wbr> rather than a zero-width
// space: it adds nothing to the text copied out of the block.
function addCommaBreaks(root) {
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var texts = [];
while (walker.nextNode()) texts.push(walker.currentNode);
texts.forEach(function (node) {
var parts = node.nodeValue.split(",");
if (parts.length < 2) return;
var frag = document.createDocumentFragment();
parts.forEach(function (part, i) {
if (i > 0) {
frag.appendChild(document.createTextNode(","));
frag.appendChild(document.createElement("wbr"));
}
if (part) frag.appendChild(document.createTextNode(part));
});
node.parentNode.replaceChild(frag, node);
});
}
// Inline-code in prose: `token` segments become monospace chips, the
// rest is plain text. Each chip is syntax-highlighted through the same
// language-agnostic highlighter as the code blocks, so method calls,
// macros, annotations, keywords and operators get colored.
function proseInto(target, text) {
if (text == null) return;
var parts = String(text).split(/`([^`]+)`/);
for (var i = 0; i < parts.length; i++) {
if (i % 2 === 1) {
var code = el("code", "ps-code-inline");
highlightInto(code, parts[i]);
target.appendChild(code);
} else if (parts[i]) {
target.appendChild(document.createTextNode(parts[i]));
}
}
}
// severity + finding-type display maps
var SEV_LABEL = { critical: "CRIT", warning: "WARN", info: "INFO" };
// Maps a finding severity to the [data-sev] attribute value driving the
// severity-pill colors in the redesigned stylesheet.
var SEV_DATA = { critical: "crit", warning: "warn", info: "info" };
// Builds a label/value cell for the finding detail meta grid.
function metaCell(label, value, mono, brand, code) {
var cell = el("div", "ps-meta-cell");
cell.appendChild(el("div", "ps-meta-cell-label", label));
var v = el("div", "ps-meta-cell-value");
if (code) highlightInto(v, value);
else v.textContent = value;
if (mono) v.classList.add("mono");
if (brand) v.classList.add("brand");
cell.appendChild(v);
return cell;
}
function findingGrouping(f) {
return f.grouping && f.grouping[0] || null;
}
function findingNamespace(f) {
var g = findingGrouping(f);
return g ? g.value : "";
}
// Label for the grouping chips and rows: the attribute that decided the
// identity, so `tenant.id=acme` never reads as a namespace.
function findingGroupingKey(f) {
var g = findingGrouping(f);
return g ? g.key : "";
}
function findingGroupingIdentity(f) {
var g = findingGrouping(f);
if (!g) return "";
var encoder = new TextEncoder();
return encoder.encode(g.key).length + ":" + g.key + "|" +
encoder.encode(g.value).length + ":" + g.value;
}
function findingGroupingLabel(f) {
var g = findingGrouping(f);
return g ? g.key + "=" + g.value : "";
}
// Builds a severity pill carrying the [data-sev] attribute.
function sevPill(severity, cls) {
var pill = el("span", cls || "ps-sev", SEV_LABEL[severity] || "INFO");
setAttr(pill, "data-sev", SEV_DATA[severity] || "info");
return pill;
}
// What each finding type means and how it is detected, shown behind
// the "?" on the detail title. Mechanisms mirror detect/ and
// docs/design/04-DETECTION.md; thresholds are named by config key, the
// report does not embed the values the producing run used.
var FINDING_HELP = {
n_plus_one_sql: "The same query shape fired once per row instead of once for the whole set. Flagged when a trace repeats one normalized SQL template at least `[detection] n_plus_one_threshold` times with as many distinct parameter sets, inside `window_duration_ms`. Identical parameters classify as Redundant SQL instead.",
n_plus_one_http: "The same endpoint called once per item instead of once in bulk. Direct classification uses repeated normalized endpoints with distinct parameter sets. Otherwise, `sanitizer_aware_classification` can classify the group heuristically from timing and trace-shape signals; always mode deliberately forces that classification without those signals. Neither path proves the parameters were collapsed or that the calls are causally N+1. The detail identifies which path fired.",
n_plus_one_messaging: "One broker publish per item instead of a batch. Flagged when a trace repeats one destination at least `[detection] n_plus_one_threshold` times with as many distinct parameter sets, inside `window_duration_ms`.",
redundant_sql: "The exact same query, template and parameters alike, ran more than once in one trace, so the later results could not differ. Two identical calls flag at info, five or more raise a warning: a cache or a local variable would remove them.",
redundant_http: "The exact same HTTP call, endpoint and parameters alike, ran more than once in one trace. Two identical calls flag at info, five or more raise a warning.",
slow_sql: "A query template ran above `[detection] slow_query_threshold_ms`, at least `slow_query_min_occurrences` times. Critical past five times the threshold.",
slow_http: "An HTTP call ran above `[detection] slow_query_threshold_ms`, at least `slow_query_min_occurrences` times. Critical past five times the threshold.",
slow_messaging: "A broker publish ran above `[detection] slow_query_threshold_ms`, at least `slow_query_min_occurrences` times. Critical past five times the threshold.",
excessive_fanout: "One parent span fans out to more child operations than `[detection] max_fanout` allows. A structural shape rather than avoidable I/O: critical past three times the threshold.",
chatty_service: "One trace leaves the service through more outbound HTTP calls than `[detection] chatty_service_min_calls` allows. Batching or a coarser API is the usual fix. Critical past three times the threshold.",
pool_saturation: "The peak number of SQL queries in flight at the same instant reached `[detection] pool_saturation_concurrent_threshold`, enough to contend on the database connection pool. A tuning or architecture signal, not avoidable I/O.",
serialized_calls: "Sibling calls with distinct templates ran strictly one after another (longest non-overlapping chain, at least `[detection] serialized_min_sequential` long) with no visible data dependency: they could run in parallel. Always info, the heuristic cannot see hidden dependencies."
};
// Config-key name in the .toml vs field name in detection_config.
var DETECTION_KEY_FIELD = {
n_plus_one_threshold: "n_plus_one_threshold",
window_duration_ms: "window_ms",
slow_query_threshold_ms: "slow_threshold_ms",
slow_query_min_occurrences: "slow_min_occurrences",
max_fanout: "max_fanout",
chatty_service_min_calls: "chatty_service_min_calls",
pool_saturation_concurrent_threshold: "pool_saturation_concurrent_threshold",
serialized_min_sequential: "serialized_min_sequential"
};
// Inline the producing run's values next to the key names. Reports
// from older binaries carry no detection_config: keys alone then.
function substituteDetectionValues(text) {
var cfg = report.detection_config;
if (!text || !cfg) return text;
Object.keys(DETECTION_KEY_FIELD).forEach(function (key) {
var v = cfg[DETECTION_KEY_FIELD[key]];
if (v === undefined || v === null) return;
text = text.split(key + "`").join(key + " = " + v + "`");
});
return text;
}
function findingHelpText(type) {
return substituteDetectionValues(FINDING_HELP[type]);
}
// Deep, plain-language method sheets behind a CLICK on the same "?"
// (hover keeps the short tooltip). Faithful to docs/METHODOLOGY.md and
// the detector docs, no source knowledge required. Links go to the
// published English docs, the site has no docs mirror in French.
var METHO_URL = "https://perf-sentinel.dev/docs/METHODOLOGY.html";
var ENERGY_URL = "https://perf-sentinel.dev/docs/ENERGY.html";
var HELP_DEEP = {
n_plus_one_sql: { t: "N+1 SQL", u: METHO_URL, s: [
["What it measures", "One query per row instead of one query for the whole set, the classic lazy-loading pattern: fetching 50 orders then firing 50 `SELECT * FROM order_item WHERE order_id = ?` instead of a single query for all 50."],
["How it is computed", "Literals are normalized away (`WHERE id = 42` becomes `WHERE id = ?`), so queries share a shape. The SQL spans of a trace are grouped by shape, and a group flags when it repeats at least `n_plus_one_threshold` times inside `window_duration_ms`, with as many distinct parameter sets, one query per row. Ten occurrences or more raise it to critical. The same shape with identical parameters is a different smell, Redundant SQL: the results could not differ."],
["What tunes it", "`[detection] n_plus_one_threshold`, `window_duration_ms`, and `sanitizer_aware_classification`, which can keep already-parameterized safe patterns out of the count."],
["Limits", "The detector counts what the trace shows: a driver that batches the queries under the hood, or repeats spread beyond the window, are invisible to it."]
]},
n_plus_one_http: { t: "N+1 HTTP", u: METHO_URL, s: [
["What it measures", "One outbound HTTP call per item instead of one bulk call: fetching a list, then calling `GET /api/users/{id}` once per element."],
["How it is computed", "Paths are normalized (`/users/42` becomes `/users/{id}`) so calls share a template. Direct classification uses repeated calls with distinct parameter sets inside `window_duration_ms`. Otherwise, the optional sanitizer-aware path uses timing and trace-shape signals; always mode deliberately forces that path without those signals. Neither path proves that instrumentation collapsed the parameters or that the calls are causally N+1. The finding reports `sanitizer_heuristic` when that path fired. Ten or more occurrences is critical."],
["What tunes it", "`[detection] n_plus_one_threshold`, `window_duration_ms`, and `sanitizer_aware_classification`."],
["Limits", "The detector sees spans, not the client's internal coalescing or caching. Use the displayed timestamps, duration distribution, distinct-parameter count and classification method to validate the signal in context."]
]},
n_plus_one_messaging: { t: "N+1 messaging", u: METHO_URL, s: [
["What it measures", "One broker publish per item instead of a batched publish."],
["How it is computed", "Publish spans are grouped by destination, and a group flags when it repeats at least `n_plus_one_threshold` times inside `window_duration_ms` with as many distinct parameter sets."],
["What tunes it", "`[detection] n_plus_one_threshold` and `window_duration_ms`."],
["Limits", "A client library may already batch the publishes it buffers before the broker sees them, so the occurrence count is an upper bound on the real waste."]
]},
redundant_sql: { t: "Redundant SQL", u: METHO_URL, s: [
["What it measures", "The exact same query, shape and parameters alike, executed more than once in one trace: the later results could not differ from the first."],
["How it is computed", "Spans are grouped by shape plus the full parameter set. Two identical executions flag at info, five or more raise a warning, the usual signature of a loop bug or a missing cache. Distinct parameters classify as N+1 instead."],
["What tunes it", "Nothing: even two identical queries in one request are worth a look."],
["Limits", "The detector cannot know the data changed between the two executions, a deliberate re-read after a write looks redundant to it."]
]},
redundant_http: { t: "Redundant HTTP", u: METHO_URL, s: [
["What it measures", "The exact same HTTP call, endpoint and parameters alike, fired more than once in one trace."],
["How it is computed", "Outbound spans are grouped by template plus the full parameter set. Two identical calls flag at info, five or more raise a warning. A local variable or a per-request cache removes them."],
["What tunes it", "Nothing: exact duplicates always flag."],
["Limits", "A response that legitimately changes between the two calls is invisible to the detector."]
]},
slow_sql: { t: "Slow SQL", u: METHO_URL, s: [
["What it measures", "A query shape whose executions run above your latency budget."],
["How it is computed", "A shape flags when it runs above `slow_query_threshold_ms` at least `slow_query_min_occurrences` times, so a single outlier does not page anyone. Five times the threshold raises it to critical. Batch runs also compute cross-trace p50/p95/p99 per shape."],
["What tunes it", "`[detection] slow_query_threshold_ms` and `slow_query_min_occurrences`."],
["Limits", "The threshold is absolute and yours to calibrate, the detector has no notion of what is normal for this query."]
]},
slow_http: { t: "Slow HTTP", u: METHO_URL, s: [
["What it measures", "An outbound HTTP endpoint whose calls run above your latency budget."],
["How it is computed", "A template flags when it runs above `slow_query_threshold_ms` at least `slow_query_min_occurrences` times. Five times the threshold raises it to critical."],
["What tunes it", "`[detection] slow_query_threshold_ms` and `slow_query_min_occurrences`."],
["Limits", "Slow findings measure duration, not waste: they carry no avoidable I/O and do not enter the waste ratio."]
]},
slow_messaging: { t: "Slow messaging", u: METHO_URL, s: [
["What it measures", "A broker publish destination whose publishes run above your latency budget."],
["How it is computed", "A destination flags when publishes run above `slow_query_threshold_ms` at least `slow_query_min_occurrences` times. Five times the threshold is critical."],
["What tunes it", "`[detection] slow_query_threshold_ms` and `slow_query_min_occurrences`."],
["Limits", "Publish latency includes broker acknowledgment behavior, which the trace cannot decompose."]
]},
excessive_fanout: { t: "Excessive fanout", u: METHO_URL, s: [
["What it measures", "One parent operation spawning more child calls than a healthy design should need, an aggregator hammering every downstream at once."],
["How it is computed", "Spans are grouped by parent, and a parent flags past `max_fanout` children. Three times the threshold raises it to critical."],
["What tunes it", "`[detection] max_fanout`."],
["Limits", "A structural signal, not avoidable I/O: the fix is architectural (composition, caching, a coarser API), so it does not enter the waste ratio."]
]},
chatty_service: { t: "Chatty service", u: METHO_URL, s: [
["What it measures", "A single request leaving the service through an unreasonable number of outbound HTTP calls, wherever they go."],
["How it is computed", "The trace's outbound HTTP spans are counted, and the trace flags past `chatty_service_min_calls`. Three times the threshold raises it to critical."],
["What tunes it", "`[detection] chatty_service_min_calls`."],
["Limits", "Whole-trace: it says the conversation is too chatty, not which single call to remove. The trace tree highlights all of them."]
]},
pool_saturation: { t: "Pool saturation", u: METHO_URL, s: [
["What it measures", "How many SQL queries were in flight at the same instant, the pressure your database connection pool actually felt."],
["How it is computed", "Query start and end timestamps are swept chronologically to find the peak concurrency, which flags at `pool_saturation_concurrent_threshold`."],
["What tunes it", "`[detection] pool_saturation_concurrent_threshold`."],
["Limits", "High concurrency is not avoidable I/O: it signals pool sizing or architectural contention, so it stays out of the waste ratio."]
]},
serialized_calls: { t: "Serialized calls", u: METHO_URL, s: [
["What it measures", "Sibling calls that ran strictly one after another although nothing visible forces the order: they could run in parallel and the user waits for the sum instead of the max."],
["How it is computed", "Siblings under one parent are sorted by end time and the longest chain of non-overlapping calls with distinct templates is extracted (weighted interval scheduling). It flags at `serialized_min_sequential` links."],
["What tunes it", "`[detection] serialized_min_sequential`."],
["Limits", "Always info on purpose: the trace cannot see a data dependency between the calls, so a legitimate pipeline looks identical to a missed parallelization."]
]},
total_co2: { t: "Total CO2", u: METHO_URL + "#uncertainty-bracket", s: [
["What it measures", "Everything the analyzed traffic emitted, by the SCI v1.0 numerator: operational `(E x I)` plus embodied `M` plus network transport `T`, summed over traces and scored per region when spans carry `cloud.region`."],
["How the bracket works", "low is half the mid value, high is double: a log-symmetric bracket that says the estimate is trusted to a factor of two, not to a percent. The model tag says how the energy figure was obtained, measured by an energy backend or modeled from I/O counts."],
["Limits", "A directional estimate for comparing runs and finding waste, not a wattmeter reading, and the project says so in its methodology."]
]},
operational_co2: { t: "Operational CO2", u: METHO_URL + "#energy-per-operation", s: [
["What it measures", "The carbon of actually running the workload: energy used times the grid carbon intensity of the resolved region."],
["How it is computed", "Energy is either measured (RAPL via Scaphandre, Kepler or Alumet, BMC via Redfish, or the embedded SPECpower table for cloud instances) or modeled from I/O counts with per-operation coefficients. Intensity comes from the embedded per-region table, hourly profiles, or Electricity Maps live when configured."],
["Why it matters", "The only SCI term a code fix can reduce: the avoidable share lives here."]
]},
embodied_co2: { t: "Embodied CO2", u: METHO_URL + "#background-energy-and-sci-primer", s: [
["What it measures", "The manufacturing carbon of the hardware, amortized over the requests it serves: the SCI `M` term."],
["How it is computed", "`[green] embodied_carbon_per_request_gco2` (strictly positive, no hardware has zero embodied carbon) times the number of requests. The applied coefficient is published with every disclosure."],
["Limits", "Real but irreducible by fixing an anti-pattern: it shrinks by serving more with less hardware, not by removing a query."]
]},
avoidable_co2: { t: "Avoidable", u: METHO_URL + "#aggregation-over-a-period", s: [
["What it measures", "The slice of operational carbon your team can actually delete by fixing the avoidable anti-patterns (N+1, redundant calls)."],
["How it is computed", "Operational carbon times the ratio of avoidable I/O operations over accounted ones, the `sci_v1_operational_ratio` methodology. Region-blind and excluding embodied by construction."],
["Why it matters", "This is the number a fix shrinks, and the honest one to celebrate: the total also moves with traffic, this one moves with code quality."]
]},
waste_ratio: { t: "I/O waste ratio", u: METHO_URL + "#io-intensity-score-iis", s: [
["What it measures", "Which share of all I/O operations (SQL, outbound HTTP, publishes) belongs to avoidable anti-patterns."],
["How it is computed", "Avoidable I/O operations, deduplicated across findings so one span never counts twice, divided by total I/O operations. Bands: healthy under 10%, moderate to 30%, high to 50%, critical above."],
["Limits", "Counts operations, not bytes or milliseconds: a tiny redundant query and a huge one weigh the same here."]
]},
iis: { t: "IIS score", u: METHO_URL + "#io-intensity-score-iis", s: [
["What it measures", "How I/O-hungry an endpoint is: its I/O operations per call, averaged over the window. The card shows the worst endpoint."],
["How it is computed", "Per (service, endpoint) pair, total I/O operations divided by invocations. Bands at 2, 5 and 10, and the top offenders ranking orders endpoints by it."],
["Limits", "A relative ranking signal: it tells you where to look first, not how many joules the endpoint burns."]
]},
db_waste: { t: "Database waste", u: ENERGY_URL, s: [
["What it measures", "The avoidable share of the database's own energy, the side of an N+1 the application trace cannot see."],
["How it is computed", "Measured mode: the declared database cgroup's energy (Alumet) times the SQL waste ratio, a CPU-only lower bound that is genuinely additional to the report totals. Estimated mode, on every run without a measured reading: the modeled energy of the window's SQL spans times the same ratio, a re-presented share of the totals, never additional. The model tag says which mode produced the figure."],
["Limits", "Kept out of every total because the database is a different scope than the instrumented services, and the two modes err in different directions."]
]},
broker_waste: { t: "Broker waste", u: ENERGY_URL, s: [
["What it measures", "The avoidable share of the message broker's energy, the twin of database waste for publishes."],
["How it is computed", "Measured on the broker cgroup (Alumet), declared from a provisioned cluster via the embedded SPECpower table, or estimated from the modeled publish energy times the messaging waste ratio. The model tag says which."],
["Limits", "A measurement under-counts (CPU only), a declaration bounds compute at full load without storage or network: the error direction depends on the source, and the figure stays out of every total."]
]},
green_tab: { t: "GreenOps methodology", u: METHO_URL + "#pipeline-at-a-glance", s: [
["The pipeline in one breath", "Count the I/O operations each endpoint performs, turn them into energy (measured by a backend when configured, a per-operation model otherwise), multiply by the grid carbon intensity of each region, then assemble the SCI v1.0 terms: operational plus embodied plus transport."],
["Measured vs modeled", "Every figure carries a model tag naming its source: RAPL, BMC, SPECpower, or the I/O proxy with its factor-of-two bracket. Measured and modeled figures are never silently mixed."],
["What it is for", "A directional waste counter to compare runs and target fixes, published with its uncertainty, not a wattmeter and not a regulatory instrument. The methodology page details every constant and its academic source."]
]}
};
function openTopicHelp(key) {
var d = HELP_DEEP[key];
var dlg = document.getElementById("topic-help");
if (!d || !dlg) return;
document.getElementById("topic-help-title").textContent = d.t;
var body = document.getElementById("topic-help-body");
body.textContent = "";
d.s.forEach(function (sec) {
body.appendChild(el("h3", null, sec[0]));
var para = el("p");
proseInto(para, substituteDetectionValues(sec[1]));
body.appendChild(para);
});
document.getElementById("topic-help-link").href = d.u;
topicHelpPriorFocus = document.activeElement;
dlg.showModal();
var closeBtn = document.getElementById("topic-help-close");
if (closeBtn) closeBtn.focus();
}
var topicHelpPriorFocus = null;
function wireTopicHelpDialog() {
var dlg = document.getElementById("topic-help");
if (!dlg) return;
var close = function () { if (dlg.open) dlg.close(); };
var closeBtn = document.getElementById("topic-help-close");
if (closeBtn) closeBtn.addEventListener("click", close);
dlg.addEventListener("close", function () {
if (topicHelpPriorFocus && topicHelpPriorFocus.focus) topicHelpPriorFocus.focus();
topicHelpPriorFocus = null;
});
dlg.addEventListener("click", function (ev) {
if (ev.target === dlg) close();
});
}
var TYPE_LABEL = {
n_plus_one_sql: "N+1 SQL",
n_plus_one_http: "N+1 HTTP",
n_plus_one_messaging: "N+1 messaging",
redundant_sql: "Redundant SQL",
redundant_http: "Redundant HTTP",
slow_sql: "Slow SQL",
slow_http: "Slow HTTP",
slow_messaging: "Slow messaging",
excessive_fanout: "Excessive fanout",
chatty_service: "Chatty service",
pool_saturation: "Pool saturation",
serialized_calls: "Serialized calls"
};
function typeLabel(t) { return TYPE_LABEL[t] || String(t); }
// -------- top bar / sidebar chrome --------
// The brand wordmark is static markup in the sidebar (inline SVG,
// theme-swapped by CSS). renderTopbar fills the sidebar version line
// and the top-bar quality-gate pill; the per-route eyebrow + title
// are set by switchTab via updateTopbarTitle.
// Run-context line in the sidebar foot, derived from the Finding
// `confidence` the producing instance stamps (mode-driven, uniform per
// run). Batch runs stamp local_batch (developer machine) or ci_batch
// (CI env detected); daemon runs stamp staging/production from the
// [daemon] environment. See detect/mod.rs::Confidence and pipeline.rs.
var CONFIDENCE_CONTEXT = {
local_batch: { label: "batch · local", tone: "neutral" },
ci_batch: { label: "batch · CI", tone: "neutral" },
daemon_staging: { label: "daemon · live · staging", tone: "warn" },
daemon_production: { label: "daemon · live · prod", tone: "ok" }
};
function renderSidebarContext() {
var ctx = document.getElementById("ps-sidebar-context");
var dot = document.getElementById("ps-sidebar-dot");
if (!ctx) return;
var conf = findings.length ? findings[0].confidence : null;
var meta = CONFIDENCE_CONTEXT[conf];
if (!meta) {
// No findings to read confidence from: fall back to mode only.
meta = payload.daemon && payload.daemon.url
? { label: "daemon · live", tone: "ok" }
: { label: "batch", tone: "neutral" };
}
ctx.textContent = meta.label;
if (dot) setAttr(dot, "data-ctx", meta.tone);
}
function renderTopbar() {
var verEl = document.getElementById("ps-sidebar-version");
if (verEl) verEl.textContent = "v" + (payload.version || "") + " · AGPL-3.0";
renderSidebarContext();
var gate = report.quality_gate || { passed: true };
var slot = document.getElementById("topbar-gate");
if (slot) {
slot.textContent = "";
slot.appendChild(
el(
"span",
gate.passed ? "ps-gate-pass" : "ps-gate-fail",
gate.passed ? "GATE PASS" : "GATE FAIL"
)
);
}
}
// Per-route top-bar eyebrow + title. Keys match the registered tab keys.
var ROUTE_META = {
overview: { title: "Overview", eyebrow: "summary" },
findings: { title: "Findings", eyebrow: "findings · triage" },
pgstat: { title: "pg_stat", eyebrow: "postgres statements" },
mysqlstat: { title: "mysql_stat", eyebrow: "mysql statement digests" },
diff: { title: "Diff", eyebrow: "diff vs baseline" },
correlations: { title: "Correlations", eyebrow: "cross-trace" },
green: { title: "Carbon", eyebrow: "greenops" },
acknowledgments: { title: "Acknowledgments", eyebrow: "acks" }
};
// What each data tab shows and where the figures come from, behind a
// "?" beside the section title. Sourced from docs/METHODOLOGY.md and
// the module docs of the producing code.
var TAB_HELP = {
correlations: "A recurring co-occurrence between two findings from different services, computed by the daemon over its rolling window (opt-in via `[daemon.correlation]`). A pair is reported once it repeats at least `min_co_occurrences` times within `lag_threshold_ms` of each other. `conf` is the share of the source finding's occurrences followed by the target, `lag` the median delay between the two, and the trace count how many times the pair co-occurred.",
pgstat: "Server-side statistics from the PostgreSQL `pg_stat_statements` extension: cumulative per-template figures (calls, total and mean time) measured by the database itself, unlike findings, which come from traces. Loaded with `--pg-stat <file>` or `--pg-stat-prometheus <url>`, top entries per ranking. A SQL span in Explain whose template matches a row links to this tab.",
mysqlstat: "Server-side statistics from the MySQL Performance Schema (`events_statements_summary_by_digest`): cumulative per-digest figures measured by the database itself. Loaded with `--mysql-stat <file>`, with the same ranking switcher as pg_stat.",
green: "GreenOps scoring on the SCI v1.0 methodology: carbon is `(E x I) + M + T`, energy times grid intensity, plus embodied hardware, plus network transport, summed over the analyzed traffic. The model tag says whether the energy was measured (RAPL, BMC, SPECpower) or modeled from I/O counts. Each card carries its own help."
};
function updateTopbarTitle(key) {
var meta = ROUTE_META[key] || { title: key, eyebrow: "" };
var eb = document.getElementById("topbar-eyebrow");
var h1 = document.getElementById("topbar-h1");
if (eb) eb.textContent = "// " + meta.eyebrow;
if (h1) {
h1.textContent = meta.title;
var row = h1.parentElement;
// Removing a hovered anchor would strand its tip: it never sees a
// mouseleave. Same discipline as openExplain.
sweepTips();
row.querySelectorAll(".ps-nav-help").forEach(function (n) { n.remove(); });
if (TAB_HELP[key]) {
var help = el("span", "ps-nav-help", "?");
setAttr(help, "aria-label", TAB_HELP[key]);
wireTip(help, TAB_HELP[key], key === "green" ? "green_tab" : null);
row.appendChild(help);
}
}
}
// -------- tabs (generic) --------
var tabs = [];
// Used by g-prefixed shortcuts and deep-link hash application to bail
// out silently when a tab is not present in the current report.
var registeredTabs = Object.create(null);
function registerTab(key, label, countFn) {
tabs.push({ key: key, label: label, countFn: countFn });
registeredTabs[key] = true;
}
// Greyed, non-navigable entry with a "?" tooltip naming the cause.
// Not in registeredTabs, so deep links and g-shortcuts bail silently.
function registerDisabledTab(key, label, tip) {
tabs.push({ key: key, label: label, disabledTip: tip });
}
// Floating tip box on hover, viewport-positioned so no ancestor clips it.
function sweepTips() {
document.querySelectorAll(".ps-tipbox").forEach(function (b) { b.remove(); });
}
var tipSweepWired = false;
function wireTip(anchor, text, deepKey) {
if (!tipSweepWired) {
tipSweepWired = true;
// Fixed-positioned tips do not follow scroll, and a re-render can
// orphan a hovered anchor: sweep on any scroll or click.
window.addEventListener("scroll", sweepTips, true);
document.addEventListener("click", sweepTips, true);
}
// The icon sits beside its heading rather than inside it, so nothing
// folds the help text into the heading name any more: make it a real
// control instead, reachable by tab and readable on focus.
setAttr(anchor, "role", "button");
setAttr(anchor, "tabindex", "0");
var box = null;
function showTip() {
// Single tip at a time.
sweepTips();
box = el("div", "ps-tipbox");
proseInto(box, text);
// Teach the click: hover alone would never reveal the sheet.
if (deepKey) {
box.appendChild(el("div", "ps-tipbox-more", "Click the ? for the full method sheet"));
}
document.body.appendChild(box);
var r = anchor.getBoundingClientRect();
var w = box.offsetWidth;
var h = box.offsetHeight;
var centeredTop = r.top + r.height / 2 - h / 2;
if (centeredTop < 8) {
// No room to center beside the anchor: drop below, caret up.
box.classList.add("ps-tipbox-below");
var left = Math.max(8, Math.min(r.left + r.width / 2 - w / 2, innerWidth - w - 8));
box.style.left = Math.round(left) + "px";
box.style.top = Math.round(r.bottom + 9) + "px";
box.style.setProperty("--tip-caret-x", Math.round(r.left + r.width / 2 - left - 4) + "px");
} else {
// Clamped low as well as high: a nowrap snippet can push the box
// past the viewport width, and a negative left clips its first
// characters off the screen.
box.style.left = Math.round(Math.max(8, Math.min(r.right + 10, innerWidth - w - 8))) + "px";
box.style.top = Math.round(Math.min(centeredTop, innerHeight - h - 8)) + "px";
}
}
function hideTip() {
if (box) { box.remove(); box = null; }
}
anchor.addEventListener("mouseenter", showTip);
anchor.addEventListener("mouseleave", hideTip);
anchor.addEventListener("focus", showTip);
anchor.addEventListener("blur", hideTip);
// Hover is the summary, click the method sheet.
if (deepKey) {
anchor.addEventListener("click", function (ev) {
ev.stopPropagation();
sweepTips();
openTopicHelp(deepKey);
});
anchor.addEventListener("keydown", function (ev) {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
ev.stopPropagation();
sweepTips();
openTopicHelp(deepKey);
});
}
}
function tabIsRegistered(key) { return !!registeredTabs[key]; }
function renderTabs() {
var bar = document.getElementById("tabs");
tabs.forEach(function (t) {
// Sidebar nav item. `data-nav` drives the hover/active styling;
// the `ps-tab` class + role="tab" preserve the existing ARIA
// tablist semantics and let switchTab find every nav item.
var btn = el("button", "ps-tab");
btn.type = "button";
btn.appendChild(el("span", "ps-nav-dot"));
btn.appendChild(el("span", "ps-nav-label", t.label));
if (t.disabledTip) {
// data-nav for the shared pill styling, but no role="tab": the
// arrow-key nav and g-shortcuts skip it, only the "?" reacts.
setAttr(btn, "data-nav", "");
btn.classList.add("ps-tab-disabled");
setAttr(btn, "aria-disabled", "true");
var help = el("span", "ps-nav-help", "?");
setAttr(help, "aria-label", t.disabledTip);
wireTip(help, t.disabledTip);
btn.appendChild(help);
bar.appendChild(btn);
return;
}
setAttr(btn, "data-nav", "");
setAttr(btn, "role", "tab");
setAttr(btn, "data-tab", t.key);
setAttr(btn, "id", "tab-" + t.key);
setAttr(btn, "aria-controls", "panel-" + t.key);
setAttr(btn, "aria-selected", "false");
setAttr(btn, "tabindex", "-1");
if (t.countFn) {
var c = t.countFn();
if (c !== null && c !== undefined) {
btn.appendChild(el("span", "ps-nav-badge", String(c)));
}
}
btn.addEventListener("click", function () { switchTab(t.key); });
bar.appendChild(btn);
});
wireTablistKeyboardNav(bar);
// default to first tab
if (tabs.length > 0) switchTab(tabs[0].key);
}
function wireTablistKeyboardNav(bar) {
// WAI-ARIA arrow-key pattern on the tablist. Coexists with the
// g-prefixed shortcuts since they listen on different keys.
bar.addEventListener("keydown", function (event) {
var target = event.target;
if (!target || target.getAttribute("role") !== "tab") return;
var buttons = Array.prototype.slice.call(
bar.querySelectorAll("button[role=\"tab\"]")
);
if (buttons.length === 0) return;
var idx = buttons.indexOf(target);
if (idx < 0) return;
var next = -1;
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
next = (idx + 1) % buttons.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (idx - 1 + buttons.length) % buttons.length;
break;
case "Home":
next = 0;
break;
case "End":
next = buttons.length - 1;
break;
case "Enter":
case " ":
event.preventDefault();
switchTab(target.dataset.tab, true);
return;
default:
return;
}
event.preventDefault();
var targetBtn = buttons[next];
if (targetBtn) {
targetBtn.focus();
switchTab(targetBtn.dataset.tab, true);
}
});
}
var currentTab = null;
function switchTab(key, fromKeyboard) {
currentTab = key;
// Only real tabs get the roving tabindex and aria-selected: the
// disabled placeholders carry no role=tab and must stay reachable by
// keyboard, otherwise their "?" cause tooltip is mouse-only.
document.querySelectorAll("#tabs [role=\"tab\"]").forEach(function (btn) {
var active = btn.dataset.tab === key;
btn.classList.toggle("active", active);
setAttr(btn, "aria-selected", active ? "true" : "false");
setAttr(btn, "tabindex", active ? "0" : "-1");
});
tabs.forEach(function (t) {
var panel = document.getElementById("panel-" + t.key);
if (panel) panel.style.display = t.key === key ? "" : "none";
});
updateTopbarTitle(key);
// The search is global and survives the switch: re-run it so the panel
// being revealed picks up the match highlighting the hidden panels skip.
applyGlobalSearch();
// Render the detail pane when landing on Findings if it is not already
// showing the current selection. Covers the lazy first render (detailFinding
// is null) and a selection that advanced while another route was active
// (renderFindingsList skips openExplain off-tab). Same gate as that render.
if (key === "findings" && selectedFinding && selectedFinding !== detailFinding) {
openExplain(selectedFinding);
}
// Move focus only for keyboard activation. Clicks and hash
// restoration must not steal focus.
if (fromKeyboard) {
var active = document.getElementById("tab-" + key);
if (active && typeof active.focus === "function") {
try { active.focus(); } catch { /* detached node */ }
}
}
scheduleHashWrite();
}
// -------- findings metrics --------
function countBySeverity() {
var c = { critical: 0, warning: 0, info: 0 };
findings.forEach(function (f) { if (c[f.severity] !== undefined) c[f.severity] += 1; });
return c;
}
// Overview KPI row: Findings / Critical / Δ baseline / co2.total, with
// tone-colored values. Adapted to the real payload (Δ baseline reads the
// diff when present; co2.total reads green_summary).
function tonedValue(card, tone) {
var v = card.querySelector(".ps-metric-value");
if (v) setAttr(v, "data-tone", tone);
setAttr(card, "data-grad", tone);
return card;
}
// Overview KPI cards double as shortcuts: click goes to the matching tab,
// optionally pre-selecting a severity chip. Only cards with a real target
// are wired (a "no baseline" placeholder stays inert).
function wireKpiCard(card, tabKey, chipKey) {
setAttr(card, "data-kpi-link", "");
// Keyboard-operable: a plain div would be unreachable by Tab and
// announce nothing, so the shortcut would be mouse-only.
setAttr(card, "role", "button");
card.tabIndex = 0;
var label = card.querySelector(".ps-metric-label");
setAttr(card, "aria-label", (label ? label.textContent + ": " : "") + "go to " + tabKey);
function activate() {
// The card counts every finding, so a leftover query would open a list
// that contradicts the number just clicked.
clearSearchQuery();
switchTab(tabKey);
if (!chipKey) return;
var chip = document.querySelector('#findings-filters .ps-chip[data-key="' + chipKey + '"]');
if (chip && chip.getAttribute("aria-checked") !== "true") chip.click();
}
card.addEventListener("click", activate);
card.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
activate();
}
});
return card;
}
function renderFindingsMetrics() {
var root = document.getElementById("findings-metrics");
root.textContent = "";
var sev = countBySeverity();
var total = findings.length;
var traces = (report.analysis && report.analysis.traces_analyzed) || 0;
// Findings: saturated semantic flat (green when clean, otherwise the
// highest severity present).
var kpiTone = total === 0 ? "ok"
: sev.critical > 0 ? "crit"
: sev.warning > 0 ? "warn"
: "info";
var findingsCard = metricCard("Findings", fmtNum(total), "across " + fmtNum(traces) + " traces");
setAttr(findingsCard, "data-kpi", kpiTone);
root.appendChild(wireKpiCard(findingsCard, "findings", "all"));
// Dominant-severity card: label, count and pastel tone all follow the
// highest severity present; the sub line lists only the lower ones.
var domCard, domChip;
if (sev.critical > 0) {
domCard = tonedValue(
metricCard("Critical", fmtNum(sev.critical), fmtNum(sev.warning) + " warn · " + fmtNum(sev.info) + " info"),
"crit"
);
domChip = "sev:critical";
} else if (sev.warning > 0) {
domCard = tonedValue(metricCard("Warn", fmtNum(sev.warning), fmtNum(sev.info) + " info"), "warn");
domChip = "sev:warning";
} else if (sev.info > 0) {
domCard = tonedValue(metricCard("Info", fmtNum(sev.info), ""), "info");
domChip = "sev:info";
} else {
domCard = tonedValue(metricCard("Critical", "0", "0 warn · 0 info"), "ok");
domChip = "all";
}
root.appendChild(wireKpiCard(domCard, "findings", domChip));
if (hasDiff) {
var nNew = (diffData.new_findings || []).length;
var nRes = (diffData.resolved_findings || []).length;
// +N new findings is a regression (red), −N resolved an improvement
// (green), matching the Diff page tones.
var dCard = metricCard("Δ Baseline", "", "new / resolved");
var dv = dCard.querySelector(".ps-metric-value");
dv.appendChild(makeTokenSpan("+" + nNew, "var(--crit-fg)", false));
dv.appendChild(document.createTextNode(" / "));
dv.appendChild(makeTokenSpan("−" + nRes, "var(--ok-fg)", false));
// Card tint follows the net direction; the tokens keep their colors.
if (nNew !== nRes) setAttr(dCard, "data-grad", nNew > nRes ? "crit" : "ok");
root.appendChild(wireKpiCard(dCard, "diff"));
} else {
root.appendChild(metricCard("Δ Baseline", "-", "no baseline"));
}
if (hasGreen) {
var co2 = greenSummary.co2;
root.appendChild(wireKpiCard(tonedValue(
metricCard("Total CO2", formatGco2(co2.total.mid),
"low " + formatGco2(co2.total.low) + " · high " + formatGco2(co2.total.high)),
"brand"
), "green"));
} else {
root.appendChild(metricCard("Total CO2", "-", "green disabled"));
}
}
// -------- quality gate --------
// Gate rules render inside the Overview hero (renderOverviewHero); this
// shared formatter is used there for the actual / threshold numbers.
function formatRuleNumber(n) {
if (!Number.isFinite(n)) return "-";
if (Number.isInteger(n)) return String(n);
return n.toFixed(3);
}
// Friendly labels for the quality-gate rule keys (see quality_gate.rs).
// Unknown / forward-rolled keys fall through to a generic humanizer so a
// future rule still reads sensibly instead of showing a raw snake_case key.
var GATE_RULE_LABELS = {
n_plus_one_sql_critical_max: "Max critical N+1 SQL",
n_plus_one_http_warning_max: "Max N+1 HTTP (warning+)",
n_plus_one_messaging_warning_max: "Max N+1 messaging (warning+)",
io_waste_ratio_max: "Max I/O waste ratio",
min_usable_span_ratio: "Min usable span ratio"
};
function humanizeGateRule(key) {
// hasOwnProperty, not a bare lookup: a rule named "constructor" or
// "toString" would otherwise resolve to an inherited function.
if (Object.prototype.hasOwnProperty.call(GATE_RULE_LABELS, key)) return GATE_RULE_LABELS[key];
var s = String(key || "");
var prefix = "";
if (s.endsWith("_max")) { prefix = "Max "; s = s.slice(0, -4); }
else if (s.endsWith("_min")) { prefix = "Min "; s = s.slice(0, -4); }
s = s.replace(/_/g, " ")
.replace(/\bn plus one\b/g, "N+1")
.replace(/\bsql\b/gi, "SQL")
.replace(/\bhttp\b/gi, "HTTP")
.replace(/\bio\b/gi, "I/O");
if (!prefix && s) s = s.charAt(0).toUpperCase() + s.slice(1);
return prefix + s;
}
// -------- overview landing --------
// Composed entirely from data already present in the payload: the
// quality-gate hero (which renders the gate rules), the KPI cards
// (#findings-metrics), the top findings by impact, and a right rail with
// diff + carbon mini summaries.
function renderOverviewPanel() {
renderOverviewHero();
renderOverviewBody();
}
function renderOverviewHero() {
var host = document.getElementById("overview-hero");
if (!host) return;
host.textContent = "";
var gate = report.quality_gate || { passed: true, rules: [] };
var rules = gate.rules || [];
var card = el("section", "ps-card ps-hero");
var left = el("div", "ps-hero-left");
setAttr(left, "data-tone", gate.passed ? "ok" : "crit");
left.appendChild(el("div", "ps-eyebrow", "// quality gate"));
var verdict = el("div", "ps-hero-verdict");
setAttr(verdict, "data-tone", gate.passed ? "ok" : "crit");
verdict.appendChild(el("span", "ps-hero-dot"));
verdict.appendChild(el("span", "ps-hero-big", gate.passed ? "PASS" : "FAIL"));
left.appendChild(verdict);
var failed = rules.filter(function (r) { return !r.passed; }).length;
left.appendChild(el(
"p",
"ps-hero-line",
gate.passed
? "All " + rules.length + " gate rules satisfied."
: failed + " of " + rules.length + " gate rules failed."
));
card.appendChild(left);
var right = el("div", "ps-hero-right");
if (rules.length > 0) {
var grid = el("div", "ps-hero-rules");
rules.forEach(function (r) {
var item = el("div", "ps-hero-rule");
var chip = el("span", "ps-hero-rule-chip");
setAttr(chip, "data-sev", r.passed ? "ok" : "crit");
chip.appendChild(iconSvg(10, r.passed ? "M5 12l4.5 4.5L19 6" : "M6 6l12 12M18 6L6 18"));
item.appendChild(chip);
var txt = el("div", "ps-hero-rule-text");
txt.appendChild(el("span", "ps-hero-rule-name", humanizeGateRule(r.rule)));
txt.appendChild(
el("span", "ps-hero-rule-detail",
formatRuleNumber(r.actual) + " / " + formatRuleNumber(r.threshold))
);
item.appendChild(txt);
grid.appendChild(item);
});
right.appendChild(grid);
}
var analysis = report.analysis || {};
var meta = el("div", "ps-hero-meta");
[
payload.input_label || "-",
fmtNum(analysis.traces_analyzed || 0) + " traces",
fmtNum(analysis.events_processed || 0) + " spans",
"v" + (payload.version || "")
].forEach(function (m) { meta.appendChild(el("span", null, m)); });
right.appendChild(meta);
// A thin report reads as a clean one unless the filtered spans are
// stated: "1 spans" looks identical whether 1 span arrived or 10 did
// and 9 lacked db.statement. See docs/LIMITATIONS.md.
var ingest = analysis.ingest;
if (ingest && ingest.spans_filtered > 0) {
var gaps = (ingest.filtered_missing_db_statement || 0)
+ (ingest.filtered_missing_http_url || 0);
var txt = fmtNum(ingest.spans_received || 0) + " spans ingested, "
+ fmtNum(ingest.spans_filtered) + " filtered";
if (gaps > 0) txt += ", " + fmtNum(gaps) + " missing db.statement or http.url";
var warn = el("div", "ps-hero-ingest", txt);
if (gaps > 0) {
setAttr(warn, "data-gap", "true");
warn.title = "Spans that are I/O operations but shipped without the attribute "
+ "perf-sentinel needs. A thin report may mean unusable instrumentation, "
+ "not a clean service.";
}
right.appendChild(warn);
}
card.appendChild(right);
host.appendChild(card);
}
function renderOverviewBody() {
var host = document.getElementById("overview-body");
if (!host) return;
host.textContent = "";
if (findings.length === 0) return;
var grid = el("div", "ps-overview-cols");
var leftCard = el("div", "ps-card");
var head = el("div", "ps-overview-card-head");
head.appendChild(el("span", "ps-overview-card-title", "Top findings by impact"));
var viewAll = el("button", "ps-overview-link");
viewAll.appendChild(el("span", "ps-link-txt", "view all"));
viewAll.appendChild(document.createTextNode(" →"));
viewAll.type = "button";
viewAll.addEventListener("click", function () { switchTab("findings"); });
head.appendChild(viewAll);
leftCard.appendChild(head);
var totalIo = ioShareBase();
var list = el("div", "ps-list");
// The payload keeps the deterministic detector order (the contract of
// every sink), so rank by real impact here: severity, then avoidable
// I/O, then IIS.
var topByImpact = findings.slice().sort(function (a, b) {
var sa = SEV_RANK[a.severity] ?? 3, sb = SEV_RANK[b.severity] ?? 3;
if (sa !== sb) return sa - sb;
var ga = a.green_impact || {}, gb = b.green_impact || {};
var ia = ga.estimated_extra_io_ops || 0, ib = gb.estimated_extra_io_ops || 0;
if (ia !== ib) return ib - ia;
return (gb.io_intensity_score || 0) - (ga.io_intensity_score || 0);
});
topByImpact.slice(0, 5).forEach(function (f) {
var row = el("div", "ps-row ps-overview-finding");
// Left column: severity + "type · ×N" then the service / endpoint.
var left = el("div", "ps-of-left");
var top = el("div", "ps-fin-topline");
top.appendChild(sevPill(f.severity));
var occ = (f.pattern && f.pattern.occurrences) || 0;
var typeText = typeLabel(f.type || f.finding_type);
top.appendChild(el("span", "ps-fin-type", occ ? typeText + " · ×" + occ : typeText));
left.appendChild(top);
var ovSub = el("span", "ps-fin-service ps-fin-sub");
highlightEndpoint(ovSub, (f.service || "-") + " · " + (f.source_endpoint || "-"));
left.appendChild(ovSub);
row.appendChild(left);
// Right column: per-finding green impact (IIS + avoidable-I/O share).
// The prototype showed a co2-per-finding figure, which the pipeline
// does not compute; green_impact is the real equivalent.
var gi = f.green_impact || {};
var right = el("div", "ps-of-right");
if (Number.isFinite(gi.io_intensity_score)) {
right.appendChild(el("span", "ps-of-primary", "IIS " + gi.io_intensity_score.toFixed(1)));
}
if (totalIo > 0 && Number.isFinite(gi.estimated_extra_io_ops)) {
right.appendChild(el(
"span",
"ps-of-secondary",
Math.round((gi.estimated_extra_io_ops / totalIo) * 100) + "% of I/O"
));
}
row.appendChild(right);
row.addEventListener("click", function () { gotoFinding(f); });
list.appendChild(row);
});
leftCard.appendChild(list);
grid.appendChild(leftCard);
var rail = el("div", "ps-overview-rail");
if (hasDiff) rail.appendChild(buildDiffMini());
if (hasGreen) rail.appendChild(buildCarbonMini());
if (rail.childNodes.length > 0) grid.appendChild(rail);
host.appendChild(grid);
}
// Switches to the Findings panel and selects a specific finding,
// clearing any active filter that would hide it.
function gotoFinding(f) {
clearSearchQuery();
switchTab("findings");
var idx = visibleFindings.indexOf(f);
if (idx < 0) {
// The target may be hidden by a chip, by the acked filter (live mode),
// or sitting past the first page. Clear the chips, reveal acked findings
// if the target is one, and grow the shown count to its position, else
// resetShownCount would cap the render at LIST_CAP and the click dies.
filterState.severity = null;
filterState.service = null;
filterState.type = null;
filterState.grouping = null;
syncFilterChips();
if (findingHiddenByAck(f)) {
var toggle = document.getElementById("findings-include-acked");
if (toggle) toggle.checked = true;
}
var pos = applyFilters().indexOf(f);
shownCount = pos >= 0 ? Math.max(LIST_CAP, pos + 1) : LIST_CAP;
renderFindingsList();
idx = visibleFindings.indexOf(f);
}
if (idx >= 0) {
selectFinding(idx);
scrollDetailToTop();
}
}
// Opens the detail pane for a finding that is NOT in the master list
// (a diff "new" finding or a synthetic correlation finding). Switches to
// Findings and clears the master selection so no list row is misleadingly
// highlighted while the detail shows an off-list finding. Clears the query
// first, or the master list lands on its empty state next to the detail.
function openExternalFindingDetail(f) {
clearSearchQuery();
switchTab("findings");
selectedIndex = -1;
selectedFinding = null;
syncSelection();
openExplain(f);
}
function buildDiffMini() {
var card = el("div", "ps-mini-card");
card.appendChild(el("span", "ps-mini-title", "Diff vs Baseline"));
var pills = el("div", "ps-mini-pills");
var nNew = (diffData.new_findings || []).length;
var nResolved = (diffData.resolved_findings || []).length;
var nChanged = (diffData.severity_changes || []).length;
var pNew = el("span", "ps-mini-pill", "+" + nNew + " new");
setAttr(pNew, "data-sev", "crit");
var pRes = el("span", "ps-mini-pill", "−" + nResolved + " resolved");
setAttr(pRes, "data-sev", "ok");
var pChg = el("span", "ps-mini-pill", nChanged + " changed");
setAttr(pChg, "data-sev", "warn");
pills.appendChild(pNew);
pills.appendChild(pRes);
pills.appendChild(pChg);
card.appendChild(pills);
var link = el("button", "ps-mini-link");
link.appendChild(el("span", "ps-link-txt", "open diff"));
link.appendChild(document.createTextNode(" →"));
link.type = "button";
link.addEventListener("click", function () { switchTab("diff"); });
card.appendChild(link);
return card;
}
function buildCarbonMini() {
var card = el("div", "ps-mini-card");
var head = el("div", "ps-mini-head");
head.appendChild(el("span", "ps-mini-title", "Carbon by region"));
// Header = operational CO2 only (the sum of the per-region bars). Embodied
// carbon (in co2.total) is hardware manufacturing, not region-attributable,
// so co2.total here would make the bars never sum to the header.
var co2 = greenSummary.co2 || {};
var total = typeof co2.operational_gco2 === "number"
? co2.operational_gco2
: (greenSummary.regions || []).reduce(function (s, r) { return s + (r.co2_gco2 || 0); }, 0);
head.appendChild(el("span", "ps-mini-total", formatGco2(total)));
card.appendChild(head);
var regions = (greenSummary.regions || []).slice();
regions.sort(function (a, b) { return (b.co2_gco2 || 0) - (a.co2_gco2 || 0); });
var top = regions.slice(0, 3);
top.forEach(function (r) {
var row = el("div", "ps-mini-row");
var rh = el("div", "ps-mini-row-head");
rh.appendChild(el("span", null, r.region || "-"));
rh.appendChild(el("span", null, formatGco2(r.co2_gco2 || 0)));
row.appendChild(rh);
var track = el("div", "ps-bar-track");
var fill = el("div", "ps-bar-fill");
// Bar = share of the operational total, so the bars decompose the header.
fill.style.width = percent(r.co2_gco2 || 0, total);
track.appendChild(fill);
row.appendChild(track);
card.appendChild(row);
});
return card;
}
// -------- filters --------
var filterState = { severity: null, service: null, type: null, grouping: null };
// Findings-list sort: one primary key with a direction, the other
// axis as an automatic descending tie-break. "severity" ranks the
// single worst detection, "impact" ranks the signature's aggregate
// avoidable ops, so a frequent info problem can outrank a rare
// critical one.
var sortState = { key: "severity", dir: "desc" };
function severityRankOf(f) {
return SEV_RANK[f.severity] ?? 3;
}
function compareFindings(a, b) {
var primary, tie;
if (sortState.key === "impact") {
primary = recurrenceOf(b).ops - recurrenceOf(a).ops;
tie = severityRankOf(a) - severityRankOf(b);
} else {
primary = severityRankOf(a) - severityRankOf(b);
tie = recurrenceOf(b).ops - recurrenceOf(a).ops;
}
if (sortState.dir === "asc") primary = -primary;
// Array.prototype.sort is stable, canonical order breaks the rest.
return primary || tie;
}
function renderFilterChips() {
var root = document.getElementById("findings-filters");
root.textContent = "";
// Severity chips (All / Critical / Warning) behave as a single
// mutually-exclusive selector, so they go inside a radiogroup.
// Service chips are independent toggles: each one is pressable and
// can be cleared by re-clicking, but they don't form a group in
// the ARIA sense.
var severityGroup = el("span", "ps-chip-group", null);
setAttr(severityGroup, "role", "radiogroup");
setAttr(severityGroup, "aria-label", "Finding severity");
root.appendChild(severityGroup);
function appendChip(parent, label, key, chipRole, stateAttr, sev) {
var btn = el("button", "ps-chip", label);
btn.type = "button";
setAttr(btn, "role", chipRole);
setAttr(btn, "data-key", key);
setAttr(btn, stateAttr, "false");
if (sev) setAttr(btn, "data-sev", sev);
btn.addEventListener("click", function () { onFilterClick(key); });
parent.appendChild(btn);
}
// Severity filters double as colored count pills (critical N / warnings
// N / info N). "All" is the implicit reset chip.
var counts = countBySeverity();
appendChip(severityGroup, "All", "all", "radio", "aria-checked");
if (counts.critical > 0) appendChip(severityGroup, "critical " + counts.critical, "sev:critical", "radio", "aria-checked", "crit");
if (counts.warning > 0) appendChip(severityGroup, "warnings " + counts.warning, "sev:warning", "radio", "aria-checked", "warn");
if (counts.info > 0) appendChip(severityGroup, "info " + counts.info, "sev:info", "radio", "aria-checked", "info");
var counter = el("span", "ps-findings-count");
setAttr(counter, "id", "findings-count");
root.appendChild(counter);
var serviceGroup = el("span", "ps-chip-group", null);
setAttr(serviceGroup, "role", "group");
setAttr(serviceGroup, "aria-label", "Finding service");
root.appendChild(serviceGroup);
var services = Object.create(null);
findings.forEach(function (f) { services[f.service] = true; });
Object.keys(services).sort().forEach(function (s) {
appendChip(serviceGroup, s, "svc:" + s, "button", "aria-pressed");
});
var typeGroup = el("span", "ps-chip-group", null);
setAttr(typeGroup, "role", "group");
setAttr(typeGroup, "aria-label", "Finding type");
root.appendChild(typeGroup);
var types = Object.create(null);
findings.forEach(function (f) { types[f.type || f.finding_type] = true; });
Object.keys(types).sort().forEach(function (type) {
appendChip(typeGroup, typeLabel(type), "type:" + type, "button", "aria-pressed");
});
var groupingGroup = el("span", "ps-chip-group", null);
setAttr(groupingGroup, "role", "group");
setAttr(groupingGroup, "aria-label", "Finding grouping");
var groupings = Object.create(null);
findings.forEach(function (f) {
var identity = findingGroupingIdentity(f);
if (identity) groupings[identity] = findingGroupingLabel(f);
});
root.appendChild(groupingGroup);
Object.keys(groupings).sort(function (a, b) {
return groupings[a].localeCompare(groupings[b]);
}).forEach(function (identity) {
appendChip(
groupingGroup,
groupings[identity],
"group:" + identity,
"button",
"aria-pressed"
);
});
var sortGroup = el("span", "ps-chip-group ps-sort-group", null);
setAttr(sortGroup, "role", "group");
setAttr(sortGroup, "aria-label", "Sort findings");
sortGroup.appendChild(el("span", "ps-sort-label", "sort"));
["severity", "impact"].forEach(function (key) {
var btn = el("button", "ps-chip");
btn.type = "button";
setAttr(btn, "data-sort-key", key);
setAttr(btn, "aria-pressed", "false");
btn.addEventListener("click", function () { onSortClick(key); });
sortGroup.appendChild(btn);
});
root.appendChild(sortGroup);
syncFilterChips();
syncSortChips();
}
// Click the inactive key to switch (direction resets to descending),
// click the active one to flip the direction.
function onSortClick(key) {
if (sortState.key === key) {
sortState.dir = sortState.dir === "desc" ? "asc" : "desc";
} else {
sortState.key = key;
sortState.dir = "desc";
}
syncSortChips();
renderFindingsList();
}
function syncSortChips() {
document.querySelectorAll("[data-sort-key]").forEach(function (btn) {
var key = btn.getAttribute("data-sort-key");
var active = sortState.key === key;
// Not `.active`: that class marks an applied filter, and a selector
// asking for the active filter chip must not also match a sort chip.
btn.classList.toggle("ps-sort-on", active);
setAttr(btn, "aria-pressed", active ? "true" : "false");
btn.textContent = active ? key + " " + (sortState.dir === "desc" ? "↓" : "↑") : key;
btn.title = active
? "Click to flip the direction"
: (key === "impact"
? "Rank by each problem's total avoidable I/O across its detections"
: "Rank by each detection's own severity");
});
}
function onFilterClick(key) {
if (key === "all") {
filterState.severity = null;
filterState.service = null;
filterState.type = null;
filterState.grouping = null;
} else if (key.startsWith("sev:")) {
var sv = key.slice(4);
filterState.severity = filterState.severity === sv ? null : sv;
} else if (key.startsWith("svc:")) {
var svc = key.slice(4);
filterState.service = filterState.service === svc ? null : svc;
} else if (key.startsWith("type:")) {
var type = key.slice(5);
filterState.type = filterState.type === type ? null : type;
} else if (key.startsWith("group:")) {
var grouping = key.slice(6);
filterState.grouping = filterState.grouping === grouping ? null : grouping;
}
syncFilterChips();
resetShownCount();
renderFindingsList();
scheduleHashWrite();
}
function syncFilterChips() {
var anyActive = !!(
filterState.severity || filterState.service || filterState.type || filterState.grouping
);
document.querySelectorAll("#findings-filters .ps-chip").forEach(function (chip) {
// Sort chips share the look but not the filter state machine.
if (chip.dataset.sortKey) return;
var key = chip.dataset.key;
var active = false;
if (key === "all") active = !anyActive;
else if (key === "sev:" + filterState.severity) active = true;
else if (key === "svc:" + filterState.service) active = true;
else if (key === "type:" + filterState.type) active = true;
else if (key === "group:" + filterState.grouping) active = true;
chip.classList.toggle("active", active);
// Severity chips and "All" are radio-role, service chips are
// toggle buttons. Pick the right ARIA state attribute per chip.
var role = chip.getAttribute("role");
var stateAttr = role === "radio" ? "aria-checked" : "aria-pressed";
setAttr(chip, stateAttr, active ? "true" : "false");
});
}
// -------- findings list --------
// Cap the initial render at LIST_CAP rows to keep the DOM small.
// Additional rows are revealed LIST_CAP at a time via the
// `findings-show-more` button, tracked by `shownCount`. Every filter,
// search or hash apply resets `shownCount` to LIST_CAP so the user
// never ends up paginated into rows that no longer match.
// Initial page size and "Show more" increment. Kept small so a long
// findings list stays scannable; the rest reveals via #findings-show-more.
var LIST_CAP = 8;
var visibleFindings = [];
var selectedIndex = -1;
// The selected finding object (not just its index) so the selection and
// its detail pane survive a list re-render (filter change, "Show more").
var selectedFinding = null;
// The finding whose detail pane is currently rendered. Lets renderFindingsList
// skip the expensive trace-tree rebuild when a re-render (e.g. every keystroke
// in the global search) leaves the selection unchanged.
var detailFinding = null;
var shownCount = LIST_CAP;
// Select a finding by its index in visibleFindings: highlight the row and
// render its detail pane. The single entry point for click + j/k so the
// master list and the always-visible detail stay in sync.
function selectFinding(idx) {
if (idx < 0 || idx >= visibleFindings.length) return;
// Re-selecting the same finding (same index AND same object) would
// rebuild an already-correct detail pane. The object check keeps it
// correct when a re-render shifts which finding sits at this index.
if (idx === selectedIndex && visibleFindings[idx] === selectedFinding) return;
selectedIndex = idx;
selectedFinding = visibleFindings[idx];
syncSelection();
openExplain(selectedFinding);
}
function anyFindingsFilterActive() {
if (filterState.severity || filterState.service || filterState.type ||
filterState.grouping || globalSearchTerm()) return true;
// The ack toggle also narrows the list, so an all-acked empty list is a
// filtered empty, not an empty report.
return findings.some(findingHiddenByAck);
}
// Live mode hides acknowledged findings unless "Show acknowledged" is on.
// Folded into the data filter so the paginated list, the counter, the
// sidebar badge and the CSV export all agree on the visible set instead of
// rendering acked rows and hiding them afterward.
function findingHiddenByAck(f) {
if (!isLiveEnabled()) return false;
var input = document.getElementById("findings-include-acked");
if (!input || input.checked) return false;
return !!(f.signature && liveState.acksBySig[f.signature]);
}
// The findings list is paginated (LIST_CAP), so its search filters the data
// here rather than hiding rendered rows: hiding would only ever search the
// first page and cap the match count at LIST_CAP. The other panels render
// every row, so they filter in the DOM (see applySearchFilter).
function applyFilters() {
var term = globalSearchTerm();
// Sorted by the active sortState (severity-worst-first by default)
// and stable, so the report's own type/trace order survives inside
// each band. The CSV export shares this order.
return findings.filter(function (f) {
if (filterState.severity && f.severity !== filterState.severity) return false;
if (filterState.service && f.service !== filterState.service) return false;
if (filterState.type && (f.type || f.finding_type) !== filterState.type) return false;
if (filterState.grouping && findingGroupingIdentity(f) !== filterState.grouping) return false;
if (findingHiddenByAck(f)) return false;
return findingMatchesSearch(f, term);
}).sort(compareFindings);
}
function renderFindingsList() {
var root = document.getElementById("findings-list");
var empty = document.getElementById("findings-empty");
root.textContent = "";
var filtered = applyFilters();
visibleFindings = filtered.slice(0, shownCount);
if (visibleFindings.length === 0) {
selectedFinding = null;
selectedIndex = -1;
// The chips and the search both reach this branch, so do not claim the
// trace set is clean when it is a filter that emptied the list.
empty.textContent = anyFindingsFilterActive()
? "No findings match the current filters."
: "No findings in this trace set.";
empty.style.display = "";
resetDetailPane();
syncShowMore(filtered.length);
reapplySearchForTab("findings", filtered.length);
return;
}
empty.style.display = "none";
visibleFindings.forEach(function (f, idx) {
root.appendChild(buildFindingRow(f, idx));
});
// Preserve the prior selection across "Show more" / re-renders; fall
// back to the first row when it is gone (e.g. a filter narrowed it out).
selectedIndex = selectedFinding ? visibleFindings.indexOf(selectedFinding) : -1;
if (selectedIndex < 0) {
selectedIndex = 0;
selectedFinding = visibleFindings[0];
}
syncSelection();
// Render the detail only when the Findings panel is the active route, so
// landing on Overview does not pay for the trace tree + highlighter of a
// finding the user has not opened. Skip when the selection is unchanged, so
// a keystroke in the global search does not rebuild the whole detail.
if (currentTab === "findings" && selectedFinding !== detailFinding) {
openExplain(selectedFinding);
}
syncShowMore(filtered.length);
// Every caller rebuilds the rows unmarked, so re-mark and refresh the
// badge here rather than leaving it to each of them to remember. Hand the
// already-computed count so the badge does not filter the array a second
// time.
reapplySearchForTab("findings", filtered.length);
// The rows are new nodes, so the live-mode Ack buttons lost the listener
// and the acked filter lost its hidden rows. No-op outside live mode.
syncFindingActionButtons();
}
// Resets the detail pane to its empty state (no finding selected).
function resetDetailPane() {
detailFinding = null;
var emptyState = document.getElementById("explain-empty");
if (emptyState) emptyState.style.display = "";
var head = document.getElementById("explain-detail-head");
if (head) head.textContent = "";
var content = document.getElementById("explain-content");
if (content) content.style.display = "none";
var ne = document.getElementById("explain-not-embedded");
if (ne) { ne.style.display = "none"; ne.textContent = ""; }
}
function syncShowMore(totalMatching) {
var counter = document.getElementById("findings-count");
if (counter) {
counter.textContent = totalMatching + " findings · " + visibleFindings.length + " shown";
}
var btn = document.getElementById("findings-show-more");
if (!btn) return;
if (visibleFindings.length >= totalMatching) {
btn.style.display = "none";
return;
}
var remaining = totalMatching - visibleFindings.length;
var next = Math.min(LIST_CAP, remaining);
btn.style.display = "";
btn.textContent = "Show " + next + " more findings (" + remaining + " remaining)";
}
function resetShownCount() { shownCount = LIST_CAP; }
function buildFindingRow(f, idx) {
var row = el("div", "ps-row");
setAttr(row, "data-idx", idx);
// Exposed for the live-mode JS so click handlers on the per-row
// Ack/Revoke buttons can resolve the signature via DOM lookup. The
// attribute is harmless in static mode (the buttons stay hidden).
if (f.signature) setAttr(row, "data-sig", f.signature);
var topLine = el("div", "ps-fin-topline");
topLine.appendChild(sevPill(f.severity));
topLine.appendChild(el("span", "ps-fin-type", typeLabel(f.type || f.finding_type)));
var occ = (f.pattern && f.pattern.occurrences) || 0;
var agg = sortState.key === "impact" ? recurrenceOf(f) : null;
if (agg && agg.ops > 0) {
// Impact mode shows the sort criterion itself: the signature's
// total avoidable ops, not this one detection's repeat count.
var metric = el("span", "ps-fin-right ps-fin-metric", "~" + fmtNum(agg.ops) + " ops");
metric.title = "detected in " + agg.count + (agg.count > 1 ? " traces" : " trace") +
" · " + fmtNum(agg.ops) + " avoidable operations in total";
topLine.appendChild(metric);
} else if (occ) {
topLine.appendChild(el("span", "ps-fin-right ps-fin-metric", "×" + occ));
}
row.appendChild(topLine);
var finSub = el("span", "ps-fin-service ps-fin-sub");
// Without it two deployments of one service read as duplicate rows.
var groupingValue = findingNamespace(f);
var endpointHost = groupingValue ? el("span", "ps-fin-endpoint-text") : finSub;
highlightEndpoint(endpointHost, (f.service || "-") + " · " + (f.source_endpoint || "-"));
if (groupingValue) {
finSub.appendChild(endpointHost);
var mark = el("span", "ps-fin-grouping", groupingValue);
mark.title = findingGroupingLabel(f);
finSub.appendChild(mark);
}
row.appendChild(finSub);
// The normalized template is not shown in the compact row but stays
// available to assistive tech. Search matches it through the data
// (findingMatchBlob), not this node.
var tpl = (f.pattern && f.pattern.template) || "";
if (tpl) row.appendChild(el("span", "ps-sr-only", tpl));
// Live-mode action container. Always rendered, hidden by CSS in
// static mode (`.ps-fin-actions { display: none }`). The
// `body.ps-live` class flipped at boot reveals it.
var actions = el("div", "ps-fin-actions");
var btn = el("button", "ps-fin-action-btn", "Ack");
btn.type = "button";
setAttr(btn, "data-action", "ack");
setAttr(btn, "aria-label", "Acknowledge this finding");
actions.appendChild(btn);
row.appendChild(actions);
row.addEventListener("click", function (event) {
var t = event.target;
// Bail only when the click landed on the action button itself, so
// its handler runs without ALSO opening the detail pane. Matching
// the whole .ps-fin-actions container made its full row a dead zone.
if (t && t.closest && t.closest(".ps-fin-action-btn")) {
return;
}
selectFinding(idx);
scrollDetailToTop();
});
return row;
}
function syncSelection() {
document.querySelectorAll("#findings-list .ps-row").forEach(function (row, idx) {
row.classList.toggle("selected", idx === selectedIndex);
});
}
// -------- explain tree --------
// Finding header for the detail pane: severity pill, type, a one-line
// description, the service/endpoint/occurrences/trace meta grid, and the
// normalized template code block. Always rendered when a finding is
// selected, independently of whether its trace survived the embed cap.
// One-line plain-language summary for the detail title. Prefers a
// server-provided message, else derives a sentence from type + count.
function findingSummary(f) {
if (f.message) return f.message;
if (f.description) return f.description;
var n = (f.pattern && f.pattern.occurrences) || 0;
switch (f.type || f.finding_type) {
case "n_plus_one_sql": return n + " near-identical queries fired within a single trace.";
case "n_plus_one_http": return n + " near-identical HTTP calls fired within a single trace.";
case "n_plus_one_messaging": return n + " messages published to one destination within a single trace.";
case "redundant_sql": return n + " duplicate queries with identical parameters.";
case "redundant_http": return n + " duplicate HTTP calls with identical parameters.";
case "slow_sql": return "A query exceeding the latency budget.";
case "slow_http": return "An HTTP call exceeding the latency budget.";
case "slow_messaging": return "A message publish exceeding the latency budget.";
case "excessive_fanout": return n + " child calls fanned out from one parent span.";
case "chatty_service": return n + " calls to the same downstream within a single trace.";
case "pool_saturation": return "Connection pool saturated during the trace.";
case "serialized_calls": return n + " calls run serially that could overlap.";
default: return "";
}
}
// Whether the findings were produced by a daemon. Read from their
// confidence stamp, not from payload.daemon: --daemon-url only wires
// the ack buttons and says nothing about where the data came from.
function isDaemonProducedRun() {
var conf = findings.length ? findings[0].confidence : null;
return conf === "daemon_staging" || conf === "daemon_production";
}
// Denominator for "% of I/O". Zero on a daemon snapshot, where findings
// span the store window but green_summary covers the last batch only.
function ioShareBase() {
if (isDaemonProducedRun()) return 0;
return (greenSummary && greenSummary.total_io_ops) || 0;
}
// "% of report I/O" from a finding's green_impact, IIS as a fallback.
function ioImpactText(f) {
var gi = f.green_impact;
if (!gi) return "-";
var totalIo = ioShareBase();
if (totalIo > 0 && Number.isFinite(gi.estimated_extra_io_ops)) {
return Math.round((gi.estimated_extra_io_ops / totalIo) * 100) + "% of I/O";
}
return Number.isFinite(gi.io_intensity_score) ? "IIS " + gi.io_intensity_score.toFixed(1) : "-";
}
// Display form: enough to recognize a trace, short enough to leave room
// for the service and endpoint beside it. The full id travels through
// the copy button and the title attribute.
function shortTraceId(traceId) {
if (!traceId) return "-";
return traceId.length > 12 ? traceId.slice(0, 12) + "…" : traceId;
}
// A 32-hex id that wraps across lines is painful to select by hand, and
// selecting it is the whole point: it goes straight into an `explain`
// command. Reuses the deep-link clipboard helper, execCommand fallback
// included, since a report opened over file:// has no async clipboard.
function appendCopyTraceIdButton(host, traceId) {
if (!traceId) return;
var btn = el("button", "ps-copy-link-btn ps-copy-trace", "Copy trace_id");
btn.type = "button";
btn.addEventListener("click", function () { copyTextToClipboard(btn, traceId); });
host.appendChild(btn);
}
function renderDetailHead(f) {
var head = document.getElementById("explain-detail-head");
head.textContent = "";
var headRow = el("div", "ps-detail-head");
headRow.appendChild(sevPill(f.severity));
var titleWrap = el("div", "ps-detail-titlewrap");
var titleRow = el("div", "ps-title-row");
var h2 = el("h2", "ps-detail-h2", typeLabel(f.type || f.finding_type));
titleRow.appendChild(h2);
var helpText = findingHelpText(f.type || f.finding_type);
if (helpText) {
var h2help = el("span", "ps-nav-help", "?");
setAttr(h2help, "aria-label", helpText);
wireTip(h2help, helpText, f.type || f.finding_type);
titleRow.appendChild(h2help);
}
titleWrap.appendChild(titleRow);
var sub = findingSummary(f);
if (sub) titleWrap.appendChild(el("p", "ps-detail-sub", sub));
headRow.appendChild(titleWrap);
head.appendChild(headRow);
var grid = el("div", "ps-meta-grid");
grid.appendChild(metaCell("service", f.service || "-"));
(f.grouping || []).forEach(function (g) {
grid.appendChild(metaCell(g.key, g.value, true));
});
grid.appendChild(metaCell("endpoint", f.source_endpoint || "-", true, false, true));
grid.appendChild(metaCell("i/o impact", ioImpactText(f)));
var pattern = f.pattern || {};
var occ = pattern.occurrences || 0;
grid.appendChild(metaCell("occurrences", occ ? ("×" + occ) : "-"));
if (Number.isFinite(pattern.window_ms)) {
grid.appendChild(metaCell("window", fmtNum(pattern.window_ms) + " ms"));
}
if (Number.isFinite(pattern.distinct_params)) {
grid.appendChild(metaCell("distinct params", fmtNum(pattern.distinct_params) + " / " + fmtNum(occ)));
}
var findingType = f.type || f.finding_type || "";
if (findingType.indexOf("n_plus_one_") === 0) {
grid.appendChild(metaCell(
"classification",
f.classification_method === "sanitizer_heuristic" ? "sanitizer heuristic" : "direct"
));
}
var timing = [];
if (Number.isFinite(pattern.span_duration_us_p50)) timing.push("p50 " + formatDurationUs(pattern.span_duration_us_p50));
if (Number.isFinite(pattern.span_duration_us_p99)) timing.push("p99 " + formatDurationUs(pattern.span_duration_us_p99));
if (Number.isFinite(pattern.span_duration_cv_x1000)) timing.push("CV " + (pattern.span_duration_cv_x1000 / 10).toFixed(1) + "%");
if (timing.length) grid.appendChild(metaCell("span timing", timing.join(" · ")));
head.appendChild(grid);
// Every sink but this one showed `suggestion`. It reads the finding, so
// it belongs in the head, where no missing trace can hide it.
if (f.suggestion) {
var sugSec = el("div", "ps-section");
sugSec.appendChild(el("span", "ps-eyebrow", "// suggestion"));
var sugBody = el("p", "ps-detail-suggestion");
proseInto(sugBody, f.suggestion);
sugSec.appendChild(sugBody);
head.appendChild(sugSec);
}
var tpl = (f.pattern && f.pattern.template) || "";
if (tpl) {
var sec = el("div", "ps-section");
sec.appendChild(el("span", "ps-eyebrow", "// normalized template"));
var code = el("code", "ps-codeblock");
highlightInto(code, tpl);
addCommaBreaks(code);
sec.appendChild(code);
head.appendChild(sec);
}
}
function openExplain(f) {
// The detail pane is about to be rebuilt: a tooltip anchored to the
// old "?" would never see its mouseleave. Swept here rather than in
// one caller, openExplain has four.
sweepTips();
detailFinding = f;
document.getElementById("explain-empty").style.display = "none";
var content = document.getElementById("explain-content");
var notEmbedded = document.getElementById("explain-not-embedded");
notEmbedded.style.display = "none";
notEmbedded.textContent = "";
// The finding header renders inline regardless of whether the trace
// itself survived the embed cap.
renderDetailHead(f);
// Only the tree needs the trace. The suggested fix, the pg_stat
// cross-reference and the foot all read the finding, so a missing
// trace must not take them down with it.
var trace = tracesById[f.trace_id];
content.style.display = "";
var breadcrumb = document.getElementById("explain-breadcrumb");
breadcrumb.textContent = "";
// Shown short so 32 hex characters do not eat the line, copied whole
// by the button: a truncated id is useless in an `explain` command,
// and the full one wrapped over two lines is painful to select.
highlightEndpoint(
breadcrumb,
"trace_id = " + shortTraceId(f.trace_id) + " · " + (f.service || "-") + " · " + (f.source_endpoint || "-")
);
if (f.trace_id) breadcrumb.title = "trace_id = " + f.trace_id;
appendCopyTraceIdButton(breadcrumb, f.trace_id);
var treeRoot = document.getElementById("explain-tree");
var evidenceNote = document.getElementById("explain-evidence-note");
treeRoot.textContent = "";
treeRoot.style.display = trace ? "" : "none";
evidenceNote.style.display = "none";
evidenceNote.textContent = "";
if (trace) {
var evidenceCount = renderTree(treeRoot, trace, f);
var occurrenceCount = (f.pattern && f.pattern.occurrences) || 0;
if (Number.isFinite(evidenceCount) && evidenceCount < occurrenceCount) {
evidenceNote.textContent = "Showing " + evidenceCount + " of " + occurrenceCount +
" offending occurrences from the representative trace.";
evidenceNote.style.display = "";
}
} else {
// Four causes, each with its own real knob: naming the wrong one
// sends the reader tuning a flag that changes nothing.
// payload.trimmed_traces says whether THIS render trimmed, on the
// batch and snapshot paths alike, so the cap message only shows
// when the cap is actually the knob. The breadcrumb above already
// carries the id and its copy button.
notEmbedded.style.display = "";
if (payload.trimmed_traces) {
notEmbedded.textContent = "Trace not embedded (trimmed to fit the size budget). Rerun with --max-traces-embedded <higher> to include it.";
} else if (embeddedTraces.length === 0) {
notEmbedded.textContent = "No span tree in this report: it was built from a Report JSON (a daemon snapshot or a saved baseline), which carries findings but not spans. Use the trace_id above against a live daemon: perf-sentinel query --daemon <URL> explain <trace_id>.";
} else if (isDaemonProducedRun()) {
notEmbedded.textContent = "Trace not in this snapshot: it aged out of the daemon's trace retention ([daemon] max_retained_traces) or was dropped by the export size cap. Recent findings keep their trees; for this one, use the trace_id above against the live daemon: perf-sentinel query --daemon <URL> explain <trace_id>.";
} else {
notEmbedded.textContent = "Trace not embedded in this report.";
}
}
var fix = document.getElementById("explain-fix");
fix.textContent = "";
var hasFix = !!f.suggested_fix;
if (hasFix) {
fix.style.display = "";
renderSuggestedFix(fix, f.suggested_fix);
} else {
fix.style.display = "none";
}
var hasPgstat = renderPgStatXref(document.getElementById("explain-pgstat"), f);
// Two columns when both pg_stat and fix render, one when only one, hidden when neither.
var cols = document.getElementById("explain-cols");
cols.style.display = hasFix || hasPgstat ? "" : "none";
cols.classList.toggle("single", hasFix !== hasPgstat);
renderDetailFoot(document.getElementById("explain-foot"), f);
}
// Cross-reference the finding's query against pg_stat. Returns true when a
// matching entry was rendered.
function renderPgStatXref(host, f) {
host.textContent = "";
var tpl = f.pattern && f.pattern.template;
var entry = tpl ? pgStatByTemplate[tpl] : null;
if (!entry) { host.style.display = "none"; return false; }
host.style.display = "";
host.appendChild(el("span", "ps-eyebrow", "// pg_stat"));
var row = el("div", "ps-pgstat-xref");
var code = el("code", null);
highlightInto(code, entry.normalized_template || tpl);
row.appendChild(code);
var occ = (f.pattern && f.pattern.occurrences) || 1;
var mean = Number.isFinite(entry.mean_exec_time_ms) ? fmtNum(entry.mean_exec_time_ms.toFixed(1)) + "ms" : "";
row.appendChild(el("span", "ps-pgstat-xref-stat", "×" + occ + " " + mean));
host.appendChild(row);
return true;
}
// Footer: source location (when code.* attributes were captured) plus the
// first/last timestamps the pattern was seen at.
// Avoidable ops of one detection. Zero for slow findings, which waste
// time rather than I/O: they sink in the impact sort and the severity
// tie-break orders them.
function extraOpsOf(f) {
var gi = f.green_impact;
return gi && Number.isFinite(gi.estimated_extra_io_ops) ? gi.estimated_extra_io_ops : 0;
}
// Detections sharing a signature, keyed like acknowledgments do. Built
// once: detection is per trace, so a recurring problem is many findings.
var recurrenceIndex = null;
function recurrenceKey(f) {
var signature = f.signature ||
[f.type || f.finding_type, f.service, f.source_endpoint,
(f.pattern && f.pattern.template) || ""].join("|");
return findingGroupingIdentity(f) + "|" + signature;
}
function recurrenceOf(f) {
if (!recurrenceIndex) {
recurrenceIndex = Object.create(null);
findings.forEach(function (other) {
var key = recurrenceKey(other);
var acc = recurrenceIndex[key];
if (!acc) {
recurrenceIndex[key] = { count: 1, first: other.first_timestamp || "", last: other.last_timestamp || "", ops: extraOpsOf(other) };
return;
}
acc.count += 1;
acc.ops += extraOpsOf(other);
// `!acc.first` matters: an empty-string seed loses every later
// `< ""` comparison and would permanently drop the line.
if (other.first_timestamp && (!acc.first || other.first_timestamp < acc.first)) acc.first = other.first_timestamp;
if (other.last_timestamp && (!acc.last || other.last_timestamp > acc.last)) acc.last = other.last_timestamp;
});
}
return recurrenceIndex[recurrenceKey(f)] || { count: 1, first: f.first_timestamp, last: f.last_timestamp };
}
function renderDetailFoot(host, f) {
host.textContent = "";
var parts = [];
var cl = f.code_location;
if (cl && cl.filepath) {
parts.push("source " + cl.filepath + (Number.isFinite(cl.lineno) ? ":" + cl.lineno : ""));
}
// Window of the whole recurrence, not of the selected trace: on a
// daemon snapshot the two timestamps of a single detection are often
// milliseconds apart, which reads as a one-off.
var rec = recurrenceOf(f);
if (rec.count > 1) parts.push("detected in " + rec.count + " traces");
var sameDay = rec.first && rec.last && rec.first.slice(0, 10) === rec.last.slice(0, 10);
if (rec.first) parts.push("seen first " + clockTime(rec.first, !sameDay));
if (rec.last) parts.push("last " + clockTime(rec.last, !sameDay));
if (parts.length === 0) { host.style.display = "none"; return; }
host.style.display = "";
host.textContent = parts.join(" · ");
}
// Date only when the window straddles days: "first 14:32 last 09:15"
// reads backwards otherwise.
function clockTime(iso, withDate) {
if (typeof iso !== "string" || iso.length < 19) return iso || "";
var time = iso.slice(11, 19) + " UTC";
return withDate ? iso.slice(0, 10) + " " + time : time;
}
/// Build the parent -> children adjacency for a span list plus the
/// roots array. Extracted from `renderTree` to keep its cognitive
/// complexity under Sonar's 15-point threshold.
function buildSpanTree(spans) {
// Null-prototype maps for all user-controlled keys (span_id,
// parent_span_id) so hostile identifiers like "__proto__" cannot
// corrupt the lookup chain. Mirrors the indexTracesById fix.
var byId = Object.create(null);
spans.forEach(function (s) { byId[s.span_id] = s; });
var childrenByParent = Object.create(null);
var roots = [];
spans.forEach(function (s, idx) {
var parent = s.parent_span_id;
if (parent && byId[parent]) {
if (!childrenByParent[parent]) childrenByParent[parent] = [];
childrenByParent[parent].push(idx);
} else {
roots.push(idx);
}
});
return { roots: roots, childrenByParent: childrenByParent };
}
/// Iterative DFS walker that appends one row per span. Depth is
/// capped at 64 to cut cycles if any, the total row count is capped
/// at 2000 to bound worst-case DOM size.
// Collapse key: spans with the same event type and normalized template
// (e.g. the N near-identical N+1 queries) fold into one row.
function collapseKey(span) {
// Culprit membership joins the key: that rule is per span id, so a
// group mixing named and unnamed siblings would let members[0] speak
// for spans the finding never reported.
var culprit = treeCulprits ? (treeCulprits[span.span_id] ? "1" : "0") : "";
return (span.event_type || "") + "|" + (span.template || span.target || "") + "|" + culprit;
}
function isLeafIdx(idx, spans, tree) {
var kids = tree.childrenByParent[spans[idx].span_id];
return !(kids && kids.length);
}
function spanIsFinding(span, targetTpl) {
return treeCulprits
? !!treeCulprits[span.span_id]
: treeHasTplMatch
? !!(targetTpl && span.template === targetTpl)
: !!(treeTargetEvt && span.event_type === treeTargetEvt &&
!(treeChildren[span.span_id] || []).length);
}
// Fold a sibling list into groups: runs of consecutive same-key leaf
// siblings become one multi-member group; everything else stays singleton.
function groupSiblings(indices, spans, tree, targetTpl) {
var groups = [];
var i = 0;
while (i < indices.length) {
var idx = indices[i];
if (isLeafIdx(idx, spans, tree)
&& (!treeCulprits || !spanIsFinding(spans[idx], targetTpl))) {
var key = collapseKey(spans[idx]);
var members = [idx];
var j = i + 1;
while (j < indices.length && isLeafIdx(indices[j], spans, tree)
&& (!treeCulprits || !spanIsFinding(spans[indices[j]], targetTpl))
&& collapseKey(spans[indices[j]]) === key) {
members.push(indices[j]);
j += 1;
}
groups.push(members);
i = j;
} else {
groups.push([idx]);
i += 1;
}
}
return groups;
}
// A collapsed leaf row: the first span's content plus a ×N badge and an
// "N × avg" duration, mirroring the model's folded N+1 rows.
function buildCollapsedRow(members, spans, depth, targetTpl) {
var row = buildSpanRow(spans[members[0]], depth, targetTpl);
var durEl = row.querySelector(".ps-span-dur");
// Badge sits between the query text and the (right-aligned) duration.
row.insertBefore(el("span", "ps-span-count", "×" + members.length), durEl);
var totalUs = members.reduce(function (s, i) { return s + (spans[i].duration_us || 0); }, 0);
if (durEl) durEl.textContent = members.length + " × " + formatDurationUs(totalUs / members.length);
return row;
}
function emitSpanTreeRows(root, spans, tree, targetTpl) {
var stack = [];
function pushGroups(indices, depth) {
var groups = groupSiblings(indices, spans, tree, targetTpl);
for (var g = groups.length - 1; g >= 0; g--) {
stack.push({ group: groups[g], depth: depth });
}
}
pushGroups(tree.roots, 0);
var emitted = 0;
var HARD_SPAN_CAP = 2000;
var MAX_DEPTH = 64;
while (stack.length > 0 && emitted < HARD_SPAN_CAP) {
var frame = stack.pop();
if (frame.depth > MAX_DEPTH) continue;
if (frame.group.length > 1) {
root.appendChild(buildCollapsedRow(frame.group, spans, frame.depth, targetTpl));
emitted += 1;
continue;
}
var span = spans[frame.group[0]];
root.appendChild(buildSpanRow(span, frame.depth, targetTpl));
emitted += 1;
var kids = tree.childrenByParent[span.span_id] || [];
if (kids.length) pushGroups(kids, frame.depth + 1);
}
}
// Whole-trace findings match no span template, so they highlight by
// event type instead: the culprit is the whole set of calls.
// Whole-trace findings whose culprit set IS an event type: every
// outbound call for chatty, every query for pool saturation. The two
// whose culprits are neither a template nor an event type, serialized
// chains and fanout children, come from `culpritSpans` instead.
var WHOLE_TRACE_HILITE = {
chatty_service: "http_out",
pool_saturation: "sql"
};
var treeHasTplMatch = false;
var treeTargetEvt = null;
var treeChildren = {};
var treeCulprits = null;
function culpritSetFor(trace, finding) {
if (!finding.signature) return null;
// Same key the sink builds: the signature covers a pattern rather than
// an occurrence, so the time bounds separate two chains on one route.
var key = trace.trace_id + "|" + finding.signature + "|" +
(finding.first_timestamp || "") + "|" + (finding.last_timestamp || "");
var grouping = findingGrouping(finding);
// Length-prefixed in UTF-8 bytes, matching the sink: `.length` would
// count UTF-16 units and miss every non-ASCII grouping value.
if (grouping) {
var encoder = new TextEncoder();
key += "|" + encoder.encode(grouping.key).length + ":" + grouping.key +
"|" + encoder.encode(grouping.value).length + ":" + grouping.value;
}
var ids = culpritSpans[key];
if (!ids || !ids.length) return null;
// Null prototype: a span id is a free-form string on the JSON, Zipkin
// and Jaeger ingest paths, so "constructor" must not read as a member.
var set = Object.create(null);
ids.forEach(function (id) { set[id] = true; });
return set;
}
function renderTree(root, trace, finding) {
var spans = trace.spans || [];
if (spans.length === 0) {
root.appendChild(el("div", "ps-span dim", "(empty trace)"));
return null;
}
var tree = buildSpanTree(spans);
var targetTpl = (finding.pattern && finding.pattern.template) || null;
// Named spans win: the sink resolved them from the detector's exact
// culprit set, so no client rule can do better.
treeCulprits = culpritSetFor(trace, finding);
treeHasTplMatch = !treeCulprits && !!targetTpl
&& spans.some(function (s) { return s.template === targetTpl; });
treeTargetEvt = (treeCulprits || treeHasTplMatch) ? null
: (WHOLE_TRACE_HILITE[finding.type || finding.finding_type] || null);
treeChildren = tree.childrenByParent || {};
// The highlight tint follows the finding severity (see [data-fsev] CSS).
var host = root.closest ? root.closest(".ps-tree") : null;
setAttr(host || root, "data-fsev", finding.severity || "");
emitSpanTreeRows(root, spans, tree, targetTpl);
// Decided on what was actually rendered, not on the whole trace: the
// emitter caps rows and depth, so a matching span can exist and never
// reach the DOM. Dimming everything with nothing lit is unreadable.
if (!root.querySelector(".ps-span.hilite")) {
root.querySelectorAll(".ps-span.dim").forEach(function (row) {
row.classList.remove("dim");
});
}
return treeCulprits ? root.querySelectorAll(".ps-span.hilite").length : null;
}
function buildSpanRow(span, depth, targetTpl) {
var row = el("div", "ps-span");
// Event-rule mode marks leaves only: the container span above a
// flood of calls is context, not a culprit.
var isFinding = spanIsFinding(span, targetTpl);
if (isFinding) row.classList.add("hilite");
else if (treeCulprits || treeHasTplMatch || treeTargetEvt) row.classList.add("dim");
// 10px base inset keeps depth-0 text off the rounded edge.
row.style.paddingLeft = (10 + depth * 20) + "px";
// Cross-nav to pg_stat: a SQL span whose normalized template matches
// a row in pg_stat becomes clickable. The click switches to the
// pg_stat tab with the matching row highlighted and the filter
// banner shown.
var pgStatMatch =
hasPgStat && span.event_type === "sql" && pgStatByTemplate[span.template]
? span.template
: null;
if (pgStatMatch) {
row.classList.add("ps-span-pgstat-link");
row.addEventListener("click", function () {
// Without this the drilled row can be filtered out, leaving the drill
// banner naming a template the table below does not show.
clearSearchQuery();
switchTab("pgstat");
showPgStatDrill(pgStatMatch);
});
}
var text = el("span", "ps-span-text");
if (isFinding) text.classList.add("ps-span-find");
var described = describeSpan(span);
highlightInto(text, described);
// The row ellipsizes, so the whole operation lives in the tooltip.
text.title = described;
row.appendChild(text);
if (span.timestamp) {
var time = el("span", "ps-span-time", spanClockTime(span.timestamp));
time.title = span.timestamp;
row.appendChild(time);
}
if (Number.isFinite(span.status_code)) {
row.appendChild(el("span", "ps-span-flag", String(span.status_code)));
}
row.appendChild(el("span", "ps-span-dur", formatDurationUs(span.duration_us)));
return row;
}
function spanClockTime(iso) {
if (typeof iso !== "string" || iso.length < 19) return iso || "";
var rest = iso.slice(19);
var fractionMatch = rest.match(/^\.\d{1,3}/);
var fractional = fractionMatch ? fractionMatch[0] : "";
// The wall clock is sliced verbatim, so it is only UTC when the input
// says Z. A +02:00 span would otherwise read two hours off, labelled
// UTC, and an operator would correlate against the wrong window.
var zone = /Z$/.test(rest) ? " UTC" : (rest.match(/[+-]\d{2}:?\d{2}$/) || [""])[0];
return iso.slice(11, 19) + fractional + (zone ? " " + zone.trim() : "");
}
function describeSpan(span) {
if (span.event_type === "sql") {
return span.template || span.target || span.operation || "(sql)";
}
if (span.event_type === "http_out") {
// span.template is already the canonical "METHOD /path" form (see
// normalize/http.rs), so don't prepend the method again. Only the
// raw-URL fallback (no template) needs the method prefixed.
if (span.template) return span.template;
return (span.operation || "GET").toUpperCase() + " " + (span.target || "");
}
// Messaging: operation alone is just the broker name, the template
// carries the destination.
return span.template || span.operation || span.target || "(span)";
}
// Display label for a framework id: drop the "_generic" suffix (so
// "rust_generic" reads "rust") and turn the remaining underscores into
// spaces ("java_jpa" -> "java jpa"). The eyebrow style upcases it.
function frameworkLabel(fw) {
if (!fw) return "-";
var s = fw.replace(/_generic$/, "").replace(/_/g, " ");
// Real product names where the framework id differs from the display.
return s.replace(/^node\b/, "node.js").replace(/^csharp\b/, "c#");
}
function renderSuggestedFix(root, fix) {
root.appendChild(el("span", "ps-eyebrow brand", "// suggested fix · " + frameworkLabel(fix.framework)));
var prose = el("span", "ps-fix-prose");
proseInto(prose, fix.recommendation || "");
root.appendChild(prose);
var href = safeHttpsHref(fix.reference_url);
if (href) {
var a = el("a", "ps-fix-link", "See documentation ↗");
setAttr(a, "href", href);
setAttr(a, "target", "_blank");
setAttr(a, "rel", "noopener noreferrer");
root.appendChild(a);
}
}
// -------- green panel --------
// Builds a metric card with a brand-toned value (Carbon panel uses
// green numbers throughout).
function greenCard(label, value, sub, tone, help, deepKey) {
var card = metricCard(label, value, sub);
// Backticked segments in the sub render as code chips, like prose.
if (sub && sub.indexOf("`") !== -1) {
var subEl = card.querySelector(".ps-metric-sub");
subEl.textContent = "";
proseInto(subEl, sub);
}
var v = card.querySelector(".ps-metric-value");
if (v) setAttr(v, "data-tone", tone || "brand");
setAttr(card, "data-grad", tone || "brand");
if (help) {
var icon = el("span", "ps-nav-help", "?");
setAttr(icon, "aria-label", help);
wireTip(icon, help, deepKey);
card.querySelector(".ps-metric-label").appendChild(icon);
}
return card;
}
// Map an interpret.rs severity band to a value tone. The bands are computed
// Rust-side from the documented thresholds (IIS 2/5/10, waste ratio
// 0.10/0.30/0.50), so the colors track the same levels as the CLI.
function bandTone(band) {
if (band === "critical") return "crit";
if (band === "high") return "warn";
if (band === "moderate") return "neutral";
if (band === "healthy") return "ok";
return "brand";
}
// SCI formula banner shown above the regions table.
function renderCarbonFormula() {
var host = document.getElementById("green-formula");
if (!host) return;
host.textContent = "";
host.appendChild(el("code", "ps-formula-code", "co2.total = (E × I) + M"));
var text = el("span", "ps-formula-text");
// "SCI v1.0" links to the Green Software Foundation (authors of the spec).
var href = safeHttpsHref("https://greensoftware.foundation/");
if (href) {
var a = el("a", "ps-source-link", "SCI v1.0");
setAttr(a, "href", href);
setAttr(a, "target", "_blank");
setAttr(a, "rel", "noopener noreferrer");
text.appendChild(a);
} else {
text.appendChild(document.createTextNode("SCI v1.0"));
}
proseInto(
text,
" numerator (ISO/IEC 21031:2024): energy used (E) × grid carbon intensity (I), plus embodied hardware (M), summed over analyzed traces. Per-region scoring when spans carry `cloud.region`."
);
host.appendChild(text);
host.style.display = "";
}
function renderGreenPanel() {
if (!hasGreen) {
document.getElementById("green-metrics").appendChild(greenCard(
"Carbon",
"-",
(report.analysis && report.analysis.traces_analyzed === 0)
? "No traces were analyzed in this run, so there is no operational, embodied or transport figure to show."
: "GreenOps scoring is off ([green] enabled = false): no operational, embodied or transport figure for this run.",
"muted"
));
return;
}
var co2 = greenSummary.co2;
var metrics = document.getElementById("green-metrics");
metrics.appendChild(greenCard(
"Total CO2",
formatGco2(co2.total.mid),
"low " + formatGco2(co2.total.low) + " · high " + formatGco2(co2.total.high),
null,
"The SCI v1.0 numerator summed over analyzed traces: operational `(E x I)` + embodied `M` + network transport `T`. The low/high bracket is log-symmetric (half, double): a directional estimate, not a wattmeter reading.",
"total_co2"
));
metrics.appendChild(greenCard("Operational CO2", formatGco2(co2.operational_gco2), "E × I", null,
"Carbon from actually running the workload: energy used times the grid carbon intensity of the resolved region. The only term a code fix can reduce.", "operational_co2"));
metrics.appendChild(greenCard("Embodied CO2", formatGco2(co2.embodied_gco2), "SCI v1.0 · M term", null,
"Hardware manufacturing carbon amortized over traffic: `[green] embodied_carbon_per_request_gco2` per request. Real, but irreducible by fixing an anti-pattern.", "embodied_co2"));
var avoidMid = co2.avoidable.mid;
// Accent marks actionable savings; a zero amount has nothing to act on.
metrics.appendChild(greenCard(
"Avoidable",
formatGco2(avoidMid),
percent(avoidMid, co2.operational_gco2) + " of operational",
avoidMid > 0 ? "accent" : "neutral",
"The operational share attributable to avoidable I/O: operational carbon times avoidable ops over accounted ops. This is the number an anti-pattern fix shrinks.",
"avoidable_co2"
));
metrics.appendChild(greenCard(
"I/O waste ratio",
Math.round((greenSummary.io_waste_ratio || 0) * 100) + "%",
(greenSummary.total_sql_io_ops || 0) > 0
? "avoidable / total ops · SQL " + fmtNum(greenSummary.avoidable_sql_io_ops || 0) + " of " + fmtNum(greenSummary.total_sql_io_ops)
: "avoidable / total ops",
bandTone(greenSummary.io_waste_ratio_band),
"Avoidable I/O operations (N+1, redundant calls) over all I/O operations of the window. Bands: healthy under 10%, moderate to 30%, high to 50%, critical above.",
"waste_ratio"
));
var topOff = greenSummary.top_offenders && greenSummary.top_offenders[0];
metrics.appendChild(greenCard(
"IIS score",
topOff && Number.isFinite(topOff.io_intensity_score) ? topOff.io_intensity_score.toFixed(1) : "-",
"I/O intensity per call",
bandTone(topOff && topOff.io_intensity_band),
"I/O Intensity Score of the worst endpoint: its I/O operations (SQL, outbound HTTP, publishes) per call. Bands at 2, 5 and 10.",
"iis"
));
// Measured on the declared Alumet database cgroup, or estimated
// from the modeled SQL energy; the model tag says which. Excluded
// from every total, hence its own card.
var dbw = greenSummary.database_waste;
if (dbw) {
var dbValue = Number.isFinite(dbw.waste_gco2) ? formatGco2(dbw.waste_gco2) : formatKwh(dbw.waste_kwh);
// The estimated figure is a re-presented share of the totals,
// only the measured cgroup reading is additional energy.
var dbScope = dbw.model === "estimated" ? "within the report totals" : "excluded from totals";
var dbSub = formatKwh(dbw.waste_kwh) + " of " + formatKwh(dbw.energy_kwh) + " · " +
Math.round((dbw.sql_waste_ratio || 0) * 100) + "% SQL · " +
(dbw.model || "-") + (dbw.region ? " · " + dbw.region : "") +
" · " + dbScope +
(dbw.model === "estimated" ? " · measured needs `[green.alumet.database]`" : "");
metrics.appendChild(greenCard(
"Database waste",
dbValue,
dbSub,
dbw.waste_kwh > 0 ? "accent" : "neutral",
"Avoidable database-side energy: measured on the declared database cgroup (Alumet) or estimated from the modeled SQL energy times the SQL waste ratio, the model tag says which. Estimated re-presents a share of the report totals, measured is an additional, separate scope.",
"db_waste"
));
} else {
// Greyed rather than absent, so a reader sees why no figure exists.
metrics.appendChild(greenCard(
"Database waste",
"-",
"not measured · no SQL activity, a measured reading needs `[green.alumet.database]`",
"muted",
"Avoidable database-side energy: measured on the declared database cgroup (Alumet) or estimated from the modeled SQL energy times the SQL waste ratio, the model tag says which. Estimated re-presents a share of the report totals, measured is an additional, separate scope.",
"db_waste"
));
}
// Broker twin of the card above: measured cgroup, declared cluster,
// or estimated from the modeled publish energy.
var mw = greenSummary.messaging_waste;
if (mw) {
var mwValue = Number.isFinite(mw.waste_gco2) ? formatGco2(mw.waste_gco2) : formatKwh(mw.waste_kwh);
var mwScope = mw.model === "estimated" ? "within the report totals" : "excluded from totals";
var mwSub = formatKwh(mw.waste_kwh) + " of " + formatKwh(mw.energy_kwh) + " · " +
Math.round((mw.messaging_waste_ratio || 0) * 100) + "% messaging · " +
(mw.model || "-") + (mw.region ? " · " + mw.region : "") +
" · " + mwScope +
(mw.model === "estimated" ? " · measured needs `[green.alumet.broker]`" : "");
metrics.appendChild(greenCard(
"Broker waste",
mwValue,
mwSub,
mw.waste_kwh > 0 ? "accent" : "neutral",
"Same as database waste, for the message broker: measured cgroup (Alumet), declared cluster (SPECpower) or estimated from the modeled publish energy. Kept out of every total.",
"broker_waste"
));
} else {
metrics.appendChild(greenCard(
"Broker waste",
"-",
"not measured · no broker publish spans, or no broker energy backend",
"muted",
"Same as database waste, for the message broker: measured cgroup (Alumet), declared cluster (SPECpower) or estimated from the modeled publish energy. Kept out of every total.",
"broker_waste"
));
}
renderCarbonFormula();
renderScoringConfigBandeau(greenSummary.scoring_config);
var regions = greenSummary.regions || [];
var header = document.getElementById("green-regions-header");
header.textContent = "Regions (" + regions.length + ")";
if (regions.length === 0) {
document.getElementById("green-regions-empty").style.display = "";
return;
}
document.getElementById("green-regions-card").style.display = "";
var tbody = document.getElementById("green-regions-body");
regions.forEach(function (r) {
var tr = document.createElement("tr");
tr.appendChild(el("td", null, regionLabel(r.region)));
tr.appendChild(el("td", null, fmtNum((r.grid_intensity_gco2_kwh || 0).toFixed(0))));
tr.appendChild(el("td", null, fmtNum(r.io_ops || 0)));
tr.appendChild(el("td", null, formatGco2(r.co2_gco2 || 0)));
tr.appendChild(buildSourceCell(r));
tr.appendChild(buildEstimatedCell(r));
tbody.appendChild(tr);
});
}
// Renders three chips above the green-regions table: API version,
// emission factor type, temporal granularity. Hidden unless the payload
// says Electricity Maps is configured: scoring_config now ships on every
// run to carry the applied coefficients, so its presence alone means
// nothing here. The 3 fields are bounded enums on the Rust side, no
// escaping needed.
function renderScoringConfigBandeau(cfg) {
var bandeau = document.getElementById("green-scoring-config");
if (!bandeau) return;
if (!cfg || cfg.electricity_maps === false) {
bandeau.style.display = "none";
return;
}
bandeau.replaceChildren(
el("span", "ps-scoring-label", "Carbon scoring:"),
buildApiVersionChip(cfg.api_version),
buildEmissionFactorChip(cfg.emission_factor_type),
buildTemporalGranularityChip(cfg.temporal_granularity)
);
bandeau.style.display = "";
}
function buildScoringChip(label, modifier, tooltip) {
var chip = el("span", "ps-scoring-chip ps-scoring-chip-" + modifier, label);
chip.setAttribute("title", tooltip);
return chip;
}
// The three chip builders below pattern-match on the known wire
// values and fall through to a verbatim-text neutral chip on
// anything else. This keeps a forward-rolled enum variant (a
// future EmissionFactorType added in a later perf-sentinel
// release) from silently masquerading as the default chip when an
// older dashboard renders a newer baseline. The bounded-input
// invariant on the Rust side (typed enum, no #[serde(other)])
// keeps the textContent path safe for in-version rendering.
function buildApiVersionChip(version) {
if (version === "v3") {
return buildScoringChip(
"Electricity Maps v3",
"warning",
"v3 is in legacy mode. Migrate to v4 to silence the deprecation warning."
);
}
if (version === "v4") {
return buildScoringChip(
"Electricity Maps v4",
"neutral",
"API v4 is the current default."
);
}
if (version === "custom") {
return buildScoringChip(
"Electricity Maps custom",
"neutral",
"Custom endpoint without /vN suffix (proxy or mock)."
);
}
return buildScoringChip(
"Electricity Maps " + String(version),
"neutral",
"Unrecognized API version, displayed verbatim."
);
}
function buildEmissionFactorChip(factor) {
if (factor === "direct") {
return buildScoringChip(
"direct",
"accent",
"Direct emissions only. Used by some Scope 2 frameworks."
);
}
if (factor === "lifecycle") {
return buildScoringChip(
"lifecycle",
"neutral",
"Default. Includes upstream emissions (manufacturing, transport)."
);
}
return buildScoringChip(
String(factor),
"neutral",
"Unrecognized emission factor type, displayed verbatim."
);
}
function buildTemporalGranularityChip(granularity) {
if (granularity === "5_minutes" || granularity === "15_minutes") {
return buildScoringChip(
granularity,
"accent",
"Sub-hour granularity. Requires a paid Electricity Maps plan."
);
}
if (granularity === "hourly") {
return buildScoringChip(
"hourly",
"neutral",
"Default. Hourly average carbon intensity."
);
}
return buildScoringChip(
String(granularity),
"neutral",
"Unrecognized temporal granularity, displayed verbatim."
);
}
// Grid location each region's carbon profile models, taken from the
// comments in score/carbon_profiles.rs (the country/zone of the grid, not
// the datacenter city: eu-central-1 models the German grid, etc.).
var REGION_LOCATIONS = {
"ap-northeast-1": "Japan", "ap-south-1": "India", "ap-southeast-1": "Singapore",
"ap-southeast-2": "Australia", "ca-central-1": "Canada", "eu-central-1": "Germany",
"eu-north-1": "Sweden", "eu-south-1": "Italy", "eu-west-1": "Ireland",
"eu-west-2": "United Kingdom", "eu-west-3": "France", "eu-west-4": "Netherlands",
"sa-east-1": "Brazil", "us-east-1": "Virginia", "us-east-2": "Ohio",
"us-west-1": "California", "us-west-2": "Oregon"
};
function regionLabel(code) {
var loc = code && Object.prototype.hasOwnProperty.call(REGION_LOCATIONS, code)
? REGION_LOCATIONS[code] : null;
return loc ? code + " (" + loc + ")" : (code || "-");
}
// Source cell: real_time rows link "Electricity Maps" to its live map;
// other (embedded proxy) sources render as plain text.
function buildSourceCell(r) {
var td = document.createElement("td");
if (r.intensity_source === "real_time") {
td.appendChild(document.createTextNode("real time ("));
var href = safeHttpsHref("https://app.electricitymaps.com/map/live/fifteen_minutes");
if (href) {
var a = el("a", "ps-source-link", "Electricity Maps");
setAttr(a, "href", href);
setAttr(a, "target", "_blank");
setAttr(a, "rel", "noopener noreferrer");
td.appendChild(a);
} else {
td.appendChild(document.createTextNode("Electricity Maps"));
}
td.appendChild(document.createTextNode(")"));
} else {
// Embedded-proxy granularity: render "monthly_hourly" as "monthly hourly".
td.textContent = r.intensity_source ? r.intensity_source.replace(/_/g, " ") : "-";
}
return td;
}
function buildEstimatedCell(r) {
var td = document.createElement("td");
if (r.intensity_estimated === true) {
var badge = el("span", "ps-badge-estimated", "Estimated");
var tip = r.intensity_estimation_method
? "Method: " + r.intensity_estimation_method +
". Estimated by Electricity Maps using their internal model. " +
"Less precise than measured data but reliable for trend analysis."
: "Estimated by Electricity Maps. Less precise than measured data " +
"but reliable for trend analysis.";
badge.setAttribute("title", tip);
td.appendChild(badge);
} else if (r.intensity_estimated === false) {
var measured = el("span", "ps-badge-measured", "Measured");
measured.setAttribute("title", "Source data measured directly from the grid operator.");
td.appendChild(measured);
} else {
td.textContent = "-";
}
return td;
}
// -------- pg_stat / diff / correlations state --------
var pgStat = payload.pg_stat || null;
// Cross-nav lookup indexed across every ranking's entries so the
// Explain-to-pg_stat hotlink fires regardless of which ranking the
// user switches to.
var pgStatByTemplate = Object.create(null);
if (pgStat && Array.isArray(pgStat.rankings)) {
pgStat.rankings.forEach(function (r) {
(r.entries || []).forEach(function (entry) {
pgStatByTemplate[entry.normalized_template] = entry;
});
});
}
var hasPgStat = Object.keys(pgStatByTemplate).length > 0;
var mysqlStat = payload.mysql_stat || null;
var mysqlStatRankings =
mysqlStat && Array.isArray(mysqlStat.rankings) ? mysqlStat.rankings : [];
var hasMysqlStat = mysqlStatRankings.some(function (r) {
return (r.entries || []).length > 0;
});
var diffData = payload.diff || null;
var hasDiff = !!diffData;
// Correlations are populated only in daemon-produced reports. The batch
// pipeline does not emit them. This branch lights up automatically
// when a future daemon report is fed into
// `perf-sentinel report --input <daemon.json>`.
var correlations =
report.correlations && report.correlations.length > 0 ? report.correlations : [];
var hasCorrelations = correlations.length > 0;
// -------- pg_stat panel --------
// Rankings stay in the stable order `rank_pg_stat` emits:
// [by_total_time, by_calls, by_mean_time, by_io_blocks]. The
// sub-switcher chips are built from `pgStatRankings` so new rankings
// appended server-side surface automatically without any JS change.
var pgStatRankings =
pgStat && Array.isArray(pgStat.rankings) ? pgStat.rankings : [];
var activeRankingIndex = 0;
// Template currently highlighted by the drill banner, if any. Used
// to decide whether to re-apply the hilite after a ranking switch.
var pgStatHiliteTemplate = null;
// Human-friendly chip labels for the four known rankings. Unknown
// labels fall through to the raw server-side string so an older or
// customized PgStatReport still renders something sensible.
var PGSTAT_LABELS = {
"top by total_exec_time": "Total time",
"top by calls": "Calls",
"top by mean_exec_time": "Mean time",
"top by shared_blks_total": "I/O blocks"
};
function activePgStatEntries() {
if (pgStatRankings.length === 0) return [];
var idx = Math.min(activeRankingIndex, pgStatRankings.length - 1);
return pgStatRankings[idx].entries || [];
}
function renderPgStatPanel() {
if (!hasPgStat) {
document.getElementById("pgstat-empty").style.display = "";
return;
}
renderPgStatRankings();
renderPgStatTable();
}
// Show or hide the "Rank by" label along with its chip row. The label is a
// sibling of the chips, so hiding the chips alone leaves it pointing at
// nothing.
function setRankByVisible(labelId, visible) {
var label = document.getElementById(labelId);
if (label) label.style.display = visible ? "" : "none";
}
function renderPgStatRankings() {
var root = document.getElementById("pgstat-rankings");
root.textContent = "";
if (pgStatRankings.length <= 1) {
// Hide the chip row when there is nothing to switch between.
root.style.display = "none";
setRankByVisible("pgstat-rankby-label", false);
return;
}
root.style.display = "";
setRankByVisible("pgstat-rankby-label", true);
// Mutually-exclusive chip group. ARIA radiogroup pattern: the
// container carries role="radiogroup", each chip is role="radio"
// with aria-checked rewritten on every selection.
setAttr(root, "role", "radiogroup");
setAttr(root, "aria-label", "pg_stat ranking");
pgStatRankings.forEach(function (r, idx) {
var label = PGSTAT_LABELS[r.label] || r.label || ("Ranking " + (idx + 1));
var btn = el("button", "ps-chip", label);
btn.type = "button";
setAttr(btn, "role", "radio");
setAttr(btn, "data-ranking-index", String(idx));
var isActive = idx === activeRankingIndex;
setAttr(btn, "aria-checked", isActive ? "true" : "false");
if (isActive) btn.classList.add("active");
btn.addEventListener("click", function () {
selectPgStatRanking(idx);
});
root.appendChild(btn);
});
}
function selectPgStatRanking(idx) {
// Persist the choice even when idx already matches activeRankingIndex.
// This covers the deep-link restore path where the hash carries the
// current ranking but sessionStorage was empty, so we still honor
// the user's implicit intent to remember this ranking.
sessionSet(STORAGE_KEYS.pgstatRanking, rankingSlugForIndex(idx));
if (idx === activeRankingIndex) return;
activeRankingIndex = idx;
var chips = document.querySelectorAll("#pgstat-rankings .ps-chip");
for (var i = 0; i < chips.length; i++) {
var isActive = i === idx;
chips[i].classList.toggle("active", isActive);
setAttr(chips[i], "aria-checked", isActive ? "true" : "false");
}
renderPgStatTable();
scheduleHashWrite();
// Preserve the hilite when the previously-hilited template exists
// in the new ranking; else silently clear the drill + hilite.
if (pgStatHiliteTemplate) {
var stillPresent = activePgStatEntries().some(function (entry) {
return entry.normalized_template === pgStatHiliteTemplate;
});
if (stillPresent) {
highlightPgStatRow(pgStatHiliteTemplate);
} else {
clearPgStatDrill();
}
}
// Re-apply the search filter against the new rowset.
reapplySearchForTab("pgstat");
}
function renderPgStatTable() {
var body = document.getElementById("pgstat-body");
body.textContent = "";
activePgStatEntries().forEach(function (entry) {
var tr = document.createElement("tr");
setAttr(tr, "data-template", entry.normalized_template);
var tplTd = el("td", "ps-pg-template");
highlightInto(tplTd, entry.normalized_template);
tr.appendChild(tplTd);
tr.appendChild(el("td", null, fmtNum(entry.calls || 0)));
tr.appendChild(el("td", null, fmtNum((entry.total_exec_time_ms || 0).toFixed(1))));
tr.appendChild(el("td", null, fmtNum((entry.mean_exec_time_ms || 0).toFixed(2))));
body.appendChild(tr);
});
refreshTableSort();
}
// First ranking whose top-N entries contain the template, or -1.
function rankingIndexWithTemplate(template) {
for (var i = 0; i < pgStatRankings.length; i++) {
var entries = pgStatRankings[i].entries || [];
var hit = entries.some(function (e) {
return e.normalized_template === template;
});
if (hit) return i;
}
return -1;
}
function showPgStatDrill(template) {
// The clicked template may sit in another ranking's top-N, not the
// active one. Switch to a ranking that lists it so the row exists and
// can be highlighted (else the drill banner shows the SQL but no row).
var inActive = activePgStatEntries().some(function (e) {
return e.normalized_template === template;
});
if (!inActive) {
var idx = rankingIndexWithTemplate(template);
if (idx >= 0) selectPgStatRanking(idx);
}
var drill = document.getElementById("pgstat-drill");
var txt = document.getElementById("pgstat-drill-text");
drill.style.display = "";
txt.textContent = template;
pgStatHiliteTemplate = template;
highlightPgStatRow(template);
}
function clearPgStatDrill() {
document.getElementById("pgstat-drill").style.display = "none";
pgStatHiliteTemplate = null;
document.querySelectorAll("#pgstat-body tr").forEach(function (row) {
row.classList.remove("hilite");
});
}
function highlightPgStatRow(template) {
var matched = null;
document.querySelectorAll("#pgstat-body tr").forEach(function (row) {
if (row.dataset.template === template) {
row.classList.add("hilite");
matched = row;
} else {
row.classList.remove("hilite");
}
});
if (matched) matched.scrollIntoView({ block: "nearest" });
}
// -------- mysql_stat panel --------
// Same sub-switcher pattern as pg_stat, without the Explain drill and
// the session/hash ranking persistence (deliberate simplification: no
// cross-nav feeds this tab). Rankings stay in the stable order
// `rank_mysql_stat` emits: [by_total_time, by_calls, by_mean_time,
// by_rows_examined].
var activeMysqlRankingIndex = 0;
var MYSQLSTAT_LABELS = {
"top by total_exec_time": "Total time",
"top by calls": "Calls",
"top by mean_exec_time": "Mean time",
"top by rows_examined": "Rows examined"
};
function activeMysqlStatEntries() {
if (mysqlStatRankings.length === 0) return [];
var idx = Math.min(activeMysqlRankingIndex, mysqlStatRankings.length - 1);
return mysqlStatRankings[idx].entries || [];
}
function renderMysqlStatPanel() {
if (!hasMysqlStat) {
document.getElementById("mysqlstat-empty").style.display = "";
return;
}
renderMysqlStatRankings();
renderMysqlStatTable();
}
function renderMysqlStatRankings() {
var root = document.getElementById("mysqlstat-rankings");
root.textContent = "";
if (mysqlStatRankings.length <= 1) {
root.style.display = "none";
setRankByVisible("mysqlstat-rankby-label", false);
return;
}
root.style.display = "";
setRankByVisible("mysqlstat-rankby-label", true);
setAttr(root, "role", "radiogroup");
setAttr(root, "aria-label", "mysql_stat ranking");
mysqlStatRankings.forEach(function (r, idx) {
var label = MYSQLSTAT_LABELS[r.label] || r.label || ("Ranking " + (idx + 1));
var btn = el("button", "ps-chip", label);
btn.type = "button";
setAttr(btn, "role", "radio");
setAttr(btn, "data-ranking-index", String(idx));
var isActive = idx === activeMysqlRankingIndex;
setAttr(btn, "aria-checked", isActive ? "true" : "false");
if (isActive) btn.classList.add("active");
btn.addEventListener("click", function () {
selectMysqlStatRanking(idx);
});
root.appendChild(btn);
});
}
function selectMysqlStatRanking(idx) {
if (idx === activeMysqlRankingIndex) return;
activeMysqlRankingIndex = idx;
var chips = document.querySelectorAll("#mysqlstat-rankings .ps-chip");
for (var i = 0; i < chips.length; i++) {
var isActive = i === idx;
chips[i].classList.toggle("active", isActive);
setAttr(chips[i], "aria-checked", isActive ? "true" : "false");
}
renderMysqlStatTable();
reapplySearchForTab("mysqlstat");
}
function renderMysqlStatTable() {
var body = document.getElementById("mysqlstat-body");
body.textContent = "";
activeMysqlStatEntries().forEach(function (entry) {
var tr = document.createElement("tr");
setAttr(tr, "data-template", entry.normalized_template);
var tplTd = el("td", "ps-pg-template");
highlightInto(tplTd, entry.normalized_template);
tr.appendChild(tplTd);
tr.appendChild(el("td", null, entry.schema_name || ""));
tr.appendChild(el("td", null, fmtNum(entry.calls || 0)));
tr.appendChild(el("td", null, fmtNum((entry.total_exec_time_ms || 0).toFixed(1))));
tr.appendChild(el("td", null, fmtNum((entry.mean_exec_time_ms || 0).toFixed(2))));
tr.appendChild(el("td", null, fmtNum(entry.rows_examined || 0)));
body.appendChild(tr);
});
refreshTableSort();
}
// -------- diff panel --------
function renderDiffPanel() {
if (!hasDiff) return;
var fresh = (diffData.new_findings || []).slice().sort(bySeverityDesc);
var resolved = (diffData.resolved_findings || []).slice().sort(bySeverityDesc);
renderDiffFindingsList("diff-new-list", fresh, true);
renderDiffFindingsList("diff-resolved-list", resolved, "resolved");
// The zone wears the worst severity it contains, so the tint warns
// before the rows do. No new findings, no tint.
var section = document.querySelector(".ps-diff-section-new");
if (section) {
if (fresh.length) setAttr(section, "data-sev", fresh[0].severity);
else section.removeAttribute("data-sev");
}
renderDiffSevTable(diffData.severity_changes || []);
renderDiffEndpTable(diffData.endpoint_metric_deltas || []);
}
var DIFF_SECTIONS = [
{
listId: "diff-new-list", headerId: "diff-new-header",
emptyId: "diff-new-empty", label: "New findings", noun: "new findings"
},
{
listId: "diff-resolved-list", headerId: "diff-resolved-header",
emptyId: "diff-resolved-empty", label: "Resolved findings", noun: "resolved findings"
}
];
// The header text and count come from syncDiffHeaders, which reads the rows
// back, so it stays right when the search hides some of them.
function renderDiffFindingsList(listId, items, kind) {
var list = document.getElementById(listId);
list.textContent = "";
items.forEach(function (f) {
list.appendChild(buildDiffFindingRow(f, kind));
});
syncDiffHeaders();
}
function countVisible(selector) {
var shown = 0;
document.querySelectorAll(selector).forEach(function (row) {
if (row.style.display !== "none") shown++;
});
return shown;
}
// Empty state for the panels that filter in the DOM. Never claim the report
// holds nothing when the search is what emptied the list. Findings has its
// own wording because the chips can empty it too.
function syncEmptyState(emptyId, shown, noun) {
var box = document.getElementById(emptyId);
if (!box) return;
box.textContent = globalSearchTerm()
? "No " + noun + " match the current search."
: "No " + noun + " in this report.";
box.style.display = shown === 0 ? "" : "none";
}
// The section headers carry counts, so they have to track the search: a
// query typed on another tab can hide every row, and a header still claiming
// "New findings (3)" over a blank list is a lie.
function syncDiffHeaders() {
DIFF_SECTIONS.forEach(function (s) {
var shown = countVisible("#" + s.listId + " .ps-row");
var hdr = document.getElementById(s.headerId);
if (hdr) hdr.textContent = s.label + " (" + shown + ")";
syncEmptyState(s.emptyId, shown, s.noun);
});
}
function syncCorrelationsEmpty() {
syncEmptyState(
"correlations-empty",
countVisible("#correlations-list .ps-corr-card"),
"correlations"
);
}
function buildDiffFindingRow(f, kind) {
var row = el("div", "ps-row ps-diff-row");
if (kind === "resolved") {
var fixed = el("span", "ps-sev", "FIXED");
setAttr(fixed, "data-sev", "ok");
row.appendChild(fixed);
} else {
row.appendChild(sevPill(f.severity));
}
row.appendChild(el("span", "ps-diff-type", typeLabel(f.type || f.finding_type)));
var diffEp = el("span", "ps-diff-endpoint");
highlightEndpoint(diffEp, f.source_endpoint || "-");
row.appendChild(diffEp);
// Keep service + normalized template searchable (the diff search filters
// on row.textContent and advertises both), without showing them.
var diffExtra = (f.service || "") + " " + ((f.pattern && f.pattern.template) || "");
if (diffExtra.trim()) row.appendChild(el("span", "ps-sr-only", diffExtra));
if (kind === "resolved") {
// Resolved findings have no trace in the current run, so there is
// nothing to open. A toast explains why without navigating away from
// the Diff tab (which would clear the user's active diff filter).
row.addEventListener("click", function () {
showToast(
"",
"This finding was resolved. Its trace lives in the baseline report, not in the current run. Open the baseline report separately to explore its traces."
);
});
} else if (kind === true || kind === "new") {
row.addEventListener("click", function () {
// Off-list finding: switch to Findings, clear the master selection,
// and render the detail (the detail pane lives in that panel).
openExternalFindingDetail(f);
});
} else {
row.style.cursor = "default";
}
return row;
}
function renderDiffSevTable(items) {
var hdr = document.getElementById("diff-sev-header");
var tbl = document.getElementById("diff-sev-table");
var body = document.getElementById("diff-sev-body");
hdr.textContent = "Severity changes (" + items.length + ")";
hdr.classList.toggle("empty", items.length === 0);
body.textContent = "";
if (items.length === 0) {
tbl.style.display = "none";
return;
}
tbl.style.display = "";
items.forEach(function (c) {
var f = c.finding || {};
var tr = document.createElement("tr");
tr.appendChild(el("td", null, typeLabel(f.type || f.finding_type)));
var epTd = el("td");
highlightEndpoint(epTd, (f.service || "-") + " · " + (f.source_endpoint || "-"));
tr.appendChild(epTd);
var beforeTd = el("td");
beforeTd.appendChild(sevPill(c.before_severity));
tr.appendChild(beforeTd);
var afterTd = el("td");
afterTd.appendChild(sevPill(c.after_severity));
tr.appendChild(afterTd);
body.appendChild(tr);
});
}
function renderDiffEndpTable(items) {
var hdr = document.getElementById("diff-endp-header");
var tbl = document.getElementById("diff-endp-table");
var body = document.getElementById("diff-endp-body");
hdr.textContent = "Endpoint metric deltas (" + items.length + ")";
hdr.classList.toggle("empty", items.length === 0);
body.textContent = "";
if (items.length === 0) {
tbl.style.display = "none";
return;
}
tbl.style.display = "";
items.forEach(function (d) {
var tr = document.createElement("tr");
tr.appendChild(el("td", null, d.service || "-"));
var epTd = el("td");
highlightEndpoint(epTd, d.endpoint || "-");
tr.appendChild(epTd);
tr.appendChild(el("td", null, fmtNum(d.before_io_ops || 0)));
tr.appendChild(el("td", "ps-diff-after", fmtNum(d.after_io_ops || 0)));
var delta = d.delta || 0;
var deltaTd = el("td", null, (delta > 0 ? "+" : "") + fmtNum(delta));
// Tone by sign: more I/O is a regression (crit), fewer is an
// improvement (ok), unchanged stays neutral.
if (delta > 0) setAttr(deltaTd, "data-tone", "crit");
else if (delta < 0) setAttr(deltaTd, "data-tone", "ok");
tr.appendChild(deltaTd);
body.appendChild(tr);
});
}
// -------- correlations panel --------
// The text a correlation card exposes to the DOM search: the source type
// label (as shown in the tag) plus both services (the path). The CSV export
// matches on this so it filters to the same rows the panel leaves visible.
function correlationMatchBlob(c) {
var src = c.source || {};
var tgt = c.target || {};
// Mirror the card tag's fallback so a search on the shown label matches.
return [
typeLabel(src.finding_type || "cross-trace"),
correlationEndpointLabel(src),
correlationEndpointLabel(tgt)
].join(" ");
}
function correlationEndpointLabel(endpoint) {
var service = endpoint.service || "?";
if (!endpoint.grouping_value) return service;
var grouping = endpoint.grouping_key
? endpoint.grouping_key + "=" + endpoint.grouping_value
: endpoint.grouping_value;
return service + "@" + grouping;
}
// Renders `CrossTraceCorrelation` entries emitted by the daemon,
// matching the Rust struct shape: `source` and `target` are
// `CorrelationEndpoint { finding_type, service, template, grouping_key, namespace }`,
// timing is `median_lag_ms` and `co_occurrence_count`.
// A correlation carries no severity of its own: borrow the matched
// real finding's per side (warn when unmatched, cards always were).
function endpointSeverity(endpoint) {
var real = findRealFindingForCorrelation(endpoint || {});
return real ? real.severity : null;
}
// Sort key: the worst side of the pair.
function correlationSeverity(c) {
var a = SEV_RANK[endpointSeverity(c.source)] ?? 3;
var b = SEV_RANK[endpointSeverity(c.target)] ?? 3;
var worst = Math.min(a, b);
return Object.keys(SEV_RANK).find(function (k) { return SEV_RANK[k] === worst; }) || null;
}
function corrTag(endpoint, sev) {
var tag = el("span", "ps-corr-tag", typeLabel((endpoint || {}).finding_type || "cross-trace").toUpperCase());
setAttr(tag, "data-sev", sev === "critical" ? "crit" : sev === "info" ? "info" : "warn");
return tag;
}
function renderCorrelationsPanel() {
if (!hasCorrelations) return;
var list = document.getElementById("correlations-list");
list.textContent = "";
// Decorate once: correlationSeverity scans the findings array, and a
// comparator would re-run it O(n log n) times.
var ordered = correlations
.map(function (c) { return { c: c, rank: SEV_RANK[correlationSeverity(c)] ?? 3 }; })
.sort(function (a, b) {
return a.rank !== b.rank ? a.rank - b.rank : (b.c.confidence || 0) - (a.c.confidence || 0);
})
.map(function (entry) { return entry.c; });
ordered.forEach(function (c) {
var src = c.source || {};
var tgt = c.target || {};
// Each side of the causal pair is its own click zone: source opens
// the triggering finding, target the triggered one. A side with no
// matching finding in this report stays inert.
var card = el("div", "ps-corr-card ps-correlation-row");
var srcReal = findRealFindingForCorrelation(src);
var srcZone = el("span", "ps-corr-side");
srcZone.appendChild(corrTag(src, srcReal ? srcReal.severity : null));
srcZone.appendChild(el("span", "ps-corr-path", correlationEndpointLabel(src)));
if (srcReal) {
srcZone.classList.add("ps-corr-side-link");
setAttr(srcZone, "role", "button");
srcZone.addEventListener("click", function () { gotoFinding(srcReal); });
}
card.appendChild(srcZone);
card.appendChild(el("span", "ps-corr-path", "→"));
var tgtReal = findRealFindingForCorrelation(tgt);
var tgtZone = el("span", "ps-corr-side");
tgtZone.appendChild(corrTag(tgt, tgtReal ? tgtReal.severity : null));
tgtZone.appendChild(el("span", "ps-corr-path", correlationEndpointLabel(tgt)));
if (tgtReal || c.sample_trace_id) {
tgtZone.classList.add("ps-corr-side-link");
setAttr(tgtZone, "role", "button");
tgtZone.addEventListener("click", function () {
if (tgtReal) gotoFinding(tgtReal);
else openExternalFindingDetail(syntheticFindingFromCorrelation(c));
});
}
card.appendChild(tgtZone);
var conf = Math.round((c.confidence || 0) * 100);
var lag = (c.median_lag_ms || 0).toFixed(0);
var n = c.co_occurrence_count || 0;
card.appendChild(
el("span", "ps-corr-meta", "conf " + conf + "% · lag " + lag + " ms · " + n + " traces")
);
list.appendChild(card);
});
syncCorrelationsEmpty();
}
function findRealFindingForCorrelation(endpoint) {
var sameKind = findings.filter(function (f) {
// Compared both ways: an endpoint with no grouping must not match a
// grouped finding either, else an ungrouped correlation side links to
// whichever deployment happens to share its service.
var sameGrouping = findingNamespace(f) === (endpoint.grouping_value || "") &&
(!endpoint.grouping_key || findingGroupingKey(f) === endpoint.grouping_key);
return (f.type || f.finding_type) === endpoint.finding_type &&
f.service === endpoint.service &&
sameGrouping;
});
var exact = sameKind.filter(function (f) {
return ((f.pattern && f.pattern.template) || "") === (endpoint.template || "");
});
if (exact.length) return exact[0];
// Template drifted (renormalized between versions, or absent on the
// endpoint): type+service is safe only when unambiguous.
return sameKind.length === 1 ? sameKind[0] : null;
}
function syntheticFindingFromCorrelation(c) {
// Shape openExplain reads. sample_trace_id is the target-side finding's
// trace (correlate_cross.rs), so the detail must describe the target: its
// template is the one present in that trace, which lets renderTree
// highlight the offending span and renderPgStatXref find the row. Add a
// type and a warning pill (cards are warn) so the head is not "undefined".
var tgt = c.target || {};
var finding = {
trace_id: c.sample_trace_id,
type: tgt.finding_type || "cross-trace",
severity: "warning",
service: tgt.service || "-",
source_endpoint: "-",
pattern: { template: tgt.template || "" }
};
if (tgt.grouping_key && tgt.grouping_value) {
finding.grouping = [{ key: tgt.grouping_key, value: tgt.grouping_value }];
}
return finding;
}
// -------- search --------
// One query, one input: the top-bar box is the only search control and the
// sole source of truth. It filters every searchable tab at once, and each
// tab reports its match count through its sidebar badge, so typing from a
// non-searchable tab (Overview, Carbon) still gives feedback. The filter
// survives tab switches.
function matchRowByText(row, q) {
return (row.textContent || "").toLowerCase().includes(q);
}
function findingMatchesSearch(f, term) {
return matchesSearch(findingMatchBlob(f), term);
}
var SEARCHABLE_TABS = {
findings: {
rowsSelector: "#findings-list .ps-row",
// Filtered upstream in applyFilters, and paginated, so the rendered
// rows all match and the count has to come from the data.
filtersData: true,
countMatches: function () { return applyFilters().length; }
},
pgstat: {
rowsSelector: "#pgstat-body tr"
},
mysqlstat: {
rowsSelector: "#mysqlstat-body tr"
},
diff: {
// Filter only the two findings lists; the severity_changes and
// endpoint_metric_deltas tables stay unfiltered (short lists).
rowsSelector: "#diff-new-list .ps-row, #diff-resolved-list .ps-row",
afterFilter: syncDiffHeaders
},
correlations: {
rowsSelector: "#correlations-list .ps-corr-card",
afterFilter: syncCorrelationsEmpty
}
};
function globalSearchTerm() {
var ts = document.getElementById("topbar-search");
return ts ? (ts.value || "").toLowerCase() : "";
}
function initSearchInputs() {
var ts = document.getElementById("topbar-search");
if (!ts) return;
ts.addEventListener("input", function () {
onSearchQueryChanged();
});
}
// A new query re-renders the findings list from its first page, since that
// panel filters on data rather than on rendered rows. renderFindingsList
// already re-marks and re-badges findings, hence the skip.
function onSearchQueryChanged() {
resetShownCount();
renderFindingsList();
applyGlobalSearch("findings");
scheduleHashWrite();
}
// Drop the query and re-apply. Escape uses it, and so do the navigation
// gestures that mean "show me this exact thing" (a KPI card, a top offender,
// a trace-tree span): the query filters like the chips do, so leaving it on
// would hide the very target being opened. A no-op when already empty, so
// those gestures do not collapse the list pagination for nothing.
function clearSearchQuery() {
var ts = document.getElementById("topbar-search");
if (!ts || !ts.value) return;
ts.value = "";
onSearchQueryChanged();
}
// -------- search-match highlighting --------
// Wrap matches in <mark class="ps-mark"> inside the filtered rows.
// unmark-then-mark keeps the pass idempotent, so it can run on every
// filter application (input events and post-render reapplies) without
// observers. Text nodes only, never inside controls or headers.
function unmarkSearchMatches(root) {
root.querySelectorAll("mark.ps-mark").forEach(function (m) {
var p = m.parentNode;
p.replaceChild(document.createTextNode(m.textContent), m);
p.normalize();
});
}
function markSearchMatches(root, q) {
var ql = q.toLowerCase();
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode: function (n) {
if (!n.nodeValue || n.nodeValue.toLowerCase().indexOf(ql) < 0) return NodeFilter.FILTER_REJECT;
var pe = n.parentElement;
if (!pe || pe.closest("button, input, th, mark")) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
var nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(function (n) {
var t = n.nodeValue;
// Offsets are computed on the lowercased copy but slice the original.
// A few characters (U+0130 and friends) lengthen when lowercased,
// which would shift every later match onto the wrong glyphs: leave
// those nodes unmarked rather than highlight the wrong text.
if (t.toLowerCase().length !== t.length) return;
var pos = 0, idx = t.toLowerCase().indexOf(ql);
var frag = document.createDocumentFragment();
while (idx >= 0) {
frag.appendChild(document.createTextNode(t.slice(pos, idx)));
frag.appendChild(el("mark", "ps-mark", t.substr(idx, q.length)));
pos = idx + q.length;
idx = t.toLowerCase().indexOf(ql, pos);
}
frag.appendChild(document.createTextNode(t.slice(pos)));
n.parentNode.replaceChild(frag, n);
});
}
// knownCount lets a caller that just computed the data-filtered total (only
// renderFindingsList) skip the second countMatches() pass over the array.
function applySearchFilter(cfg, q, mark, knownCount) {
var matches = 0;
document.querySelectorAll(cfg.rowsSelector).forEach(function (row) {
unmarkSearchMatches(row);
// A data-filtered panel (findings) renders only matching rows upstream
// in applyFilters, so never hide one here: its rendered text can differ
// from what the data filter matched on.
var visible = cfg.filtersData || !q || matchRowByText(row, q);
if (!cfg.filtersData) row.style.display = visible ? "" : "none";
if (!visible) return;
matches++;
if (mark && q.length >= 2) markSearchMatches(row, q);
});
if (cfg.afterFilter) cfg.afterFilter();
if (!cfg.countMatches) return matches;
return knownCount != null ? knownCount : cfg.countMatches();
}
// Match counts ride the sidebar badge each tab already has: every searchable
// tab registers a countFn, so the badge exists. A null match count restores
// that registered total.
function updateNavBadge(tabKey, matches) {
var btn = document.getElementById("tab-" + tabKey);
var badge = btn ? btn.querySelector(".ps-nav-badge") : null;
if (!badge) return;
if (matches != null) {
badge.textContent = String(matches);
return;
}
tabs.forEach(function (t) {
if (t.key !== tabKey || !t.countFn) return;
var c = t.countFn();
if (c !== null && c !== undefined) badge.textContent = String(c);
});
}
// Filter one tab against the current global query and refresh its badge.
// Used both by the global pass and by the re-render hooks, whose rows come
// back with default display.
function reapplySearchForTab(tabKey, knownCount) {
var cfg = SEARCHABLE_TABS[tabKey];
if (!cfg) return;
var q = globalSearchTerm();
var matches = applySearchFilter(cfg, q, tabKey === currentTab, knownCount);
// A countMatches panel reports its filtered total whether or not a query
// is active, so its badge keeps one meaning and agrees with the counter
// inside the panel. The others fall back to their registered total.
updateNavBadge(tabKey, q || cfg.countMatches ? matches : null);
}
// Only the active panel gets its matches highlighted: markSearchMatches
// walks every text node of every visible row, and nobody can read marks on
// a hidden panel. Switching tabs re-runs this, so the new panel marks then.
// skipTab lets a caller that already re-rendered a panel skip it here.
function applyGlobalSearch(skipTab) {
Object.keys(SEARCHABLE_TABS).forEach(function (tabKey) {
if (tabKey !== skipTab) reapplySearchForTab(tabKey);
});
}
function focusSearch() {
var ts = document.getElementById("topbar-search");
if (!ts) return;
ts.focus();
ts.select();
}
function closeSearch(input) {
clearSearchQuery();
if (input) input.blur();
}
// -------- trim banner --------
function renderTrimBanner() {
var t = payload.trimmed_traces;
var f = payload.trimmed_findings;
if (!t && !f) return;
var parts = [];
if (f) {
parts.push("Showing " + f.kept + " of " + f.total +
" findings (trimmed for file size, critical first; the JSON report keeps the full set).");
}
if (t) {
parts.push("Showing " + t.kept + " of " + t.total +
" traces (trimmed for file size). Rerun with --max-traces-embedded <higher> to see more.");
}
var b = document.getElementById("trim-banner");
b.style.display = "";
b.textContent = parts.join(" ");
}
// -------- report warnings banner --------
// Run-level, so it sits outside the panels and stays readable on every
// tab. Same "[kind] message" shape as the CLI and the monitor TUI.
// ponytail: one tone for every kind; add a kind -> class map when a kind
// needs a different action from the reader.
function renderReportWarnings() {
var details = report.warning_details || [];
var rows = details.length
? details.map(function (w) { return { kind: w.kind, message: w.message }; })
: (report.warnings || []).map(function (m) { return { kind: null, message: m }; });
if (!rows.length) return;
var b = document.getElementById("report-warnings");
b.style.display = "";
// Only lost data earns the warn tone; the other kinds are advisory.
var lostData = rows.some(function (w) { return w.kind === "ingestion_drops"; });
setAttr(b, "data-tone", lostData ? "warn" : "info");
rows.forEach(function (w) {
var row = el("div", null, null);
if (w.kind) row.appendChild(el("span", "ps-warn-kind", w.kind));
// Through `proseInto` like every other prose surface: a warning names
// config keys and commands, and they must read as code here too.
proseInto(row, w.message);
b.appendChild(row);
});
}
// -------- theme toggle --------
// Click cycles through auto -> light -> dark -> auto. The actual
// data-theme attribute and button label are handled by applyTheme,
// defined above alongside the initial boot-time resolution.
document.getElementById("theme-toggle").addEventListener("click", function () {
var idx = THEME_MODES.indexOf(currentThemeMode());
var next = THEME_MODES[(idx + 1) % THEME_MODES.length];
sessionSet(STORAGE_KEYS.theme, next);
applyTheme(next, true);
});
// -------- density toggle --------
// The head bootstrap script already stamped data-density before first
// paint (compact unless localStorage says comfort). The label previews
// the target mode while hovered, and shows the current one otherwise.
(function wireDensityToggle() {
var btn = document.getElementById("density-toggle");
if (!btn) return;
function syncDensityLabel(hovering) {
var mode = document.documentElement.getAttribute("data-density");
var shown = hovering ? (mode === "compact" ? "Comfort" : "Compact")
: (mode === "compact" ? "Compact" : "Comfort");
var span = document.getElementById("density-label");
if (span) span.textContent = shown;
btn.title = mode === "compact" ? "Switch to comfort density" : "Switch to compact density";
}
btn.addEventListener("mouseenter", function () { syncDensityLabel(true); });
btn.addEventListener("mouseleave", function () { syncDensityLabel(false); });
btn.addEventListener("click", function () {
var next = document.documentElement.getAttribute("data-density") === "compact" ? "comfort" : "compact";
document.documentElement.setAttribute("data-density", next);
try { localStorage.setItem("perf-sentinel:density", next); } catch (e) { /* storage denied */ }
// A mouse click leaves the cursor on the button (preview the next
// flip), but keyboard and touch activation do not, and no mouseleave
// would ever correct an inverted label there.
syncDensityLabel(btn.matches(":hover"));
});
syncDensityLabel(false);
})();
// -------- sortable tables --------
// Every .ps-table with an id gets clickable headers. Click cycles
// default -> first direction -> reverse -> default (numeric columns
// start descending, text ascending). Shift+click adds a secondary
// tie-breaker key. State persists per table in localStorage and can be
// shared through the `tsort` hash key (ignored by readHash's router).
// Renderers that rebuild a tbody call refreshTableSort() afterwards:
// fresh rows carry no data-orig-idx, which re-triggers the stored sort.
// Higher rank = more severe, so the default descending direction puts
// CRIT on top like every other "biggest first" column.
var SORT_SEV_RANK = { CRIT: 3, WARN: 2, INFO: 1, OK: 0 };
// Unit-aware numeric cells: CO2 columns mix g/mg/ug and durations mix
// s/ms/us, so magnitudes only compare after scaling to a common unit.
var SORT_UNIT_SCALE = { ug: 1e-6, mg: 1e-3, g: 1, kg: 1e3, us: 1e-3, ms: 1, s: 1e3 };
// Anchored: a cell is numeric only if it is *entirely* a number with an
// optional known unit. Keeps ISO timestamps and hex signatures textual.
var SORT_NUM_RE = /^([+-]?\d[\d, \s]*(?:\.\d+)?)\s*(%|×|[a-zµ]{1,2})?$/i;
function sortNumericValue(t) {
var m = SORT_NUM_RE.exec(t.trim());
if (!m) return NaN;
var n = parseFloat(m[1].replace(/[, \s]/g, ""));
if (isNaN(n)) return NaN;
var unit = m[2];
if (!unit || unit === "%" || unit === "×") return n;
var scale = SORT_UNIT_SCALE[unit.toLowerCase().replace("µ", "u")];
// Unknown unit: not comparable as a number, fall back to text.
return scale === undefined ? NaN : n * scale;
}
var tableSortState = {};
try { tableSortState = JSON.parse(localStorage.getItem("perf-sentinel:tablesort") || "{}") || {}; } catch (e) { tableSortState = {}; }
// A tampered or future-format value must not throw mid-boot (that would
// take hash routing and live mode down with it): keep only valid entries.
Object.keys(tableSortState).forEach(function (k) {
var v = tableSortState[k];
var ok = Array.isArray(v) && v.every(function (s) {
return s && Number.isInteger(s.i) && (s.dir === "ascending" || s.dir === "descending");
});
if (!ok) delete tableSortState[k];
});
// Shared links seed the sorts before the first render.
try {
var fsortMatch = (location.hash || "").match(/[&#]fsort=(severity|impact)\.(asc|desc)/);
if (fsortMatch) {
sortState.key = fsortMatch[1];
sortState.dir = fsortMatch[2];
}
var tsortMatch = (location.hash || "").match(/[&#]tsort=([^&]+)/);
if (tsortMatch) decodeURIComponent(tsortMatch[1]).split(",").forEach(function (part) {
var li = part.lastIndexOf(":");
if (li < 0) return;
var spec = part.slice(li + 1);
if (!/^\d+[ad](\.\d+[ad])*$/.test(spec)) return;
tableSortState[part.slice(0, li)] = spec.split(".").map(function (s) {
return { i: parseInt(s, 10), dir: /a$/.test(s) ? "ascending" : "descending" };
});
});
} catch (e) { /* hostile hash: ignore */ }
function saveTableSort() {
try { localStorage.setItem("perf-sentinel:tablesort", JSON.stringify(tableSortState)); } catch (e) { /* storage denied */ }
}
// Serialized sort keys for copy-link, scoped to the tables of the tab
// being shared. Sending the whole persisted state would reorder the
// recipient's other tabs and overwrite their own preferences. Null when
// the shared tab has no active sort.
function tableSortHashValue() {
var panel = document.getElementById("panel-" + currentTab);
if (!panel) return null;
var active = Array.prototype.slice.call(panel.querySelectorAll(".ps-table")).map(function (t) {
return t.id;
}).filter(function (k) {
return k && tableSortState[k] && tableSortState[k].length;
});
if (!active.length) return null;
return active.map(function (k) {
return k + ":" + tableSortState[k].map(function (s) {
return s.i + (s.dir === "ascending" ? "a" : "d");
}).join(".");
}).join(",");
}
function sortCellText(tr, i) {
var td = tr.children[i];
return td ? td.textContent.trim() : "";
}
function sortColKind(rows, i) {
var num = 0, sev = 0, tot = 0;
rows.forEach(function (r) {
var t = sortCellText(r, i);
if (!t) return;
tot++;
if (SORT_SEV_RANK[t.toUpperCase()] !== undefined) sev++;
else if (!isNaN(sortNumericValue(t))) num++;
});
if (!tot) return "text";
if (sev / tot > 0.7) return "sev";
if (num / tot > 0.7) return "num";
return "text";
}
function sortFirstDir(kind) { return kind === "text" ? "ascending" : "descending"; }
function sortCmp(kind, ta, tb) {
// Unknown pills and unparseable numbers sink to the bottom of the
// first (descending) pass rather than displacing real values.
if (kind === "sev") return (SORT_SEV_RANK[ta.toUpperCase()] ?? -1) - (SORT_SEV_RANK[tb.toUpperCase()] ?? -1);
if (kind === "num") {
var na = sortNumericValue(ta);
if (isNaN(na)) na = -Infinity;
var nb = sortNumericValue(tb);
if (isNaN(nb)) nb = -Infinity;
return na - nb;
}
return ta.localeCompare(tb);
}
function syncSortHeaders(table) {
var keys = tableSortState[table.id] || [];
table.querySelectorAll("thead th").forEach(function (th, i) {
var ico = th.querySelector(".ps-sort-ico");
var pos = keys.findIndex(function (k) { return k.i === i; });
if (pos < 0) {
th.removeAttribute("aria-sort");
if (ico) ico.textContent = "↕";
return;
}
var arrow = keys[pos].dir === "ascending" ? "↑" : "↓";
if (ico) ico.textContent = keys.length > 1 ? arrow + (pos + 1) : arrow;
// aria-sort announces the primary key only.
if (pos === 0) th.setAttribute("aria-sort", keys[pos].dir);
else th.removeAttribute("aria-sort");
});
}
function applyTableSort(table) {
var tbody = table.tBodies[0];
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.children);
rows.forEach(function (r, idx) { if (!r.dataset.origIdx) r.dataset.origIdx = String(idx); });
var keys = (tableSortState[table.id] || []).map(function (k) {
return { i: k.i, dir: k.dir, kind: sortColKind(rows, k.i) };
});
rows.sort(function (a, b) {
// Highlighted rows (pg_stat drill) stay pinned on top while a sort is
// active. With no keys the table must restore the renderer's own
// order, pin included, or "default" would not be the default.
if (keys.length) {
var ha = a.classList.contains("hilite") ? 0 : 1;
var hb = b.classList.contains("hilite") ? 0 : 1;
if (ha !== hb) return ha - hb;
}
for (var k = 0; k < keys.length; k++) {
var c = sortCmp(keys[k].kind, sortCellText(a, keys[k].i), sortCellText(b, keys[k].i));
if (c) return keys[k].dir === "ascending" ? c : -c;
}
return (+a.dataset.origIdx) - (+b.dataset.origIdx);
});
rows.forEach(function (r) { tbody.appendChild(r); });
syncSortHeaders(table);
}
function onSortHeaderClick(table, i, ev) {
var tbody = table.tBodies[0];
if (!tbody) return;
var rows = Array.prototype.slice.call(tbody.children);
var kind = sortColKind(rows, i);
var keys = tableSortState[table.id] || [];
var pos = keys.findIndex(function (k) { return k.i === i; });
if (ev && ev.shiftKey && keys.length && pos !== 0) {
// Shift+click: secondary tie-breaker (cycle down -> up -> removed).
if (pos < 0) keys.push({ i: i, dir: sortFirstDir(kind) });
else if (keys[pos].dir === sortFirstDir(kind)) keys[pos].dir = sortFirstDir(kind) === "ascending" ? "descending" : "ascending";
else keys.splice(pos, 1);
} else {
var cur = pos === 0 && keys.length === 1 ? keys[0].dir : null;
var f = sortFirstDir(kind);
var next = cur === null ? f : (cur === f ? (f === "ascending" ? "descending" : "ascending") : null);
keys = next === null ? [] : [{ i: i, dir: next }];
}
tableSortState[table.id] = keys;
saveTableSort();
applyTableSort(table);
}
function wireSortableTable(table) {
if (table.dataset.sortWired) return;
table.dataset.sortWired = "1";
table.querySelectorAll("thead th").forEach(function (th, i) {
if (!th.textContent.trim()) return;
th.setAttribute("data-sortable", "");
th.setAttribute("title", "Sort by " + th.textContent.trim() + ", Shift+click adds it as a tie-breaker (secondary sort)");
var ico = el("span", "ps-sort-ico", "↕");
th.appendChild(ico);
th.tabIndex = 0;
th.addEventListener("click", function (ev) { onSortHeaderClick(table, i, ev); });
th.addEventListener("keydown", function (ev) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
onSortHeaderClick(table, i, ev);
}
});
});
}
function refreshTableSort() {
document.querySelectorAll(".ps-table").forEach(function (t) {
if (!t.id) return;
wireSortableTable(t);
var keys = tableSortState[t.id];
if (!keys || !keys.length) return;
var tb = t.tBodies[0];
// Fresh rows (no origIdx yet) mean the tbody was re-rendered:
// re-apply the persisted sort.
if (tb && tb.children.length && !tb.children[0].dataset.origIdx) applyTableSort(t);
});
}
// -------- CSV export --------
// Rows are assembled as arrays of cell values, then each cell is
// RFC 4180 escaped and the joined line is written to a Blob. No DOM
// injection involved, so this bypasses the innerHTML hazard entirely.
function csvEscape(value) {
if (value === null || value === undefined) return "";
var s = String(value);
// Formula-injection guard (OWASP CSV Injection). Excel, LibreOffice
// and Google Sheets all interpret a cell that starts with =, +, -,
// @ or a tab as a formula. A hostile SQL template or service name
// that begins with one of those characters would execute on open.
// The apostrophe prefix neutralizes the formula without losing the
// original text: spreadsheets display the cell as-is and drop the
// leading apostrophe, consumers that parse the CSV literally keep
// it. Strict first-byte check: do not strip leading whitespace
// (a leading tab is itself a formula trigger on some clients). A plain
// signed number (e.g. a negative delta "-12") is exempt: it is not a
// formula, and prefixing it would corrupt numeric columns.
if (s.length > 0 && "=+-@\t".includes(s.charAt(0)) && !/^[+-]?\d*\.?\d+$/.test(s)) {
s = "'" + s;
}
if (s.includes(",") || s.includes("\"") ||
s.includes("\n") || s.includes("\r")) {
return "\"" + s.replaceAll("\"", "\"\"") + "\"";
}
return s;
}
function csvLine(cells) {
return cells.map(csvEscape).join(",");
}
// Note on numeric cells: producers pass JS numbers straight through
// csvEscape, which calls String(n). That drops trailing zeros
// (`840.0` -> `"840"`) because V8 Number.toString() normalizes the
// decimal representation. Excel and every major CSV consumer handle
// this correctly, so we keep the cheap path rather than forcing
// `.toFixed(n)` per column.
function buildCsv(header, rows) {
var lines = [csvLine(header)];
rows.forEach(function (r) { lines.push(csvLine(r)); });
return lines.join("\r\n") + "\r\n";
}
function sanitizeLabelForFilename(s) {
if (!s) return "";
var cleaned = String(s).replaceAll(/[^A-Za-z0-9]+/g, "-");
cleaned = cleaned.replaceAll(/-+/g, "-").replaceAll(/^-|-$/g, "");
return cleaned;
}
function pad2(n) { return n < 10 ? "0" + n : String(n); }
function timestampForFilename() {
var d = new Date();
return d.getFullYear() +
pad2(d.getMonth() + 1) +
pad2(d.getDate()) + "-" +
pad2(d.getHours()) +
pad2(d.getMinutes()) +
pad2(d.getSeconds());
}
function buildCsvFilename(tabName) {
var label = sanitizeLabelForFilename(payload.input_label || "");
var prefix = label ? "perf-sentinel-" + label : "perf-sentinel";
return prefix + "-" + tabName + "-" + timestampForFilename() + ".csv";
}
function downloadCsv(tabName, csvText) {
var blob = new Blob([csvText], { type: "text/csv;charset=utf-8" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = buildCsvFilename(tabName);
document.body.appendChild(a);
a.click();
a.remove();
// Delay the revoke so the browser has the URL in hand when it
// kicks off the download. Immediate revoke breaks the download on
// some browsers. 0ms is enough to escape the current task.
setTimeout(function () { URL.revokeObjectURL(url); }, 0);
}
// The query is global, so a searchable tab's term is just that query.
// Non-searchable tabs export unfiltered.
function searchTermFor(tabKey) {
if (!SEARCHABLE_TABS || !SEARCHABLE_TABS[tabKey]) return "";
return globalSearchTerm();
}
function matchesSearch(haystack, term) {
if (!term) return true;
return String(haystack || "").toLowerCase().includes(term);
}
function exportFindingsCsv() {
// trace_id first among the identifiers: the export's main downstream
// use is feeding an id back into `explain` or a trace backend, and it
// was the one field the table never showed in full.
var header = [
"severity", "type", "service", "grouping_key", "grouping_value",
"grouping_all", "endpoint", "trace_id", "template",
"occurrences", "distinct_params", "window_ms", "classification_method",
"span_duration_us_p50", "span_duration_us_p99", "span_duration_cv_x1000",
"first_timestamp", "last_timestamp",
"code_function", "code_filepath", "code_lineno",
"suggestion", "suggested_fix_framework", "suggested_fix_recommendation",
"confidence"
];
// applyFilters already narrows to the chips and the search term.
var rows = applyFilters().map(function (f) {
var p = f.pattern || {};
var loc = f.code_location || {};
var fix = f.suggested_fix || {};
var type = f.type || f.finding_type || "";
return [
f.severity || "",
type,
f.service || "",
findingGroupingKey(f),
findingNamespace(f),
(f.grouping || []).map(function (g) { return g.key + "=" + g.value; }).join(" "),
f.source_endpoint || "",
f.trace_id || "",
p.template || "",
p.occurrences ?? "",
p.distinct_params ?? "",
p.window_ms ?? "",
f.classification_method || (type.indexOf("n_plus_one_") === 0 ? "direct" : ""),
p.span_duration_us_p50 ?? "",
p.span_duration_us_p99 ?? "",
p.span_duration_cv_x1000 ?? "",
f.first_timestamp || "",
f.last_timestamp || "",
loc.function || "",
loc.filepath || "",
loc.lineno ?? "",
f.suggestion || "",
fix.framework || "",
fix.recommendation || "",
f.confidence ?? ""
];
});
downloadCsv("findings", buildCsv(header, rows));
}
function exportPgStatCsv() {
var term = searchTermFor("pgstat");
var header = [
"template", "calls", "total_exec_time_ms", "mean_exec_time_ms",
"rows", "shared_blks_hit", "shared_blks_read", "seen_in_traces"
];
var rows = activePgStatEntries().filter(function (e) {
// Match the SAME text the visible row filter sees (template plus
// the formatted numeric cells of renderPgStatTable).
var blob = [
e.normalized_template,
fmtNum(e.calls || 0),
fmtNum((e.total_exec_time_ms || 0).toFixed(1)),
fmtNum((e.mean_exec_time_ms || 0).toFixed(2))
].join(" ");
return matchesSearch(blob, term);
}).map(function (e) {
return [
e.normalized_template || "",
e.calls ?? "",
e.total_exec_time_ms ?? "",
e.mean_exec_time_ms ?? "",
e.rows ?? "",
e.shared_blks_hit ?? "",
e.shared_blks_read ?? "",
e.seen_in_traces ? "true" : "false"
];
});
downloadCsv("pgstat", buildCsv(header, rows));
}
function exportMysqlStatCsv() {
var term = searchTermFor("mysqlstat");
var header = [
"template", "schema_name", "calls", "total_exec_time_ms",
"mean_exec_time_ms", "rows_sent", "rows_examined", "seen_in_traces"
];
var rows = activeMysqlStatEntries().filter(function (e) {
// Match the SAME text the visible row filter sees (matchRowByText
// reads row.textContent): template, schema and the formatted
// numeric cells, so a numeric search exports what stays visible.
var blob = [
e.normalized_template,
e.schema_name || "",
fmtNum(e.calls || 0),
fmtNum((e.total_exec_time_ms || 0).toFixed(1)),
fmtNum((e.mean_exec_time_ms || 0).toFixed(2)),
fmtNum(e.rows_examined || 0)
].join(" ");
return matchesSearch(blob, term);
}).map(function (e) {
return [
e.normalized_template || "",
e.schema_name || "",
e.calls ?? "",
e.total_exec_time_ms ?? "",
e.mean_exec_time_ms ?? "",
e.rows_sent ?? "",
e.rows_examined ?? "",
e.seen_in_traces ? "true" : "false"
];
});
downloadCsv("mysqlstat", buildCsv(header, rows));
}
// The text a finding is searched on, in both the Findings list and the CSV
// exports. Carries the type twice: the raw slug and the label the row shows,
// so "n_plus_one_sql" and "N+1 SQL" both match.
function findingMatchBlob(f) {
var type = f.type || f.finding_type;
return [
f.severity, type, typeLabel(type), f.service,
(f.grouping || []).map(function (g) { return g.key + " " + g.value; }).join(" "),
f.source_endpoint,
f.pattern && f.pattern.template
].join(" ");
}
function exportDiffCsv() {
if (!hasDiff) return;
var term = searchTermFor("diff");
var header = [
"section", "severity", "type", "service", "endpoint", "template",
"occurrences", "before_severity", "after_severity",
"before_io_ops", "after_io_ops", "delta"
];
var rows = [];
(diffData.new_findings || []).forEach(function (f) {
if (!matchesSearch(findingMatchBlob(f), term)) return;
var p = f.pattern || {};
rows.push([
"new", f.severity || "", f.type || f.finding_type || "",
f.service || "", f.source_endpoint || "", p.template || "",
p.occurrences ?? "",
"", "", "", "", ""
]);
});
(diffData.resolved_findings || []).forEach(function (f) {
if (!matchesSearch(findingMatchBlob(f), term)) return;
var p = f.pattern || {};
rows.push([
"resolved", f.severity || "", f.type || f.finding_type || "",
f.service || "", f.source_endpoint || "", p.template || "",
p.occurrences ?? "",
"", "", "", "", ""
]);
});
// The DOM search filters only the two findings lists (see
// SEARCHABLE_TABS.diff), so these two tables stay unfiltered on screen and
// the export must not drop their rows either, or it would omit rows the
// user can see.
(diffData.severity_changes || []).forEach(function (c) {
var f = c.finding || {};
var p = f.pattern || {};
rows.push([
"severity_change", "", f.type || f.finding_type || "",
f.service || "", f.source_endpoint || "", p.template || "",
p.occurrences ?? "",
c.before_severity || "", c.after_severity || "",
"", "", ""
]);
});
(diffData.endpoint_metric_deltas || []).forEach(function (d) {
rows.push([
"endpoint_delta", "", "",
d.service || "", d.endpoint || "", "",
"", "", "",
d.before_io_ops ?? "",
d.after_io_ops ?? "",
d.delta ?? ""
]);
});
downloadCsv("diff", buildCsv(header, rows));
}
function exportCorrelationsCsv() {
var term = searchTermFor("correlations");
var header = [
"service_a", "grouping_key_a", "grouping_value_a", "type_a", "template_a",
"service_b", "grouping_key_b", "grouping_value_b", "type_b", "template_b",
"confidence", "co_occurrence_count",
"source_total_occurrences", "median_lag_ms",
"first_seen", "last_seen"
];
var rows = correlations.filter(function (c) {
return matchesSearch(correlationMatchBlob(c), term);
}).map(function (c) {
var s = c.source || {};
var t = c.target || {};
return [
s.service || "", s.grouping_key || "", s.grouping_value || "",
s.finding_type || "", s.template || "",
t.service || "", t.grouping_key || "", t.grouping_value || "",
t.finding_type || "", t.template || "",
c.confidence ?? "",
c.co_occurrence_count ?? "",
c.source_total_occurrences ?? "",
c.median_lag_ms ?? "",
c.first_seen || "", c.last_seen || ""
];
});
downloadCsv("correlations", buildCsv(header, rows));
}
// -------- deep-link hash --------
// Shape: `#<tab>[&k=v[&k=v...]]`. Medium-scope: tab, search term,
// pg_stat ranking slug, findings severity chip, findings service chip.
// Writes replace history entries (no back/forward pollution).
var RANKING_SLUGS = ["total_time", "calls", "mean_time", "io_blocks"];
function rankingSlugForIndex(idx) {
return RANKING_SLUGS[idx] || String(idx);
}
function rankingIndexForSlug(slug) {
if (RANKING_SLUGS.includes(slug)) return RANKING_SLUGS.indexOf(slug);
// Pure-digit slug only: parseInt would silently accept "2abc" as 2.
return /^\d+$/.test(slug) ? Number(slug) : -1;
}
// Keys accepted from the URL fragment. Anything outside this set is
// ignored so a malformed or malicious hash cannot override the tab
// via a later `tab=` pair, inject arbitrary properties onto `state`,
// or confuse `applyHash`. The tab itself is positional (first
// token), not a key=value pair. Stored as a Set so existence checks
// are O(1) regardless of how many keys land here in the future.
var HASH_KEYS = new Set(["search", "ranking", "severity", "service", "type", "grouping"]);
function readHash() {
var raw = (location.hash || "").replace(/^#/, "");
if (!raw) return null;
var parts = raw.split("&");
var state = { tab: parts.shift() || null };
parts.forEach(function (p) {
if (!p) return;
var eq = p.indexOf("=");
if (eq < 0) return;
var k = p.slice(0, eq);
if (!HASH_KEYS.has(k)) return;
var v = p.slice(eq + 1);
try {
v = decodeURIComponent(v);
} catch {
// Malformed percent-encoding in a shared URL, keep the raw
// value so downstream string compares still work.
return;
}
state[k] = v;
});
return state;
}
function currentStateForHash() {
var state = { tab: currentTab };
// The query is global, so it belongs in the URL whatever the tab is,
// including a non-searchable one the user typed from.
var term = globalSearchTerm();
if (term) state.search = term;
if (currentTab === "findings") {
if (filterState.severity) state.severity = filterState.severity;
if (filterState.service) state.service = filterState.service;
if (filterState.type) state.type = filterState.type;
if (filterState.grouping) state.grouping = filterState.grouping;
}
if (currentTab === "pgstat") {
state.ranking = rankingSlugForIndex(activeRankingIndex);
}
return state;
}
function encodeHashFromState(state) {
if (!state || !state.tab) return "";
var parts = [state.tab];
["search", "ranking", "severity", "service", "type", "grouping"].forEach(function (k) {
var v = state[k];
if (v !== null && v !== undefined && v !== "") {
parts.push(k + "=" + encodeURIComponent(String(v)));
}
});
return parts.join("&");
}
// Guards against applyHash / writeHash ping-pong. The hashchange
// listener compares this against location.hash to tell user-driven
// changes from our own writeHash calls.
var _lastWrittenHash = null;
function writeHash(state) {
var hashStr = encodeHashFromState(state);
var full = hashStr ? "#" + hashStr : "";
try {
history.replaceState(null, "", full || location.pathname + location.search);
_lastWrittenHash = hashStr;
} catch {
// Old browsers without history.replaceState: fall back to a
// direct hash assignment. Pushes one history entry per change,
// the lesser of two evils.
location.hash = hashStr;
_lastWrittenHash = hashStr;
}
}
// Debounce writeHash to smooth keystrokes in search inputs and bursty
// chip clicks; 200ms is barely perceptible and keeps history quiet.
var hashWriteTimer = null;
function scheduleHashWrite() {
if (hashWriteTimer) clearTimeout(hashWriteTimer);
hashWriteTimer = setTimeout(function () {
hashWriteTimer = null;
writeHash(currentStateForHash());
}, 200);
}
function applyHash(state) {
if (!state || !state.tab) return false;
if (!tabIsRegistered(state.tab)) return false;
// Restore the global query first, so the re-renders below already filter
// against it. The hash is the authority: an absent term clears a stale
// one. switchTab then runs applyGlobalSearch for the remaining tabs.
var ts = document.getElementById("topbar-search");
if (ts) ts.value = state.search || "";
// Apply filter state before switching so the target tab already
// shows the correct view. Severity / service chips modify
// filterState and re-render the findings list.
if (state.tab === "findings") {
filterState.severity = null;
filterState.service = null;
filterState.type = null;
filterState.grouping = null;
if (state.severity === "critical" || state.severity === "warning" || state.severity === "info") {
filterState.severity = state.severity;
}
// Only honor a service that a chip actually exists for. A stale or
// hand-edited hash naming an absent service would filter the list to
// nothing with no active chip to explain why.
if (state.service && findings.some(function (f) { return f.service === state.service; })) {
filterState.service = state.service;
}
if (state.type && findings.some(function (f) {
return (f.type || f.finding_type) === state.type;
})) {
filterState.type = state.type;
}
if (state.grouping && findings.some(function (f) {
return findingGroupingIdentity(f) === state.grouping;
})) {
filterState.grouping = state.grouping;
}
syncFilterChips();
}
if (state.tab === "pgstat") {
var idx = rankingIndexForSlug(state.ranking || "");
if (idx >= 0 && idx < pgStatRankings.length) {
// Single source of truth: selectPgStatRanking handles both the
// sessionStorage write and the early-return when idx already
// matches activeRankingIndex.
selectPgStatRanking(idx);
}
}
// The query is global, so rebuild the findings list whatever the target
// tab is. Its rows are filtered on data, and a stale list would contradict
// the badge switchTab is about to refresh.
resetShownCount();
renderFindingsList();
switchTab(state.tab);
return true;
}
// -------- cheatsheet modal --------
var CHEATSHEET_ROWS = [
["j", "Move finding selection down"],
["k", "Move finding selection up"],
["Enter", "Open the selected finding"],
["Esc", "Close help, close search, clear filters"],
["/", "Focus the search box (searches every tab)"],
["⌘K / Ctrl+K", "Focus the global search"],
["g o", "Go to Overview"],
["g f", "Go to Findings"],
["g p", "Go to pg_stat (if available)"],
["g d", "Go to Diff (if available)"],
["g c", "Go to Correlations (if available)"],
["g r", "Go to Carbon (if available)"],
["g a", "Go to Acknowledgments (live mode)"],
["Click header", "Sort a table by that column (cycles down, up, default)"],
["Shift+Click header", "Add a column as tie-breaker (secondary sort)"],
["?", "Show this cheatsheet"]
];
function populateCheatsheet() {
var body = document.getElementById("cheatsheet-body");
if (!body) return;
body.textContent = "";
CHEATSHEET_ROWS.forEach(function (row) {
var tr = document.createElement("tr");
var tdK = document.createElement("td");
var kbd = el("span", "ps-kbd", row[0]);
tdK.appendChild(kbd);
tr.appendChild(tdK);
tr.appendChild(el("td", null, row[1]));
body.appendChild(tr);
});
}
// Native <dialog> element: the browser manages the backdrop, the
// focus trap, and the Esc-to-close behavior. We only layer focus
// restoration and backdrop-click-to-close on top.
function isCheatsheetOpen() {
var dlg = document.getElementById("cheatsheet");
return !!(dlg && dlg.open);
}
// Stash the element that owned focus when the cheatsheet opened so
// closing restores the j/k cursor position in the Findings list (or
// wherever the user came from). Cleared on close.
var cheatsheetPriorFocus = null;
function openCheatsheet() {
var dlg = document.getElementById("cheatsheet");
if (!dlg || dlg.open) return;
cheatsheetPriorFocus = document.activeElement;
dlg.showModal();
var closeBtn = document.getElementById("cheatsheet-close");
if (closeBtn) closeBtn.focus();
}
function closeCheatsheet() {
var dlg = document.getElementById("cheatsheet");
if (dlg && dlg.open) dlg.close();
}
function toggleCheatsheet() {
if (isCheatsheetOpen()) closeCheatsheet();
else openCheatsheet();
}
function anyFilterChipActive() {
return !!(
filterState.severity || filterState.service || filterState.type || filterState.grouping
);
}
function clearFilterChips() {
filterState.severity = null;
filterState.service = null;
filterState.type = null;
filterState.grouping = null;
syncFilterChips();
resetShownCount();
renderFindingsList();
}
// -------- keyboard --------
function isSearchInputFocused() {
var active = document.activeElement;
return !!(active && active.tagName === "INPUT" && active.classList.contains("ps-search"));
}
function isTextInputFocused() {
var a = document.activeElement;
if (!a) return false;
var tag = a.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return true;
return a.isContentEditable === true;
}
/// Escape ladder: close a modal, close search, clear filter chips.
/// Each tier returns as soon as it acts.
function handleEscape(ev) {
if (isCheatsheetOpen()) {
closeCheatsheet();
ev.preventDefault();
return;
}
// Any other open modal (topic help, ack, auth) closes itself on
// Escape: stop here so the same keydown does not also clear the
// user's filter chips underneath it.
var openDlg = document.querySelector("dialog[open]");
if (openDlg) {
openDlg.close();
ev.preventDefault();
return;
}
if (isSearchInputFocused()) {
closeSearch(document.activeElement);
ev.preventDefault();
return;
}
if (currentTab === "findings" && anyFilterChipActive()) {
clearFilterChips();
scheduleHashWrite();
ev.preventDefault();
}
}
/// Slash handling: focus the search box. The search is global, so this
/// works from every tab, including the non-searchable ones. Guarded like the
/// "?" handler: a slash typed into any text field belongs to that field, not
/// to the shortcut.
function handleSlash(ev) {
if (isTextInputFocused()) return;
focusSearch();
ev.preventDefault();
}
/// j / k / enter navigation on the Findings list.
function handleFindingsNav(ev) {
if (visibleFindings.length === 0) return;
// In the master/detail layout the detail pane follows selection, so j/k
// re-open the now-selected finding (not just move the highlight).
if (ev.key === "j") {
selectFinding((selectedIndex + 1) % visibleFindings.length);
scrollSelectedIntoView();
} else if (ev.key === "k") {
selectFinding((selectedIndex - 1 + visibleFindings.length) % visibleFindings.length);
scrollSelectedIntoView();
} else if (ev.key === "Enter" && selectedIndex >= 0) {
selectFinding(selectedIndex);
}
ev.preventDefault();
}
// g-prefixed tab shortcuts. `g` alone arms a 1000ms window; the next
// key consumes it regardless of whether it matches a tab. Hidden tabs
// are a silent no-op (no feedback, no flash).
var pendingGTimer = null;
var G_MAPPING = {
o: "overview", f: "findings", p: "pgstat",
d: "diff", c: "correlations", r: "green", a: "acknowledgments"
};
function clearPendingG() {
if (pendingGTimer) {
clearTimeout(pendingGTimer);
pendingGTimer = null;
}
}
function handleGPrefix(ev) {
// Ignore OS-level key autorepeat: holding `g` must not rearm the
// prefix, and holding the second letter must not fire the tab
// switch twice. The dispatcher continues past this handler so
// autorepeat on j/k/Enter still drives Findings navigation.
if (ev.repeat) return false;
if (ev.key === "g" && pendingGTimer === null) {
pendingGTimer = setTimeout(function () { pendingGTimer = null; }, 1000);
return true;
}
if (pendingGTimer !== null) {
clearPendingG();
var target = G_MAPPING[ev.key];
if (target && tabIsRegistered(target)) {
switchTab(target);
ev.preventDefault();
}
return true;
}
return false;
}
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") { handleEscape(ev); return; }
// Cmd/Ctrl+K focuses the always-visible top-bar search (the ⌘K hint).
// The modifier keeps it distinct from the bare `k` finding-nav key.
if ((ev.metaKey || ev.ctrlKey) && (ev.key === "k" || ev.key === "K")) {
var topbarSearch = document.getElementById("topbar-search");
if (topbarSearch) { topbarSearch.focus(); topbarSearch.select(); ev.preventDefault(); }
return;
}
// `?` opens the cheatsheet unless a text input is consuming it.
if (ev.key === "?" && !isTextInputFocused()) {
toggleCheatsheet();
ev.preventDefault();
return;
}
if (isCheatsheetOpen()) return;
if (ev.key === "/") { handleSlash(ev); return; }
if (isSearchInputFocused()) return;
if (isTextInputFocused()) return;
if (handleGPrefix(ev)) return;
if (currentTab !== "findings") return;
if (ev.key === "j" || ev.key === "k" || ev.key === "Enter") {
handleFindingsNav(ev);
}
});
function scrollSelectedIntoView() {
var rows = document.querySelectorAll("#findings-list .ps-row");
if (selectedIndex >= 0 && selectedIndex < rows.length) {
rows[selectedIndex].scrollIntoView({ block: "nearest" });
}
}
// A click halfway down the list opens the detail pane whose top may be
// scrolled out of view: go back to the very top of the page, filter
// chips included. No-op when the pane top is already visible, and not
// wired to j/k, which must keep the list row in view instead.
function scrollDetailToTop() {
var pane = document.getElementById("explain-content");
if (!pane) return;
var bar = document.querySelector(".ps-topbar");
var offset = (bar ? bar.offsetHeight : 0) + 8;
if (pane.getBoundingClientRect().top >= offset) return;
window.scrollTo({ top: 0, behavior: "smooth" });
}
document.getElementById("pgstat-drill-clear").addEventListener("click", clearPgStatDrill);
// -------- boot helpers --------
// Each helper owns one slice of boot work so the IIFE body stays
// linear and cognitive complexity stays bounded. Sonar flags the
// IIFE itself when too many branches accumulate at this level.
function registerAllTabs() {
registerTab("overview", "Overview", null);
registerTab("findings", "Findings", function () { return findings.length; });
if (hasPgStat) {
registerTab("pgstat", "pg_stat", function () {
// Badge shows the currently-active ranking's entry count. Every
// ranking contains the same top-N entries, just reordered, so
// the number is identical across rankings (see rank_pg_stat).
return activePgStatEntries().length;
});
} else {
registerDisabledTab("pgstat", "pg_stat",
"No `pg_stat_statements` data in this report. Pass `--pg-stat <file>` or `--pg-stat-prometheus <url>` to `perf-sentinel report` to enable this tab.");
}
if (hasMysqlStat) {
registerTab("mysqlstat", "mysql_stat", function () {
return activeMysqlStatEntries().length;
});
} else {
registerDisabledTab("mysqlstat", "mysql_stat",
"No MySQL Performance Schema data in this report. Pass `--mysql-stat <file>` (an `events_statements_summary_by_digest` export) to `perf-sentinel report` to enable this tab.");
}
if (hasDiff) {
registerTab("diff", "Diff", function () {
return (
(diffData.new_findings || []).length + (diffData.resolved_findings || []).length
);
});
}
if (!hasDiff) {
registerDisabledTab("diff", "Diff",
"No baseline to compare against. Pass `--before <baseline.json>` (from `analyze --format json`) to `perf-sentinel report` to enable this tab.");
}
if (hasCorrelations) {
registerTab("correlations", "Correlations", function () {
return correlations.length;
});
} else {
var conf = findings.length ? findings[0].confidence : null;
// A quiet daemon window has no finding to read the mode from, so
// fall back to the live handle exactly like renderSidebarContext:
// only when the confidence itself says nothing.
var daemonRun = CONFIDENCE_CONTEXT[conf]
? (conf === "daemon_staging" || conf === "daemon_production")
: !!(payload.daemon && payload.daemon.url);
registerDisabledTab("correlations", "Correlations", daemonRun
? "The daemon reported no cross-trace correlation: `[daemon.correlation]` may be disabled in the config, or none was detected in the window."
: "Cross-trace correlations are a daemon-only output (`perf-sentinel watch`). This report came from a batch run, which analyzes each trace alone.");
}
// Always registered: a disabled Carbon tab explains itself instead
// of disappearing.
registerTab("green", "Carbon", null);
if (payload.daemon && payload.daemon.url) {
registerTab("acknowledgments", "Acknowledgments", function () {
return liveAcksCount();
});
}
renderTabs();
}
function restorePersistedPgStatRanking() {
// Restore the persisted ranking before the first render so the
// initial table already reflects the user's last choice. A hash
// with a ranking slug overrides this, inside `applyHash`.
if (!hasPgStat) return;
var storedRanking = sessionGet(STORAGE_KEYS.pgstatRanking);
if (!storedRanking) return;
var storedIdx = rankingIndexForSlug(storedRanking);
if (storedIdx >= 0 && storedIdx < pgStatRankings.length) {
activeRankingIndex = storedIdx;
}
}
function renderAllPanels() {
renderReportWarnings();
renderTrimBanner();
renderFindingsMetrics();
// Gate rules render inside the Overview hero (renderOverviewHero); there
// is no longer a standalone gate-rules table.
renderOverviewPanel();
renderFilterChips();
restorePersistedPgStatRanking();
renderFindingsList();
if (hasPgStat) renderPgStatPanel();
if (hasMysqlStat) renderMysqlStatPanel();
if (hasDiff) renderDiffPanel();
if (hasCorrelations) renderCorrelationsPanel();
renderGreenPanel();
}
function wireCheatsheetDialog() {
var closeBtn = document.getElementById("cheatsheet-close");
if (closeBtn) closeBtn.addEventListener("click", closeCheatsheet);
var dlg = document.getElementById("cheatsheet");
if (!dlg) return;
// Native <dialog> manages Esc-to-close and the focus trap for us.
// We only restore focus to wherever it was before the dialog
// opened, on the close event (covers both our manual close and
// the native Esc path).
dlg.addEventListener("close", restorePriorFocus);
// Click outside the dialog's content area closes it. With
// showModal(), clicks on the backdrop hit the dialog itself,
// clicks on any descendant hit that descendant.
dlg.addEventListener("click", function (ev) {
if (ev.target === dlg) closeCheatsheet();
});
var footerBtn = document.getElementById("footer-shortcuts");
if (footerBtn) footerBtn.addEventListener("click", openCheatsheet);
}
function restorePriorFocus() {
var prior = cheatsheetPriorFocus;
cheatsheetPriorFocus = null;
if (!prior || typeof prior.focus !== "function") return;
try {
prior.focus();
} catch {
// Prior element was removed while the dialog was open (most
// likely a re-render). Silent fallback: the browser picks a
// sensible default.
return;
}
}
function wireShowMoreButton() {
var btn = document.getElementById("findings-show-more");
if (!btn) return;
btn.addEventListener("click", function () {
shownCount += LIST_CAP;
renderFindingsList();
});
}
function wireExportButtons() {
var exportHandlers = {
findings: exportFindingsCsv,
pgstat: exportPgStatCsv,
mysqlstat: exportMysqlStatCsv,
diff: exportDiffCsv,
correlations: exportCorrelationsCsv
};
Object.keys(exportHandlers).forEach(function (tabKey) {
var btn = document.getElementById(tabKey + "-export");
if (btn) btn.addEventListener("click", exportHandlers[tabKey]);
});
}
// -------- copy-link buttons --------
function copyCurrentLink(btn) {
var href = location.href;
// The copied link also restores any active table sort.
var tsort = tableSortHashValue();
if (location.hash && tsort) {
href = href.replace(/([&#])tsort=[^&]*/g, "$1").replace(/[&#]$/, "");
href += "&tsort=" + encodeURIComponent(tsort);
}
// And the findings-list sort. The scrub runs unconditionally: a link
// opened with an fsort and then sorted back to the default would
// otherwise be copied still carrying the stale one.
if (location.hash) {
href = href.replace(/([&#])fsort=[^&]*/g, "$1").replace(/[&#]$/, "");
if (sortState.key !== "severity" || sortState.dir !== "desc") {
href += "&fsort=" + encodeURIComponent(sortState.key + "." + sortState.dir);
}
}
copyTextToClipboard(btn, href);
}
// Copy `text`, flashing the button label. Shared by the per-tab deep
// links and the trace_id button.
function copyTextToClipboard(btn, text) {
// Capture the label once so repeat clicks inside the flash window
// do not end up restoring "Copied" as the baseline.
if (!btn.dataset.baseLabel) btn.dataset.baseLabel = btn.textContent;
var baseLabel = btn.dataset.baseLabel;
function flashCopied() {
btn.textContent = "Copied";
// Swap the CSS-mask chain icon for a check during the flash.
btn.classList.add("ps-copied");
if (btn._copyFlashTimer) clearTimeout(btn._copyFlashTimer);
btn._copyFlashTimer = setTimeout(function () {
btn._copyFlashTimer = null;
btn.textContent = baseLabel;
btn.classList.remove("ps-copied");
}, 1500);
}
// file:// origins and older browsers reject navigator.clipboard,
// so fall back to a throwaway textarea + execCommand.
function fallbackCopy() {
try {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "absolute";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand("copy");
ta.remove();
if (ok) {
flashCopied();
} else {
console.error("copyTextToClipboard: execCommand('copy') returned false");
}
} catch (err) {
console.error("copyTextToClipboard fallback failed", err);
}
}
if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
navigator.clipboard.writeText(text).then(flashCopied, function (err) {
console.error("navigator.clipboard.writeText rejected", err);
fallbackCopy();
});
} else {
fallbackCopy();
}
}
function wireCopyLinkButtons() {
["findings", "pgstat", "mysqlstat", "diff", "correlations"].forEach(function (tabKey) {
var btn = document.getElementById(tabKey + "-copy-link");
if (!btn) return;
btn.addEventListener("click", function () {
copyCurrentLink(btn);
});
});
}
function applyInitialHashOrWrite() {
// Apply the deep-link hash last so it can override the initial
// ranking / filter chips / tab selection derived above. Falls
// through silently when the hash is missing, malformed, or points
// to a tab that is not registered in this report.
var initialHash = readHash();
if (applyHash(initialHash)) return;
// No hash: emit the current state so sharing the URL immediately
// after load produces a self-describing link.
writeHash(currentStateForHash());
}
function wireHashChangeListener() {
// Skip events triggered by our own writeHash replaceState, apply
// only real user-driven changes.
globalThis.addEventListener("hashchange", function () {
var raw = (location.hash || "").replace(/^#/, "");
if (raw === _lastWrittenHash) return;
applyHash(readHash());
});
}
// -------- live mode (since 0.5.23) --------
// The whole live-mode block stays inside the existing IIFE so it
// shares scope with `payload`, `findings`, `renderTabs`,
// `renderFindingsList` and the `el`/`setAttr` helpers. Running
// outside would re-parse the `<script id="report-data">` block, a
// pure overhead.
//
// Activation: only when the embedded payload carries
// `payload.daemon.url`. Static reports (the 0.5.22 default) never
// touch this code path.
//
// Security:
// - The X-API-Key sits in `liveState.apiKey` and `sessionStorage`.
// It is never copied into the DOM as text, never logged, never
// shipped in a `data-*` attribute.
// - All user-facing rendering is `textContent`, never `innerHTML`.
// - `connect-src` in the CSP whitelists only the daemon URL the
// operator passed to `--daemon-url`, validated by the CLI.
var DAEMON_API_KEY_STORAGE = "perf-sentinel.daemon.api-key";
var DAEMON_ACKS_CAP = 1000;
var DAEMON_FETCH_TIMEOUT_MS = 10000;
var liveState = {
enabled: false,
daemonUrl: null,
apiKey: null,
acks: [],
acksBySig: Object.create(null),
pendingAuthRetry: null,
activeFindingsAbort: null
};
function liveAcksCount() {
return liveState.acks.length;
}
function isLiveEnabled() {
return liveState.enabled;
}
function setApiKey(value) {
// Guard against an empty / whitespace-only value reaching live
// state. `submitAuthModal` already trims and rejects empty
// before getting here, but keep this helper safe to call from
// anywhere; an accidental empty write would put liveState and
// sessionStorage into an asymmetric state where one side reads
// truthy and the other falsy.
if (typeof value !== "string" || value.trim() === "") {
clearApiKey();
return;
}
liveState.apiKey = value;
sessionSet(DAEMON_API_KEY_STORAGE, value);
}
function clearApiKey() {
liveState.apiKey = null;
// Use removeItem rather than `sessionSet(KEY, "")` so a debugger
// inspecting sessionStorage does not see a stale empty entry that
// could mistake for "key was set, then someone null'd it".
try {
globalThis.sessionStorage.removeItem(DAEMON_API_KEY_STORAGE);
} catch {
// sessionStorage can throw on some hardened browsers (Safari
// Private Browsing pre-15, embedded WebViews). Fall back to the
// sessionSet pattern so the in-memory state stays consistent.
sessionSet(DAEMON_API_KEY_STORAGE, "");
}
}
function liveDaemonBase() {
return liveState.daemonUrl || "";
}
function fetchWithAuth(path, opts) {
var options = opts || {};
var headers = {};
if (options.headers) {
Object.keys(options.headers).forEach(function (k) {
headers[k] = options.headers[k];
});
}
headers["Accept"] = "application/json";
if (liveState.apiKey) {
headers["X-API-Key"] = liveState.apiKey;
}
// Bound every request with an AbortController so a hung daemon
// does not leave a request pending until the browser's default
// network timeout (which is multi-minute in Chrome).
var ctrl = (typeof AbortController === "function") ? new AbortController() : null;
var init = {
method: options.method || "GET",
headers: headers,
credentials: "omit",
mode: "cors"
};
if (options.body !== undefined && options.body !== null) {
init.body = options.body;
}
if (ctrl) {
init.signal = ctrl.signal;
} else if (options.signal) {
init.signal = options.signal;
}
var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, DAEMON_FETCH_TIMEOUT_MS) : null;
return fetch(liveDaemonBase() + path, init).finally(function () {
if (timer) clearTimeout(timer);
});
}
function setDaemonStatus(state, label) {
var dot = document.getElementById("ps-daemon-dot");
var text = document.getElementById("ps-daemon-status-text");
if (dot) {
dot.classList.remove("connected", "disconnected", "unauthorized");
dot.classList.add(state);
}
if (text) text.textContent = label;
}
function showToast(kind, message) {
var existing = document.getElementById("ps-toast");
if (existing) existing.remove();
var t = el("div", "ps-toast " + (kind || ""), message);
setAttr(t, "id", "ps-toast");
setAttr(t, "role", kind === "error" ? "alert" : "status");
document.body.appendChild(t);
setTimeout(function () {
t.remove();
}, 5000);
}
// Post-ack toast with a direct Undo (DELETE without the revoke
// confirmation dialog). The classic Revoke button keeps its confirm.
function showUndoAckToast(sig) {
var existing = document.getElementById("ps-toast");
if (existing) existing.remove();
var t = el("div", "ps-toast success", "Acknowledged. ");
setAttr(t, "id", "ps-toast");
setAttr(t, "role", "status");
var undo = el("button", "ps-toast-undo", "Undo");
undo.type = "button";
function undoAck() {
var path = "/api/findings/" + encodeURIComponent(sig) + "/ack";
return fetchWithAuth(path, { method: "DELETE" }).then(function (r) {
if (r.status === 204) {
showToast("success", "Acknowledgment undone.");
return refreshLiveState();
}
// Same 401 path as revokeAck: reopen the auth modal and retry
// instead of dead-ending on a rotated key.
return handleAuthRetry(r, undoAck, null, "revoke");
}).catch(function () {
showToast("error", "Network error reaching the daemon.");
});
}
undo.addEventListener("click", function () {
t.remove();
undoAck();
});
t.appendChild(undo);
document.body.appendChild(t);
setTimeout(function () { t.remove(); }, 8000);
}
function pingStatus() {
return fetchWithAuth("/api/status").then(function (r) {
if (r.status === 200) {
setDaemonStatus("connected", "Connected");
return true;
}
if (r.status === 401) {
// Stale key: a daemon-side rotation invalidated whatever we
// stored. Drop the cached value so the next write call opens
// the auth modal cleanly instead of retrying with the bad
// key.
if (liveState.apiKey) {
clearApiKey();
syncLogoutButtonVisibility();
}
setDaemonStatus("unauthorized", "Authentication required");
return false;
}
setDaemonStatus("disconnected", "HTTP " + r.status);
return false;
}).catch(function () {
setDaemonStatus("disconnected", "Unreachable");
return false;
});
}
function clearAcksState() {
liveState.acks = [];
liveState.acksBySig = Object.create(null);
}
function applyAcksPayload(data) {
var arr = Array.isArray(data) ? data : [];
liveState.acks = arr;
liveState.acksBySig = Object.create(null);
for (var a of arr) {
if (a && a.signature) liveState.acksBySig[a.signature] = a;
}
}
function handleAcksResponse(r) {
if (!r.ok) {
if (r.status === 401) {
setDaemonStatus("unauthorized", "Authentication required");
}
clearAcksState();
return null;
}
return r.json().then(applyAcksPayload);
}
function fetchAcks() {
return fetchWithAuth("/api/acks").then(handleAcksResponse).catch(clearAcksState);
}
function renderAcksPanel() {
var body = document.getElementById("acks-body");
var empty = document.getElementById("acks-empty");
var footer = document.getElementById("acks-footer");
var card = document.getElementById("acks-card");
if (!body) return;
body.textContent = "";
if (liveState.acks.length === 0) {
if (empty) empty.style.display = "";
if (card) card.style.display = "none";
if (footer) footer.textContent = "";
return;
}
if (empty) empty.style.display = "none";
if (card) card.style.display = "";
liveState.acks.forEach(function (a) {
body.appendChild(buildAckRow(a));
});
refreshTableSort();
if (footer) {
var note = liveState.acks.length + " active";
if (liveState.acks.length >= DAEMON_ACKS_CAP) {
note += " (showing up to " + DAEMON_ACKS_CAP + ", consult the daemon for the full list)";
}
note += ". TOML CI acks are not listed here, see .perf-sentinel-acknowledgments.toml.";
footer.textContent = note;
}
}
function buildAckRow(ack) {
var tr = document.createElement("tr");
var sig = document.createElement("td");
sig.textContent = ack.signature || "-";
tr.appendChild(sig);
var by = document.createElement("td");
by.textContent = ack.by || "-";
tr.appendChild(by);
var reason = document.createElement("td");
reason.textContent = ack.reason || "";
tr.appendChild(reason);
var expires = document.createElement("td");
expires.textContent = ack.expires_at ? ack.expires_at : "never";
tr.appendChild(expires);
var act = document.createElement("td");
var btn = el("button", "ps-fin-action-btn revoke", "Revoke");
btn.type = "button";
setAttr(btn, "data-sig", ack.signature || "");
btn.addEventListener("click", function () {
revokeAck(ack.signature);
});
act.appendChild(btn);
tr.appendChild(act);
return tr;
}
function syncFindingActionButtons() {
if (!isLiveEnabled()) return;
var rows = document.querySelectorAll("#findings-list .ps-row[data-sig]");
rows.forEach(function (row) {
var sig = row.dataset.sig || "";
if (!sig) return;
var btn = row.querySelector(".ps-fin-action-btn");
if (!btn) return;
var isAcked = !!liveState.acksBySig[sig];
btn.textContent = isAcked ? "Revoke" : "Ack";
setAttr(btn, "data-action", isAcked ? "revoke" : "ack");
btn.classList.toggle("revoke", isAcked);
// Replace the listener on each render. Cheaper than a single
// event-delegation handler that has to walk back up to find the
// signature, given we only have at most a few hundred rows in
// the visible window.
btn.onclick = function (event) {
event.stopPropagation();
if (isAcked) revokeAck(sig);
else openAckModal(sig);
};
});
}
// An acknowledgment is keyed on the signature alone, which is deliberate:
// it states the code is accepted debt, and that does not vary by
// deployment. But the list shows one row per grouping, so the operator
// clicking Ack on one row must be told the other rows go with it.
function ackScopeText(sig) {
var groupings = [];
// An ungrouped row counts too: a signature shared by one grouped and one
// ungrouped finding still covers both, and staying silent there is
// exactly the mis-scoped ack this note exists to prevent.
var ungrouped = false;
findings.forEach(function (f) {
if (f.signature !== sig) return;
var grouping = findingGroupingLabel(f);
if (!grouping) {
ungrouped = true;
return;
}
if (groupings.indexOf(grouping) !== -1) return;
groupings.push(grouping);
});
if (ungrouped && groupings.length) groupings.push("(no grouping)");
if (groupings.length < 2) return "";
// The findings list can be trimmed for file size, so the count is a
// floor rather than a total. Saying "at least" beats overstating.
var prefix = payload.trimmed_findings ? "Covers at least " : "Covers ";
return prefix + groupings.length + " groupings: " +
groupings.sort().join(", ");
}
function openAckModal(sig) {
var dlg = document.getElementById("ack-modal");
var sigInput = document.getElementById("ack-modal-sig");
var reasonInput = document.getElementById("ack-modal-reason");
var byInput = document.getElementById("ack-modal-by");
var err = document.getElementById("ack-modal-error");
var scope = document.getElementById("ack-modal-scope");
if (!dlg || !sigInput || !reasonInput) return;
sigInput.value = sig;
reasonInput.value = "";
if (byInput) byInput.value = "";
if (err) { err.style.display = "none"; err.textContent = ""; }
if (scope) {
var text = ackScopeText(sig);
scope.textContent = text;
// Explicit "block": the class defaults to display:none, so clearing
// the inline style would let the CSS keep it hidden.
scope.style.display = text ? "block" : "none";
}
if (typeof dlg.showModal === "function") dlg.showModal();
}
function closeAckModal() {
var dlg = document.getElementById("ack-modal");
if (dlg && typeof dlg.close === "function") dlg.close();
}
function expiresIsoFromShortcut(value) {
if (!value) return null;
var now = new Date();
var hours = 0;
if (value === "24h") hours = 24;
else if (value === "7d") hours = 24 * 7;
else if (value === "30d") hours = 24 * 30;
else return null;
var dt = new Date(now.getTime() + hours * 3600 * 1000);
return dt.toISOString();
}
function submitAckModal(event) {
event.preventDefault();
var sig = (document.getElementById("ack-modal-sig") || {}).value || "";
var reason = ((document.getElementById("ack-modal-reason") || {}).value || "").trim();
var expiresValue = (document.getElementById("ack-modal-expires") || {}).value || "";
var by = ((document.getElementById("ack-modal-by") || {}).value || "").trim();
var err = document.getElementById("ack-modal-error");
if (!sig) return;
if (!reason) {
if (err) { err.textContent = "Reason is required."; err.style.display = "block"; }
return;
}
var body = { reason: reason };
if (by) body.by = by;
var expiresIso = expiresIsoFromShortcut(expiresValue);
if (expiresIso) body.expires_at = expiresIso;
postAck(sig, body, err);
}
function postAck(sig, body, errEl) {
var path = "/api/findings/" + encodeURIComponent(sig) + "/ack";
fetchWithAuth(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
}).then(function (r) {
if (r.status === 201) {
closeAckModal();
showUndoAckToast(sig);
return refreshLiveState();
}
return handleAuthRetry(r, function () { postAck(sig, body, errEl); }, errEl, "create");
}).catch(function () {
if (errEl) { errEl.textContent = "Network error reaching the daemon."; errEl.style.display = "block"; }
});
}
function revokeAck(sig) {
if (!sig) return;
if (!globalThis.confirm || !globalThis.confirm("Revoke acknowledgment for " + sig + "?")) return;
var path = "/api/findings/" + encodeURIComponent(sig) + "/ack";
fetchWithAuth(path, { method: "DELETE" }).then(function (r) {
if (r.status === 204) {
showToast("success", "Acknowledgment revoked.");
return refreshLiveState();
}
return handleAuthRetry(r, function () { revokeAck(sig); }, null, "revoke");
}).catch(function () {
showToast("error", "Network error reaching the daemon.");
});
}
function handleAuthRetry(response, retry, errEl, opLabel) {
if (response.status === 401) {
liveState.pendingAuthRetry = retry;
// Tell apart the first 401 (no key cached) from a retry-after-
// wrong-key 401 so the user gets feedback on the second attempt.
// Drop the bad key on the way so the next request goes out
// unauthenticated and the auth flow restarts cleanly.
var hadKey = liveState.apiKey !== null && liveState.apiKey !== "";
if (hadKey) {
clearApiKey();
syncLogoutButtonVisibility();
}
openAuthModal(hadKey ? "Invalid API key, please try again." : null);
return;
}
var msg = "HTTP " + response.status + " on " + opLabel;
if (errEl) { errEl.textContent = msg; errEl.style.display = "block"; }
else showToast("error", msg);
}
function openAuthModal(errorMessage) {
var dlg = document.getElementById("auth-modal");
var keyInput = document.getElementById("auth-modal-key");
var err = document.getElementById("auth-modal-error");
if (!dlg) return;
if (keyInput) keyInput.value = "";
if (err) {
if (errorMessage) {
err.textContent = errorMessage;
err.style.display = "block";
} else {
err.style.display = "none";
err.textContent = "";
}
}
if (typeof dlg.showModal === "function") dlg.showModal();
if (keyInput) {
// Defer focus until after the dialog has actually opened.
setTimeout(function () { keyInput.focus(); }, 0);
}
}
function closeAuthModal() {
var dlg = document.getElementById("auth-modal");
if (dlg && typeof dlg.close === "function") dlg.close();
}
function submitAuthModal(event) {
event.preventDefault();
var keyInput = document.getElementById("auth-modal-key");
var err = document.getElementById("auth-modal-error");
var k = ((keyInput || {}).value || "").trim();
if (!k) {
if (err) { err.textContent = "API key is required."; err.style.display = "block"; }
return;
}
setApiKey(k);
syncLogoutButtonVisibility();
closeAuthModal();
var retry = liveState.pendingAuthRetry;
liveState.pendingAuthRetry = null;
pingStatus();
if (typeof retry === "function") retry();
}
function refreshLiveState() {
return Promise.all([pingStatus(), fetchAcks()]).then(function () {
renderAcksPanel();
// Acks changed, so which findings pass applyFilters changed too:
// re-render (which re-wires the action buttons at its exit) rather
// than only relabelling the buttons on the stale rowset.
renderFindingsList();
syncAcksTabBadge();
});
}
function syncAcksTabBadge() {
// The tab badge is rendered once at boot via `renderTabs` from the
// `countFn` we registered for "acknowledgments". After an
// ack/revoke that changed `liveState.acks.length`, the badge is
// stale until the user switches tabs (which would re-trigger
// nothing since `renderTabs` is not re-run). Patch the DOM
// directly: the cheapest correct refresh path. Recreate the
// badge span when missing so a deferred-fetch boot path (badge
// not created because countFn returned null) still surfaces the
// count once acks load.
var btn = document.getElementById("tab-acknowledgments");
if (!btn) return;
var count = liveAcksCount();
if (count === null || count === undefined) return;
var badge = btn.querySelector(".ps-nav-badge");
if (badge) {
badge.textContent = String(count);
} else {
btn.appendChild(el("span", "ps-nav-badge", String(count)));
}
}
function wireRefreshButton() {
var btn = document.getElementById("ps-refresh-btn");
if (!btn) return;
btn.addEventListener("click", function () {
var ico = btn.querySelector(".ps-refresh-ico");
if (ico) {
btn.classList.add("ps-spinning");
ico.addEventListener("animationend", function () { btn.classList.remove("ps-spinning"); }, { once: true });
}
btn.disabled = true;
refreshLiveState().then(function () { btn.disabled = false; });
});
}
function syncLogoutButtonVisibility() {
var btn = document.getElementById("ps-logout-btn");
if (!btn) return;
if (liveState.apiKey) btn.hidden = false;
else btn.hidden = true;
}
function wireLogoutButton() {
var btn = document.getElementById("ps-logout-btn");
if (!btn) return;
btn.addEventListener("click", function () {
clearApiKey();
syncLogoutButtonVisibility();
showToast("success", "API key cleared from this tab.");
pingStatus();
});
}
function wireIncludeAckedToggle() {
var input = document.getElementById("findings-include-acked");
if (!input) return;
// Toggling "Show acknowledged" changes which findings pass applyFilters,
// so re-render from the first page rather than toggle row display.
input.addEventListener("change", function () {
resetShownCount();
renderFindingsList();
});
}
function wireAuthModal() {
var form = document.getElementById("auth-modal-form");
var cancel = document.getElementById("auth-modal-cancel");
var close = document.getElementById("auth-modal-close");
if (form) form.addEventListener("submit", submitAuthModal);
if (cancel) cancel.addEventListener("click", function () {
closeAuthModal();
liveState.pendingAuthRetry = null;
setDaemonStatus("unauthorized", "Authentication required");
});
if (close) close.addEventListener("click", function () {
closeAuthModal();
liveState.pendingAuthRetry = null;
});
}
function wireAckModal() {
var form = document.getElementById("ack-modal-form");
var cancel = document.getElementById("ack-modal-cancel");
var close = document.getElementById("ack-modal-close");
if (form) form.addEventListener("submit", submitAckModal);
if (cancel) cancel.addEventListener("click", closeAckModal);
if (close) close.addEventListener("click", closeAckModal);
}
function stripTrailingSlashes(s) {
// Linear scan from the end. The Rust CLI already trims trailing
// slashes via `validate_url` before embedding the URL in the
// payload, so this is defense-in-depth against a corrupted
// artifact, not the primary normalization path. Avoids the regex
// engine's backtracking surface that SonarCloud flags as ReDoS-
// adjacent on `/\/+$/`. `codePointAt` is the Unicode-correct
// counterpart of `charCodeAt` (47 is U+002F SOLIDUS in both).
var end = s.length;
while (end > 0 && s.codePointAt(end - 1) === 47) end--;
return s.substring(0, end);
}
function bootLiveMode() {
if (!payload.daemon || !payload.daemon.url) return;
liveState.enabled = true;
liveState.daemonUrl = stripTrailingSlashes(String(payload.daemon.url));
var stored = sessionGet(DAEMON_API_KEY_STORAGE);
if (stored) liveState.apiKey = stored;
document.body.classList.add("ps-live");
wireRefreshButton();
wireLogoutButton();
wireIncludeAckedToggle();
wireAuthModal();
wireAckModal();
syncLogoutButtonVisibility();
setDaemonStatus("disconnected", "Connecting...");
refreshLiveState();
}
// -------- boot --------
renderTopbar();
registerAllTabs();
renderAllPanels();
initSearchInputs();
populateCheatsheet();
wireCheatsheetDialog();
wireTopicHelpDialog();
wireShowMoreButton();
wireExportButtons();
wireCopyLinkButtons();
applyInitialHashOrWrite();
refreshTableSort();
wireHashChangeListener();
bootLiveMode();
// Show the platform's real search shortcut (the key handler accepts both).
(function adaptShortcutHint() {
var isMac = /Mac|iPhone|iPad/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || "");
if (!isMac) {
var kbd = document.querySelector("#topbar-search-box .ps-search-kbd");
if (kbd) kbd.textContent = "Ctrl+K";
}
})();
})();
</script>
</body>
</html>