flowmaster-mission-control-.../fetch_scenarios.mjs
Shad 3ffd0e68a7 Mission Control demo v2
Polished command-center for FlowMaster with two data modes:
- SNAPSHOT: bundled src/scenarios.json from demo.flow-master.ai
- LIVE: in-browser fetch via src/lib/api.ts (dev-login + bearer)

Scenarios:
- procurement, extra-1, extra-2 (live from EA2)
- ar, hcm, gl, service (industry blueprints, same typed shell)

Honesty pass after Oracle review:
- No invented numbers (Telemetry derives SLA + agent acceptance from real data)
- Preview-only actions fire toasts naming the endpoint to wire them
- Blueprint tours framed as 'industry blueprint', not 'we don't have this yet'
- Mode pill + last-fetch age + refresh in topbar
- Dev CORS dodged via vite proxy; production deploys same-origin

18 vitest tests + 26 playwright smoke assertions + DOM layout audit.

Constraint: cross-origin live mode rejected by browser → fall back to snapshot
Rejected: hardcoded SLA % | dishonest demo metrics
Directive: wire preview-only action handlers to /api/runtime/transactions/{id}/actions to ship them for real
Confidence: high
Scope-risk: narrow
Not-tested: production deployment via flowmaster-ops overlay
2026-06-14 00:09:32 +04:00

201 lines
6.7 KiB
JavaScript

