669 lines
25 KiB
HTML
669 lines
25 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Fusion Claims — Workflow Explorer</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
|
|
<script src="./workflows.js"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1115;
|
|
--panel: #161a22;
|
|
--panel-2: #1d2330;
|
|
--border: #2a3040;
|
|
--text: #e6e9ef;
|
|
--muted: #8a93a8;
|
|
--accent: #4f8cff;
|
|
--accent-soft: rgba(79,140,255,.15);
|
|
--ok: #3fbf7f;
|
|
--warn: #f4b400;
|
|
--err: #ff5c6c;
|
|
--entry: #9b5de5;
|
|
--terminal: #5f6b80;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; height: 100%; }
|
|
body { display: flex; min-height: 100vh; }
|
|
aside {
|
|
width: 280px; flex-shrink: 0;
|
|
background: var(--panel);
|
|
border-right: 1px solid var(--border);
|
|
padding: 20px 0;
|
|
overflow-y: auto;
|
|
}
|
|
aside h1 {
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: var(--muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: .08em;
|
|
margin: 0 20px 12px;
|
|
}
|
|
aside .subtitle {
|
|
font-size: 11px;
|
|
color: var(--muted);
|
|
margin: 0 20px 20px;
|
|
line-height: 1.4;
|
|
}
|
|
.wf-list { list-style: none; padding: 0; margin: 0; }
|
|
.wf-list li {
|
|
padding: 12px 20px;
|
|
cursor: pointer;
|
|
border-left: 3px solid transparent;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
gap: 8px;
|
|
transition: background .15s;
|
|
}
|
|
.wf-list li:hover { background: var(--panel-2); }
|
|
.wf-list li.active {
|
|
background: var(--accent-soft);
|
|
border-left-color: var(--accent);
|
|
}
|
|
.wf-list li .name { font-weight: 500; }
|
|
.wf-list li .gap-badge {
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
background: var(--err);
|
|
color: #fff;
|
|
min-width: 20px;
|
|
text-align: center;
|
|
}
|
|
.wf-list li .gap-badge.zero { background: var(--ok); }
|
|
main {
|
|
flex: 1;
|
|
padding: 32px 40px;
|
|
overflow-y: auto;
|
|
max-width: calc(100vw - 280px);
|
|
}
|
|
h2 {
|
|
margin: 0 0 4px;
|
|
font-size: 24px;
|
|
font-weight: 700;
|
|
}
|
|
.field-name {
|
|
color: var(--muted);
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 12px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.stats {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, 1fr);
|
|
gap: 12px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.stat {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 14px 16px;
|
|
}
|
|
.stat .label {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: .06em;
|
|
color: var(--muted);
|
|
}
|
|
.stat .value {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
margin-top: 4px;
|
|
}
|
|
.stat.ok .value { color: var(--ok); }
|
|
.stat.err .value { color: var(--err); }
|
|
.stat.warn .value { color: var(--warn); }
|
|
.tabs {
|
|
display: flex;
|
|
gap: 2px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 20px;
|
|
}
|
|
.tabs button {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--muted);
|
|
font: inherit;
|
|
padding: 10px 16px;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -1px;
|
|
font-weight: 500;
|
|
}
|
|
.tabs button:hover { color: var(--text); }
|
|
.tabs button.active {
|
|
color: var(--accent);
|
|
border-bottom-color: var(--accent);
|
|
}
|
|
.tab-panel { display: none; }
|
|
.tab-panel.active { display: block; }
|
|
|
|
.mermaid-wrap {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 24px;
|
|
overflow-x: auto;
|
|
}
|
|
.mermaid-wrap svg { max-width: 100%; height: auto; }
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
th, td {
|
|
padding: 10px 14px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 13px;
|
|
}
|
|
th {
|
|
background: var(--panel-2);
|
|
font-weight: 600;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: .05em;
|
|
color: var(--muted);
|
|
}
|
|
tbody tr:last-child td { border-bottom: none; }
|
|
tbody tr:hover { background: var(--panel-2); }
|
|
tbody tr.has-issue td:first-child { border-left: 3px solid var(--err); }
|
|
code.state-key {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 12px;
|
|
background: var(--panel-2);
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
color: var(--accent);
|
|
}
|
|
.pill {
|
|
display: inline-block;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: .03em;
|
|
}
|
|
.pill.ok { background: rgba(63,191,127,.2); color: var(--ok); }
|
|
.pill.warn { background: rgba(244,180,0,.2); color: var(--warn); }
|
|
.pill.err { background: rgba(255,92,108,.2); color: var(--err); }
|
|
.pill.entry { background: rgba(155,93,229,.2); color: var(--entry); }
|
|
.pill.terminal { background: rgba(95,107,128,.35); color: #c2c9d9; }
|
|
.count {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
.tr-list { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 8px; }
|
|
.tr-row {
|
|
display: grid;
|
|
grid-template-columns: 200px 30px 200px 1fr 140px;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 13px;
|
|
}
|
|
.tr-row:last-child { border-bottom: none; }
|
|
.tr-row:hover { background: var(--panel-2); }
|
|
.tr-arrow { color: var(--muted); text-align: center; }
|
|
.tr-trigger { color: var(--muted); font-family: ui-monospace, monospace; font-size: 12px; word-break: break-all; }
|
|
.tr-trigger .file { color: #555; display: block; margin-top: 2px; }
|
|
.tr-kind {
|
|
text-align: right;
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
letter-spacing: .04em;
|
|
}
|
|
.kind-wizard { color: #4f8cff; }
|
|
.kind-action_method { color: #9b5de5; }
|
|
.kind-cron { color: #f4b400; }
|
|
.kind-auto_write { color: #3fbf7f; }
|
|
.kind-ui_button { color: #ff5c6c; }
|
|
|
|
.gaps {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-left: 4px solid var(--err);
|
|
border-radius: 8px;
|
|
padding: 20px 24px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.gaps.zero { border-left-color: var(--ok); }
|
|
.gaps h3 {
|
|
margin: 0 0 12px;
|
|
font-size: 15px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.gaps p { color: var(--muted); margin: 0; }
|
|
.gaps ul { margin: 0; padding-left: 20px; }
|
|
.gaps li { padding: 4px 0; font-size: 13px; }
|
|
.gaps li code {
|
|
font-family: ui-monospace, monospace;
|
|
background: var(--panel-2);
|
|
padding: 1px 5px;
|
|
border-radius: 3px;
|
|
color: var(--accent);
|
|
font-size: 12px;
|
|
}
|
|
.gap-kind {
|
|
display: inline-block;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
font-weight: 700;
|
|
padding: 1px 6px;
|
|
border-radius: 3px;
|
|
margin-right: 6px;
|
|
letter-spacing: .05em;
|
|
}
|
|
.gap-kind.unreachable { background: rgba(244,180,0,.25); color: var(--warn); }
|
|
.gap-kind.dead-end { background: rgba(255,92,108,.25); color: var(--err); }
|
|
.gap-kind.missing-path { background: rgba(79,140,255,.25); color: var(--accent); }
|
|
.gap-kind.hold-loss { background: rgba(155,93,229,.25); color: var(--entry); }
|
|
|
|
.legend {
|
|
display: flex;
|
|
gap: 16px;
|
|
flex-wrap: wrap;
|
|
font-size: 12px;
|
|
color: var(--muted);
|
|
margin-bottom: 8px;
|
|
}
|
|
.legend span { display: inline-flex; align-items: center; gap: 6px; }
|
|
.legend .swatch { width: 10px; height: 10px; border-radius: 2px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<aside>
|
|
<h1>Fusion Claims</h1>
|
|
<p class="subtitle">Workflow Explorer — 5 parallel state machines on <code style="color:var(--accent)">sale.order</code>. Click a workflow to inspect.</p>
|
|
<ul class="wf-list" id="wf-list"></ul>
|
|
</aside>
|
|
<main id="main">
|
|
<div id="wf-content"></div>
|
|
</main>
|
|
|
|
<script>
|
|
// ============================================================
|
|
// DOM helpers — no innerHTML, all createElement / textContent
|
|
// ============================================================
|
|
function el(tag, opts, children) {
|
|
const node = document.createElement(tag);
|
|
if (opts) {
|
|
if (opts.class) node.className = opts.class;
|
|
if (opts.id) node.id = opts.id;
|
|
if (opts.text != null) node.textContent = opts.text;
|
|
if (opts.style) Object.assign(node.style, opts.style);
|
|
if (opts.data) Object.entries(opts.data).forEach(([k,v]) => node.dataset[k] = v);
|
|
if (opts.on) Object.entries(opts.on).forEach(([evt,fn]) => node.addEventListener(evt, fn));
|
|
}
|
|
if (children) {
|
|
(Array.isArray(children) ? children : [children]).forEach(c => {
|
|
if (c == null) return;
|
|
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
|
});
|
|
}
|
|
return node;
|
|
}
|
|
|
|
// Render a string that may contain <code>...</code> spans safely.
|
|
// Splits on our own markers and builds real DOM nodes.
|
|
function renderSafeInline(parent, text) {
|
|
// Only recognise <code>...</code> — everything else is literal text.
|
|
const parts = text.split(/(<code>[^<]*<\/code>)/);
|
|
parts.forEach(part => {
|
|
if (part.startsWith('<code>') && part.endsWith('</code>')) {
|
|
const codeText = part.slice(6, -7);
|
|
parent.appendChild(el('code', {text: codeText}));
|
|
} else if (part) {
|
|
parent.appendChild(document.createTextNode(part));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// Gap analysis
|
|
// ============================================================
|
|
function analyseWorkflow(wf) {
|
|
const stateKeys = wf.states.map(s => s.key);
|
|
const inbound = new Map();
|
|
const outbound = new Map();
|
|
stateKeys.forEach(k => { inbound.set(k, []); outbound.set(k, []); });
|
|
|
|
wf.transitions.forEach(t => {
|
|
if (t.to && inbound.has(t.to)) inbound.get(t.to).push(t);
|
|
if (t.from && t.from !== '*' && outbound.has(t.from)) outbound.get(t.from).push(t);
|
|
});
|
|
|
|
const wildcardInTo = new Set();
|
|
wf.transitions.forEach(t => { if (t.from === '*') wildcardInTo.add(t.to); });
|
|
|
|
const terminal = new Set(wf.terminal || []);
|
|
const gaps = [];
|
|
const stateStatus = {};
|
|
|
|
stateKeys.forEach(key => {
|
|
const label = wf.states.find(s => s.key === key).label;
|
|
const isDefault = key === wf.default;
|
|
const isTerminal = terminal.has(key);
|
|
const hasInbound = inbound.get(key).length > 0 || wildcardInTo.has(key);
|
|
const hasOutbound = outbound.get(key).length > 0 || wf.transitions.some(t => t.from === key);
|
|
|
|
let status = 'ok';
|
|
const issues = [];
|
|
|
|
if (!isDefault && !hasInbound) {
|
|
status = 'err';
|
|
issues.push({kind: 'unreachable', msg: 'No code path sets this state. It will never be reached via normal workflow — only via manual DB edit or stale ORM context.'});
|
|
}
|
|
if (!isTerminal && !hasOutbound && !isDefault) {
|
|
status = 'err';
|
|
issues.push({kind: 'dead-end', msg: 'Once an order lands here, there is no action method or wizard to transition it out. Users will have to edit the record directly.'});
|
|
}
|
|
|
|
stateStatus[key] = {
|
|
status, issues, isDefault, isTerminal,
|
|
inbound: inbound.get(key),
|
|
outbound: wf.transitions.filter(t => t.from === key)
|
|
};
|
|
issues.forEach(iss => gaps.push({state: key, label, ...iss}));
|
|
});
|
|
|
|
// Workflow-specific heuristics
|
|
if (wf.field === 'x_fc_adp_application_status') {
|
|
if (!wf.transitions.some(t => t.to === 'rejected')) {
|
|
gaps.push({kind: 'missing-path', state: 'rejected', label: 'Rejected by ADP',
|
|
msg: 'No transition writes <code>rejected</code>. The state is declared but nothing reaches it. An ADP rejection has nowhere to land.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.from === 'rejected')) {
|
|
gaps.push({kind: 'missing-path', state: 'rejected', label: 'Rejected by ADP',
|
|
msg: 'No <code>action_resubmit_from_rejected</code> exists (only <code>action_resubmit_from_withdrawn</code>). A rejected application cannot be brought back into the workflow.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.to === 'denied')) {
|
|
gaps.push({kind: 'missing-path', state: 'denied', label: 'Application Denied',
|
|
msg: 'No code path sets <code>denied</code>. Declared as a selection value but has no action method to assign it.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.to === 'expired')) {
|
|
gaps.push({kind: 'missing-path', state: 'expired', label: 'Application Expired',
|
|
msg: 'No cron or method sets <code>expired</code>. Declared but unreachable — the ADP expiry logic was never implemented.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.to === 'cancelled')) {
|
|
gaps.push({kind: 'missing-path', state: 'cancelled', label: 'Cancelled',
|
|
msg: 'No action method writes <code>cancelled</code> on the ADP workflow.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.to === 'withdrawn')) {
|
|
gaps.push({kind: 'missing-path', state: 'withdrawn', label: 'Withdrawn',
|
|
msg: '<code>action_resubmit_from_withdrawn</code> exists (line 3667) but no method WRITES <code>withdrawn</code> in the first place. Dead end on entry.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.to === 'needs_correction')) {
|
|
gaps.push({kind: 'missing-path', state: 'needs_correction', label: 'Needs Correction',
|
|
msg: 'The write() override at line 6017 handles <code>needs_correction</code> document-clearing logic, but no code path sets the state TO <code>needs_correction</code>. Only reachable via manual edit.'});
|
|
}
|
|
}
|
|
|
|
if (wf.field === 'x_fc_mod_status') {
|
|
if (!wf.transitions.some(t => t.from === 'funding_denied')) {
|
|
gaps.push({kind: 'dead-end', state: 'funding_denied', label: 'Denied',
|
|
msg: 'No way to revive a denied MOD case. No resubmit, no cancellation path. Once denied, the order is stuck unless someone edits <code>x_fc_mod_status</code> directly.'});
|
|
}
|
|
}
|
|
|
|
if (['x_fc_sa_status', 'x_fc_odsp_std_status', 'x_fc_ow_status'].includes(wf.field)) {
|
|
const resume = wf.transitions.find(t => t.from === 'on_hold');
|
|
if (resume && resume.to === 'quotation') {
|
|
gaps.push({kind: 'hold-loss', state: 'on_hold', label: 'On Hold',
|
|
msg: '<code>action_odsp_resume</code> always resumes to <code>quotation</code>, losing all progress regardless of where the order was put on hold. An order held at <code>ready_delivery</code> is reset to the start.'});
|
|
}
|
|
if (!wf.transitions.some(t => t.from === 'denied')) {
|
|
gaps.push({kind: 'dead-end', state: 'denied', label: 'Denied',
|
|
msg: 'No path out of <code>denied</code>. Once set, the case is stuck.'});
|
|
}
|
|
}
|
|
|
|
return {gaps, stateStatus};
|
|
}
|
|
|
|
// ============================================================
|
|
// Mermaid flowchart builder — produces plain text, Mermaid parses it.
|
|
// ============================================================
|
|
function buildMermaid(wf, stateStatus) {
|
|
const lines = ['flowchart LR'];
|
|
wf.states.forEach(s => {
|
|
const st = stateStatus[s.key];
|
|
const safeLabel = s.label.replace(/"/g, '"');
|
|
const shape = st.isTerminal ? `(("${safeLabel}"))` :
|
|
st.isDefault ? `(["${safeLabel}"])` :
|
|
`["${safeLabel}"]`;
|
|
lines.push(` ${s.key}${shape}`);
|
|
});
|
|
const seen = new Set();
|
|
wf.transitions.forEach(t => {
|
|
if (t.from === '*') return;
|
|
const key = `${t.from}->${t.to}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
lines.push(` ${t.from} --> ${t.to}`);
|
|
});
|
|
wf.states.forEach(s => {
|
|
const st = stateStatus[s.key];
|
|
let cls = 'ok';
|
|
if (st.status === 'err') {
|
|
if (st.issues.some(i => i.kind === 'unreachable')) cls = 'unreachable';
|
|
else cls = 'deadend';
|
|
} else if (st.isDefault) cls = 'entry';
|
|
else if (st.isTerminal) cls = 'terminal';
|
|
lines.push(` class ${s.key} ${cls}`);
|
|
});
|
|
lines.push(' classDef ok fill:#1d2330,stroke:#3fbf7f,color:#e6e9ef,stroke-width:1.5px');
|
|
lines.push(' classDef entry fill:#2b1d40,stroke:#9b5de5,color:#e6e9ef,stroke-width:2.5px');
|
|
lines.push(' classDef terminal fill:#1a2030,stroke:#5f6b80,color:#c2c9d9,stroke-width:1.5px');
|
|
lines.push(' classDef unreachable fill:#2a2418,stroke:#f4b400,color:#f4b400,stroke-width:2px,stroke-dasharray:5 3');
|
|
lines.push(' classDef deadend fill:#2a1820,stroke:#ff5c6c,color:#ff5c6c,stroke-width:2px');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ============================================================
|
|
// Renderer — DOM-based, no innerHTML
|
|
// ============================================================
|
|
const wfData = window.WORKFLOWS_DATA;
|
|
const wfKeys = Object.keys(wfData);
|
|
let activeWf = wfKeys[0];
|
|
let activeTab = 'flow';
|
|
|
|
function renderSidebar() {
|
|
const list = document.getElementById('wf-list');
|
|
while (list.firstChild) list.removeChild(list.firstChild);
|
|
wfKeys.forEach(k => {
|
|
const wf = wfData[k];
|
|
const {gaps} = analyseWorkflow(wf);
|
|
const li = el('li', {
|
|
class: k === activeWf ? 'active' : '',
|
|
on: {click: () => { activeWf = k; activeTab = 'flow'; renderSidebar(); renderContent(); }}
|
|
}, [
|
|
el('span', {class: 'name', text: wf.label}),
|
|
el('span', {class: 'gap-badge' + (gaps.length === 0 ? ' zero' : ''), text: String(gaps.length)})
|
|
]);
|
|
list.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function makeStat(label, value, cls) {
|
|
return el('div', {class: 'stat' + (cls ? ' ' + cls : '')}, [
|
|
el('div', {class: 'label', text: label}),
|
|
el('div', {class: 'value', text: String(value)})
|
|
]);
|
|
}
|
|
|
|
function makeGapListItem(g) {
|
|
const li = el('li');
|
|
const kind = el('span', {class: 'gap-kind ' + g.kind, text: g.kind.replace('-', ' ')});
|
|
li.appendChild(kind);
|
|
const strong = el('strong', {text: g.label});
|
|
li.appendChild(strong);
|
|
li.appendChild(document.createTextNode(' — '));
|
|
renderSafeInline(li, g.msg);
|
|
return li;
|
|
}
|
|
|
|
function renderContent() {
|
|
const wf = wfData[activeWf];
|
|
const {gaps, stateStatus} = analyseWorkflow(wf);
|
|
const container = document.getElementById('wf-content');
|
|
while (container.firstChild) container.removeChild(container.firstChild);
|
|
|
|
const wizardCount = wf.transitions.filter(t => t.kind === 'wizard').length;
|
|
const cronCount = wf.transitions.filter(t => t.kind === 'cron').length;
|
|
const autoCount = wf.transitions.filter(t => t.kind === 'auto_write').length;
|
|
|
|
container.appendChild(el('h2', {text: wf.label}));
|
|
const fn = el('div', {class: 'field-name'});
|
|
fn.appendChild(document.createTextNode(wf.field + ' · default: '));
|
|
fn.appendChild(el('code', {text: wf.default}));
|
|
container.appendChild(fn);
|
|
|
|
const stats = el('div', {class: 'stats'}, [
|
|
makeStat('States', wf.states.length),
|
|
makeStat('Transitions', wf.transitions.length),
|
|
makeStat('Gaps', gaps.length, gaps.length === 0 ? 'ok' : 'err'),
|
|
makeStat('Wizards', wizardCount),
|
|
makeStat('Crons / Auto', cronCount + autoCount)
|
|
]);
|
|
container.appendChild(stats);
|
|
|
|
// Gaps panel
|
|
const gapsBox = el('div', {class: 'gaps' + (gaps.length === 0 ? ' zero' : '')});
|
|
gapsBox.appendChild(el('h3', {text: gaps.length === 0
|
|
? '\u2713 No gaps detected'
|
|
: '\u26A0 ' + gaps.length + ' gap' + (gaps.length === 1 ? '' : 's') + ' detected'}));
|
|
if (gaps.length === 0) {
|
|
gapsBox.appendChild(el('p', {text: 'This workflow has full coverage: every declared state is reachable, every non-terminal state has an exit, and all transitions are backed by code paths.'}));
|
|
} else {
|
|
const ul = el('ul');
|
|
gaps.forEach(g => ul.appendChild(makeGapListItem(g)));
|
|
gapsBox.appendChild(ul);
|
|
}
|
|
container.appendChild(gapsBox);
|
|
|
|
// Tabs
|
|
const tabs = el('div', {class: 'tabs'});
|
|
const tabDefs = [
|
|
{key: 'flow', label: 'Flowchart'},
|
|
{key: 'states', label: 'States (' + wf.states.length + ')'},
|
|
{key: 'transitions', label: 'Transitions (' + wf.transitions.length + ')'}
|
|
];
|
|
tabDefs.forEach(t => {
|
|
tabs.appendChild(el('button', {
|
|
class: activeTab === t.key ? 'active' : '',
|
|
text: t.label,
|
|
on: {click: () => { activeTab = t.key; renderContent(); }}
|
|
}));
|
|
});
|
|
container.appendChild(tabs);
|
|
|
|
// Flow tab
|
|
if (activeTab === 'flow') {
|
|
const legend = el('div', {class: 'legend'}, [
|
|
el('span', null, [el('span', {class: 'swatch', style: {background: '#9b5de5'}}), 'Entry state']),
|
|
el('span', null, [el('span', {class: 'swatch', style: {background: '#3fbf7f'}}), 'Healthy']),
|
|
el('span', null, [el('span', {class: 'swatch', style: {background: '#f4b400'}}), 'Unreachable']),
|
|
el('span', null, [el('span', {class: 'swatch', style: {background: '#ff5c6c'}}), 'Dead-end']),
|
|
el('span', null, [el('span', {class: 'swatch', style: {background: '#5f6b80'}}), 'Terminal'])
|
|
]);
|
|
container.appendChild(legend);
|
|
const wrap = el('div', {class: 'mermaid-wrap'});
|
|
const mm = el('div', {class: 'mermaid', id: 'mermaid-' + activeWf});
|
|
mm.textContent = buildMermaid(wf, stateStatus);
|
|
wrap.appendChild(mm);
|
|
container.appendChild(wrap);
|
|
|
|
// Render mermaid async
|
|
mermaid.initialize({startOnLoad: false, theme: 'base', securityLevel: 'strict', themeVariables: {
|
|
background: '#161a22', primaryColor: '#1d2330', primaryTextColor: '#e6e9ef',
|
|
primaryBorderColor: '#3fbf7f', lineColor: '#4f8cff'
|
|
}});
|
|
const src = mm.textContent;
|
|
const renderId = 'mm-svg-' + activeWf + '-' + Date.now();
|
|
mermaid.render(renderId, src).then(result => {
|
|
while (mm.firstChild) mm.removeChild(mm.firstChild);
|
|
// mermaid.render returns an SVG string — parse via DOMParser, no innerHTML
|
|
const doc = new DOMParser().parseFromString(result.svg, 'image/svg+xml');
|
|
const svgNode = doc.documentElement;
|
|
mm.appendChild(document.importNode(svgNode, true));
|
|
}).catch(err => {
|
|
while (mm.firstChild) mm.removeChild(mm.firstChild);
|
|
const pre = el('pre', {style: {color: 'var(--err)', whiteSpace: 'pre-wrap'}});
|
|
pre.textContent = 'Mermaid error: ' + err.message + '\n\n' + src;
|
|
mm.appendChild(pre);
|
|
});
|
|
}
|
|
|
|
// States tab
|
|
if (activeTab === 'states') {
|
|
const table = el('table');
|
|
const thead = el('thead');
|
|
const headRow = el('tr');
|
|
['State', 'Key', 'Status', 'In', 'Out'].forEach(h => headRow.appendChild(el('th', {text: h})));
|
|
thead.appendChild(headRow);
|
|
table.appendChild(thead);
|
|
const tbody = el('tbody');
|
|
wf.states.forEach(s => {
|
|
const st = stateStatus[s.key];
|
|
let pillClass = 'ok', pillLabel = 'Healthy';
|
|
if (st.isDefault) { pillClass = 'entry'; pillLabel = 'Entry'; }
|
|
else if (st.isTerminal) { pillClass = 'terminal'; pillLabel = 'Terminal'; }
|
|
if (st.status === 'err') {
|
|
if (st.issues.some(i => i.kind === 'unreachable')) { pillClass = 'warn'; pillLabel = 'Unreachable'; }
|
|
else { pillClass = 'err'; pillLabel = 'Dead-end'; }
|
|
}
|
|
const tr = el('tr', {class: st.status === 'err' ? 'has-issue' : ''});
|
|
tr.appendChild(el('td', null, [el('strong', {text: s.label})]));
|
|
tr.appendChild(el('td', null, [el('code', {class: 'state-key', text: s.key})]));
|
|
tr.appendChild(el('td', null, [el('span', {class: 'pill ' + pillClass, text: pillLabel})]));
|
|
tr.appendChild(el('td', {class: 'count', text: String(st.inbound.length)}));
|
|
tr.appendChild(el('td', {class: 'count', text: String(st.outbound.length)}));
|
|
tbody.appendChild(tr);
|
|
});
|
|
table.appendChild(tbody);
|
|
container.appendChild(table);
|
|
}
|
|
|
|
// Transitions tab
|
|
if (activeTab === 'transitions') {
|
|
const list = el('div', {class: 'tr-list'});
|
|
wf.transitions.forEach(t => {
|
|
const row = el('div', {class: 'tr-row'});
|
|
row.appendChild(el('div', null, [el('code', {class: 'state-key', text: t.from})]));
|
|
row.appendChild(el('div', {class: 'tr-arrow', text: '\u2192'}));
|
|
row.appendChild(el('div', null, [el('code', {class: 'state-key', text: t.to})]));
|
|
const trig = el('div', {class: 'tr-trigger'});
|
|
trig.appendChild(document.createTextNode(t.trigger));
|
|
const fileLine = el('span', {class: 'file', text: t.file + (t.line ? ':' + t.line : '')});
|
|
trig.appendChild(fileLine);
|
|
row.appendChild(trig);
|
|
const kind = el('div', {class: 'tr-kind'});
|
|
kind.appendChild(el('span', {class: 'kind-' + t.kind, text: t.kind.replace('_', ' ')}));
|
|
row.appendChild(kind);
|
|
list.appendChild(row);
|
|
});
|
|
container.appendChild(list);
|
|
}
|
|
}
|
|
|
|
renderSidebar();
|
|
renderContent();
|
|
</script>
|
|
</body>
|
|
</html>
|