Files
cx-ui/triagelib/ui_v2.py
Parham Monfared a039e0b5fd CX Triage: alert diagnosis over the CX-Tools collectors
Read-only triage for the Infrahub error alerts. Pulls the Prometheus alert
queue, re-checks each alert's condition against live state to separate real
work from noise, diagnoses it using the CX runbooks, and drafts the customer
comms with contacts resolved from Infrahub.

Findings from validating against production:
- "Suspected Rogue VM" fires on spare GPU capacity, not rogue VMs: In_Use_Gpus
  equals the physical count on 71 of 75 firing hosts, so the rule reduces to
  "this host has a free GPU". Verified against OpenStack on 10 hosts.
- "Exists in Infrahub but does not exist in OpenStack" matches every VM because
  openstack_nova_server_status returns no series; excluded as a rule defect.
- Prometheus activeAt is reset several times a day by dips in the Resources
  metric, so alert ages are recovered from ALERTS history instead.

Takes ~2,650 firing alerts down to ~20 that need a decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 06:48:34 +01:00

573 lines
33 KiB
Python

"""Action-oriented UI.
Design intent: the reader already knows the runbooks. Each case shows the state
of the world as a picture, one line of verdict, and the buttons that actually
move it forward. Everything else is collapsed.
"""
from __future__ import annotations
PAGE = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CX Triage</title>
<style>
:root {
--bg:#0d1017; --panel:#141821; --panel2:#1b202b; --line:#262d3a; --line2:#333b4a;
--fg:#e8ebf2; --dim:#8a93a5; --faint:#5d6675; --accent:#4c8dff;
--ok:#35c46a; --warn:#e0a336; --bad:#f2545b; --info:#59a0f5; --violet:#a97bf0;
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
--r:10px;
}
@media (prefers-color-scheme:light){:root{
--bg:#f4f6f9;--panel:#fff;--panel2:#f1f4f8;--line:#e0e5ec;--line2:#cfd6e0;
--fg:#151a22;--dim:#5b6473;--faint:#8e97a5;--accent:#1f6feb;
--ok:#12864a;--warn:#96650a;--bad:#cf2530;--info:#0969da;--violet:#7a44d6;}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
button{font:inherit;font-size:13px;padding:7px 13px;border-radius:8px;border:1px solid var(--line2);
background:var(--panel2);color:var(--fg);cursor:pointer;transition:.12s}
button:hover:not(:disabled){border-color:var(--accent)}
button:disabled{opacity:.45;cursor:not-allowed}
button.pri{background:var(--accent);border-color:var(--accent);color:#fff;font-weight:600}
button.pri:hover:not(:disabled){filter:brightness(1.1)}
button.danger{border-color:color-mix(in srgb,var(--bad) 50%,transparent);color:var(--bad)}
button.sm{padding:4px 9px;font-size:12px}
header{display:flex;align-items:center;gap:14px;padding:9px 18px;background:var(--panel);
border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20;flex-wrap:wrap}
.brand{font-weight:680;font-size:14px;letter-spacing:.2px}
.brand em{font-style:normal;color:var(--faint);font-weight:400;font-size:12.5px}
.navlink{font-size:12.5px;color:var(--accent);text-decoration:none;border:1px solid var(--line2);padding:4px 10px;border-radius:7px}
.navlink:hover{border-color:var(--accent)}
.chips{display:flex;gap:6px;flex-wrap:wrap;margin-left:6px}
.chip{font-size:11.5px;padding:3px 10px;border-radius:99px;border:1px solid var(--line2);
background:var(--panel2);color:var(--dim);cursor:pointer;white-space:nowrap;user-select:none}
.chip.on{background:var(--accent);border-color:var(--accent);color:#fff}
.chip b{font-weight:700}
.chip.overdue:not(.on){color:var(--bad);border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
.spacer{margin-left:auto}
.dotstat{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:5px}
.filters{background:var(--panel);border-bottom:1px solid var(--line);padding:7px 18px;
display:flex;flex-direction:column;gap:5px;position:sticky;top:51px;z-index:19}
.frow{display:flex;align-items:center;gap:9px}
.flab{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);width:44px;flex:none}
main{display:grid;grid-template-columns:320px 1fr;height:calc(100vh - 118px)}
@media(max-width:940px){main{grid-template-columns:1fr}}
#rail{border-right:1px solid var(--line);background:var(--panel);overflow-y:auto}
.case .bar.chronic,.case .bar.low_impact{background:var(--faint)}
.case .bar.rule_defect{background:var(--violet)}
.case .bar.pending{background:var(--info);opacity:.5}
.case .bar.resolved{background:var(--ok)}
.statepill{font-size:9.5px;text-transform:uppercase;letter-spacing:.4px;padding:0 5px;
border-radius:3px;border:1px solid var(--line2);color:var(--faint);margin-left:6px}
.statepill.pending{color:var(--info);border-color:color-mix(in srgb,var(--info) 45%,transparent)}
#stage{overflow-y:auto;padding:22px 26px 60px}
.grp{border-bottom:1px solid var(--line)}
.grp-h{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;
background:var(--panel2);user-select:none;font-size:12px;letter-spacing:.3px;text-transform:uppercase;color:var(--dim)}
.grp-h:hover{color:var(--fg)}
.car{font-size:9px;width:9px;transition:.12s}
.grp.shut .car{transform:rotate(-90deg)}
.grp.shut .grp-b{display:none}
.grp-h .n{margin-left:auto;font-size:11px;padding:1px 7px;border-radius:99px;background:var(--bg);color:var(--dim);text-transform:none}
.grp-h .n.hot{background:var(--bad);color:#fff}
.case{padding:9px 14px;border-top:1px solid var(--line);cursor:pointer;display:flex;gap:9px;align-items:flex-start}
.case:hover{background:var(--panel2)}
.case.on{background:color-mix(in srgb,var(--accent) 15%,transparent);box-shadow:inset 3px 0 var(--accent)}
.case.off{opacity:.5}
.case .bar{width:3px;align-self:stretch;border-radius:2px;background:var(--faint);flex:none}
.case .bar.overdue{background:var(--bad)} .case .bar.real{background:var(--warn)}
.case .bar.unverified{background:var(--info)}
.case .mid{min-width:0;flex:1}
.case .nm{font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.case .sub{color:var(--dim);font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.case .ag{font-family:var(--mono);font-size:11px;color:var(--faint);flex:none}
.hero{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:20px 22px;margin-bottom:14px}
.crumb{font-size:11.5px;color:var(--faint);letter-spacing:.3px;text-transform:uppercase;margin-bottom:9px;
display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.vd{font-size:20px;font-weight:660;line-height:1.3;letter-spacing:-.2px}
.vsub{color:var(--dim);margin-top:7px;font-size:13.5px;max-width:80ch}
.badge{font-size:11px;padding:2px 9px;border-radius:99px;border:1px solid var(--line2);color:var(--dim);
text-transform:none;letter-spacing:0}
.badge.overdue{background:var(--bad);border-color:var(--bad);color:#fff;font-weight:600}
.badge.real{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 50%,transparent)}
.badge.rule_defect{color:var(--violet);border-color:color-mix(in srgb,var(--violet) 50%,transparent)}
.badge.resolved{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent)}
/* ---- visuals ---- */
.viz{margin:18px 0 4px}
.states{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.sbox{flex:1;min-width:150px;background:var(--panel2);border:1px solid var(--line);border-radius:9px;padding:11px 14px}
.sbox .lbl{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint)}
.sbox .val{font-family:var(--mono);font-size:16px;font-weight:600;margin-top:3px}
.sbox.bad{border-color:color-mix(in srgb,var(--bad) 45%,transparent)}
.sbox.bad .val{color:var(--bad)}
.sbox.ok .val{color:var(--ok)}
.link{font-size:20px;color:var(--faint);flex:none}
.link.bad{color:var(--bad)}
.slots{display:flex;gap:4px;margin-top:8px;flex-wrap:wrap}
.slot{flex:1 1 78px;min-width:70px;height:52px;border-radius:7px;border:1px solid var(--line2);
display:flex;flex-direction:column;justify-content:center;padding:5px 7px;overflow:hidden}
.slot .sn{font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.slot .ss{font-size:9px;text-transform:uppercase;letter-spacing:.4px;opacity:.75;margin-top:1px}
.slot.vm{background:color-mix(in srgb,var(--ok) 22%,transparent);
border-color:color-mix(in srgb,var(--ok) 55%,transparent)}
.slot.vm.bad{background:color-mix(in srgb,var(--bad) 20%,transparent);
border-color:color-mix(in srgb,var(--bad) 55%,transparent)}
.slot.vm.unlinked{background:color-mix(in srgb,var(--bad) 28%,transparent);
border-color:var(--bad)}
.slot.unaccounted{background:color-mix(in srgb,var(--bad) 16%,transparent);
border-color:color-mix(in srgb,var(--bad) 50%,transparent);border-style:dashed;
align-items:center;justify-content:center;color:var(--bad)}
.slot.free{border-style:dashed;border-color:var(--line2);color:var(--faint);
align-items:center;justify-content:center;background:transparent}
.slotnum{font-size:9px;color:var(--faint);margin-bottom:1px}
.gpubar{display:flex;height:34px;border-radius:8px;overflow:hidden;border:1px solid var(--line2);margin-top:6px}
.gseg{display:flex;align-items:center;justify-content:center;font-size:11.5px;font-weight:600;
font-family:var(--mono);color:#fff;min-width:0;overflow:hidden;white-space:nowrap;padding:0 4px}
.gseg.alloc{background:color-mix(in srgb,var(--ok) 78%,#000)}
.gseg.gap{background:color-mix(in srgb,var(--warn) 72%,#000)}
.gseg.gapbad{background:color-mix(in srgb,var(--bad) 72%,#000)}
.glegend{display:flex;gap:16px;margin-top:8px;font-size:12px;color:var(--dim);flex-wrap:wrap}
.glegend i{width:9px;height:9px;border-radius:2px;display:inline-block;margin-right:5px}
.roster{margin-top:8px;border:1px solid var(--line);border-radius:9px;overflow:hidden}
.rrow{display:grid;grid-template-columns:1fr 118px 26px 118px 52px;gap:8px;align-items:center;
padding:7px 12px;border-top:1px solid var(--line);font-size:12.5px}
.rrow:first-child{border-top:none;background:var(--panel2);font-size:10.5px;text-transform:uppercase;
letter-spacing:.5px;color:var(--faint)}
.rrow .rn{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600}
.rrow .rs{font-family:var(--mono);font-size:12px}
.rrow .eq{text-align:center;font-size:14px;color:var(--ok)}
.rrow.bad .eq{color:var(--bad)} .rrow.bad .rs{color:var(--bad)}
.rrow.unlinked{background:color-mix(in srgb,var(--bad) 9%,transparent)}
.rrow.unlinked .rs.ih{color:var(--bad);font-style:italic}
.rrow .rg{text-align:right;color:var(--faint);font-family:var(--mono);font-size:11.5px}
.claims{display:flex;flex-direction:column;gap:7px;margin-top:6px}
.claim{display:flex;gap:10px;align-items:center;background:var(--panel2);border:1px solid var(--line);
border-radius:8px;padding:8px 12px;font-size:13px}
.claim .cn{font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ---- actions ---- */
.acts{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:16px 18px;margin-bottom:14px}
.acts h3{margin:0 0 4px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim)}
.actrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}
.who{display:flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--line);
border-radius:8px;padding:8px 12px;margin-top:11px;font-size:13px;flex-wrap:wrap}
.who .em{font-family:var(--mono);font-size:12.5px}
.hintline{font-size:12px;color:var(--faint);margin-top:9px}
.cmd{display:flex;gap:8px;align-items:center;background:var(--bg);border:1px solid var(--line);
border-radius:7px;padding:7px 10px;margin-top:8px;font-family:var(--mono);font-size:12.5px}
.cmd code{flex:1;min-width:0;overflow-x:auto;white-space:nowrap}
details.fold{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);margin-bottom:10px}
details.fold>summary{cursor:pointer;padding:11px 18px;font-size:12px;text-transform:uppercase;
letter-spacing:.5px;color:var(--dim);user-select:none;list-style:none;display:flex;align-items:center;gap:8px}
details.fold>summary::-webkit-details-marker{display:none}
details.fold>summary::before{content:"";font-size:10px;color:var(--faint)}
details.fold[open]>summary::before{content:""}
details.fold>summary:hover{color:var(--fg)}
.foldb{padding:2px 18px 16px}
table{width:100%;border-collapse:collapse}
td{padding:5px 6px;border-bottom:1px solid var(--line);vertical-align:top;font-size:12.5px}
tr:last-child td{border-bottom:none}
td.k{color:var(--dim);width:180px;white-space:nowrap}
td.v{font-family:var(--mono);word-break:break-word}
.t-ok{color:var(--ok)}.t-warn{color:var(--warn)}.t-bad{color:var(--bad)}
.det{display:block;color:var(--faint);font-family:inherit;font-size:11.5px;margin-top:2px}
ol.steps{margin:0;padding-left:18px;font-size:13px}
ol.steps li{margin-bottom:7px}
ol.steps .ow{font-size:10.5px;padding:1px 6px;border-radius:4px;background:var(--panel2);color:var(--dim);margin-left:5px}
ol.steps .ow.i{color:var(--warn)}
pre{background:var(--bg);border:1px solid var(--line);padding:10px;border-radius:7px;overflow-x:auto;
font-size:11.5px;margin:0}
/* ---- drawer ---- */
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.55);opacity:0;pointer-events:none;transition:.16s;z-index:40}
#scrim.on{opacity:1;pointer-events:auto}
#drawer{position:fixed;top:0;right:0;height:100%;width:min(620px,94vw);background:var(--panel);
border-left:1px solid var(--line);z-index:50;transform:translateX(100%);transition:.18s;
display:flex;flex-direction:column}
#drawer.on{transform:none}
.dh{padding:15px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:10px}
.dh h2{margin:0;font-size:15px;font-weight:650;flex:1}
.db{padding:18px 20px;overflow-y:auto;flex:1}
.df{padding:14px 20px;border-top:1px solid var(--line);display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.fld{margin-bottom:14px}
.fld label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);margin-bottom:5px}
.fld input,.fld textarea,.fld select{width:100%;font:inherit;font-size:13px;padding:8px 10px;
border-radius:7px;border:1px solid var(--line2);background:var(--bg);color:var(--fg)}
.fld textarea{min-height:230px;resize:vertical;line-height:1.55}
.warnbox{border:1px solid color-mix(in srgb,var(--warn) 50%,transparent);
background:color-mix(in srgb,var(--warn) 11%,transparent);border-radius:8px;padding:10px 12px;
font-size:12.5px;margin-bottom:14px}
.empty{color:var(--faint);text-align:center;padding:70px 20px}
.spin{width:15px;height:15px;border:2px solid var(--line2);border-top-color:var(--accent);
border-radius:50%;display:inline-block;animation:sp .7s linear infinite;vertical-align:-3px}
@keyframes sp{to{transform:rotate(360deg)}}
.banner{margin:0 0 12px;padding:10px 13px;border-radius:8px;font-size:12.5px;
border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);
background:color-mix(in srgb,var(--warn) 9%,transparent)}
</style>
</head>
<body>
<header>
<div class="brand">CX Triage <em id="qsum"></em></div>
<a class="navlink" href="/linkage">Linkage scan</a>
<a class="navlink" href="/settings">Settings</a>
<div class="spacer"></div>
<span class="badge" id="conn"></span>
<button id="refresh" class="sm">Refresh</button>
</header>
<div class="filters">
<div class="frow"><span class="flab">Status</span><div class="chips" id="chips"></div></div>
<div class="frow"><span class="flab">Type</span><div class="chips" id="kinds"></div></div>
</div>
<main>
<aside id="rail"><div class="empty"><span class="spin"></span></div></aside>
<section id="stage"><div class="empty">Select a case.</div></section>
</main>
<div id="scrim"></div>
<aside id="drawer">
<div class="dh"><h2 id="dTitle">Action</h2><button class="sm" onclick="closeDrawer()">Close</button></div>
<div class="db" id="dBody"></div>
<div class="df" id="dFoot"></div>
</aside>
<script>
const $=s=>document.querySelector(s);
const esc=s=>String(s==null?"":s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const api=(p,o)=>fetch(p,o).then(r=>r.json());
let S={data:null,sel:null,poll:null,gen:0,shut:{},filter:"actionable",kind:"all",diag:null};
// Prometheus states we never surface: an alert that has not committed yet
// is not work, and showing it invites acting on something that may clear.
const HIDDEN_STATES=["pending"];
const VERDICT_HELP={
overdue:"The condition still holds and has passed the point where the runbook says to contact the customer.",
real:"The condition still holds right now - re-checked against live Infrahub/OpenStack state.",
unverified:"Could not be re-checked, so it is kept in the queue rather than hidden on a guess.",
chronic:"Still true, but it has been true for days - already known rather than new work.",
low_impact:"Still true, but the owner is internal or the VM is platform-owned.",
pending:"Prometheus has not committed to this alert yet; it may still clear on its own.",
resolved:"The condition no longer holds - Infrahub/OpenStack have moved on since it fired.",
rule_defect:"The alert rule itself is wrong, so the alert is not evidence of a problem. Opening the case explains which part of the expression misfires.",
suppressed:"Hidden by a rule you configured in Settings.",
};
const VERDICT_ORDER=["overdue","real","unverified","chronic","low_impact","pending","resolved","rule_defect"];
async function load(force){
const d=await api("/api/alerts"+(force?"?force=1":""));
S.data=d; drawChips(); drawRail();
const ic=d.integrations||{};
$("#conn").innerHTML=`Zendesk ${ic.zendesk?"&#9679; connected":"&#9675; not connected"} &nbsp;|&nbsp; Jira ${ic.jira?"&#9679; connected":"&#9675; not connected"}`;
}
function allCases(){return (S.data?.groups||[]).flatMap(g=>g.alerts);}
function drawChips(){
const all=allCases();
const counts={};
all.forEach(a=>{const v=a.screen?.verdict||"unverified";counts[v]=(counts[v]||0)+1;});
const act=all.filter(a=>a.screen?.actionable&&!HIDDEN_STATES.includes(a.state)).length;
const chips=[`<span class="chip ${S.filter==="actionable"?"on":""}" data-f="actionable">To action <b>${act}</b></span>`];
VERDICT_ORDER.forEach(v=>{ if(!counts[v]) return;
chips.push(`<span class="chip ${v} ${S.filter===v?"on":""}" data-f="${v}">${esc(S.data.summary.labels[v]||v)} <b>${counts[v]}</b></span>`);});
chips.push(`<span class="chip ${S.filter==="all"?"on":""}" data-f="all" title="Everything except alerts Prometheus has not committed to yet">All firing</span>`);
$("#chips").innerHTML=chips.join("");
document.querySelectorAll("#chips .chip").forEach(c=>c.onclick=()=>{S.filter=c.dataset.f;drawChips();drawRail();});
const byKind={};
all.forEach(a=>{byKind[a.kind]=(byKind[a.kind]||0)+1;});
const kinds=[`<span class="chip ${S.kind==="all"?"on":""}" data-k="all">All types</span>`];
(S.data.groups||[]).forEach(g=>{
kinds.push(`<span class="chip ${S.kind===g.kind?"on":""}" data-k="${esc(g.kind)}">${esc(g.title.replace(/^Instance in /,"").replace(/ state$/,""))} <b>${byKind[g.kind]||0}</b></span>`);});
$("#kinds").innerHTML=kinds.join("");
document.querySelectorAll("#kinds .chip").forEach(c=>c.onclick=()=>{S.kind=c.dataset.k;drawChips();drawRail();});
const t=S.data.totals||{};
$("#qsum").textContent=`${act} to action \u00b7 ${t.cx||0} CX alerts \u00b7 ${t.prometheus||0} firing in Prometheus`;
}
function visible(a){
if(S.kind!=="all" && a.kind!==S.kind) return false;
const st=a.state||"firing";
// Pending alerts are only reachable by asking for them by name.
if(HIDDEN_STATES.includes(st) && S.filter!==st) return false;
if(S.filter==="all") return true;
if(S.filter==="actionable") return !!a.screen?.actionable;
return a.screen?.verdict===S.filter;
}
function drawRail(){
const out=[];
for(const g of S.data?.groups||[]){
const rows=(g.alerts||[]).filter(visible);
if(!rows.length) continue;
const hot=rows.filter(a=>a.screen?.actionable).length;
const shut=S.shut[g.kind]===true;
out.push(`<div class="grp ${shut?"shut":""}" data-k="${esc(g.kind)}">
<div class="grp-h"><span class="car">&#9660;</span>${esc(g.title)}
<span class="n ${hot?"hot":""}" title="${rows.length} shown of ${g.total} firing">${
rows.length}${rows.length<g.total?` <span style="opacity:.6">of ${g.total}</span>`:""}</span></div>
<div class="grp-b">${rows.map(row).join("")}</div></div>`);
}
$("#rail").innerHTML=out.length?out.join(""):'<div class="empty">Nothing here.</div>';
document.querySelectorAll(".grp-h").forEach(h=>h.onclick=()=>{
const k=h.parentElement.dataset.k;S.shut[k]=!S.shut[k];drawRail();});
document.querySelectorAll(".case").forEach(c=>c.onclick=()=>open(c.dataset.id));
}
function subjectOf(a){
if(a.kind==="duplicate_ip") return a.floating_ip;
if(["rogue_vm","total_gpus","orphan_vm"].includes(a.kind)) return a.host;
return a.instance_name||a.openstack_id||"unknown";
}
function row(a){
const v=a.screen?.verdict||"unverified";
return `<div class="case ${S.sel===a.id?"on":""} ${a.screen?.actionable?"":"off"}" data-id="${esc(a.id)}">
<span class="bar ${esc(v)}" title="${esc(S.data.summary.labels[v]||v)}"></span>
<span class="mid"><span class="nm">${esc(subjectOf(a))}${a.state==="pending"?'<span class="statepill pending">pending</span>':""}</span>
<span class="sub">${esc([a.org_name,a.region_label||a.region].filter(Boolean).join(" · "))}</span></span>
<span class="ag">${esc(a.effective_age_text||"")}</span></div>`;
}
async function open(id, force){
// Every request gets a generation number. Clicking a second case while the
// first is still polling used to leave the first timer running, and its
// result would later overwrite the stage - collapsing whatever the reader
// had expanded, and sometimes showing the wrong case. Stale generations now
// stop themselves.
const gen = ++S.gen;
S.sel=id; drawRail();
if(S.poll){clearInterval(S.poll);S.poll=null;}
$("#stage").innerHTML='<div class="empty"><span class="spin"></span><br><br>Checking Infrahub, OpenStack and InfraInsight…</div>';
const st=await api("/api/triage",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({alert_id:id,force:!!force})});
if(gen!==S.gen) return;
if(st.error){$("#stage").innerHTML=`<div class="empty t-bad">${esc(st.error)}</div>`;return;}
const timer=setInterval(async()=>{
if(gen!==S.gen){clearInterval(timer);return;}
const j=await api("/api/jobs/"+st.job_id);
if(gen!==S.gen){clearInterval(timer);return;}
if(j.state==="running") return;
clearInterval(timer); if(S.poll===timer) S.poll=null;
if(j.state==="error"){$("#stage").innerHTML=`<pre>${esc(j.error)}</pre>`;return;}
S.diag=j.result; drawCase(j.result);
},900);
S.poll=timer;
}
function rosterHTML(v){
const rows=v.roster||[];
if(!rows.length) return "";
const unlinked=rows.filter(r=>!r.linked).length, mismatched=rows.filter(r=>r.linked&&!r.match).length;
return `<div class="roster">
<div class="rrow"><span>VM on this host</span><span>Infrahub</span><span></span><span>OpenStack</span><span class="rg">GPU</span></div>
${rows.map(r=>`<div class="rrow ${r.linked?(r.match?"":"bad"):"unlinked"}">
<span class="rn">${esc(r.name)}${r.tempest?' <span class="badge">tempest</span>':""}</span>
<span class="rs ih">${esc(r.ih_status)}</span>
<span class="eq">${r.linked?(r.match?"=":"\u2260"):"\u2717"}</span>
<span class="rs">${esc(r.os_status)}</span>
<span class="rg">${esc(r.gpus)}</span></div>`).join("")}
</div>
<div class="glegend"><span>${rows.length} VM(s) on host</span>
<span class="${unlinked?"t-bad":""}">${unlinked} with no Infrahub record</span>
<span class="${mismatched?"t-bad":""}">${mismatched} state mismatch(es)</span></div>`;
}
function vizHTML(d){
const v=d.visual||{};
if(v.type==="states"){
const bad=!v.match;
return `<div class="viz"><div class="states">
<div class="sbox ${bad?"bad":"ok"}"><div class="lbl">Infrahub says</div><div class="val">${esc(v.infrahub)}</div></div>
<div class="link ${bad?"bad":""}">${bad?"&#8800;":"&#61;"}</div>
<div class="sbox ${bad?"bad":"ok"}"><div class="lbl">OpenStack says</div><div class="val">${esc(v.openstack)}</div></div>
</div>
<div class="glegend">
${v.task&&v.task!=="None"?`<span>task state <b>${esc(v.task)}</b></span>`:""}
<span>host <b>${esc(v.never_built?"never placed":v.host)}</b></span>
${v.flavor?`<span>flavor <b>${esc(v.flavor)}</b></span>`:""}
${v.fault&&v.fault!=="None"?`<span class="t-bad">fault present</span>`:""}
</div></div>`;
}
if(v.type==="gpu"){
const slots=v.slots||[];
const named=slots.filter(x=>x.kind==="vm").length;
const un=slots.filter(x=>x.kind==="unaccounted").length;
const free=slots.filter(x=>x.kind==="free").length;
return `<div class="viz">
<div class="slots">${slots.map((x,i)=>{
if(x.kind==="vm") return `<div class="slot vm ${x.linked?(x.match?"":"bad"):"unlinked"}" title="${esc(x.name)} — Infrahub ${esc(x.ih_status)} / OpenStack ${esc(x.os_status)}">
<span class="slotnum">GPU ${i+1}</span>
<span class="sn">${esc(x.name)}</span>
<span class="ss">${x.linked?(x.match?"in sync":"state mismatch"):"not in Infrahub"}</span></div>`;
if(x.kind==="unaccounted") return `<div class="slot unaccounted" title="The host reports this GPU in use, but no instance claims it">
<span class="sn">unaccounted</span></div>`;
return `<div class="slot free" title="Physically present, nothing using it"><span class="sn">free</span></div>`;
}).join("")}</div>
<div class="glegend">
<span>${v.physical!=null?v.physical+" GPU sockets on this host":"GPU count unknown"}</span>
<span><i style="background:color-mix(in srgb,var(--ok) 60%,transparent)"></i>${named} held by ${v.instances} VM(s)</span>
${un?`<span class="t-bad"><i style="background:color-mix(in srgb,var(--bad) 55%,transparent)"></i>${un} in use but unclaimed</span>`:""}
${free?`<span><i style="border:1px dashed var(--line2)"></i>${free} free</span>`:""}
${v.in_use_metric!=null?`<span class="sub">host reports ${v.in_use_metric} in use</span>`:""}
</div>
${rosterHTML(v)}</div>`;
}
if(v.roster && v.type!=="gpu"){ return `<div class="viz">${rosterHTML(v)}</div>`; }
if(v.type==="claimants"){
return `<div class="viz"><div class="claims">${(v.items||[]).map(c=>`
<div class="claim"><span class="cn">${esc(c.name)}</span>
<span class="badge">${esc(c.ih_status)} / ${esc(c.os_status)}</span>
<span style="color:var(--dim);font-size:12px">${esc(c.verdict||"")}</span></div>`).join("")}</div></div>`;
}
return "";
}
function drawCase(d){
const a=d.alert||{}, sc=a.screen||{}, ig=d.integrations||{};
const acts=(ig.actions||[]);
const zd=acts.find(x=>x.kind==="zendesk"), jr=acts.find(x=>x.kind==="jira");
const manual=acts.filter(x=>x.kind==="manual");
const out=[];
out.push(`<div class="hero">
<div class="crumb">
<span class="badge ${esc(sc.verdict||"")}" title="${esc(VERDICT_HELP[sc.verdict]||"")}">${esc(sc.label||"")}</span>
<span>${esc(a.title)}</span><span>&middot;</span><span>${esc(a.region_label||a.region||"")}</span>
<span>&middot;</span><span>held ${esc(a.effective_age_text||"?")}</span>
<span>&middot;</span><span class="${a.state==="pending"?"t-warn":""}">${esc(a.state||"firing")} in prometheus</span>
${a.age_is_reset?`<span title="Prometheus activeAt was reset by a metric-pipeline dip">&middot; prometheus says ${esc(a.age_text)}</span>`:""}
</div>
<div class="vd">${esc(d.verdict||d.error||"No verdict")}</div>
${d.assessment?`<div class="vsub">${esc(d.assessment)}</div>`:""}
${vizHTML(d)}
</div>`);
// The point of the screen: what to do now.
const hasAny = zd||jr||manual.length;
out.push(`<div class="acts">
<h3>Do this</h3>
${sc.actionable?"":`<div class="hintline">This case is screened out (${esc(sc.label)}). ${esc(sc.reason||"")}</div>`}
${zd?`<div class="who">
<span style="color:var(--dim)">Customer</span>
<span class="em">${esc((zd.recipients||[])[0]||"unresolved")}</span>
${a.org_name?`<span class="badge">${esc(a.org_name)}</span>`:""}
</div>`:""}
<div class="actrow">
${zd?`<button class="pri" onclick="openZendesk()">Contact customer via Zendesk</button>`:""}
${jr?`<button onclick="openJira()">Escalate to Infrastructure (Jira)</button>`:""}
${!sc.actionable?`<button class="sm" onclick="open('${esc(a.id)}',true)">Re-run full diagnosis</button>`:""}
${!hasAny?`<span class="hintline">No outbound action for this case — the runbook keeps it internal.</span>`:""}
</div>
${manual.map(m=>`<div class="cmd"><code>${esc(m.payload.command||m.label)}</code>
${m.payload.command?`<button class="sm" onclick="cp(this,'${esc(m.payload.command)}')">Copy</button>`:""}</div>`).join("")}
${manual.length?`<div class="hintline">Run these yourself — CX Triage is read-only and never mutates the platform.</div>`:""}
</div>`);
if((d.notes||[]).length)
out.push(`<details class="fold"><summary>Caveats (${d.notes.length})</summary><div class="foldb">
<ul style="margin:0;padding-left:18px;font-size:13px;color:var(--warn)">
${d.notes.map(n=>`<li>${esc(n)}</li>`).join("")}</ul></div></details>`);
out.push(`<details class="fold"><summary>Why — what the platforms say</summary><div class="foldb"><table>
${(d.findings||[]).map(f=>`<tr><td class="k">${esc(f.label)}</td><td class="v t-${esc(f.tone)}">${esc(f.value)}
${f.detail?`<span class="det">${esc(f.detail)}</span>`:""}</td></tr>`).join("")}
</table></div></details>`);
out.push(`<details class="fold"><summary>Runbook steps (${(d.actions||[]).length})</summary><div class="foldb">
<ol class="steps">${(d.actions||[]).map(x=>`<li>${esc(x.text)}
<span class="ow ${x.owner!=="CX"?"i":""}">${esc(x.owner)}</span>
${x.guide?`<span class="det">Guide: ${esc(x.guide)}</span>`:""}</li>`).join("")}</ol></div></details>`);
out.push(`<details class="fold"><summary>Raw evidence</summary><div class="foldb">
<pre>${esc(JSON.stringify({screen:a.screen,labels:a.labels,evidence:d.evidence},null,2))}</pre></div></details>`);
$("#stage").innerHTML=out.join("");
}
function cp(btn,text){navigator.clipboard.writeText(text).then(()=>{const o=btn.textContent;btn.textContent="Copied";setTimeout(()=>btn.textContent=o,1100);});}
/* ---------- drawers ---------- */
function showDrawer(){$("#scrim").classList.add("on");$("#drawer").classList.add("on");}
function closeDrawer(){$("#scrim").classList.remove("on");$("#drawer").classList.remove("on");}
$("#scrim").onclick=closeDrawer;
function openZendesk(){
const ig=S.diag.integrations||{}, z=(ig.actions||[]).find(x=>x.kind==="zendesk");
if(!z) return;
const t=z.payload.ticket||{};
$("#dTitle").textContent="Contact customer via Zendesk";
$("#dBody").innerHTML=`
${z.enabled?"":`<div class="warnbox"><b>Preview only.</b> ${esc(z.blocked_reason)} Nothing will be sent.</div>`}
${z.payload._when?`<div class="warnbox">When to send: ${esc(z.payload._when)}</div>`:""}
<div class="fld"><label>To</label><input id="zTo" value="${esc((t.requester||{}).email||"")}"></div>
<div class="fld"><label>Subject</label><input id="zSub" value="${esc(t.subject||"")}"></div>
<div class="fld"><label>Message (approved runbook wording — edit if needed)</label>
<textarea id="zBody">${esc((t.comment||{}).body||"")}</textarea></div>
<div class="fld"><label>Priority</label><select id="zPri">
${["low","normal","high","urgent"].map(p=>`<option ${p===t.priority?"selected":""}>${p}</option>`).join("")}
</select></div>
<div class="fld"><label>Tags</label><input id="zTags" value="${esc((t.tags||[]).join(", "))}"></div>`;
$("#dFoot").innerHTML=`
<button class="pri" id="zSend" ${z.enabled?"":"disabled"}>Send to customer</button>
<button onclick="closeDrawer()">Cancel</button>
<span class="hintline" style="margin:0">${z.enabled?"You will be asked to confirm.":"Configure Zendesk to enable sending."}</span>`;
if(z.enabled) $("#zSend").onclick=confirmSend;
showDrawer();
}
function confirmSend(){
const to=$("#zTo").value;
$("#dFoot").innerHTML=`<span style="font-size:13px">Send a public reply to <b>${esc(to)}</b>?</span>
<button class="pri" id="zYes">Yes, send</button><button onclick="openZendesk()">Back</button>`;
$("#zYes").onclick=async()=>{
$("#zYes").disabled=true;$("#zYes").textContent="Sending…";
const r=await api("/api/actions/zendesk",{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({alert_id:S.sel,to,subject:$("#zSub").value,body:$("#zBody").value,
priority:$("#zPri").value,tags:$("#zTags").value.split(",").map(s=>s.trim()).filter(Boolean)})});
$("#dFoot").innerHTML=r.ok
? `<span class="t-ok">Sent — ticket #${esc(r.ticket_id)}</span><button onclick="closeDrawer()">Close</button>`
: `<span class="t-bad">${esc(r.error||"Send failed")}</span><button onclick="openZendesk()">Back</button>`;
};
}
function openJira(){
const ig=S.diag.integrations||{}, j=(ig.actions||[]).find(x=>x.kind==="jira");
if(!j) return;
const f=j.payload.fields||{};
$("#dTitle").textContent="Escalate to Infrastructure";
$("#dBody").innerHTML=`
${j.enabled?"":`<div class="warnbox"><b>Preview only.</b> ${esc(j.blocked_reason)}</div>`}
<div class="fld"><label>Project</label><input id="jProj" value="${esc((f.project||{}).key||"")}"></div>
<div class="fld"><label>Summary</label><input id="jSum" value="${esc(f.summary||"")}"></div>
<div class="fld"><label>Description</label><textarea id="jDesc">${esc(f.description||"")}</textarea></div>
<div class="fld"><label>Labels</label><input id="jLab" value="${esc((f.labels||[]).join(", "))}"></div>`;
$("#dFoot").innerHTML=`<button class="pri" ${j.enabled?"":"disabled"}>Create issue</button>
<button onclick="closeDrawer()">Cancel</button>
<span class="hintline" style="margin:0">${j.enabled?"":"Configure Jira to enable."}</span>`;
showDrawer();
}
$("#refresh").onclick=()=>load(true);
load(false);
setInterval(()=>load(false),60000);
</script>
</body>
</html>
"""