// Multi-scenario live read from demo.flow-master.ai.
//
// Strategy:
// 1. dev-login → bearer token
// 2. /api/ea2/work-items?view=all → enumerate every definition_key
// actually referenced by live work, then count cases per definition.
// 3. For each top-N definition, fetch its graph (`/api/ea2/process-definitions/{k}/graph`)
// and a few full runtime transactions.
// 4. Classify into family buckets (procurement/AR/HCM/GL close/service ops)
// using definition display_name + node labels.
// 5. Write src/scenarios.json with the picked scenarios + the raw work-item
// board so the UI can show a real "All work" view.
//
// READ-ONLY. Run: `node fetch_scenarios.mjs`.
import { writeFileSync } from "node:fs";
const BASE = process.env.FM_BASE || "https://demo.flow-master.ai";
const EMAIL = process.env.FM_EMAIL || "dev@flow-master.ai";
const login = await fetch(`${BASE}/api/v1/auth/dev-login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: EMAIL }),
});
if (!login.ok) throw new Error(`dev-login ${login.status}`);
const token = (await login.json()).access_token;
const H = { Authorization: `Bearer ${token}` };
const tryGet = async (path) => {
try {
const r = await fetch(`${BASE}${path}`, { headers: H });
if (!r.ok) return null;
return await r.json();
} catch {
return null;
}
};
// 1. Pull the full live work-item board.
const board = await tryGet("/api/ea2/work-items?view=all");
const workItems = board?.items || [];
console.log(`${workItems.length} live work-items`);
// 2. Enumerate unique definition_keys + per-def metadata.
const byDef = new Map();
for (const w of workItems) {
const k = w.definition_key;
if (!k) continue;
if (!byDef.has(k)) {
byDef.set(k, { key: k, hubs: new Set(), cases: [], statuses: {} });
}
const r = byDef.get(k);
if (w.hub) r.hubs.add(w.hub);
r.cases.push(w);
r.statuses[w.status] = (r.statuses[w.status] || 0) + 1;
}
console.log(`${byDef.size} distinct definitions in work board`);
// 3. Filter to meaningful definitions (≥1 running OR ≥2 total cases).
const candidates = [...byDef.values()].filter((d) => {
const total = d.cases.length;
const running = (d.statuses.running || 0) + (d.statuses.waiting_for_user || 0);
return total >= 2 || running >= 1;
});
candidates.sort((a, b) => b.cases.length - a.cases.length);
console.log(`${candidates.length} candidates after filter`);
// 4. Fetch graph + representative transaction per candidate.
const enriched = [];
for (const c of candidates.slice(0, 20)) {
const graph = await tryGet(`/api/ea2/process-definitions/${c.key}/graph`);
if (!graph?.process_definition?.config?.nodes?.length) {
console.warn(` ! ${c.key.slice(0, 8)} no graph`);
continue;
}
const headlineCase =
c.cases.find((w) => w.status === "running") ||
c.cases.find((w) => w.status === "waiting_for_user") ||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
c.cases[0];
let headlineRt = null;
if (headlineCase?.transaction_id) {
headlineRt = await tryGet(`/api/runtime/transactions/${headlineCase.transaction_id}`);
}
const recent = [];
for (const w of c.cases.slice(0, 6)) {
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
const r = await tryGet(`/api/runtime/transactions/${w.transaction_id}`);
if (r) recent.push(r);
if (recent.length >= 3) break;
}
enriched.push({
key: c.key,
name: graph.process_definition.display_name || graph.process_definition.name,
hubs: [...c.hubs],
statuses: c.statuses,
cases: c.cases,
graph,
headlineCase,
headlineRt,
recent,
});
console.log(
`${c.key.slice(0, 8)} ${(graph.process_definition.display_name || "—").slice(0, 36)} nodes=${graph.process_definition.config.nodes.length} cases=${c.cases.length} rt=${headlineRt ? "live" : "no"}`
);
}
// 5. Classify into families.
const families = [
{
id: "procurement", label: "Procurement to Pay", subtitle: "Requisition → PO → 3-way match",
accent: "#3b82f6",
match: (e) => /procure|purchas|pr_to_po|atlas|requisition|po\b/i.test(`${e.name} ${e.hubs.join(" ")}`),
},
{
id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections",
accent: "#10b981",
match: (e) => /refund|credit|collect|receivable|ar\b|invoice/i.test(e.name),
},
{
id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave",
accent: "#a855f7",
match: (e) => /onboard|offboard|hire|hcm|employee|leave|payroll|hr\b/i.test(e.name),
},
{
id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals",
accent: "#f59e0b",
match: (e) => /close|ledger|journal|accrual|reconcil|gl\b/i.test(e.name),
},
{
id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support",
accent: "#ef4444",
match: (e) => /ticket|incident|support|service|case\b/i.test(e.name),
},
];
const scenarios = [];
const used = new Set();
for (const fam of families) {
const pick = enriched
.filter((e) => !used.has(e.key) && fam.match(e))
.sort((a, b) => b.cases.length - a.cases.length)[0];
if (!pick) {
console.log(` ⚠ family ${fam.id} unmatched`);
continue;
}
used.add(pick.key);
scenarios.push({ family: fam, ...pick });
}
// Top up to at least 4 with largest remaining.
const remaining = enriched.filter((e) => !used.has(e.key));
remaining.sort((a, b) => b.cases.length - a.cases.length);
while (scenarios.length < 4 && remaining.length) {
const e = remaining.shift();
scenarios.push({
family: {
id: `extra-${scenarios.length}`,
label: e.name || "Process",
subtitle: `${e.cases.length} live cases`,
accent: "#64748b",
match: () => false,
},
...e,
});
used.add(e.key);
}
console.log(
`\n${scenarios.length} scenarios: ` +
scenarios.map((s) => `${s.family.id}:${s.name?.slice(0, 24)}`).join(" | ")
);
// 6. Write artifact.
const out = {
fetchedFrom: BASE,
fetchedAt: new Date().toISOString(),
totals: {
workItems: workItems.length,
distinctDefs: byDef.size,
scenarios: scenarios.length,
},
board: workItems,
scenarios: scenarios.map((s) => ({
id: s.family.id,
family: s.family,
def_key: s.key,
def_name: s.name,
hubs: s.hubs,
statuses: s.statuses,
cases: s.cases,
graph: s.graph,
headlineTx: s.headlineCase?.transaction_id || null,
headlineRt: s.headlineRt,
recent: s.recent,
})),
};
writeFileSync(new URL("./src/scenarios.json", import.meta.url), JSON.stringify(out, null, 2));
console.log(`\n✓ wrote src/scenarios.json (${scenarios.length} scenarios, ${workItems.length} board items)`);