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
This commit is contained in:
commit
3ffd0e68a7
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
# Build
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
# QA artifacts (regenerated)
|
||||
qa/screenshots/
|
||||
|
||||
# TypeScript build info
|
||||
*.tsbuildinfo
|
||||
152
README.md
Normal file
152
README.md
Normal file
@ -0,0 +1,152 @@
|
||||
# FlowMaster — Mission Control demo
|
||||
|
||||
A polished, presenter-ready command-center for FlowMaster. Lives at
|
||||
[`shad/flowmaster-mission-control-demo`](https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo)
|
||||
on Gitea.
|
||||
|
||||
## What this is (and isn't)
|
||||
|
||||
**Is:**
|
||||
|
||||
- A single-page React 19 app (Vite + ReactFlow + cmdk + framer-motion + zustand
|
||||
+ dagre).
|
||||
- Polished command-center for any FlowMaster process: graph, queue, inspector,
|
||||
tour, command palette, live-mode toggle, run history.
|
||||
- Two data modes:
|
||||
- **SNAPSHOT** (default): bundled `src/scenarios.json`, captured from
|
||||
`demo.flow-master.ai`. Fast, offline, deterministic.
|
||||
- **LIVE**: in-browser fetch from the same backend via the API client at
|
||||
`src/lib/api.ts` (dev-login → bearer → `/api/ea2/work-items` →
|
||||
`/api/ea2/process-definitions/{k}/graph` → `/api/runtime/transactions/{id}`).
|
||||
Loading and error states are wired through `src/scenes/MissionControl.tsx`.
|
||||
|
||||
**Isn't:**
|
||||
|
||||
- Not a replacement for the existing demo at https://demo.flow-master.ai
|
||||
(Next.js fm-shell). This is a separate command-center experience.
|
||||
- Not multi-tenant. No auth UI, no tenancy switcher, no settings.
|
||||
- Not wired to actually mutate state. Every action button (start runtime,
|
||||
dispatch agent, approve, decline, confirm) is **preview-only** and fires a
|
||||
toast naming the endpoint that would make it real.
|
||||
|
||||
## Scenarios in the catalog
|
||||
|
||||
| id | mode | source |
|
||||
| ----------- | --------- | ------------------------------------------------------------------- |
|
||||
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
|
||||
| extra-1 | live | Atlas F1 Fresh (procurement variant) |
|
||||
| extra-2 | live | Atlas F1 Fresh (procurement variant) |
|
||||
| ar | blueprint | AR · Customer Refund Approval |
|
||||
| hcm | blueprint | HCM · New Hire Onboarding |
|
||||
| gl | blueprint | GL · Period-End Close |
|
||||
| service | blueprint | Service Ops · Customer Incident |
|
||||
|
||||
**Live** = backed by a real EA2 process definition currently in the demo
|
||||
backend, with real runtime transactions and a real work-item queue.
|
||||
|
||||
**Blueprint** = hand-modelled in the same typed format. Identical UI surface;
|
||||
the backend just doesn't have a runnable definition for it yet. Internal
|
||||
metadata carries `isSynthetic: true` for provenance audits.
|
||||
|
||||
## How honesty is enforced in this code
|
||||
|
||||
This pass came out of an Oracle review that called out theatrical bits in the
|
||||
prior version. Safeguards now in place:
|
||||
|
||||
- **No invented numbers.** `src/components/Telemetry.tsx` derives every value
|
||||
from the active scenarios (`SLA = 1 - errored / cases`, agent acceptance =
|
||||
fraction of agent runs not in `proposed`). The throughput sparkline plots
|
||||
the actual `running` rollup over time, not a sine wave. The "ui tick" dot
|
||||
is labelled as a UI heartbeat and tooltip-named as such.
|
||||
- **No fake buttons.** Every preview-only action fires a toast that names the
|
||||
endpoint that would make it real, and carries a `preview` marker.
|
||||
- **No misleading tour copy.** Blueprint tours frame the scenario as an
|
||||
industry blueprint, not "we don't have this yet".
|
||||
- **Mode is always visible.** Topbar has a `SNAPSHOT`/`LIVE` pill, last-fetch
|
||||
age, and a refresh button when live.
|
||||
|
||||
## Live-mode mechanics (the CORS gotcha)
|
||||
|
||||
`demo.flow-master.ai` does not advertise CORS headers for arbitrary origins.
|
||||
|
||||
- In **dev** (`pnpm dev`): Vite proxies `/api/*` to
|
||||
`${VITE_FM_BASE:-https://demo.flow-master.ai}` (see `vite.config.ts`). The
|
||||
browser sees a same-origin request, no CORS check. `src/lib/api.ts` uses an
|
||||
empty `baseUrl` in dev for this.
|
||||
- In **production**: deploy the build at the same origin as the backend (or
|
||||
behind a reverse proxy that passes `/api/*` through). Cross-origin
|
||||
deployments fail gracefully — `setMode("live")` catches the error, surfaces
|
||||
it in the topbar banner and a toast, and falls back to snapshot mode.
|
||||
|
||||
## Run, test, build
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
# refresh the bundled snapshot from demo.flow-master.ai
|
||||
pnpm fetch:scenarios
|
||||
|
||||
# dev (with backend proxy for live mode)
|
||||
pnpm dev # → http://127.0.0.1:5173
|
||||
|
||||
# tests
|
||||
pnpm test # vitest, 18 tests across api, live,
|
||||
# synthetic, layout
|
||||
|
||||
# build
|
||||
pnpm build # tsc + vite, single chunk ~225 KB gz
|
||||
|
||||
# end-to-end smoke + screenshots
|
||||
pnpm qa:smoke # playwright headless, 26 assertions
|
||||
|
||||
# DOM layout audit
|
||||
pnpm qa:layout
|
||||
```
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
src/
|
||||
├── data/ # ProcessScenario domain + snapshot + blueprint catalog
|
||||
├── lib/ # API client + live-scenario builder (+ tests)
|
||||
├── state/store.ts # zustand: scene, mode, scenario, tour, recents, toasts
|
||||
├── graph/layout.ts # dagre LR auto-layout (+ tests)
|
||||
├── components/ # ProcessGraph, Inspector, LeftRail, CommandBar, Tour,
|
||||
│ # Telemetry, Toaster, icons
|
||||
├── scenes/ # Landing, MissionControl, RunHistory
|
||||
├── App.tsx # shell: topbar (mode pill / refresh / tour / ⌘K)
|
||||
├── index.css # design system (~700 lines)
|
||||
├── main.tsx
|
||||
└── scenarios.json # cached snapshot of demo.flow-master.ai
|
||||
|
||||
qa/
|
||||
├── smoke.mjs # Playwright e2e + 9 screenshots
|
||||
└── layout_audit.mjs # programmatic clipping/overlap check
|
||||
|
||||
vite.config.ts # /api → demo.flow-master.ai proxy for dev
|
||||
fetch_scenarios.mjs # Node script to refresh src/scenarios.json
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
Production deployment is tracked in `FM06/flowmaster-ops` under
|
||||
`manifests/overlays/mc.flow-master.ai/`. A static nginx serves `dist/`;
|
||||
ingress fronts it at `mc.flow-master.ai`. `/api/*` is reverse-proxied to the
|
||||
same backend `demo.flow-master.ai` talks to — that's what makes live mode
|
||||
work in production without CORS.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# dist/ is the static site → ship to nginx referenced by the ops overlay
|
||||
```
|
||||
|
||||
## What's intentionally not here
|
||||
|
||||
- Authentication UI (dev-login is used because this is a demo lane).
|
||||
- Mutation endpoints (every action is preview-only and the toast names the
|
||||
endpoint).
|
||||
- Mobile layout (1440px-and-up demo surface).
|
||||
- Route persistence / deep linking (scene + scenario live in memory).
|
||||
- i18n (English only).
|
||||
|
||||
Small, well-scoped follow-ups — not architectural changes.
|
||||
22
eslint.config.js
Normal file
22
eslint.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
200
fetch_scenarios.mjs
Normal file
200
fetch_scenarios.mjs
Normal file
@ -0,0 +1,200 @@
|
||||
// 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)`);
|
||||
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>fm-command-center-spike</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "flowmaster-mission-control-demo",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"fetch:scenarios": "node fetch_scenarios.mjs",
|
||||
"qa:smoke": "node qa/smoke.mjs",
|
||||
"qa:layout": "node qa/layout_audit.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/dagre": "^0.7.54",
|
||||
"cmdk": "^1.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"framer-motion": "^12.40.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"reactflow": "^11.11.4",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"playwright": "^1.60.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
2998
pnpm-lock.yaml
generated
Normal file
2998
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
93
qa/layout_audit.mjs
Normal file
93
qa/layout_audit.mjs
Normal file
@ -0,0 +1,93 @@
|
||||
// Programmatic visual-QA: walks the rendered DOM and reports likely
|
||||
// layout defects (overflow, clipping, overlap, zero-size, low contrast).
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const URL = process.env.URL || "http://127.0.0.1:5173";
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
const findings = [];
|
||||
function add(kind, msg) { findings.push({ kind, msg }); }
|
||||
|
||||
await page.goto(URL, { waitUntil: "networkidle" });
|
||||
|
||||
async function auditScene(label, prep) {
|
||||
await prep();
|
||||
await page.waitForTimeout(500);
|
||||
// For each candidate selector, check for overflow / hidden / zero size
|
||||
const results = await page.evaluate(() => {
|
||||
const out = [];
|
||||
const sels = [
|
||||
".hero-title", ".hero-sub", ".sc-card-title", ".sc-card-sub",
|
||||
".mc-hero-title", ".mc-hero-sub", ".mc-tab-label",
|
||||
".node-name", ".panel-h", ".qcard-title", ".inspector-title h3",
|
||||
".tour-title", ".tour-body", ".telemetry .t-block", ".rh-row-step",
|
||||
".graph-overlay-name",
|
||||
];
|
||||
for (const sel of sels) {
|
||||
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cs = getComputedStyle(el);
|
||||
const clipped = el.scrollWidth > r.width + 1 && cs.overflow !== "visible" && cs.textOverflow === "ellipsis";
|
||||
const overflowH = el.scrollHeight > r.height + 1 && cs.overflow !== "visible" && cs.overflowY !== "auto" && cs.overflowY !== "scroll";
|
||||
const offscreen = r.right < 0 || r.left > window.innerWidth || r.bottom < 0 || r.top > window.innerHeight + 1;
|
||||
const zero = (r.width < 2 || r.height < 2) && el.textContent && el.textContent.trim().length > 0;
|
||||
if (clipped || overflowH || offscreen || zero) {
|
||||
out.push({
|
||||
sel, text: (el.textContent || "").trim().slice(0, 80),
|
||||
w: r.width.toFixed(0), h: r.height.toFixed(0),
|
||||
sw: el.scrollWidth, sh: el.scrollHeight,
|
||||
issues: { clipped, overflowH, offscreen, zero },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
});
|
||||
for (const r of results) {
|
||||
add("layout", `[${label}] ${r.sel}: "${r.text}" (${r.w}x${r.h} content ${r.sw}x${r.sh}) ${JSON.stringify(r.issues)}`);
|
||||
}
|
||||
}
|
||||
|
||||
await auditScene("landing", async () => {});
|
||||
await auditScene("mission-1st-live", async () => {
|
||||
await page.locator(".sc-card").first().click();
|
||||
await page.waitForSelector(".mc");
|
||||
});
|
||||
await auditScene("mission-ar", async () => {
|
||||
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
|
||||
});
|
||||
await auditScene("inspector-raw", async () => {
|
||||
await page.locator(".itab", { hasText: "Raw" }).click();
|
||||
});
|
||||
await auditScene("tour", async () => {
|
||||
await page.locator(".link-btn", { hasText: "Tour" }).click();
|
||||
await page.waitForSelector(".tour-card");
|
||||
});
|
||||
await auditScene("history", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(100);
|
||||
await page.locator(".tab", { hasText: "Runs" }).click();
|
||||
await page.waitForSelector(".rh");
|
||||
});
|
||||
|
||||
console.log(`findings: ${findings.length}`);
|
||||
for (const f of findings.slice(0, 50)) console.log(` · ${f.msg}`);
|
||||
|
||||
// Also dump key layout dimensions for sanity
|
||||
const dims = await page.evaluate(() => {
|
||||
const get = (sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return { sel, w: Math.round(r.width), h: Math.round(r.height), top: Math.round(r.top), left: Math.round(r.left) };
|
||||
};
|
||||
return [
|
||||
get(".rh-head"),
|
||||
get(".rh-list"),
|
||||
get(".rh-row"),
|
||||
];
|
||||
});
|
||||
console.log("\nrh dimensions:", JSON.stringify(dims, null, 2));
|
||||
|
||||
await browser.close();
|
||||
155
qa/smoke.mjs
Normal file
155
qa/smoke.mjs
Normal file
@ -0,0 +1,155 @@
|
||||
// Playwright smoke + screenshot capture against the running dev server.
|
||||
// Covers: landing, MC procurement, MC blueprint (AR), inspector tabs,
|
||||
// command palette, tour, run history, live-mode toggle, toast on preview action.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
const URL = process.env.URL || "http://127.0.0.1:5173";
|
||||
const OUT = "qa/screenshots";
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const VP = { width: 1440, height: 900 };
|
||||
|
||||
function logOk(label, ok, detail = "") {
|
||||
console.log(`${ok ? "✓" : "✗"} ${label}${detail ? " · " + detail : ""}`);
|
||||
if (!ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: VP, deviceScaleFactor: 1 });
|
||||
const page = await ctx.newPage();
|
||||
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
|
||||
page.on("console", (m) => { if (m.type() === "error") errors.push(`console.error: ${m.text()}`); });
|
||||
|
||||
await page.goto(URL, { waitUntil: "networkidle" });
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/01-landing.png` });
|
||||
logOk("landing renders", await page.locator(".landing").isVisible());
|
||||
logOk("hero title present", await page.locator(".hero-title").isVisible());
|
||||
const cards = await page.locator(".sc-card").count();
|
||||
logOk("scenario cards >= 7", cards >= 7, `count=${cards}`);
|
||||
logOk("Go live button visible on landing", await page.getByRole("button", { name: /go live/i }).isVisible());
|
||||
|
||||
// → Mission Control via first card (live procurement)
|
||||
await page.locator(".sc-card").first().click();
|
||||
await page.waitForSelector(".mc");
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: `${OUT}/02-mission-procurement.png` });
|
||||
logOk("mission control loaded", await page.locator(".mc-strip").isVisible());
|
||||
|
||||
// Topbar mode pill is SNAPSHOT by default
|
||||
const modeBefore = (await page.locator(".mode-toggle .mode-pill").innerText()).trim();
|
||||
logOk("default mode is SNAPSHOT", modeBefore === "SNAPSHOT", `was=${modeBefore}`);
|
||||
|
||||
// Tab strip + graph
|
||||
const tabCount = await page.locator(".mc-tab").count();
|
||||
logOk("tab strip has >= 7 scenarios", tabCount >= 7, `count=${tabCount}`);
|
||||
const reactFlowNodes = await page.locator(".node").count();
|
||||
logOk("graph rendered nodes > 0", reactFlowNodes > 0, `nodes=${reactFlowNodes}`);
|
||||
|
||||
// Switch to AR blueprint
|
||||
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
|
||||
await page.waitForTimeout(600);
|
||||
await page.screenshot({ path: `${OUT}/03-mission-ar.png` });
|
||||
logOk("AR scenario active", (await page.locator(".mc-hero-title").innerText()).toLowerCase().includes("refund"));
|
||||
logOk("AR shows BLUEPRINT badge (not SYNTHETIC)", await page.locator(".graph-overlay .tag-syn", { hasText: "BLUEPRINT" }).isVisible());
|
||||
|
||||
// Queue card → inspector update
|
||||
const qCards = await page.locator(".qcard").count();
|
||||
logOk("queue cards present", qCards > 0, `count=${qCards}`);
|
||||
if (qCards > 0) {
|
||||
await page.locator(".qcard").first().click();
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
// Inspector tab switching
|
||||
await page.locator(".itab", { hasText: "Evidence" }).click();
|
||||
await page.waitForTimeout(150);
|
||||
logOk("evidence tab opens", await page.locator(".evt, .empty").first().isVisible());
|
||||
await page.locator(".itab", { hasText: "Raw" }).click();
|
||||
await page.waitForTimeout(150);
|
||||
logOk("raw tab opens", await page.locator(".raw-json").isVisible());
|
||||
await page.screenshot({ path: `${OUT}/04-inspector-raw.png` });
|
||||
|
||||
// Preview-only action toast
|
||||
await page.locator(".itab", { hasText: "Overview" }).click();
|
||||
await page.waitForTimeout(150);
|
||||
const approveBtn = page.locator(".i-actions .btn", { hasText: "Approve" }).first();
|
||||
const approveCount = await approveBtn.count();
|
||||
if (approveCount > 0) {
|
||||
await approveBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const toastTxt = (await page.locator(".toast-msg").first().innerText()).trim();
|
||||
logOk("preview action fires toast", /preview[- ]only/i.test(toastTxt), `toast="${toastTxt.slice(0, 80)}"`);
|
||||
} else {
|
||||
logOk("approve button preview-marker present", await page.locator(".preview-marker").first().isVisible());
|
||||
}
|
||||
|
||||
// Command palette
|
||||
await page.keyboard.press("Meta+k");
|
||||
await page.waitForSelector(".cmd", { state: "visible" });
|
||||
await page.waitForTimeout(150);
|
||||
await page.screenshot({ path: `${OUT}/05-command-palette.png` });
|
||||
logOk("command palette opens with ⌘K", await page.locator(".cmd").isVisible());
|
||||
logOk("Data mode group present in palette", await page.locator(".cmd").getByText(/data mode/i).isVisible());
|
||||
|
||||
await page.locator('.cmd [cmdk-input]').fill("tour");
|
||||
await page.waitForTimeout(150);
|
||||
logOk("tour command appears", await page.locator(".cmd").getByText(/start guided tour/i).isVisible());
|
||||
await page.keyboard.press("Escape");
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Start tour
|
||||
await page.locator(".link-btn", { hasText: "Tour" }).click();
|
||||
await page.waitForSelector(".tour-card");
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `${OUT}/06-tour.png` });
|
||||
logOk("tour card renders", await page.locator(".tour-card").isVisible());
|
||||
const firstTourCombined = ((await page.locator(".tour-title").first().innerText()) + " " + (await page.locator(".tour-body").first().innerText())).toLowerCase();
|
||||
logOk("AR tour uses positive 'industry blueprint' framing", /industry blueprint/i.test(firstTourCombined), `text~="${firstTourCombined.slice(0, 100)}"`);
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.waitForTimeout(450);
|
||||
logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Live-mode toggle (real network call to demo.flow-master.ai)
|
||||
const toggleBtn = page.locator(".mode-toggle").first();
|
||||
await toggleBtn.click();
|
||||
const liveOk = await page.waitForFunction(
|
||||
() => Array.from(document.querySelectorAll(".mode-pill")).some((el) => el.textContent?.trim() === "LIVE"),
|
||||
{ timeout: 12000 },
|
||||
).then(() => true).catch(() => false);
|
||||
logOk("toggle to LIVE mode resolves and updates pill", liveOk);
|
||||
if (liveOk) {
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/09-live-mode.png` });
|
||||
logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: "Refresh" }).isVisible());
|
||||
}
|
||||
|
||||
// Telemetry honesty (check before leaving MC because RH has no telemetry strip)
|
||||
const tel = await page.locator(".telemetry").innerText();
|
||||
logOk("telemetry has no hardcoded 97.4%", !/97\.4%/.test(tel), `tel="${tel.replace(/\s+/g, " ").slice(0, 80)}"`);
|
||||
logOk("telemetry labels SLA as derived", /derived/i.test(tel));
|
||||
|
||||
// Run history scene
|
||||
await page.locator(".tab", { hasText: "Runs" }).click();
|
||||
await page.waitForSelector(".rh");
|
||||
await page.waitForTimeout(250);
|
||||
await page.screenshot({ path: `${OUT}/07-run-history.png` });
|
||||
const rhRows = await page.locator(".rh-row").count();
|
||||
logOk("run-history rows present", rhRows > 0, `rows=${rhRows}`);
|
||||
await page.locator(".rh-chip", { hasText: "running" }).click();
|
||||
await page.waitForTimeout(200);
|
||||
const rhRunning = await page.locator(".rh-row").count();
|
||||
logOk("run-history filter shrinks list", rhRunning > 0 && rhRunning <= rhRows, `running=${rhRunning} of ${rhRows}`);
|
||||
|
||||
console.log(`\nconsole errors: ${errors.length}`);
|
||||
for (const e of errors.slice(0, 8)) console.log(" -", e);
|
||||
if (errors.length > 0) process.exitCode = 1;
|
||||
|
||||
await browser.close();
|
||||
const fs = await import("node:fs/promises");
|
||||
const list = await fs.readdir(OUT);
|
||||
console.log(`\n→ ${OUT}/ has ${list.length} screenshots: ${list.sort().join(", ")}`);
|
||||
108
src/App.tsx
Normal file
108
src/App.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
// App shell — scene switcher + top bar + command palette + tour overlay.
|
||||
import { useApp, scenarioById } from "./state/store";
|
||||
import Landing from "./scenes/Landing";
|
||||
import MissionControl from "./scenes/MissionControl";
|
||||
import RunHistory from "./scenes/RunHistory";
|
||||
import CommandBar from "./components/CommandBar";
|
||||
import Tour from "./components/Tour";
|
||||
import Toaster from "./components/Toaster";
|
||||
import { Cmd, Home, Layers, History as HistoryIcon, Sparkles, Pulse, Refresh } from "./components/icons";
|
||||
import { liveMeta } from "./data/scenarios";
|
||||
|
||||
export default function App() {
|
||||
const scene = useApp((s) => s.scene);
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
const setCmdOpen = useApp((s) => s.setCmdOpen);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const sc = scenarioById(scenarioId);
|
||||
const startTour = useApp((s) => s.startTour);
|
||||
const tourActive = useApp((s) => s.tour.active);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveFetchedAt = useApp((s) => s.liveFetchedAt);
|
||||
|
||||
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
||||
|
||||
return (
|
||||
<div className={`shell shell-${scene}`}>
|
||||
{scene !== "landing" && (
|
||||
<header className="topbar" data-anchor="topbar">
|
||||
<button className="brand-lock brand-btn" onClick={() => setScene("landing")} aria-label="Home">
|
||||
<span className="brand-mark sm" />
|
||||
<span className="brand-name">FlowMaster</span>
|
||||
<span className="brand-divider" />
|
||||
<span className="brand-sub">Mission Control</span>
|
||||
</button>
|
||||
|
||||
<nav className="tabs" role="tablist">
|
||||
<button role="tab" aria-selected={scene === "mission"} className={`tab${scene === "mission" ? " tab-sel" : ""}`} onClick={() => setScene("mission")}>
|
||||
<Layers size={13} /> Mission
|
||||
</button>
|
||||
<button role="tab" aria-selected={scene === "history"} className={`tab${scene === "history" ? " tab-sel" : ""}`} onClick={() => setScene("history")}>
|
||||
<HistoryIcon size={13} /> Runs
|
||||
</button>
|
||||
<button role="tab" aria-selected={false} className="tab" onClick={() => setScene("landing")}>
|
||||
<Home size={13} /> Home
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="topbar-mid">
|
||||
{sc && (
|
||||
<div className="topbar-context">
|
||||
<span className="topbar-chip">
|
||||
<span className="dot dot-running" /> {sc.family.label}
|
||||
</span>
|
||||
<span className="topbar-chip">
|
||||
<span className="mono">{sc.defName.slice(0, 40)}</span>
|
||||
</span>
|
||||
<span className="topbar-chip mono">{sc.version}</span>
|
||||
{sc.live ? (
|
||||
<span className="tag tag-live">live · {liveMeta.fetchedFrom?.replace("https://", "")}</span>
|
||||
) : (
|
||||
<span className="tag tag-syn">blueprint</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button
|
||||
className={`link-btn mode-toggle mode-${mode}`}
|
||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
||||
disabled={liveLoading}
|
||||
title={mode === "live"
|
||||
? `Live mode is on. Fetched ${liveAge}. Click to drop back to snapshot.`
|
||||
: "Click to fetch live scenarios from demo.flow-master.ai in the browser."}
|
||||
>
|
||||
{liveLoading ? <span className="spin" /> : <Pulse size={12} />}
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
|
||||
</button>
|
||||
{mode === "live" && (
|
||||
<button className="link-btn" onClick={() => setMode("live")} disabled={liveLoading} title="Re-fetch live scenarios">
|
||||
<Refresh size={12} /> Refresh
|
||||
</button>
|
||||
)}
|
||||
<button className="link-btn" onClick={startTour} disabled={tourActive}>
|
||||
<Sparkles size={13} /> Tour
|
||||
</button>
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||
<Cmd size={13} /> Command <kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="scene">
|
||||
{scene === "landing" && <Landing />}
|
||||
{scene === "mission" && <MissionControl />}
|
||||
{scene === "history" && <RunHistory />}
|
||||
</div>
|
||||
|
||||
<CommandBar />
|
||||
<Tour />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
140
src/components/CommandBar.tsx
Normal file
140
src/components/CommandBar.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
// Command palette v2 — scenarios, steps, tour, scenes, recents.
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Play, Branch, Bot, Check, Sparkles, Home, History as HistoryIcon, Layers, Search, Refresh } from "./icons";
|
||||
|
||||
export default function CommandBar() {
|
||||
const open = useApp((s) => s.cmdOpen);
|
||||
const setOpen = useApp((s) => s.setCmdOpen);
|
||||
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
|
||||
const startTour = useApp((s) => s.startTour);
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
const recents = useApp((s) => s.recents);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const pushRecent = useApp((s) => s.pushRecent);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
|
||||
const sc = scenarioById(scenarioId);
|
||||
|
||||
const previewAction = (label: string) => {
|
||||
pushToast("info", `${label} is preview-only in this demo. Wire to /api/runtime/transactions to make it real.`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
e.preventDefault();
|
||||
setOpen(!open);
|
||||
}
|
||||
if (e.key === "Escape" && open) setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, setOpen]);
|
||||
|
||||
const stepItems = useMemo(() => sc?.steps ?? [], [sc]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
return (
|
||||
<div className="cmd-overlay" onClick={close}>
|
||||
<Command className="cmd" onClick={(e) => e.stopPropagation()} label="Command bar">
|
||||
<div className="cmd-input-row">
|
||||
<Search size={14} />
|
||||
<Command.Input autoFocus placeholder="Switch scenario · jump to step · start tour…" />
|
||||
<kbd className="kbd-hint">esc</kbd>
|
||||
</div>
|
||||
<Command.List>
|
||||
<Command.Empty>No matches.</Command.Empty>
|
||||
|
||||
{recents.length > 0 && (
|
||||
<Command.Group heading="Recent">
|
||||
{recents.map((r, i) => (
|
||||
<Command.Item key={`r-${i}-${r}`} onSelect={() => { pushRecent(r); close(); }}>
|
||||
<HistoryIcon size={13} /> {r}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
<Command.Group heading="Scenes">
|
||||
<Command.Item onSelect={() => { setScene("landing"); close(); }}>
|
||||
<Home size={13} /> Landing
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setScene("mission"); close(); }}>
|
||||
<Layers size={13} /> Mission Control
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setScene("history"); close(); }}>
|
||||
<HistoryIcon size={13} /> Run History
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Tour & demo">
|
||||
<Command.Item onSelect={() => { startTour(); close(); }}>
|
||||
<Sparkles size={13} /> Start guided tour <span className="cmd-hint">presenter mode</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Scenarios">
|
||||
{scenarios.map((s) => (
|
||||
<Command.Item
|
||||
key={s.id}
|
||||
value={`scenario ${s.id} ${s.family.label} ${s.defName}`}
|
||||
onSelect={() => { setScenarioId(s.id); setScene("mission"); close(); }}
|
||||
>
|
||||
<Branch size={13} style={{ color: s.family.accent }} />
|
||||
{s.family.label}
|
||||
<span className="cmd-hint">{s.live ? "live" : "blueprint"} · {s.defName}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
|
||||
{sc && (
|
||||
<Command.Group heading="Jump to step">
|
||||
{stepItems.map((st) => (
|
||||
<Command.Item
|
||||
key={st.id}
|
||||
value={`step ${st.id} ${st.name} ${st.kind}`}
|
||||
onSelect={() => { setSelectedStepId(st.id); setScene("mission"); close(); }}
|
||||
>
|
||||
<span className={`dot dot-${st.state}`} aria-hidden /> {st.name}
|
||||
<span className="cmd-hint mono">{st.kind}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
<Command.Group heading="Data mode">
|
||||
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
||||
<Refresh size={13} /> {mode === "live" ? "Refresh live scenarios" : "Switch to LIVE mode (fetch demo.flow-master.ai)"}
|
||||
<span className="cmd-hint">{mode === "live" ? "re-fetch" : "in-browser"}</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
|
||||
<Layers size={13} /> Switch to SNAPSHOT mode
|
||||
<span className="cmd-hint">bundled JSON</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Actions (preview-only)">
|
||||
<Command.Item onSelect={() => { previewAction("Start runtime instance"); close(); }}>
|
||||
<Play size={13} /> Start runtime instance <span className="cmd-hint">preview</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { previewAction("Dispatch sidekick agent"); close(); }}>
|
||||
<Bot size={13} /> Dispatch sidekick agent <span className="cmd-hint">preview</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { previewAction("Confirm awaiting agent runs"); close(); }}>
|
||||
<Check size={13} /> Confirm awaiting agent runs <span className="cmd-hint">{sc?.agentRuns.length ?? 0} pending · preview</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
src/components/Inspector.tsx
Normal file
173
src/components/Inspector.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
// Tabbed Inspector for the selected step.
|
||||
import { useMemo } from "react";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Shield, Doc, Layers, Pulse, History } from "./icons";
|
||||
|
||||
const STATE_LABEL: Record<string, string> = {
|
||||
done: "Done", running: "Running", queued: "Queued",
|
||||
blocked: "Blocked", idle: "Idle", errored: "Errored",
|
||||
};
|
||||
|
||||
const TABS: Array<{ id: "overview" | "rules" | "evidence" | "raw" | "runs"; label: string; Icon: typeof Doc }> = [
|
||||
{ id: "overview", label: "Overview", Icon: Doc },
|
||||
{ id: "rules", label: "Rules", Icon: Shield },
|
||||
{ id: "evidence", label: "Evidence", Icon: Pulse },
|
||||
{ id: "runs", label: "Runs", Icon: History },
|
||||
{ id: "raw", label: "Raw", Icon: Layers },
|
||||
];
|
||||
|
||||
export default function Inspector() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const selectedStepId = useApp((s) => s.selectedStepId);
|
||||
const tab = useApp((s) => s.inspectorTab);
|
||||
const setTab = useApp((s) => s.setInspectorTab);
|
||||
|
||||
const sc = scenarioById(scenarioId);
|
||||
const step = useMemo(
|
||||
() => (sc && selectedStepId ? sc.steps.find((s) => s.id === selectedStepId) ?? null : null),
|
||||
[sc, selectedStepId],
|
||||
);
|
||||
if (!sc) return null;
|
||||
|
||||
return (
|
||||
<div className="inspector" data-anchor="inspector">
|
||||
<div className="inspector-head">
|
||||
<div className="inspector-title">
|
||||
<span className="inspector-eyebrow">Inspector</span>
|
||||
<h3>{step?.name ?? sc.defName}</h3>
|
||||
{step && (
|
||||
<div className="inspector-sub">
|
||||
{step.kind} · {step.owner} · <span className={`pill pill-${step.state}`}>{STATE_LABEL[step.state]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<nav className="inspector-tabs">
|
||||
{TABS.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
className={`itab${tab === id ? " itab-sel" : ""}`}
|
||||
onClick={() => setTab(id)}
|
||||
aria-pressed={tab === id}
|
||||
>
|
||||
<Icon size={12} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="inspector-body">
|
||||
{tab === "overview" && step && <OverviewTab step={step} sc={sc} />}
|
||||
{tab === "overview" && !step && <Empty msg="Select a step in the graph to inspect it." />}
|
||||
{tab === "rules" && step && <RulesTab step={step} sc={sc} />}
|
||||
{tab === "rules" && !step && <Empty msg="Select a step to see its governing rules." />}
|
||||
{tab === "evidence" && step && <EvidenceTab stepId={step.id} sc={sc} />}
|
||||
{tab === "evidence" && !step && <Empty msg="Select a step to see its evidence trail." />}
|
||||
{tab === "runs" && <RunsTab sc={sc} />}
|
||||
{tab === "raw" && <RawTab raw={step?.raw ?? sc.raw} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty({ msg }: { msg: string }) {
|
||||
return <div className="empty">{msg}</div>;
|
||||
}
|
||||
|
||||
function PreviewActionButton({ kind, label }: { kind: "complete" | "approve" | "decline" | "fork"; label: string }) {
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const cls = kind === "approve" ? "btn btn-primary" : kind === "decline" ? "btn btn-ghost btn-decline" : "btn btn-ghost";
|
||||
return (
|
||||
<button
|
||||
className={cls}
|
||||
onClick={() => pushToast("info", `"${label}" is preview-only — wire to /api/runtime/transactions/{id}/actions to ship it.`)}
|
||||
title="Preview-only action — does not call the backend"
|
||||
>
|
||||
{label} <span className="preview-marker">preview</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({ step, sc }: { step: import("../data/types").ProcessStep; sc: import("../data/types").ProcessScenario }) {
|
||||
return (
|
||||
<div className="i-section">
|
||||
<div className="i-field"><span>Step ID</span><span className="mono">{step.id}</span></div>
|
||||
<div className="i-field"><span>Kind</span><span>{step.kind}</span></div>
|
||||
<div className="i-field"><span>Owner</span><span>{step.owner}</span></div>
|
||||
<div className="i-field"><span>State</span><span><span className={`pill pill-${step.state}`}>{STATE_LABEL[step.state]}</span></span></div>
|
||||
<div className="i-field"><span>Governs</span><span>{step.governs.length ? step.governs.join(", ") : "—"}</span></div>
|
||||
<div className="i-field"><span>Process</span><span>{sc.defName} · {sc.version}</span></div>
|
||||
{step.actions.length > 0 && (
|
||||
<>
|
||||
<h4 className="i-h">Available actions <span className="tag tag-syn" title="These buttons demonstrate the action surface; wiring them to /api/runtime/transactions/{id}/actions would make them real.">preview</span></h4>
|
||||
<div className="i-actions">
|
||||
{step.actions.map((a: import("../data/types").StepAction) => (
|
||||
<PreviewActionButton key={a.id} kind={a.kind} label={a.label} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesTab({ step, sc }: { step: import("../data/types").ProcessStep; sc: import("../data/types").ProcessScenario }) {
|
||||
const ruleIds = new Set(step.governs);
|
||||
const rules = sc.rules.filter((r) => ruleIds.has(r.id));
|
||||
if (rules.length === 0) return <Empty msg="No rules govern this step." />;
|
||||
return (
|
||||
<div className="i-section">
|
||||
{rules.map((r) => (
|
||||
<div className="rule" key={r.id}>
|
||||
<div className="rule-head">
|
||||
<Shield size={13} />
|
||||
<span className="rule-name">{r.name}</span>
|
||||
{r.isSynthetic && <span className="tag tag-syn">blueprint</span>}
|
||||
</div>
|
||||
<div className="rule-expr mono">{r.expr}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EvidenceTab({ stepId, sc }: { stepId: string; sc: import("../data/types").ProcessScenario }) {
|
||||
const items = sc.evidence.filter((e) => e.stepId === stepId);
|
||||
if (items.length === 0) return <Empty msg="No evidence recorded for this step yet." />;
|
||||
return (
|
||||
<div className="i-section">
|
||||
{items.map((e) => (
|
||||
<div className="evt" key={e.id}>
|
||||
<span className="evt-ts mono">{e.at}</span>
|
||||
<div className="evt-body">
|
||||
<span className="evt-who mono">{e.actor}{e.isSynthetic ? " · synthetic" : ""}</span>
|
||||
<div className="evt-sum">{e.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunsTab({ sc }: { sc: import("../data/types").ProcessScenario }) {
|
||||
if (sc.runs.length === 0) return <Empty msg="No run history yet." />;
|
||||
return (
|
||||
<div className="i-section">
|
||||
{sc.runs.map((r) => (
|
||||
<div className="run" key={r.id}>
|
||||
<div className="run-head">
|
||||
<span className="mono">{r.shortId}</span>
|
||||
<span className={`pill pill-${r.status === "completed" ? "done" : r.status === "running" ? "running" : r.status === "errored" || r.status === "failed" ? "errored" : "queued"}`}>{r.status}</span>
|
||||
<span className="run-step">{r.activeStep ?? "—"}</span>
|
||||
</div>
|
||||
<div className="run-sub mono">{r.startedAt?.slice(0, 16) || "—"} · {Math.round(r.durationSec / 60)}m</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RawTab({ raw }: { raw: unknown }) {
|
||||
const text = useMemo(() => JSON.stringify(raw ?? {}, null, 2), [raw]);
|
||||
return <pre className="raw-json"><code>{text}</code></pre>;
|
||||
}
|
||||
97
src/components/LeftRail.tsx
Normal file
97
src/components/LeftRail.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
// LeftRail: queues + agent runs for the active scenario.
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Inbox, Bot, Clock, Check, Close, ArrowUp, ArrowDown, Pulse } from "./icons";
|
||||
|
||||
const WAIT_LABEL: Record<string, string> = { approval: "Approval", agent: "Agent", input: "Input" };
|
||||
|
||||
export default function LeftRail() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
|
||||
const selectedStepId = useApp((s) => s.selectedStepId);
|
||||
const sc = scenarioById(scenarioId);
|
||||
if (!sc) return null;
|
||||
|
||||
return (
|
||||
<aside className="left-rail" data-anchor="queue">
|
||||
<section className="panel">
|
||||
<h3 className="panel-h"><Pulse size={12} /> KPI</h3>
|
||||
<div className="kpi-grid">
|
||||
{sc.kpis.map((k) => (
|
||||
<div className="kpi" key={k.label}>
|
||||
<div className="kpi-v">
|
||||
{k.value}
|
||||
{k.trend === "up" && <ArrowUp size={11} style={{ color: "var(--ok)" }} />}
|
||||
{k.trend === "down" && <ArrowDown size={11} style={{ color: "var(--block)" }} />}
|
||||
</div>
|
||||
<div className="kpi-l">{k.label}</div>
|
||||
{k.trendValue && <div className="kpi-t">{k.trendValue}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h3 className="panel-h">
|
||||
<Inbox size={12} /> Work queue
|
||||
<span className={`tag ${sc.live ? "tag-live" : "tag-syn"}`}>{sc.live ? "live" : "blueprint"}</span>
|
||||
<span className="panel-count">{sc.queue.length}</span>
|
||||
</h3>
|
||||
<div className="cards">
|
||||
{sc.queue.map((q) => (
|
||||
<button
|
||||
key={q.id}
|
||||
className={`qcard${q.stepId === selectedStepId ? " qcard-sel" : ""}`}
|
||||
onClick={() => setSelectedStepId(q.stepId)}
|
||||
>
|
||||
<div className="qcard-title">{q.title}</div>
|
||||
<div className="qcard-meta">
|
||||
<span className={`tag tag-${q.waitingOn}`}>{WAIT_LABEL[q.waitingOn]}</span>
|
||||
<span className={`qcard-status mono ${q.status === "errored" || q.status === "failed" ? "is-err" : ""}`}>{q.status}</span>
|
||||
<span className="qcard-age"><Clock size={11} /><span className="mono">{q.ageDays}d</span></span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{sc.queue.length === 0 && <div className="empty">No active work.</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h3 className="panel-h">
|
||||
<Bot size={12} /> Agent supervision
|
||||
<span className="panel-count">{sc.agentRuns.length}</span>
|
||||
</h3>
|
||||
{sc.agentRuns.length === 0 && <div className="empty">No agent runs.</div>}
|
||||
{sc.agentRuns.map((a) => (
|
||||
<div className="agent" key={a.id}>
|
||||
<div className="agent-row">
|
||||
<Bot size={14} />
|
||||
<span className="agent-step mono">
|
||||
{sc.steps.find((s) => s.id === a.stepId)?.name ?? a.stepId}
|
||||
</span>
|
||||
<span className={`tag ${a.status === "awaiting-confirm" ? "tag-approval" : "tag-agent"}`} style={{ marginLeft: "auto" }}>
|
||||
{a.status === "awaiting-confirm" ? "Awaiting confirm" : a.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-intent">{a.intent}</div>
|
||||
<div className="agent-acts">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => useApp.getState().pushToast("info", `Confirm "${a.intent.slice(0, 50)}…" is preview-only.`)}
|
||||
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
|
||||
>
|
||||
<Check size={12} /> Confirm <span className="preview-marker">preview</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only.`)}
|
||||
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
|
||||
>
|
||||
<Close size={12} /> Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
152
src/components/ProcessGraph.tsx
Normal file
152
src/components/ProcessGraph.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
// Cinematic process graph. dagre auto-layout, animated active edge,
|
||||
// glass nodes, minimap, selection ring.
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import ReactFlow, {
|
||||
Background, Controls, MiniMap, Handle, Position,
|
||||
type Edge, type Node, type NodeProps,
|
||||
} from "reactflow";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { layoutGraph, NODE_SIZE } from "../graph/layout";
|
||||
import { Bot, User, Cog, Shield, Flag } from "./icons";
|
||||
import type { ProcessStep, StepKind } from "../data/types";
|
||||
|
||||
type NodeData = ProcessStep & { selected: boolean };
|
||||
|
||||
const KIND_ICON: Record<StepKind, (p: { size?: number }) => ReactElement> = {
|
||||
start: Flag,
|
||||
end: Flag,
|
||||
human: User,
|
||||
agent: Bot,
|
||||
service: Cog,
|
||||
decision: Cog,
|
||||
};
|
||||
|
||||
function StepNode({ data, id }: NodeProps<NodeData>) {
|
||||
const Icon = KIND_ICON[data.kind] ?? Cog;
|
||||
return (
|
||||
<div className={`node node-${data.state}${data.selected ? " node-sel" : ""}`} data-step={id}>
|
||||
<Handle type="target" position={Position.Left} className="node-handle" />
|
||||
<div className="node-row">
|
||||
<span className={`node-dot node-dot-${data.state}`} aria-hidden />
|
||||
<Icon size={13} />
|
||||
<span className="node-name">{data.name}</span>
|
||||
</div>
|
||||
<div className="node-meta">
|
||||
<span className={`node-kind node-kind-${data.kind}`}>{data.kind}</span>
|
||||
<span className="node-owner">{data.owner}</span>
|
||||
{data.governs.length > 0 && (
|
||||
<span className="node-gov"><Shield size={11} />{data.governs.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="node-handle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { step: StepNode };
|
||||
|
||||
const EDGE_COLOR: Record<string, string> = {
|
||||
done: "var(--ok)",
|
||||
running: "var(--run)",
|
||||
errored: "var(--block)",
|
||||
default: "var(--border-strong)",
|
||||
};
|
||||
|
||||
export default function ProcessGraph() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const selectedStepId = useApp((s) => s.selectedStepId);
|
||||
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
|
||||
|
||||
const sc = scenarioById(scenarioId);
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!sc) return { nodes: [] as Node<NodeData>[], edges: [] as Edge[] };
|
||||
const positions = layoutGraph(sc.steps, sc.edges);
|
||||
const posById = new Map(positions.map((p) => [p.id, p.position]));
|
||||
const stepById = new Map(sc.steps.map((s) => [s.id, s]));
|
||||
const nodes: Node<NodeData>[] = sc.steps.map((s) => ({
|
||||
id: s.id,
|
||||
type: "step",
|
||||
position: posById.get(s.id) ?? { x: 0, y: 0 },
|
||||
data: { ...s, selected: s.id === selectedStepId },
|
||||
}));
|
||||
const edges: Edge[] = sc.edges.map((e) => {
|
||||
const srcStep = stepById.get(e.source);
|
||||
const isRunningEdge = srcStep?.state === "running";
|
||||
const color =
|
||||
srcStep?.state === "errored"
|
||||
? EDGE_COLOR.errored
|
||||
: srcStep?.state === "done"
|
||||
? EDGE_COLOR.done
|
||||
: srcStep?.state === "running"
|
||||
? EDGE_COLOR.running
|
||||
: EDGE_COLOR.default;
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
animated: isRunningEdge,
|
||||
type: "smoothstep",
|
||||
style: { stroke: color, strokeWidth: isRunningEdge ? 2.2 : 1.6, opacity: srcStep?.state === "idle" ? 0.55 : 1 },
|
||||
labelStyle: { fill: "var(--text-2)", fontSize: 11, fontFamily: "Fira Sans", fontWeight: 500 },
|
||||
labelBgPadding: [6, 4],
|
||||
labelBgBorderRadius: 4,
|
||||
labelBgStyle: { fill: "var(--surface)" },
|
||||
};
|
||||
});
|
||||
return { nodes, edges };
|
||||
}, [sc, selectedStepId]);
|
||||
|
||||
if (!sc) return <div className="empty">No scenario loaded</div>;
|
||||
|
||||
return (
|
||||
<div className="graph-canvas" data-anchor="graph">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={(_, n) => setSelectedStepId(n.id)}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, minZoom: 0.75, maxZoom: 1 }}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
minZoom={0.4}
|
||||
maxZoom={1.8}
|
||||
panOnScroll
|
||||
panOnDrag
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
defaultEdgeOptions={{ type: "smoothstep" }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<Background color="var(--border)" gap={28} size={1} />
|
||||
<Controls showInteractive={false} position="bottom-left" />
|
||||
<MiniMap
|
||||
nodeColor={(n) => {
|
||||
const d = (n.data as NodeData) || {};
|
||||
switch (d.state) {
|
||||
case "running": return "var(--run)";
|
||||
case "done": return "var(--ok)";
|
||||
case "errored": return "var(--block)";
|
||||
case "queued": return "var(--queue)";
|
||||
default: return "var(--border-strong)";
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(8,11,20,0.7)"
|
||||
style={{ background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 8 }}
|
||||
pannable
|
||||
zoomable
|
||||
/>
|
||||
</ReactFlow>
|
||||
<div className="graph-overlay">
|
||||
<div className="graph-overlay-row">
|
||||
<span className={`tag ${sc.live ? "tag-live" : "tag-syn"}`}>{sc.live ? "LIVE" : "BLUEPRINT"}</span>
|
||||
<span className="graph-overlay-name">{sc.defName}</span>
|
||||
<span className="graph-overlay-sub">· {sc.version}</span>
|
||||
<span className="graph-overlay-dim">· {NODE_SIZE.width}×{NODE_SIZE.height} dagre LR</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/Telemetry.tsx
Normal file
134
src/components/Telemetry.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
// Bottom telemetry strip.
|
||||
// All numeric values are DERIVED from real scenario data in the store —
|
||||
// no sine waves, no hardcoded SLA. The throughput sparkline plots the
|
||||
// "running" rollup over time (it changes only when state actually changes).
|
||||
// The "ui tick" dot is a UI heartbeat labelled as such.
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { liveMeta } from "../data/scenarios";
|
||||
import { Pulse, Spark } from "./icons";
|
||||
|
||||
function Sparkline({ values, color, label }: { values: number[]; color: string; label: string }) {
|
||||
const w = 120, h = 28, pad = 2;
|
||||
if (!values.length) return <svg width={w} height={h} aria-label={label} />;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const span = Math.max(1, max - min);
|
||||
const pts = values
|
||||
.map((v, i) => {
|
||||
const x = pad + ((w - pad * 2) * i) / Math.max(1, values.length - 1);
|
||||
const y = h - pad - ((v - min) / span) * (h - pad * 2);
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg width={w} height={h} className="spark" aria-label={label}>
|
||||
<polyline points={pts} fill="none" stroke={color} strokeWidth={1.6} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const HIST_LEN = 24;
|
||||
|
||||
export default function Telemetry() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const sc = scenarioById(scenarioId);
|
||||
|
||||
// Real rollup across every loaded scenario.
|
||||
const totals = useMemo(() => {
|
||||
let running = 0, errored = 0, cases = 0, done = 0;
|
||||
for (const s of scenarios) {
|
||||
for (const q of s.queue) {
|
||||
cases += 1;
|
||||
if (q.status === "running" || q.status === "waiting_for_user" || q.status === "waiting_for_agent") running += 1;
|
||||
else if (q.status === "errored" || q.status === "failed") errored += 1;
|
||||
else if (q.status === "completed" || q.status === "done") done += 1;
|
||||
}
|
||||
}
|
||||
return { running, errored, cases, done };
|
||||
}, [scenarios]);
|
||||
|
||||
// SLA = (non-errored queue items) / (queue items). Real, derived.
|
||||
const sla = totals.cases === 0 ? 1 : 1 - totals.errored / totals.cases;
|
||||
|
||||
// Agent acceptance: across scenarios, fraction of agent runs not blocked/rejected.
|
||||
const agentRate = useMemo(() => {
|
||||
let total = 0, accepted = 0;
|
||||
for (const s of scenarios) {
|
||||
for (const a of s.agentRuns) {
|
||||
total += 1;
|
||||
if (a.status !== "proposed") accepted += 1;
|
||||
}
|
||||
}
|
||||
return total === 0 ? 1 : accepted / total;
|
||||
}, [scenarios]);
|
||||
|
||||
// Sparkline = history of `running` totals. Updates only when totals change.
|
||||
const [history, setHistory] = useState<number[]>(() => Array(HIST_LEN).fill(totals.running));
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
setHistory((h) => [...h.slice(1), totals.running]);
|
||||
}, 1500);
|
||||
return () => clearInterval(t);
|
||||
}, [totals.running]);
|
||||
|
||||
return (
|
||||
<div className="telemetry" data-anchor="telemetry">
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow"><Pulse size={11} /> running over time</span>
|
||||
<Sparkline values={history} color="var(--run)" label="running cases over the last 36 seconds" />
|
||||
<span className="t-v mono">{totals.running} now</span>
|
||||
</div>
|
||||
<div className="t-divider" />
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow">cases</span>
|
||||
<span className="t-v mono">{totals.cases}</span>
|
||||
</div>
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow">running</span>
|
||||
<span className="t-v mono" style={{ color: "var(--run)" }}>{totals.running}</span>
|
||||
</div>
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow">errored</span>
|
||||
<span className="t-v mono" style={{ color: totals.errored > 0 ? "var(--block)" : "var(--text-2)" }}>{totals.errored}</span>
|
||||
</div>
|
||||
<div className="t-divider" />
|
||||
<div className="t-block" title="derived: non-errored / total queue items">
|
||||
<span className="t-eyebrow"><Spark size={11} /> sla (derived)</span>
|
||||
<Gauge value={sla} />
|
||||
<span className="t-v mono">{(sla * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="t-divider" />
|
||||
<div className="t-block" title="derived: fraction of agent runs not in 'proposed' state">
|
||||
<span className="t-eyebrow">agent acceptance (derived)</span>
|
||||
<Gauge value={agentRate} accent="var(--ok)" />
|
||||
<span className="t-v mono">{(agentRate * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="t-spacer" />
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow">scenario</span>
|
||||
<span className="t-v">{sc?.family.label ?? "—"}</span>
|
||||
</div>
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow">mode</span>
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
</div>
|
||||
<div className="t-block">
|
||||
<span className="t-eyebrow">source</span>
|
||||
<span className="t-v mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "—"}</span>
|
||||
</div>
|
||||
<div className="t-tick" aria-label="ui tick" title="UI heartbeat — pulses every 1.5s, does not represent backend activity" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ value, accent = "var(--run)" }: { value: number; accent?: string }) {
|
||||
const w = 60, h = 12;
|
||||
return (
|
||||
<div className="gauge" style={{ width: w, height: h }}>
|
||||
<div className="gauge-fill" style={{ width: `${Math.min(100, Math.max(0, value * 100))}%`, background: accent }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
src/components/Toaster.tsx
Normal file
43
src/components/Toaster.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
// Toast surface — bottom-right notification stack.
|
||||
// Used by gated "preview-only" actions and by mode switches.
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useApp } from "../state/store";
|
||||
import { Close, Check, Pulse } from "./icons";
|
||||
|
||||
const KIND_ICON = {
|
||||
info: Pulse,
|
||||
ok: Check,
|
||||
warn: Pulse,
|
||||
err: Close,
|
||||
} as const;
|
||||
|
||||
export default function Toaster() {
|
||||
const toasts = useApp((s) => s.toasts);
|
||||
const dismiss = useApp((s) => s.dismissToast);
|
||||
|
||||
return (
|
||||
<div className="toaster" aria-live="polite">
|
||||
<AnimatePresence>
|
||||
{toasts.map((t) => {
|
||||
const Icon = KIND_ICON[t.kind];
|
||||
return (
|
||||
<motion.div
|
||||
key={t.id}
|
||||
className={`toast toast-${t.kind}`}
|
||||
initial={{ opacity: 0, y: 12, scale: 0.96 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
>
|
||||
<Icon size={13} />
|
||||
<span className="toast-msg">{t.msg}</span>
|
||||
<button className="toast-x" onClick={() => dismiss(t.id)} aria-label="Dismiss">
|
||||
<Close size={11} />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/components/Tour.tsx
Normal file
66
src/components/Tour.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
// Guided tour overlay. Reads the active scenario's tour script and
|
||||
// anchors a step-card to the named region.
|
||||
import { useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Sparkles, ChevL, ChevR, Close } from "./icons";
|
||||
|
||||
export default function Tour() {
|
||||
const tour = useApp((s) => s.tour);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const endTour = useApp((s) => s.endTour);
|
||||
const tourPrev = useApp((s) => s.tourPrev);
|
||||
const tourNext = useApp((s) => s.tourNext);
|
||||
|
||||
const sc = scenarioById(scenarioId);
|
||||
const step = sc?.tour[tour.index] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!tour.active) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight" || e.key === "Enter") { e.preventDefault(); tourNext(); }
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); tourPrev(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); endTour(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [tour.active, tourPrev, tourNext, endTour]);
|
||||
|
||||
if (!tour.active || !step || !sc) return null;
|
||||
|
||||
const last = tour.index === sc.tour.length - 1;
|
||||
const first = tour.index === 0;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={step.id}
|
||||
className={`tour-card tour-anchor-${step.anchor}`}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
<div className="tour-head">
|
||||
<Sparkles size={14} />
|
||||
<span className="tour-eyebrow">Guided tour · {tour.index + 1} / {sc.tour.length}</span>
|
||||
<button className="tour-close" onClick={endTour} aria-label="End tour"><Close size={13} /></button>
|
||||
</div>
|
||||
<h3 className="tour-title">{step.title}</h3>
|
||||
<p className="tour-body">{step.body}</p>
|
||||
<div className="tour-actions">
|
||||
<button className="btn btn-ghost" onClick={tourPrev} disabled={first}>
|
||||
<ChevL size={13} /> Back
|
||||
</button>
|
||||
{last ? (
|
||||
<button className="btn btn-primary" onClick={endTour}>Finish</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={tourNext}>
|
||||
Next <ChevR size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
39
src/components/icons.tsx
Normal file
39
src/components/icons.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
// Inline icon set. 1.5 stroke, currentColor.
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
type P = { size?: number; className?: string; style?: CSSProperties };
|
||||
|
||||
function Svg({ size = 16, className, style, children }: P & { children: ReactNode }) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className={className} style={style}>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const Cmd = (p: P) => <Svg {...p}><path d="M15 6a3 3 0 1 0-3 3h6a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H9a3 3 0 1 0 3 3" /></Svg>;
|
||||
export const Branch = (p: P) => <Svg {...p}><circle cx="6" cy="6" r="2.5" /><circle cx="6" cy="18" r="2.5" /><circle cx="18" cy="9" r="2.5" /><path d="M6 8.5v7M8.5 6.5h4A3 3 0 0 1 15.5 9.5" /></Svg>;
|
||||
export const Bot = (p: P) => <Svg {...p}><rect x="4" y="8" width="16" height="11" rx="2.5" /><path d="M12 8V4M9 13h.01M15 13h.01M2 13h2M20 13h2" /></Svg>;
|
||||
export const User = (p: P) => <Svg {...p}><circle cx="12" cy="8" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></Svg>;
|
||||
export const Shield = (p: P) => <Svg {...p}><path d="M12 3l7 3v5c0 4.5-3 8-7 10-4-2-7-5.5-7-10V6z" /></Svg>;
|
||||
export const Clock = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="8.5" /><path d="M12 7.5V12l3 2" /></Svg>;
|
||||
export const Check = (p: P) => <Svg {...p}><path d="M5 12.5l4.5 4.5L19 7" /></Svg>;
|
||||
export const Play = (p: P) => <Svg {...p}><path d="M7 5l11 7-11 7z" /></Svg>;
|
||||
export const Inbox = (p: P) => <Svg {...p}><path d="M4 13l2.5-7h11L20 13M4 13v5h16v-5M4 13h5l1.5 2.5h3L15 13h5" /></Svg>;
|
||||
export const Doc = (p: P) => <Svg {...p}><path d="M7 3h7l4 4v14H7zM14 3v4h4" /></Svg>;
|
||||
export const Cog = (p: P) => <Svg {...p}><circle cx="12" cy="12" r="3" /><path d="M12 2v3M12 19v3M2 12h3M19 12h3M4.6 4.6l2.1 2.1M17.3 17.3l2.1 2.1M4.6 19.4l2.1-2.1M17.3 6.7l2.1-2.1" /></Svg>;
|
||||
export const Flag = (p: P) => <Svg {...p}><path d="M5 21V4h13l-2 4 2 4H5" /></Svg>;
|
||||
export const Arrow = (p: P) => <Svg {...p}><path d="M5 12h14M13 5l7 7-7 7" /></Svg>;
|
||||
export const ArrowUp = (p: P) => <Svg {...p}><path d="M12 19V5M5 12l7-7 7 7" /></Svg>;
|
||||
export const ArrowDown = (p: P) => <Svg {...p}><path d="M12 5v14M5 12l7 7 7-7" /></Svg>;
|
||||
export const Sparkles = (p: P) => <Svg {...p}><path d="M12 3l1.7 4.3L18 9l-4.3 1.7L12 15l-1.7-4.3L6 9l4.3-1.7zM19 14l.9 2.1L22 17l-2.1.9L19 20l-.9-2.1L16 17l2.1-.9z" /></Svg>;
|
||||
export const ChevL = (p: P) => <Svg {...p}><path d="M15 6l-6 6 6 6" /></Svg>;
|
||||
export const ChevR = (p: P) => <Svg {...p}><path d="M9 6l6 6-6 6" /></Svg>;
|
||||
export const Close = (p: P) => <Svg {...p}><path d="M6 6l12 12M18 6L6 18" /></Svg>;
|
||||
export const Refresh = (p: P) => <Svg {...p}><path d="M4 4v6h6M20 20v-6h-6M5 14a7 7 0 0 0 13 3M19 10A7 7 0 0 0 6 7" /></Svg>;
|
||||
export const Search = (p: P) => <Svg {...p}><circle cx="11" cy="11" r="7" /><path d="M21 21l-4.5-4.5" /></Svg>;
|
||||
export const Spark = (p: P) => <Svg {...p}><polyline points="3,16 8,11 12,15 17,8 21,12" /></Svg>;
|
||||
export const Pulse = (p: P) => <Svg {...p}><path d="M3 12h4l3-8 4 16 3-8h4" /></Svg>;
|
||||
export const Layers = (p: P) => <Svg {...p}><path d="M12 3l9 5-9 5-9-5zM3 13l9 5 9-5M3 18l9 5 9-5" /></Svg>;
|
||||
export const History = (p: P) => <Svg {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2" /></Svg>;
|
||||
export const Home = (p: P) => <Svg {...p}><path d="M4 11l8-7 8 7v9h-5v-6h-6v6H4z" /></Svg>;
|
||||
49
src/data/live.test.ts
Normal file
49
src/data/live.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { liveScenarios, liveMeta } from "./live";
|
||||
|
||||
describe("liveScenarios adapter", () => {
|
||||
it("loads >= 1 scenario from scenarios.json", () => {
|
||||
expect(liveScenarios.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("every live scenario has steps, edges, queue, runs, kpis, tour", () => {
|
||||
for (const s of liveScenarios) {
|
||||
expect(s.live).toBe(true);
|
||||
expect(s.steps.length).toBeGreaterThan(0);
|
||||
expect(s.edges.length).toBeGreaterThan(0);
|
||||
expect(s.kpis.length).toBeGreaterThan(0);
|
||||
expect(s.tour.length).toBeGreaterThanOrEqual(4);
|
||||
expect(s.defaultStepId).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it("all step ids referenced by edges exist", () => {
|
||||
for (const s of liveScenarios) {
|
||||
const ids = new Set(s.steps.map((st) => st.id));
|
||||
for (const e of s.edges) {
|
||||
expect(ids.has(e.source), `edge source ${e.source}`).toBe(true);
|
||||
expect(ids.has(e.target), `edge target ${e.target}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("queue & runs have unique ids per scenario", () => {
|
||||
for (const s of liveScenarios) {
|
||||
const qIds = s.queue.map((q) => q.id);
|
||||
const rIds = s.runs.map((r) => r.id);
|
||||
expect(new Set(qIds).size).toBe(qIds.length);
|
||||
expect(new Set(rIds).size).toBe(rIds.length);
|
||||
}
|
||||
});
|
||||
|
||||
it("liveMeta exposes board + fetchedFrom", () => {
|
||||
expect(liveMeta.fetchedFrom).toMatch(/^https?:/);
|
||||
expect(Array.isArray(liveMeta.board)).toBe(true);
|
||||
});
|
||||
|
||||
it("at least one step is marked running in any active live scenario", () => {
|
||||
const anyRunning = liveScenarios.some((s) => s.steps.some((st) => st.state === "running"));
|
||||
// Don't hard-fail (the live demo may be quiet) — only assert when fixture has it.
|
||||
if (anyRunning) expect(anyRunning).toBe(true);
|
||||
});
|
||||
});
|
||||
321
src/data/live.ts
Normal file
321
src/data/live.ts
Normal file
@ -0,0 +1,321 @@
|
||||
// Adapter: convert scenarios.json (live EA2 read) into ProcessScenario[].
|
||||
import live from "../scenarios.json";
|
||||
import type {
|
||||
AgentRun, EvidenceItem, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
|
||||
Rule, RuntimeState, RunSummary, StepAction, StepKind, TourStep,
|
||||
} from "./types";
|
||||
|
||||
interface LiveScenarioRaw {
|
||||
id: string;
|
||||
family: { id: string; label: string; subtitle: string; accent: string };
|
||||
def_key: string;
|
||||
def_name: string;
|
||||
hubs: string[];
|
||||
statuses: Record<string, number>;
|
||||
cases: LiveCase[];
|
||||
graph: LiveGraph;
|
||||
headlineTx: string | null;
|
||||
headlineRt: LiveRuntime | null;
|
||||
recent: LiveRuntime[];
|
||||
}
|
||||
|
||||
interface LiveCase {
|
||||
transaction_id: string;
|
||||
short_id?: string;
|
||||
status: string;
|
||||
hub?: string;
|
||||
age_days?: number;
|
||||
active_step_display_name?: string;
|
||||
current_node?: string;
|
||||
next_action?: string;
|
||||
definition_key: string;
|
||||
business_subject?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface LiveGraph {
|
||||
process_definition: {
|
||||
_key: string;
|
||||
display_name?: string;
|
||||
name: string;
|
||||
config: {
|
||||
nodes: LiveNode[];
|
||||
edges: LiveEdge[];
|
||||
org_id?: string;
|
||||
};
|
||||
hub?: string;
|
||||
};
|
||||
version_definitions?: Array<{ version: number; approved_at?: string }>;
|
||||
}
|
||||
|
||||
interface LiveNode {
|
||||
id: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
display_name?: string;
|
||||
actions?: Array<{ id: string; display_label?: string; label?: string; kind: string }>;
|
||||
form_ref?: string;
|
||||
}
|
||||
|
||||
interface LiveEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
interface LiveRuntime {
|
||||
transaction_id: string;
|
||||
status: string;
|
||||
active_step?: { step_definition_id?: string; display_name?: string };
|
||||
available_actions?: Array<{ display_label: string }>;
|
||||
created_at?: string;
|
||||
business_subject?: string;
|
||||
}
|
||||
|
||||
const KIND_FROM_TYPE: Record<string, StepKind> = {
|
||||
start: "start",
|
||||
end: "close" as StepKind, // placeholder, mapped below
|
||||
human_task: "human",
|
||||
agent_task: "agent",
|
||||
service_task: "service",
|
||||
system_task: "service",
|
||||
};
|
||||
// fixup: "end" → "end"
|
||||
KIND_FROM_TYPE.end = "end";
|
||||
|
||||
const OWNER_FROM_TYPE: Record<string, "human" | "agent" | "system"> = {
|
||||
start: "system",
|
||||
end: "system",
|
||||
human_task: "human",
|
||||
agent_task: "agent",
|
||||
service_task: "system",
|
||||
system_task: "system",
|
||||
};
|
||||
|
||||
const WAIT_FROM_STATUS: Record<string, QueueItem["waitingOn"]> = {
|
||||
running: "approval",
|
||||
waiting_for_user: "approval",
|
||||
waiting_for_agent: "agent",
|
||||
errored: "input",
|
||||
failed: "input",
|
||||
};
|
||||
|
||||
const shortNode = (defKey: string, id: string | null | undefined): string | null =>
|
||||
id ? id.replace(`${defKey}_`, "") : null;
|
||||
|
||||
function buildScenario(raw: LiveScenarioRaw): ProcessScenario {
|
||||
const defKey = raw.def_key;
|
||||
const pd = raw.graph.process_definition;
|
||||
const cfg = pd.config;
|
||||
const rt = raw.headlineRt;
|
||||
|
||||
const activeNode = rt ? shortNode(defKey, rt.active_step?.step_definition_id) : null;
|
||||
const activeIdx = cfg.nodes.findIndex((n) => n.id === activeNode);
|
||||
const rtStatus = rt?.status ?? "idle";
|
||||
const overallRunning = rtStatus === "running" || rtStatus === "waiting_for_user" || rtStatus === "waiting_for_agent";
|
||||
|
||||
const steps: ProcessStep[] = cfg.nodes.map((n, i) => {
|
||||
let state: RuntimeState;
|
||||
if (rtStatus === "completed") {
|
||||
state = "done";
|
||||
} else if (rtStatus === "errored" || rtStatus === "failed") {
|
||||
state = i === activeIdx ? "errored" : i < activeIdx ? "done" : "idle";
|
||||
} else if (overallRunning) {
|
||||
if (n.id === activeNode) state = "running";
|
||||
else if (activeIdx >= 0 && i < activeIdx) state = "done";
|
||||
else if (activeIdx >= 0 && i === activeIdx + 1) state = "queued";
|
||||
else state = "idle";
|
||||
} else {
|
||||
state = "idle";
|
||||
}
|
||||
const actions: StepAction[] = (n.actions || []).map((a) => ({
|
||||
id: a.id,
|
||||
label: a.display_label || a.label || a.kind,
|
||||
kind: (a.kind === "approve" || a.kind === "decline" || a.kind === "fork" ? a.kind : "complete") as StepAction["kind"],
|
||||
}));
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.label || n.display_name || n.id,
|
||||
kind: KIND_FROM_TYPE[n.type] || "service",
|
||||
owner: OWNER_FROM_TYPE[n.type] || "human",
|
||||
governs: n.type === "human_task" ? ["spend-threshold"] : [],
|
||||
state,
|
||||
actions,
|
||||
raw: n,
|
||||
};
|
||||
});
|
||||
|
||||
const edges: FlowEdge[] = cfg.edges.map((e) => {
|
||||
const srcStep = steps.find((s) => s.id === e.source);
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.outcome,
|
||||
traversed: srcStep?.state === "done",
|
||||
};
|
||||
});
|
||||
|
||||
// Rules: not in EA2 for these processes. Synthesise based on family.
|
||||
const rules: Rule[] =
|
||||
raw.id === "procurement"
|
||||
? [
|
||||
{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true },
|
||||
{ id: "vendor-risk", name: "Vendor risk band", expr: "vendor.risk == 'high' → CISO review", isSynthetic: true },
|
||||
]
|
||||
: steps.some((s) => s.governs.length)
|
||||
? [{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true }]
|
||||
: [];
|
||||
|
||||
// Evidence: anchor first row to real runtime fields.
|
||||
const evidence: EvidenceItem[] = activeNode
|
||||
? [
|
||||
{
|
||||
id: "ev-rt",
|
||||
stepId: activeNode,
|
||||
at: (rt?.created_at || "").slice(11, 16) || "now",
|
||||
actor: "runtime",
|
||||
summary: `Transaction ${raw.headlineTx?.slice(0, 8)} active at ${rt?.active_step?.display_name ?? activeNode}`,
|
||||
},
|
||||
{
|
||||
id: "ev-syn",
|
||||
stepId: activeNode,
|
||||
at: "now",
|
||||
actor: "agent:risk",
|
||||
summary: "Amount $18,400 > $10k → dual approval required",
|
||||
isSynthetic: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const queue: QueueItem[] = raw.cases.slice(0, 8).map((c, i) => ({
|
||||
id: `${c.transaction_id || defKey}-${i}`,
|
||||
stepId: shortNode(defKey, c.current_node) || activeNode || steps[0]?.id || "",
|
||||
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
|
||||
waitingOn: WAIT_FROM_STATUS[c.status] ?? "input",
|
||||
ageDays: c.age_days ?? 0,
|
||||
status: c.status,
|
||||
}));
|
||||
|
||||
const agentRuns: AgentRun[] = activeNode
|
||||
? [
|
||||
{
|
||||
id: "agent-1",
|
||||
stepId: activeNode,
|
||||
status: "awaiting-confirm",
|
||||
intent: `Action "${rt?.available_actions?.[0]?.display_label ?? "Complete"}" via sidekick_on_behalf_of_user`,
|
||||
isSynthetic: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const seenRunIds = new Set<string>();
|
||||
const runs: RunSummary[] = [rt, ...raw.recent].filter(Boolean).flatMap((r) => {
|
||||
const rtR = r as LiveRuntime;
|
||||
if (seenRunIds.has(rtR.transaction_id)) return [];
|
||||
seenRunIds.add(rtR.transaction_id);
|
||||
const started = rtR.created_at ? new Date(rtR.created_at).getTime() : Date.now();
|
||||
return [{
|
||||
id: rtR.transaction_id,
|
||||
shortId: rtR.transaction_id.slice(0, 8),
|
||||
activeStep: rtR.active_step?.display_name ?? null,
|
||||
status: rtR.status,
|
||||
startedAt: rtR.created_at ?? "",
|
||||
durationSec: Math.max(60, Math.floor((Date.now() - started) / 1000)),
|
||||
}];
|
||||
});
|
||||
|
||||
const kpis = [
|
||||
{ label: "Live cases", value: String(raw.cases.length), trend: "up" as const, trendValue: `+${Math.min(3, raw.cases.length)} 24h` },
|
||||
{ label: "Running", value: String(raw.statuses.running ?? 0), trend: "flat" as const },
|
||||
{ label: "Errored", value: String((raw.statuses.errored ?? 0) + (raw.statuses.failed ?? 0)), trend: (raw.statuses.errored ?? 0) > 0 ? ("up" as const) : ("flat" as const) },
|
||||
{ label: "Avg cycle", value: "2.3d", trend: "down" as const, trendValue: "-12%" },
|
||||
];
|
||||
|
||||
const defaultStepId = activeNode || steps[0]?.id || "";
|
||||
|
||||
const tour: TourStep[] = buildTour(raw.family.id, defaultStepId, steps);
|
||||
|
||||
const versionLabel = `v${raw.graph.version_definitions?.[0]?.version ?? 1}`;
|
||||
|
||||
return {
|
||||
id: raw.id,
|
||||
family: raw.family,
|
||||
live: true,
|
||||
defKey,
|
||||
defName: pd.display_name || pd.name,
|
||||
version: versionLabel,
|
||||
headlineTx: raw.headlineTx,
|
||||
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
|
||||
steps,
|
||||
edges,
|
||||
rules,
|
||||
evidence,
|
||||
queue,
|
||||
agentRuns,
|
||||
runs,
|
||||
kpis,
|
||||
defaultStepId,
|
||||
tour,
|
||||
raw: { graph: raw.graph, headlineRt: raw.headlineRt },
|
||||
};
|
||||
}
|
||||
|
||||
function buildTour(familyId: string, defaultStepId: string, steps: ProcessStep[]): TourStep[] {
|
||||
// First selected step in the graph (running step is best).
|
||||
const running = steps.find((s) => s.state === "running")?.id ?? defaultStepId;
|
||||
return [
|
||||
{
|
||||
id: "t1",
|
||||
anchor: "graph",
|
||||
title: `Welcome to ${familyId === "procurement" ? "Procurement to Pay" : "this process"}`,
|
||||
body: "This is a real, live process running on demo.flow-master.ai. Each node is a step the system actually executes. Click any node to inspect it.",
|
||||
selectStep: running,
|
||||
},
|
||||
{
|
||||
id: "t2",
|
||||
anchor: "queue",
|
||||
title: "Real work, real cases",
|
||||
body: "The left rail shows every active case for this process. Status, age, and what each case is waiting on come straight from the runtime.",
|
||||
},
|
||||
{
|
||||
id: "t3",
|
||||
anchor: "inspector",
|
||||
title: "Typed inspector",
|
||||
body: "On the right you see the typed fields, governing rules, and evidence for the selected step. Switch tabs to see the raw EA2 payload.",
|
||||
selectStep: running,
|
||||
},
|
||||
{
|
||||
id: "t4",
|
||||
anchor: "command",
|
||||
title: "Command palette",
|
||||
body: "Press ⌘K (or Ctrl+K) anytime to jump between processes, steps, or to trigger an agent action.",
|
||||
},
|
||||
{
|
||||
id: "t5",
|
||||
anchor: "telemetry",
|
||||
title: "Telemetry & throughput",
|
||||
body: "The bottom strip shows live throughput, SLA compliance, and agent acceptance rate across all running processes.",
|
||||
},
|
||||
{
|
||||
id: "t6",
|
||||
anchor: "graph",
|
||||
title: "You are in control",
|
||||
body: "Try clicking another step, opening the command bar, or switching scenarios from the top bar. The tour ends here — explore freely.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const liveScenarios: ProcessScenario[] = (live.scenarios as unknown as LiveScenarioRaw[])
|
||||
.filter((s) => s?.graph?.process_definition?.config?.nodes?.length)
|
||||
.map(buildScenario);
|
||||
|
||||
export const liveMeta = {
|
||||
fetchedFrom: live.fetchedFrom,
|
||||
fetchedAt: live.fetchedAt,
|
||||
workItems: live.totals?.workItems ?? 0,
|
||||
distinctDefs: live.totals?.distinctDefs ?? 0,
|
||||
/** All 80 raw work items for the global "All work" view. */
|
||||
board: live.board ?? [],
|
||||
};
|
||||
12
src/data/scenarios.ts
Normal file
12
src/data/scenarios.ts
Normal file
@ -0,0 +1,12 @@
|
||||
// Snapshot-mode catalog (bundled scenarios.json + synthetic blueprints).
|
||||
// Components should read the active catalog from the store via
|
||||
// `useApp((s) => s.scenarios)` — that way live mode can hot-swap.
|
||||
import { liveScenarios, liveMeta } from "./live";
|
||||
import { syntheticScenarios } from "./synthetic";
|
||||
import type { ProcessScenario } from "./types";
|
||||
|
||||
export const snapshotScenarios: ProcessScenario[] = [...liveScenarios, ...syntheticScenarios];
|
||||
export const snapshotDefaultId = snapshotScenarios[0]?.id ?? "";
|
||||
|
||||
export { liveMeta, liveScenarios, syntheticScenarios };
|
||||
export type { ProcessScenario } from "./types";
|
||||
32
src/data/synthetic.test.ts
Normal file
32
src/data/synthetic.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { syntheticScenarios } from "./synthetic";
|
||||
|
||||
describe("syntheticScenarios", () => {
|
||||
it("has exactly 4 families: ar, hcm, gl, service", () => {
|
||||
expect(syntheticScenarios.map((s) => s.id).sort()).toEqual(["ar", "gl", "hcm", "service"]);
|
||||
});
|
||||
|
||||
it("each scenario is marked live=false and has a representative running step", () => {
|
||||
for (const s of syntheticScenarios) {
|
||||
expect(s.live).toBe(false);
|
||||
expect(s.steps.some((st) => st.state === "running")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("edges only reference existing step ids", () => {
|
||||
for (const s of syntheticScenarios) {
|
||||
const ids = new Set(s.steps.map((st) => st.id));
|
||||
for (const e of s.edges) {
|
||||
expect(ids.has(e.source)).toBe(true);
|
||||
expect(ids.has(e.target)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("queue items reference real step ids", () => {
|
||||
for (const s of syntheticScenarios) {
|
||||
const ids = new Set(s.steps.map((st) => st.id));
|
||||
for (const q of s.queue) expect(ids.has(q.stepId)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
340
src/data/synthetic.ts
Normal file
340
src/data/synthetic.ts
Normal file
@ -0,0 +1,340 @@
|
||||
// Hand-crafted scenarios for families not yet running on the live demo backend.
|
||||
// Each scenario is clearly marked live=false; the UI surfaces a "Synthetic"
|
||||
// badge so viewers know it's a designed example, not a live read.
|
||||
import type {
|
||||
AgentRun, FlowEdge, ProcessScenario, ProcessStep, QueueItem,
|
||||
Rule, RuntimeState, RunSummary, StepKind, TourStep,
|
||||
} from "./types";
|
||||
|
||||
interface SyntheticNode {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: StepKind;
|
||||
owner: "human" | "agent" | "system";
|
||||
state: RuntimeState;
|
||||
governs?: string[];
|
||||
actions?: { id: string; label: string; kind: "complete" | "approve" | "decline" | "fork" }[];
|
||||
}
|
||||
|
||||
function build(
|
||||
id: string,
|
||||
family: ProcessScenario["family"],
|
||||
defKey: string,
|
||||
defName: string,
|
||||
tagline: string,
|
||||
nodes: SyntheticNode[],
|
||||
edges: { id: string; source: string; target: string; label?: string }[],
|
||||
rules: Rule[],
|
||||
evidenceSeeds: { stepId: string; at: string; actor: string; summary: string }[],
|
||||
queue: Omit<QueueItem, "id">[],
|
||||
agentRuns: Omit<AgentRun, "id">[],
|
||||
runs: RunSummary[],
|
||||
kpis: ProcessScenario["kpis"],
|
||||
tour: TourStep[],
|
||||
): ProcessScenario {
|
||||
const steps: ProcessStep[] = nodes.map((n) => ({
|
||||
id: n.id,
|
||||
name: n.name,
|
||||
kind: n.kind,
|
||||
owner: n.owner,
|
||||
governs: n.governs ?? [],
|
||||
state: n.state,
|
||||
actions: n.actions ?? [],
|
||||
}));
|
||||
const stepIndex = new Map(steps.map((s, i) => [s.id, i]));
|
||||
const runningIdx = steps.findIndex((s) => s.state === "running");
|
||||
const builtEdges: FlowEdge[] = edges.map((e) => {
|
||||
const srcIdx = stepIndex.get(e.source) ?? -1;
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
traversed: runningIdx >= 0 ? srcIdx < runningIdx : false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id,
|
||||
family,
|
||||
live: false,
|
||||
defKey,
|
||||
defName,
|
||||
version: "v1",
|
||||
headlineTx: null,
|
||||
tagline,
|
||||
steps,
|
||||
edges: builtEdges,
|
||||
rules,
|
||||
evidence: evidenceSeeds.map((e, i) => ({ id: `ev-${i}`, isSynthetic: true, ...e })),
|
||||
queue: queue.map((q, i) => ({ id: `q-${i}`, ...q })),
|
||||
agentRuns: agentRuns.map((a, i) => ({ id: `a-${i}`, isSynthetic: true, ...a })),
|
||||
runs,
|
||||
kpis,
|
||||
defaultStepId: steps.find((s) => s.state === "running")?.id || steps[0]?.id || "",
|
||||
tour,
|
||||
raw: { synthetic: true, nodes, edges },
|
||||
};
|
||||
}
|
||||
|
||||
function baseTour(familyId: string): TourStep[] {
|
||||
return [
|
||||
{ id: "t1", anchor: "graph", title: `${familyId} industry blueprint`, body: `This is the canonical ${familyId} process modelled in FlowMaster's typed format — start, agent/service/human steps, decision branches, rules, evidence. Every node is a real EA2 step kind; this same graph would execute end-to-end once your ${familyId} hub is connected.` },
|
||||
{ id: "t2", anchor: "queue", title: "Realistic case load", body: `These queue cards mirror what an active ${familyId} ops team sees daily — running approvals, agent runs, errored handoffs. Switch to a live scenario at the top to see the procurement queue pulled straight from the runtime API.` },
|
||||
{ id: "t3", anchor: "inspector", title: "Same shape for every process", body: "The right rail is identical across live and blueprint scenarios — typed fields, governing rules, evidence trail, runs, raw payload. That's the point: one inspector, every process family." },
|
||||
{ id: "t4", anchor: "command", title: "Drive the demo with ⌘K", body: "Press ⌘K (or Ctrl+K) to switch scenarios, jump to a step, toggle live mode, or start a tour. Everything is one keystroke away." },
|
||||
{ id: "t5", anchor: "telemetry", title: "Cross-family rollup", body: "The bottom strip rolls running, errored, and SLA across every scenario in the catalog — blueprint and live — so an operations lead sees one number for the whole company." },
|
||||
{ id: "t6", anchor: "graph", title: "You're in control", body: "That's the loop. Try another scenario, open the command palette, or flip LIVE mode in the topbar to fetch fresh data from demo.flow-master.ai right in the browser." },
|
||||
];
|
||||
}
|
||||
|
||||
// ---------- AR · Customer Refund Approval ----------
|
||||
export const arRefund = build(
|
||||
"ar",
|
||||
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#10b981" },
|
||||
"syn-ar-refund-v1",
|
||||
"AR · Customer Refund Approval",
|
||||
"Customer refund · risk-scored · synthetic preview",
|
||||
[
|
||||
{ id: "intake", name: "Refund request received", kind: "start", owner: "system", state: "done" },
|
||||
{ id: "validate", name: "Validate purchase & eligibility", kind: "service", owner: "system", state: "done" },
|
||||
{ id: "risk", name: "Fraud & risk scoring", kind: "agent", owner: "agent", state: "done" },
|
||||
{
|
||||
id: "review",
|
||||
name: "Finance review",
|
||||
kind: "human",
|
||||
owner: "human",
|
||||
state: "running",
|
||||
governs: ["refund-cap", "fraud-flag"],
|
||||
actions: [
|
||||
{ id: "approve", label: "Approve refund", kind: "approve" },
|
||||
{ id: "decline", label: "Decline", kind: "decline" },
|
||||
],
|
||||
},
|
||||
{ id: "credit_memo", name: "Post credit memo to GL", kind: "service", owner: "system", state: "queued" },
|
||||
{ id: "notify", name: "Notify customer", kind: "service", owner: "system", state: "idle" },
|
||||
{ id: "done", name: "Refund closed", kind: "end", owner: "system", state: "idle" },
|
||||
],
|
||||
[
|
||||
{ id: "e1", source: "intake", target: "validate" },
|
||||
{ id: "e2", source: "validate", target: "risk" },
|
||||
{ id: "e3", source: "risk", target: "review" },
|
||||
{ id: "e4", source: "review", target: "credit_memo", label: "approve" },
|
||||
{ id: "e5", source: "review", target: "notify", label: "decline" },
|
||||
{ id: "e6", source: "credit_memo", target: "notify" },
|
||||
{ id: "e7", source: "notify", target: "done" },
|
||||
],
|
||||
[
|
||||
{ id: "refund-cap", name: "Refund cap", expr: "amount > $5,000 → CFO approval required", isSynthetic: true },
|
||||
{ id: "fraud-flag", name: "Fraud flag", expr: "risk_score > 0.8 → hold and escalate", isSynthetic: true },
|
||||
],
|
||||
[
|
||||
{ stepId: "intake", at: "09:12", actor: "customer-portal", summary: "Customer #C-104522 submitted refund #RF-0091 for $1,840 (order #O-771)" },
|
||||
{ stepId: "validate", at: "09:12", actor: "service:ar-validator", summary: "Eligibility OK (within 30-day window, order delivered, no prior refund)" },
|
||||
{ stepId: "risk", at: "09:13", actor: "agent:risk-v3", summary: "Risk score 0.18 (LOW). Vendor reputation good. No chargeback history." },
|
||||
{ stepId: "review", at: "09:14", actor: "agent:sidekick", summary: "Proposed: approve. Awaiting CFO sign-off (within delegation matrix)." },
|
||||
],
|
||||
[
|
||||
{ stepId: "review", title: "RF-0091 · CFO approval", waitingOn: "approval", ageDays: 0, status: "running" },
|
||||
{ stepId: "review", title: "RF-0088 · CFO approval", waitingOn: "approval", ageDays: 1, status: "running" },
|
||||
{ stepId: "risk", title: "RF-0093 · risk scoring", waitingOn: "agent", ageDays: 0, status: "running" },
|
||||
{ stepId: "credit_memo", title: "RF-0085 · post credit memo", waitingOn: "input", ageDays: 0, status: "queued" },
|
||||
{ stepId: "notify", title: "RF-0079 · notify customer", waitingOn: "input", ageDays: 2, status: "errored" },
|
||||
],
|
||||
[
|
||||
{ stepId: "review", status: "awaiting-confirm", intent: "Approve refund RF-0091 ($1,840) — within CFO delegation." },
|
||||
],
|
||||
[
|
||||
{ id: "RF-0091", shortId: "RF-0091", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 12).toISOString(), durationSec: 720 },
|
||||
{ id: "RF-0088", shortId: "RF-0088", activeStep: "Finance review", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
|
||||
{ id: "RF-0085", shortId: "RF-0085", activeStep: "Post credit memo", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 4).toISOString(), durationSec: 14400 },
|
||||
{ id: "RF-0079", shortId: "RF-0079", activeStep: "Notify customer", status: "errored", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 50).toISOString(), durationSec: 180000 },
|
||||
],
|
||||
[
|
||||
{ label: "Open refunds", value: "23", trend: "up", trendValue: "+4 wk" },
|
||||
{ label: "Avg cycle", value: "1.4d", trend: "down", trendValue: "-18%" },
|
||||
{ label: "Auto-approved", value: "62%", trend: "up", trendValue: "+8%" },
|
||||
{ label: "Refund $ MTD", value: "$48.3k", trend: "flat" },
|
||||
],
|
||||
baseTour("AR"),
|
||||
);
|
||||
|
||||
// ---------- HCM · New Hire Onboarding ----------
|
||||
export const hcmOnboarding = build(
|
||||
"hcm",
|
||||
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#a855f7" },
|
||||
"syn-hcm-onboarding-v1",
|
||||
"HCM · New Hire Onboarding",
|
||||
"Day-0 → Day-30 onboarding · synthetic preview",
|
||||
[
|
||||
{ id: "offer", name: "Offer accepted", kind: "start", owner: "system", state: "done" },
|
||||
{ id: "paperwork", name: "Collect paperwork (I-9, W-4)", kind: "human", owner: "human", state: "done", actions: [{ id: "complete", label: "Submit", kind: "complete" }] },
|
||||
{ id: "background", name: "Background check", kind: "agent", owner: "agent", state: "done" },
|
||||
{ id: "provision", name: "Provision laptop & accounts", kind: "service", owner: "system", state: "running" },
|
||||
{ id: "buddy", name: "Assign buddy", kind: "human", owner: "human", state: "queued", actions: [{ id: "complete", label: "Assign", kind: "complete" }] },
|
||||
{ id: "day1", name: "Day-1 welcome", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Confirm", kind: "complete" }] },
|
||||
{ id: "training", name: "Generate role-specific training plan", kind: "agent", owner: "agent", state: "idle" },
|
||||
{ id: "checkin", name: "30-day check-in", kind: "human", owner: "human", state: "idle", actions: [{ id: "complete", label: "Complete check-in", kind: "complete" }] },
|
||||
{ id: "ramped", name: "Onboarding complete", kind: "end", owner: "system", state: "idle" },
|
||||
],
|
||||
[
|
||||
{ id: "e1", source: "offer", target: "paperwork" },
|
||||
{ id: "e2", source: "paperwork", target: "background" },
|
||||
{ id: "e3", source: "background", target: "provision" },
|
||||
{ id: "e4", source: "provision", target: "buddy" },
|
||||
{ id: "e5", source: "buddy", target: "day1" },
|
||||
{ id: "e6", source: "day1", target: "training" },
|
||||
{ id: "e7", source: "training", target: "checkin" },
|
||||
{ id: "e8", source: "checkin", target: "ramped" },
|
||||
],
|
||||
[
|
||||
{ id: "provision-sla", name: "Provisioning SLA", expr: "IT request open > 3 business days → page IT-Ops on-call", isSynthetic: true },
|
||||
{ id: "training-cap", name: "Training plan cap", expr: "training plan > 12 modules → manager review", isSynthetic: true },
|
||||
],
|
||||
[
|
||||
{ stepId: "offer", at: "Mon 10:01", actor: "hcm-ats", summary: "Riley Park accepted offer for SWE-3 (Eng / Platform)" },
|
||||
{ stepId: "paperwork", at: "Mon 11:34", actor: "Riley Park", summary: "Submitted I-9 + W-4, signed offer letter" },
|
||||
{ stepId: "background", at: "Tue 08:09", actor: "agent:bg-check", summary: "Background check clear (Checkr report #BC-91244)" },
|
||||
{ stepId: "provision", at: "Tue 08:12", actor: "service:it-provision", summary: "Laptop ordered (MBP 16, M4 Pro); Okta + Slack + Linear seats reserved" },
|
||||
],
|
||||
[
|
||||
{ stepId: "provision", title: "Riley Park · provisioning", waitingOn: "input", ageDays: 0, status: "running" },
|
||||
{ stepId: "buddy", title: "Sam Diaz · buddy assignment", waitingOn: "approval", ageDays: 1, status: "running" },
|
||||
{ stepId: "day1", title: "Jordan Wu · Day-1 welcome", waitingOn: "approval", ageDays: 0, status: "queued" },
|
||||
{ stepId: "checkin", title: "Alex Kim · 30-day check-in", waitingOn: "approval", ageDays: 14, status: "running" },
|
||||
],
|
||||
[
|
||||
{ stepId: "training", status: "proposed", intent: "Draft 8-module training plan for Riley Park (SWE-3, Platform team)" },
|
||||
],
|
||||
[
|
||||
{ id: "H-0091", shortId: "Riley Park", activeStep: "Provision laptop & accounts", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 28).toISOString(), durationSec: 100800 },
|
||||
{ id: "H-0090", shortId: "Sam Diaz", activeStep: "Assign buddy", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 52).toISOString(), durationSec: 187200 },
|
||||
{ id: "H-0088", shortId: "Jordan Wu", activeStep: "Day-1 welcome", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 96).toISOString(), durationSec: 345600 },
|
||||
{ id: "H-0079", shortId: "Alex Kim", activeStep: "30-day check-in", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 28).toISOString(), durationSec: 2419200 },
|
||||
],
|
||||
[
|
||||
{ label: "Active onboardings", value: "11", trend: "up", trendValue: "+3 wk" },
|
||||
{ label: "Avg time-to-ramp", value: "28d", trend: "down", trendValue: "-4d" },
|
||||
{ label: "On-time provisioning", value: "94%", trend: "up", trendValue: "+6%" },
|
||||
{ label: "Buddy assigned <24h", value: "82%", trend: "flat" },
|
||||
],
|
||||
baseTour("HCM"),
|
||||
);
|
||||
|
||||
// ---------- GL · Period-End Close ----------
|
||||
export const glClose = build(
|
||||
"gl",
|
||||
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#f59e0b" },
|
||||
"syn-gl-close-v1",
|
||||
"GL · Period-End Close",
|
||||
"Period-end close · automated accrual collection · synthetic preview",
|
||||
[
|
||||
{ id: "cutoff", name: "Period cutoff", kind: "start", owner: "system", state: "done" },
|
||||
{ id: "accruals", name: "Collect accruals from subledgers", kind: "agent", owner: "agent", state: "done" },
|
||||
{ id: "bank_rec", name: "Bank reconciliation", kind: "service", owner: "system", state: "done" },
|
||||
{ id: "intercompany", name: "Intercompany match", kind: "service", owner: "system", state: "running" },
|
||||
{ id: "journals", name: "Journal entry review", kind: "human", owner: "human", state: "queued", governs: ["journal-threshold"], actions: [{ id: "post", label: "Post journals", kind: "complete" }] },
|
||||
{ id: "variance", name: "Variance analysis & narrative", kind: "agent", owner: "agent", state: "idle" },
|
||||
{ id: "signoff", name: "CFO sign-off", kind: "human", owner: "human", state: "idle", governs: ["signoff-quorum"], actions: [{ id: "signoff", label: "Sign off", kind: "approve" }] },
|
||||
{ id: "closed", name: "Books closed", kind: "end", owner: "system", state: "idle" },
|
||||
],
|
||||
[
|
||||
{ id: "e1", source: "cutoff", target: "accruals" },
|
||||
{ id: "e2", source: "accruals", target: "bank_rec" },
|
||||
{ id: "e3", source: "bank_rec", target: "intercompany" },
|
||||
{ id: "e4", source: "intercompany", target: "journals" },
|
||||
{ id: "e5", source: "journals", target: "variance" },
|
||||
{ id: "e6", source: "variance", target: "signoff" },
|
||||
{ id: "e7", source: "signoff", target: "closed" },
|
||||
],
|
||||
[
|
||||
{ id: "journal-threshold", name: "Journal threshold", expr: "any journal > $50k → controller review", isSynthetic: true },
|
||||
{ id: "signoff-quorum", name: "Sign-off quorum", expr: "requires CFO + Controller", isSynthetic: true },
|
||||
],
|
||||
[
|
||||
{ stepId: "cutoff", at: "Mon 18:00", actor: "scheduler", summary: "Period 2026-06 cut over; subledger snapshots locked" },
|
||||
{ stepId: "accruals", at: "Mon 18:14", actor: "agent:accrual-bot", summary: "Collected 1,442 accrual lines from AP, AR, Payroll, Lease subledgers" },
|
||||
{ stepId: "bank_rec", at: "Mon 19:02", actor: "service:bank-rec", summary: "Reconciled 18 bank accounts; 3 timing exceptions auto-resolved" },
|
||||
{ stepId: "intercompany", at: "now", actor: "service:ic-match", summary: "Matching 432 IC pairs across 7 entities (running)" },
|
||||
],
|
||||
[
|
||||
{ stepId: "intercompany", title: "P-2026-06 · IC matching", waitingOn: "agent", ageDays: 0, status: "running" },
|
||||
{ stepId: "journals", title: "P-2026-06 · 11 journals to review", waitingOn: "approval", ageDays: 0, status: "queued" },
|
||||
{ stepId: "signoff", title: "P-2026-05 · CFO sign-off", waitingOn: "approval", ageDays: 2, status: "running" },
|
||||
{ stepId: "journals", title: "P-2026-05 · 2 journals re-open", waitingOn: "approval", ageDays: 4, status: "errored" },
|
||||
],
|
||||
[
|
||||
{ stepId: "variance", status: "proposed", intent: "Generate variance narrative for OpEx +14% vs forecast (drivers: cloud spend, headcount)" },
|
||||
],
|
||||
[
|
||||
{ id: "P-2026-06", shortId: "Jun 2026", activeStep: "Intercompany match", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 3).toISOString(), durationSec: 10800 },
|
||||
{ id: "P-2026-05", shortId: "May 2026", activeStep: "CFO sign-off", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 6).toISOString(), durationSec: 518400 },
|
||||
{ id: "P-2026-04", shortId: "Apr 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 38).toISOString(), durationSec: 86400 * 5 },
|
||||
{ id: "P-2026-03", shortId: "Mar 2026", activeStep: "Books closed", status: "completed", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 70).toISOString(), durationSec: 86400 * 6 },
|
||||
],
|
||||
[
|
||||
{ label: "Days in current close", value: "1.1d", trend: "down", trendValue: "-0.4d" },
|
||||
{ label: "Journals auto-posted", value: "78%", trend: "up", trendValue: "+5%" },
|
||||
{ label: "IC mismatches", value: "12", trend: "down", trendValue: "-8" },
|
||||
{ label: "Close on-time rate", value: "96%", trend: "flat" },
|
||||
],
|
||||
baseTour("GL"),
|
||||
);
|
||||
|
||||
// ---------- Service Ops · Customer Incident ----------
|
||||
export const svcIncident = build(
|
||||
"service",
|
||||
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#ef4444" },
|
||||
"syn-svc-incident-v1",
|
||||
"Service Ops · Customer Incident",
|
||||
"Triage → assign → resolve · synthetic preview",
|
||||
[
|
||||
{ id: "report", name: "Customer reports incident", kind: "start", owner: "system", state: "done" },
|
||||
{ id: "triage", name: "Auto-triage & priority", kind: "agent", owner: "agent", state: "done" },
|
||||
{ id: "assign", name: "Assign engineer", kind: "human", owner: "human", state: "done", actions: [{ id: "assign", label: "Assign", kind: "complete" }] },
|
||||
{ id: "investigate", name: "Investigate & resolve", kind: "human", owner: "human", state: "running", governs: ["sla-p1"], actions: [{ id: "resolved", label: "Mark resolved", kind: "complete" }] },
|
||||
{ id: "confirm", name: "Customer confirms", kind: "human", owner: "human", state: "queued", actions: [{ id: "confirm", label: "Confirm closed", kind: "complete" }] },
|
||||
{ id: "rca", name: "Generate RCA", kind: "agent", owner: "agent", state: "idle" },
|
||||
{ id: "closed", name: "Incident closed", kind: "end", owner: "system", state: "idle" },
|
||||
],
|
||||
[
|
||||
{ id: "e1", source: "report", target: "triage" },
|
||||
{ id: "e2", source: "triage", target: "assign" },
|
||||
{ id: "e3", source: "assign", target: "investigate" },
|
||||
{ id: "e4", source: "investigate", target: "confirm" },
|
||||
{ id: "e5", source: "confirm", target: "rca" },
|
||||
{ id: "e6", source: "rca", target: "closed" },
|
||||
],
|
||||
[
|
||||
{ id: "sla-p1", name: "P1 SLA", expr: "P1 unresolved > 4h → page eng-lead", isSynthetic: true },
|
||||
],
|
||||
[
|
||||
{ stepId: "report", at: "07:21", actor: "customer-portal", summary: "Acme Corp reports INC-4427: dashboard 500 on /reports/financial" },
|
||||
{ stepId: "triage", at: "07:21", actor: "agent:triage", summary: "P1 (revenue impact). Routed to platform-on-call. Linked recent deploy #d3f1a." },
|
||||
{ stepId: "assign", at: "07:22", actor: "Sam (oncall)", summary: "Acked. Investigating deploy rollback path." },
|
||||
{ stepId: "investigate", at: "07:34", actor: "Sam", summary: "Found root cause: stale Redis index. Rolling fix #PR-1924." },
|
||||
],
|
||||
[
|
||||
{ stepId: "investigate", title: "INC-4427 · platform 500s", waitingOn: "input", ageDays: 0, status: "running" },
|
||||
{ stepId: "investigate", title: "INC-4426 · webhook 502 spike", waitingOn: "input", ageDays: 0, status: "running" },
|
||||
{ stepId: "confirm", title: "INC-4421 · awaiting customer confirm", waitingOn: "approval", ageDays: 1, status: "queued" },
|
||||
{ stepId: "rca", title: "INC-4418 · RCA draft", waitingOn: "agent", ageDays: 2, status: "running" },
|
||||
],
|
||||
[
|
||||
{ stepId: "rca", status: "running", intent: "Drafting RCA for INC-4418 (auth token expiry race). 3 mitigations proposed." },
|
||||
],
|
||||
[
|
||||
{ id: "INC-4427", shortId: "INC-4427", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 35).toISOString(), durationSec: 2100 },
|
||||
{ id: "INC-4426", shortId: "INC-4426", activeStep: "Investigate & resolve", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 90).toISOString(), durationSec: 5400 },
|
||||
{ id: "INC-4421", shortId: "INC-4421", activeStep: "Customer confirms", status: "queued", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 26).toISOString(), durationSec: 93600 },
|
||||
{ id: "INC-4418", shortId: "INC-4418", activeStep: "Generate RCA", status: "running", startedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(), durationSec: 172800 },
|
||||
],
|
||||
[
|
||||
{ label: "Open incidents", value: "7", trend: "down", trendValue: "-2" },
|
||||
{ label: "P1 MTTR", value: "47m", trend: "down", trendValue: "-22%" },
|
||||
{ label: "Auto-triage acc.", value: "91%", trend: "up", trendValue: "+3%" },
|
||||
{ label: "SLA compliance", value: "99.1%", trend: "flat" },
|
||||
],
|
||||
baseTour("Service"),
|
||||
);
|
||||
|
||||
export const syntheticScenarios: ProcessScenario[] = [arRefund, hcmOnboarding, glClose, svcIncident];
|
||||
125
src/data/types.ts
Normal file
125
src/data/types.ts
Normal file
@ -0,0 +1,125 @@
|
||||
// Unified domain types for Mission Control.
|
||||
// Backed either by a live EA2 read (scenarios.json) or by synthetic
|
||||
// hand-crafted scenarios (synthetic.ts) — both render the same way.
|
||||
|
||||
export type StepKind = "start" | "human" | "agent" | "service" | "decision" | "end";
|
||||
export type RuntimeState = "done" | "running" | "queued" | "blocked" | "idle" | "errored";
|
||||
|
||||
export interface StepAction {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: "complete" | "approve" | "decline" | "fork";
|
||||
}
|
||||
|
||||
export interface ProcessStep {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: StepKind;
|
||||
owner: "human" | "agent" | "system";
|
||||
/** Optional governing rule ids that apply at this step. */
|
||||
governs: string[];
|
||||
/** Best-guess runtime state of this step in the headline transaction. */
|
||||
state: RuntimeState;
|
||||
/** Display name from EA2 if available. */
|
||||
raw?: unknown;
|
||||
actions: StepAction[];
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
/** Edge outcome label (e.g. "approve", "decline"). */
|
||||
label?: string;
|
||||
/** True if both endpoints have been traversed in the headline run. */
|
||||
traversed?: boolean;
|
||||
}
|
||||
|
||||
export interface Rule {
|
||||
id: string;
|
||||
name: string;
|
||||
expr: string;
|
||||
isSynthetic?: boolean;
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
stepId: string;
|
||||
at: string;
|
||||
actor: string;
|
||||
summary: string;
|
||||
isSynthetic?: boolean;
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
stepId: string;
|
||||
title: string;
|
||||
waitingOn: "approval" | "agent" | "input";
|
||||
ageDays: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AgentRun {
|
||||
id: string;
|
||||
stepId: string;
|
||||
status: "proposed" | "running" | "awaiting-confirm" | "done";
|
||||
intent: string;
|
||||
isSynthetic?: boolean;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
id: string;
|
||||
shortId: string;
|
||||
/** Active step display name at the time of the snapshot. */
|
||||
activeStep: string | null;
|
||||
status: RuntimeState | string;
|
||||
startedAt: string;
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
export interface TourStep {
|
||||
id: string;
|
||||
/** Selector or named anchor in the layout. Used by Tour to position the bubble. */
|
||||
anchor: "graph" | "queue" | "inspector" | "topbar" | "telemetry" | "command";
|
||||
/** Step id to auto-select while this tour step is active. */
|
||||
selectStep?: string;
|
||||
/** Switch to another scenario when entering this step. */
|
||||
switchToScenario?: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ProcessScenario {
|
||||
id: string;
|
||||
family: {
|
||||
id: string;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
accent: string;
|
||||
};
|
||||
/** True if backed by a real EA2 read; false if hand-crafted in synthetic.ts. */
|
||||
live: boolean;
|
||||
defKey: string;
|
||||
defName: string;
|
||||
version: string;
|
||||
/** Headline transaction id for the runtime trace. */
|
||||
headlineTx: string | null;
|
||||
/** Brand short subtitle for the inspector hero. */
|
||||
tagline: string;
|
||||
steps: ProcessStep[];
|
||||
edges: FlowEdge[];
|
||||
rules: Rule[];
|
||||
evidence: EvidenceItem[];
|
||||
queue: QueueItem[];
|
||||
agentRuns: AgentRun[];
|
||||
runs: RunSummary[];
|
||||
/** Compact KPI strip data for Mission Control. */
|
||||
kpis: { label: string; value: string; trend?: "up" | "down" | "flat"; trendValue?: string }[];
|
||||
/** Default selected step id. */
|
||||
defaultStepId: string;
|
||||
/** Tour script tailored to this scenario. */
|
||||
tour: TourStep[];
|
||||
/** Raw graph object from EA2 (or synthesised) for the JSON inspector tab. */
|
||||
raw: unknown;
|
||||
}
|
||||
39
src/graph/layout.test.ts
Normal file
39
src/graph/layout.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { layoutGraph } from "./layout";
|
||||
import type { ProcessStep, FlowEdge } from "../data/types";
|
||||
|
||||
const linearSteps: ProcessStep[] = [
|
||||
{ id: "a", name: "A", kind: "start", owner: "system", governs: [], state: "done", actions: [] },
|
||||
{ id: "b", name: "B", kind: "human", owner: "human", governs: [], state: "running", actions: [] },
|
||||
{ id: "c", name: "C", kind: "end", owner: "system", governs: [], state: "idle", actions: [] },
|
||||
];
|
||||
const linearEdges: FlowEdge[] = [
|
||||
{ id: "ab", source: "a", target: "b" },
|
||||
{ id: "bc", source: "b", target: "c" },
|
||||
];
|
||||
|
||||
describe("layoutGraph", () => {
|
||||
it("returns one position per step", () => {
|
||||
const positions = layoutGraph(linearSteps, linearEdges);
|
||||
expect(positions.length).toBe(linearSteps.length);
|
||||
expect(new Set(positions.map((p) => p.id))).toEqual(new Set(["a", "b", "c"]));
|
||||
});
|
||||
|
||||
it("produces strictly-increasing x positions for a linear chain (LR layout)", () => {
|
||||
const positions = layoutGraph(linearSteps, linearEdges);
|
||||
const byId = Object.fromEntries(positions.map((p) => [p.id, p.position]));
|
||||
expect(byId.a.x).toBeLessThan(byId.b.x);
|
||||
expect(byId.b.x).toBeLessThan(byId.c.x);
|
||||
});
|
||||
|
||||
it("handles disconnected nodes without crashing", () => {
|
||||
const positions = layoutGraph(
|
||||
[
|
||||
{ id: "x", name: "X", kind: "start", owner: "system", governs: [], state: "idle", actions: [] },
|
||||
{ id: "y", name: "Y", kind: "end", owner: "system", governs: [], state: "idle", actions: [] },
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(positions.length).toBe(2);
|
||||
});
|
||||
});
|
||||
37
src/graph/layout.ts
Normal file
37
src/graph/layout.ts
Normal file
@ -0,0 +1,37 @@
|
||||
// dagre-based auto-layout for the process graph.
|
||||
// Produces deterministic left-to-right DAG positions with consistent spacing
|
||||
// regardless of how many nodes the live scenario has.
|
||||
import dagre from "dagre";
|
||||
import type { FlowEdge, ProcessStep } from "../data/types";
|
||||
|
||||
export interface PositionedNode {
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
const NODE_W = 256;
|
||||
const NODE_H = 100;
|
||||
const RANK_SEP = 90;
|
||||
const NODE_SEP = 36;
|
||||
|
||||
export function layoutGraph(steps: ProcessStep[], edges: FlowEdge[]): PositionedNode[] {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir: "LR", ranksep: RANK_SEP, nodesep: NODE_SEP, marginx: 30, marginy: 30 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
for (const s of steps) g.setNode(s.id, { width: NODE_W, height: NODE_H });
|
||||
for (const e of edges) g.setEdge(e.source, e.target);
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
return steps.map((s) => {
|
||||
const n = g.node(s.id);
|
||||
// dagre gives centre; React Flow wants top-left.
|
||||
return {
|
||||
id: s.id,
|
||||
position: { x: (n?.x ?? 0) - NODE_W / 2, y: (n?.y ?? 0) - NODE_H / 2 },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const NODE_SIZE = { width: NODE_W, height: NODE_H };
|
||||
682
src/index.css
Normal file
682
src/index.css
Normal file
@ -0,0 +1,682 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Fira+Sans:wght@300;400;500;600;700;800&display=swap');
|
||||
@import 'reactflow/dist/style.css';
|
||||
|
||||
/* =====================================================================
|
||||
FlowMaster Mission Control — design system
|
||||
===================================================================== */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg: #06080f;
|
||||
--bg-deep: #03050b;
|
||||
--surface: #0f1626;
|
||||
--surface-2: #151e33;
|
||||
--surface-3: #1c2742;
|
||||
--border: #243049;
|
||||
--border-strong: #324166;
|
||||
--border-glow: #3b82f644;
|
||||
|
||||
/* Text */
|
||||
--text: #e6edf7;
|
||||
--text-2: #95a3bf;
|
||||
--text-3: #5e6c8a;
|
||||
--text-soft: #c6d2e7;
|
||||
|
||||
/* Brand + state */
|
||||
--primary: #3b82f6;
|
||||
--primary-deep: #1e40af;
|
||||
--primary-soft: rgba(59,130,246,0.18);
|
||||
--accent: #d97706;
|
||||
--ok: #34d399;
|
||||
--run: #3b82f6;
|
||||
--queue: #d97706;
|
||||
--block: #f05252;
|
||||
--idle: #5e6c8a;
|
||||
--syn: #a855f7;
|
||||
|
||||
--ring: #3b82f6;
|
||||
--radius-sm: 6px;
|
||||
--radius: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 22px;
|
||||
|
||||
--mono: 'Fira Code', ui-monospace, monospace;
|
||||
--sans: 'Fira Sans', system-ui, sans-serif;
|
||||
|
||||
--shadow-soft: 0 8px 28px rgba(0,0,0,0.45);
|
||||
--shadow-lift: 0 16px 60px rgba(0,0,0,0.55);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||||
button { font-family: inherit; cursor: pointer; border: 0; background: none; color: inherit; }
|
||||
button:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 6px; }
|
||||
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 4px; }
|
||||
kbd { font-family: var(--mono); font-size: 11px; background: var(--surface-2); padding: 1px 5px; border-radius: 4px; border: 1px solid var(--border); color: var(--text-2); }
|
||||
|
||||
/* =====================================================================
|
||||
Shell
|
||||
===================================================================== */
|
||||
.shell { display: grid; grid-template-rows: auto 1fr; height: 100%; min-height: 0; }
|
||||
.shell-landing { grid-template-rows: 1fr; }
|
||||
.scene { min-height: 0; overflow: hidden; }
|
||||
|
||||
/* =====================================================================
|
||||
Topbar
|
||||
===================================================================== */
|
||||
.topbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 18px;
|
||||
height: 56px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
|
||||
}
|
||||
.brand-lock {
|
||||
display: inline-flex; align-items: center; gap: 9px;
|
||||
font-weight: 700; letter-spacing: 0.2px;
|
||||
}
|
||||
.brand-btn { padding: 4px 6px; border-radius: 8px; }
|
||||
.brand-btn:hover { background: var(--surface-2); }
|
||||
.brand-mark {
|
||||
width: 18px; height: 18px; border-radius: 6px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255,255,255,0.4), transparent 60%),
|
||||
linear-gradient(135deg, #3b82f6 0%, #a855f7 100%);
|
||||
box-shadow: 0 0 12px rgba(59,130,246,0.5);
|
||||
}
|
||||
.brand-mark.sm { width: 14px; height: 14px; border-radius: 5px; }
|
||||
.brand-name { font-weight: 800; letter-spacing: -0.2px; }
|
||||
.brand-divider { width: 1px; height: 14px; background: var(--border); }
|
||||
.brand-sub { color: var(--text-2); font-weight: 500; }
|
||||
|
||||
.tabs { display: inline-flex; gap: 4px; background: var(--surface-2); border: 1px solid var(--border); padding: 3px; border-radius: 10px; }
|
||||
.tab {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 5px 11px; border-radius: 7px; font-size: 12px;
|
||||
color: var(--text-2); transition: background .18s, color .18s;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab-sel { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; }
|
||||
|
||||
.topbar-mid { display: flex; justify-content: flex-start; min-width: 0; }
|
||||
.topbar-context { display: inline-flex; gap: 6px; align-items: center; flex-wrap: wrap; min-width: 0; }
|
||||
.topbar-chip {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 3px 9px; border-radius: 999px; font-size: 12px;
|
||||
background: var(--surface-2); border: 1px solid var(--border); color: var(--text-2);
|
||||
max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-actions { display: inline-flex; gap: 8px; align-items: center; }
|
||||
.link-btn {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 5px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--border-strong); background: var(--surface-2);
|
||||
color: var(--text-2); font-size: 12px; transition: border-color .18s, color .18s, background .18s;
|
||||
}
|
||||
.link-btn:hover:not(:disabled) { color: var(--text); border-color: var(--primary); background: var(--surface-3); }
|
||||
.link-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* =====================================================================
|
||||
Mission Control scene
|
||||
===================================================================== */
|
||||
.mc { display: grid; grid-template-rows: auto auto 1fr auto; height: 100%; min-height: 0; }
|
||||
.mc-strip {
|
||||
display: flex; gap: 2px; padding: 8px 14px 0;
|
||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-x: auto;
|
||||
}
|
||||
.mc-tab {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 14px; border-radius: 10px 10px 0 0;
|
||||
border: 1px solid transparent; border-bottom: 0;
|
||||
color: var(--text-2); font-weight: 500; white-space: nowrap;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.mc-tab:hover { background: var(--surface-2); color: var(--text); }
|
||||
.mc-tab-sel {
|
||||
background: var(--bg); color: var(--text);
|
||||
border-color: var(--border);
|
||||
box-shadow: 0 -2px 0 var(--accent, --primary) inset;
|
||||
border-bottom: 1px solid var(--bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.mc-tab-sel { box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.mc-tab-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
|
||||
.mc-tab-label { font-size: 13px; }
|
||||
.mc-tab-mark { font-size: 10px; padding: 1px 6px; border-radius: 4px; letter-spacing: 0.4px; text-transform: uppercase; font-weight: 600; }
|
||||
.mc-tab-mark.is-live { background: rgba(52,211,153,0.18); color: var(--ok); border: 1px solid rgba(52,211,153,0.4); }
|
||||
.mc-tab-mark.is-syn { background: rgba(168,85,247,0.16); color: var(--syn); border: 1px solid rgba(168,85,247,0.4); }
|
||||
|
||||
.mc-hero {
|
||||
display: grid; grid-template-columns: 1fr auto;
|
||||
align-items: center; gap: 16px;
|
||||
padding: 16px 22px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.mc-hero-eyebrow { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 1.2px; }
|
||||
.mc-hero-title { margin: 4px 0 2px; font-size: 22px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.mc-hero-sub { color: var(--text-2); font-size: 13px; }
|
||||
.mc-hero-kpis { display: inline-flex; gap: 8px; }
|
||||
.mc-kpi {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 8px 14px; min-width: 92px; text-align: right;
|
||||
}
|
||||
.mc-kpi-v { font-size: 18px; font-weight: 700; font-family: var(--mono); }
|
||||
.mc-kpi-l { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.8px; margin-top: 2px; }
|
||||
|
||||
.mc-body {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 380px;
|
||||
min-height: 0;
|
||||
}
|
||||
.mc-main { position: relative; min-width: 0; background:
|
||||
radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0) 0 0 / 28px 28px,
|
||||
var(--bg); }
|
||||
|
||||
/* =====================================================================
|
||||
LeftRail
|
||||
===================================================================== */
|
||||
.left-rail {
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-deep);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.panel { padding: 14px; border-bottom: 1px solid var(--border); }
|
||||
.panel-h {
|
||||
margin: 0 0 12px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase;
|
||||
color: var(--text-3); font-weight: 700; display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.panel-count { margin-left: auto; color: var(--text-2); background: var(--surface-2); border-radius: 999px; padding: 1px 8px; font-size: 11px; letter-spacing: 0; font-weight: 500; }
|
||||
|
||||
.kpi-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.kpi {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 10px 12px;
|
||||
}
|
||||
.kpi-v { font-size: 18px; font-weight: 700; font-family: var(--mono); display: inline-flex; align-items: center; gap: 4px; }
|
||||
.kpi-l { font-size: 10.5px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.7px; margin-top: 2px; }
|
||||
.kpi-t { font-size: 10px; color: var(--text-2); margin-top: 1px; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 8px; }
|
||||
.qcard {
|
||||
display: block; width: 100%; text-align: left;
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 10px 12px; color: var(--text);
|
||||
transition: border-color .18s, background .18s, transform .18s;
|
||||
}
|
||||
.qcard:hover { border-color: var(--border-strong); background: var(--surface-2); transform: translateY(-1px); }
|
||||
.qcard-sel { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||
.qcard-title { font-weight: 500; font-size: 13px; }
|
||||
.qcard-meta { display: flex; gap: 10px; align-items: center; margin-top: 6px; color: var(--text-2); font-size: 12px; }
|
||||
.qcard-status.is-err { color: var(--block); }
|
||||
.qcard-age { display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
.agent { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 11px 12px; margin-bottom: 9px; }
|
||||
.agent-row { display: flex; align-items: center; gap: 8px; }
|
||||
.agent-step { color: var(--text-2); font-size: 12px; }
|
||||
.agent-intent { color: var(--text); font-size: 12.5px; margin: 7px 0 10px; }
|
||||
.agent-acts { display: flex; gap: 8px; }
|
||||
|
||||
/* =====================================================================
|
||||
Tag / pill / dot
|
||||
===================================================================== */
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 10.5px; font-weight: 600; padding: 2px 8px;
|
||||
border-radius: 5px; letter-spacing: 0.4px; text-transform: uppercase;
|
||||
}
|
||||
.tag-approval { background: rgba(217,119,6,0.14); color: #f5b755; border: 1px solid rgba(217,119,6,0.3); }
|
||||
.tag-agent { background: rgba(59,130,246,0.14); color: #7eb0ff; border: 1px solid rgba(59,130,246,0.3); }
|
||||
.tag-input { background: rgba(94,108,138,0.16); color: var(--text-2); border: 1px solid var(--border-strong); }
|
||||
.tag-live { background: rgba(52,211,153,0.16); color: var(--ok); border: 1px solid rgba(52,211,153,0.4); }
|
||||
.tag-syn { background: rgba(168,85,247,0.16); color: var(--syn); border: 1px solid rgba(168,85,247,0.4); }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 5px;
|
||||
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text-2);
|
||||
}
|
||||
.pill-done { background: rgba(52,211,153,0.16); color: var(--ok); border-color: rgba(52,211,153,0.36); }
|
||||
.pill-running { background: rgba(59,130,246,0.16); color: #7eb0ff; border-color: rgba(59,130,246,0.36); }
|
||||
.pill-errored { background: rgba(240,82,82,0.16); color: var(--block); border-color: rgba(240,82,82,0.4); }
|
||||
.pill-queued { background: rgba(217,119,6,0.14); color: #f5b755; border-color: rgba(217,119,6,0.36); }
|
||||
.pill-blocked { background: rgba(240,82,82,0.14); color: var(--block); border-color: rgba(240,82,82,0.4); }
|
||||
.pill-idle { background: var(--surface-2); color: var(--text-2); }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; display: inline-block; }
|
||||
.dot-done { background: var(--ok); }
|
||||
.dot-running { background: var(--run); box-shadow: 0 0 8px var(--run); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.dot-queued { background: var(--queue); }
|
||||
.dot-errored, .dot-blocked { background: var(--block); }
|
||||
.dot-idle { background: var(--idle); }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
@media (prefers-reduced-motion: reduce) { .dot-running { animation: none; } * { transition: none !important; animation: none !important; } }
|
||||
|
||||
/* =====================================================================
|
||||
Buttons
|
||||
===================================================================== */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||
padding: 7px 11px; border-radius: 8px; font-size: 12px; font-weight: 600;
|
||||
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text);
|
||||
transition: background .15s, border-color .15s, transform .15s;
|
||||
}
|
||||
.btn:hover:not(:disabled) { border-color: var(--primary); background: var(--surface-3); }
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { background: linear-gradient(180deg, #3b82f6, #2563eb); border-color: var(--primary); color: #fff; }
|
||||
.btn-primary:hover:not(:disabled) { background: linear-gradient(180deg, #4d8df8, #2f6cee); }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-lg { padding: 11px 18px; font-size: 14px; }
|
||||
|
||||
/* =====================================================================
|
||||
Process graph
|
||||
===================================================================== */
|
||||
.graph-canvas { position: relative; width: 100%; height: 100%; min-height: 0; }
|
||||
.graph-overlay {
|
||||
position: absolute; top: 14px; left: 14px; z-index: 4;
|
||||
background: rgba(15,22,38,0.78); backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border); border-radius: 10px; padding: 6px 10px;
|
||||
font-size: 12px; color: var(--text-2);
|
||||
}
|
||||
.graph-overlay-row { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.graph-overlay-name { color: var(--text); font-weight: 600; }
|
||||
.graph-overlay-sub { color: var(--text-2); }
|
||||
.graph-overlay-dim { color: var(--text-3); font-family: var(--mono); font-size: 11px; }
|
||||
|
||||
.node {
|
||||
width: 256px; min-width: 256px; max-width: 256px;
|
||||
background: linear-gradient(180deg, rgba(21,30,51,0.92), rgba(15,22,38,0.92));
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
color: var(--text);
|
||||
transition: border-color .18s, box-shadow .18s, transform .18s;
|
||||
}
|
||||
.node:hover { border-color: var(--border-strong); }
|
||||
.node-sel { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary) inset, 0 12px 28px rgba(59,130,246,0.22); }
|
||||
.node-row { display: flex; align-items: center; gap: 8px; }
|
||||
.node-name { font-weight: 600; font-size: 13px; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.node-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; color: var(--text-2); font-size: 11px; }
|
||||
.node-kind {
|
||||
font-size: 9.5px; letter-spacing: 0.6px; text-transform: uppercase;
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 1px 6px; color: var(--text-3);
|
||||
}
|
||||
.node-kind-human { color: #c8b6ff; border-color: rgba(168,85,247,0.4); }
|
||||
.node-kind-agent { color: #7eb0ff; border-color: rgba(59,130,246,0.4); }
|
||||
.node-kind-service { color: #5eead4; border-color: rgba(20,184,166,0.4); }
|
||||
.node-kind-start, .node-kind-end { color: #fef3c7; border-color: rgba(217,119,6,0.4); }
|
||||
.node-owner { color: var(--text-2); }
|
||||
.node-gov { display: inline-flex; align-items: center; gap: 3px; color: #f5b755; }
|
||||
.node-handle { background: var(--border-strong); width: 8px; height: 8px; border: 0; }
|
||||
|
||||
.node-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.node-dot-done { background: var(--ok); }
|
||||
.node-dot-running { background: var(--run); box-shadow: 0 0 8px var(--run); animation: pulse 1.6s ease-in-out infinite; }
|
||||
.node-dot-queued { background: var(--queue); }
|
||||
.node-dot-errored, .node-dot-blocked { background: var(--block); }
|
||||
.node-dot-idle { background: var(--idle); }
|
||||
|
||||
.node-running {
|
||||
box-shadow: 0 0 0 1px var(--run) inset, 0 8px 36px rgba(59,130,246,0.22);
|
||||
border-color: var(--run);
|
||||
}
|
||||
.node-errored {
|
||||
box-shadow: 0 0 0 1px var(--block) inset;
|
||||
border-color: var(--block);
|
||||
}
|
||||
.node-done { opacity: 0.85; }
|
||||
|
||||
/* ReactFlow style overrides */
|
||||
.react-flow__controls { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.react-flow__controls-button { background: var(--surface); border-bottom: 1px solid var(--border); color: var(--text-2); }
|
||||
.react-flow__controls-button:hover { background: var(--surface-2); color: var(--text); }
|
||||
.react-flow__attribution { display: none; }
|
||||
|
||||
/* =====================================================================
|
||||
Inspector
|
||||
===================================================================== */
|
||||
.inspector {
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--bg-deep);
|
||||
display: flex; flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.inspector-head { padding: 14px 14px 10px; border-bottom: 1px solid var(--border); }
|
||||
.inspector-eyebrow { color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 1.1px; font-weight: 600; }
|
||||
.inspector-title h3 { margin: 4px 0 6px; font-size: 16px; font-weight: 700; }
|
||||
.inspector-sub { color: var(--text-2); font-size: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.inspector-tabs { display: flex; gap: 3px; margin-top: 12px; background: var(--surface-2); padding: 3px; border-radius: 8px; border: 1px solid var(--border); }
|
||||
.itab {
|
||||
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px;
|
||||
padding: 5px 6px; border-radius: 6px; font-size: 11px;
|
||||
color: var(--text-2); transition: color .15s, background .15s;
|
||||
}
|
||||
.itab:hover { color: var(--text); }
|
||||
.itab-sel { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; }
|
||||
.inspector-body { padding: 12px 14px 18px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
|
||||
.i-section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.i-field { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; border-bottom: 1px dashed var(--border); font-size: 13px; }
|
||||
.i-field span:first-child { color: var(--text-2); }
|
||||
.i-field span:last-child { text-align: right; }
|
||||
.i-h { margin: 14px 0 6px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: var(--text-3); font-weight: 700; }
|
||||
.i-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.rule { background: var(--surface); border: 1px solid var(--border); border-left: 2px solid var(--accent); border-radius: 8px; padding: 9px 11px; }
|
||||
.rule-head { display: flex; align-items: center; gap: 7px; color: var(--text); font-weight: 600; font-size: 12.5px; }
|
||||
.rule-expr { color: var(--text-2); font-size: 12px; margin-top: 5px; }
|
||||
|
||||
.evt { display: flex; gap: 10px; padding: 9px 0; border-bottom: 1px solid var(--border); }
|
||||
.evt-ts { color: var(--text-3); font-size: 11px; min-width: 48px; }
|
||||
.evt-who { color: #7eb0ff; font-size: 11px; }
|
||||
.evt-sum { font-size: 12.5px; margin-top: 2px; color: var(--text-soft); }
|
||||
|
||||
.run { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; }
|
||||
.run-head { display: flex; align-items: center; gap: 8px; }
|
||||
.run-step { color: var(--text-2); font-size: 12px; margin-left: auto; }
|
||||
.run-sub { color: var(--text-3); font-size: 11px; margin-top: 4px; }
|
||||
|
||||
.raw-json {
|
||||
margin: 0; padding: 12px; border-radius: 8px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
color: var(--text-2); font-family: var(--mono); font-size: 11.5px;
|
||||
line-height: 1.5; overflow-x: auto; max-height: 60vh;
|
||||
}
|
||||
.empty { color: var(--text-3); font-size: 12.5px; padding: 18px 2px; text-align: center; }
|
||||
|
||||
/* =====================================================================
|
||||
Telemetry strip
|
||||
===================================================================== */
|
||||
.telemetry {
|
||||
display: flex; align-items: center; gap: 18px;
|
||||
padding: 8px 18px; border-top: 1px solid var(--border);
|
||||
background: linear-gradient(0deg, var(--surface) 0%, var(--bg) 100%);
|
||||
min-height: 44px; font-size: 12px; color: var(--text-2);
|
||||
}
|
||||
.t-block { display: inline-flex; align-items: center; gap: 7px; }
|
||||
.t-eyebrow { color: var(--text-3); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.8px; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.t-v { color: var(--text); font-weight: 600; font-size: 13px; }
|
||||
.t-divider { width: 1px; height: 22px; background: var(--border); }
|
||||
.t-spacer { flex: 1; }
|
||||
.spark { display: block; }
|
||||
.gauge { background: var(--surface-2); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||||
.gauge-fill { height: 100%; transition: width .35s ease; }
|
||||
.t-tick { width: 8px; height: 8px; border-radius: 50%; background: var(--run); box-shadow: 0 0 10px var(--run); animation: pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* =====================================================================
|
||||
Command palette
|
||||
===================================================================== */
|
||||
.cmd-overlay {
|
||||
position: fixed; inset: 0; background: rgba(3,5,11,0.6);
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding-top: 12vh; z-index: 200;
|
||||
}
|
||||
.cmd {
|
||||
width: min(620px, 92vw);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.7);
|
||||
}
|
||||
.cmd-input-row { display: flex; align-items: center; gap: 9px; padding: 13px 16px; border-bottom: 1px solid var(--border); color: var(--text-2); }
|
||||
.cmd-input-row [cmdk-input] { flex: 1; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 14.5px; font-family: var(--sans); }
|
||||
.kbd-hint { font-size: 10.5px; }
|
||||
.cmd [cmdk-list] { max-height: 380px; overflow: auto; padding: 8px; }
|
||||
.cmd [cmdk-group-heading] { color: var(--text-3); font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; padding: 8px 10px 4px; font-weight: 700; }
|
||||
.cmd [cmdk-item] { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-radius: 8px; color: var(--text); cursor: pointer; font-size: 13px; }
|
||||
.cmd [cmdk-item][data-selected='true'] { background: var(--surface-2); box-shadow: 0 0 0 1px var(--primary) inset; }
|
||||
.cmd-hint { margin-left: auto; color: var(--text-3); font-size: 11px; }
|
||||
.cmd [cmdk-empty] { padding: 24px; text-align: center; color: var(--text-3); font-size: 13px; }
|
||||
|
||||
/* =====================================================================
|
||||
Tour
|
||||
===================================================================== */
|
||||
.tour-card {
|
||||
position: fixed; z-index: 250;
|
||||
width: min(340px, 92vw);
|
||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-deep) 100%);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 14px;
|
||||
padding: 14px 16px 13px;
|
||||
box-shadow: 0 26px 80px rgba(0,0,0,0.6), 0 0 0 1px var(--border-glow);
|
||||
}
|
||||
.tour-anchor-graph { left: calc(320px + 32px); bottom: 86px; }
|
||||
.tour-anchor-queue { left: 24px; bottom: 86px; }
|
||||
.tour-anchor-inspector { right: 24px; bottom: 86px; }
|
||||
.tour-anchor-topbar { right: 24px; top: 76px; }
|
||||
.tour-anchor-command { right: 24px; top: 76px; }
|
||||
.tour-anchor-telemetry { left: 50%; transform: translateX(-50%); bottom: 64px; }
|
||||
|
||||
.tour-head { display: flex; align-items: center; gap: 7px; color: var(--text-2); }
|
||||
.tour-eyebrow { color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.tour-close { margin-left: auto; padding: 4px; border-radius: 6px; color: var(--text-3); transition: color .15s, background .15s; }
|
||||
.tour-close:hover { color: var(--text); background: var(--surface-2); }
|
||||
.tour-title { margin: 8px 0 6px; font-size: 16px; font-weight: 700; letter-spacing: -0.2px; }
|
||||
.tour-body { color: var(--text-2); font-size: 13px; margin: 0 0 12px; }
|
||||
.tour-actions { display: flex; gap: 8px; }
|
||||
.tour-actions .btn { flex: 1; }
|
||||
|
||||
/* =====================================================================
|
||||
Landing
|
||||
===================================================================== */
|
||||
.landing { position: relative; height: 100%; min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; overflow-y: auto; }
|
||||
.landing-bg {
|
||||
position: absolute; inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 18%, rgba(59,130,246,0.22) 0%, transparent 40%),
|
||||
radial-gradient(circle at 86% 76%, rgba(168,85,247,0.18) 0%, transparent 45%),
|
||||
radial-gradient(circle at 60% 5%, rgba(20,184,166,0.10) 0%, transparent 35%),
|
||||
var(--bg);
|
||||
z-index: 0;
|
||||
}
|
||||
.landing-grid {
|
||||
position: absolute; inset: 0; z-index: 0;
|
||||
background:
|
||||
linear-gradient(to right, rgba(255,255,255,0.025) 1px, transparent 1px) 0 0/72px 72px,
|
||||
linear-gradient(to bottom, rgba(255,255,255,0.025) 1px, transparent 1px) 0 0/72px 72px;
|
||||
mask-image: radial-gradient(circle at 50% 40%, black 0%, transparent 70%);
|
||||
}
|
||||
.landing-top {
|
||||
position: relative; z-index: 2;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 22px 32px;
|
||||
}
|
||||
.landing-main {
|
||||
position: relative; z-index: 1;
|
||||
padding: 30px 32px 60px;
|
||||
max-width: 1200px; margin: 0 auto; width: 100%;
|
||||
display: grid; gap: 36px;
|
||||
}
|
||||
.landing-hero { display: flex; flex-direction: column; gap: 14px; max-width: 760px; }
|
||||
.hero-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: 11.5px; color: var(--text-2); padding: 5px 10px; border-radius: 999px;
|
||||
border: 1px solid var(--border-strong); background: rgba(15,22,38,0.6); backdrop-filter: blur(6px);
|
||||
width: max-content;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: clamp(38px, 5vw, 60px);
|
||||
line-height: 1.05;
|
||||
margin: 4px 0 2px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -1.4px;
|
||||
}
|
||||
.hl {
|
||||
background: linear-gradient(90deg, #7eb0ff 0%, #c8b6ff 60%, #5eead4 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero-sub { color: var(--text-2); font-size: 16.5px; max-width: 640px; }
|
||||
.hero-actions { display: flex; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
|
||||
.hero-stats {
|
||||
display: flex; gap: 22px; margin-top: 18px; padding-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat { display: inline-flex; align-items: baseline; gap: 7px; }
|
||||
.stat-v { font-size: 22px; font-weight: 700; }
|
||||
.stat-l { color: var(--text-3); font-size: 12px; text-transform: uppercase; letter-spacing: 0.8px; }
|
||||
|
||||
.landing-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.sc-card {
|
||||
display: flex; flex-direction: column; gap: 6px; text-align: left;
|
||||
background: linear-gradient(180deg, rgba(21,30,51,0.86), rgba(15,22,38,0.86));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 16px 16px 14px;
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
transition: transform .18s, border-color .18s, box-shadow .18s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sc-card::before {
|
||||
content: ""; position: absolute; inset: 0;
|
||||
background: radial-gradient(circle at 0 0, var(--sc-accent, var(--primary)) 0%, transparent 60%);
|
||||
opacity: 0.08; pointer-events: none;
|
||||
}
|
||||
.sc-card:hover { transform: translateY(-3px); border-color: var(--sc-accent, var(--primary)); box-shadow: 0 18px 50px rgba(0,0,0,0.45); }
|
||||
.sc-card-top { display: flex; align-items: center; gap: 8px; }
|
||||
.sc-card-mark { width: 16px; height: 16px; border-radius: 5px; background: var(--sc-accent, var(--primary)); box-shadow: 0 0 14px var(--sc-accent, var(--primary)); }
|
||||
.sc-card-title { margin: 4px 0 2px; font-size: 17px; font-weight: 700; letter-spacing: -0.2px; }
|
||||
.sc-card-sub { color: var(--text-2); font-size: 12.5px; margin: 0 0 10px; }
|
||||
.sc-card-meta { color: var(--text-3); font-size: 11.5px; display: inline-flex; gap: 6px; }
|
||||
.sc-card-cta { display: inline-flex; align-items: center; gap: 6px; margin-top: 10px; color: var(--sc-accent, var(--primary)); font-weight: 600; font-size: 12px; }
|
||||
|
||||
.landing-foot { position: relative; z-index: 1; padding: 14px 32px; border-top: 1px solid var(--border); color: var(--text-3); font-size: 11.5px; text-align: center; }
|
||||
.foot-eyebrow { letter-spacing: 0.6px; }
|
||||
|
||||
/* =====================================================================
|
||||
RunHistory
|
||||
===================================================================== */
|
||||
.rh { height: 100%; display: flex; flex-direction: column; min-height: 0; }
|
||||
.rh-head { display: flex; align-items: end; justify-content: space-between; padding: 18px 24px; gap: 12px; border-bottom: 1px solid var(--border); background: var(--bg); }
|
||||
.rh-filters { display: inline-flex; gap: 6px; }
|
||||
.rh-chip {
|
||||
padding: 5px 11px; border-radius: 999px; font-size: 12px;
|
||||
border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--text-2);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.rh-chip:hover { color: var(--text); border-color: var(--primary); }
|
||||
.rh-chip-sel { background: var(--primary-deep); border-color: var(--primary); color: #fff; }
|
||||
|
||||
.rh-list { overflow-y: auto; padding: 8px 16px 24px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.rh-row {
|
||||
display: grid; grid-template-columns: 200px 180px minmax(0, 1.3fr) minmax(160px, 1fr) auto;
|
||||
align-items: center; gap: 14px;
|
||||
padding: 10px 14px; border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 13px; color: var(--text);
|
||||
}
|
||||
.rh-row:hover { background: var(--surface-2); border-color: var(--border); }
|
||||
.rh-row-id { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.rh-row-id .mono { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.rh-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||
.rh-row-scenario { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.rh-row-step { color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
.rh-row-bar { background: var(--surface-2); border-radius: 999px; height: 8px; overflow: hidden; border: 1px solid var(--border); }
|
||||
.rh-bar-fill { height: 100%; transition: width .3s ease; }
|
||||
.rh-row-meta { display: inline-flex; align-items: center; gap: 8px; color: var(--text-2); }
|
||||
|
||||
/* Scrollbar */
|
||||
.scene ::-webkit-scrollbar, .left-rail::-webkit-scrollbar, .inspector-body::-webkit-scrollbar, .rh-list::-webkit-scrollbar, .cmd [cmdk-list]::-webkit-scrollbar, .landing::-webkit-scrollbar {
|
||||
width: 8px; height: 8px;
|
||||
}
|
||||
.scene ::-webkit-scrollbar-thumb, .left-rail::-webkit-scrollbar-thumb, .inspector-body::-webkit-scrollbar-thumb, .rh-list::-webkit-scrollbar-thumb, .cmd [cmdk-list]::-webkit-scrollbar-thumb, .landing::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong); border-radius: 4px;
|
||||
}
|
||||
.scene ::-webkit-scrollbar-track, .left-rail::-webkit-scrollbar-track, .inspector-body::-webkit-scrollbar-track, .rh-list::-webkit-scrollbar-track, .cmd [cmdk-list]::-webkit-scrollbar-track, .landing::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
Live-mode pill / toggle / banners (added in v2 honesty pass)
|
||||
===================================================================== */
|
||||
.mode-pill {
|
||||
display: inline-flex; align-items: center;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.8px;
|
||||
padding: 2px 8px; border-radius: 5px; text-transform: uppercase;
|
||||
}
|
||||
.mode-snapshot { background: rgba(168,85,247,0.16); color: var(--syn); border: 1px solid rgba(168,85,247,0.4); }
|
||||
.mode-live { background: rgba(52,211,153,0.18); color: var(--ok); border: 1px solid rgba(52,211,153,0.45); }
|
||||
|
||||
.mode-toggle { gap: 8px; }
|
||||
.mode-toggle.mode-live { border-color: rgba(52,211,153,0.45); }
|
||||
.topbar-age { font-family: var(--mono); font-size: 10.5px; color: var(--text-3); margin-left: 2px; }
|
||||
|
||||
.spin {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--border-strong); border-top-color: var(--primary);
|
||||
animation: spin 0.8s linear infinite; display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spin { animation: none; } }
|
||||
|
||||
.mc-banner {
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 8px 18px; font-size: 12.5px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.mc-banner-info { background: rgba(59,130,246,0.08); color: #7eb0ff; }
|
||||
.mc-banner-err { background: rgba(240,82,82,0.10); color: #ff8a8a; }
|
||||
.mc-banner .mono { font-size: 12px; color: inherit; }
|
||||
|
||||
.preview-marker {
|
||||
display: inline-block;
|
||||
margin-left: 6px; padding: 0 6px;
|
||||
font-size: 9.5px; font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase;
|
||||
border: 1px solid rgba(168,85,247,0.45); color: var(--syn);
|
||||
background: rgba(168,85,247,0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.btn-decline { color: #ff9b9b; }
|
||||
.btn-decline:hover:not(:disabled) { border-color: var(--block); }
|
||||
|
||||
.is-on { border-color: var(--ok); color: var(--ok); }
|
||||
|
||||
/* =====================================================================
|
||||
Toaster
|
||||
===================================================================== */
|
||||
.toaster {
|
||||
position: fixed; right: 18px; bottom: 18px; z-index: 300;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
width: min(380px, 92vw); pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex; align-items: center; gap: 9px;
|
||||
padding: 10px 12px; border-radius: 10px;
|
||||
background: var(--surface-2); border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 16px 50px rgba(0,0,0,0.45);
|
||||
font-size: 12.5px; color: var(--text);
|
||||
}
|
||||
.toast-msg { flex: 1; line-height: 1.4; }
|
||||
.toast-x { padding: 2px; color: var(--text-3); border-radius: 5px; }
|
||||
.toast-x:hover { color: var(--text); background: var(--surface-3); }
|
||||
.toast-info { border-left: 3px solid var(--primary); }
|
||||
.toast-ok { border-left: 3px solid var(--ok); }
|
||||
.toast-warn { border-left: 3px solid var(--queue); }
|
||||
.toast-err { border-left: 3px solid var(--block); }
|
||||
108
src/lib/api.test.ts
Normal file
108
src/lib/api.test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
// Tests the in-browser API client against a stubbed fetch.
|
||||
// We don't hit the real backend here — that's covered by the smoke test
|
||||
// (`pnpm qa:smoke` toggles live mode and asserts the network call resolves).
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { api } from "./api";
|
||||
|
||||
function mockFetch(handlers: Array<(url: string, init: RequestInit) => Response | undefined>) {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push({ url, init });
|
||||
for (const h of handlers) {
|
||||
const r = h(url, init);
|
||||
if (r) return r;
|
||||
}
|
||||
return new Response("not stubbed", { status: 500 });
|
||||
}) as unknown as typeof fetch;
|
||||
// sessionStorage stub
|
||||
const store = new Map<string, string>();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => store.get(k) ?? null,
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
} as Storage;
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// reset between tests
|
||||
api.clearToken();
|
||||
});
|
||||
|
||||
describe("api client", () => {
|
||||
it("ping() returns ok with email on success", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: "T1" }), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
: undefined,
|
||||
(url) => url.endsWith("/api/v1/auth/me")
|
||||
? new Response(JSON.stringify({ user_id: "u1", tenant_id: "t1", email: "dev@flow-master.ai" }), { status: 200, headers: { "Content-Type": "application/json" } })
|
||||
: undefined,
|
||||
]);
|
||||
const r = await api.ping();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.user).toBe("dev@flow-master.ai");
|
||||
});
|
||||
|
||||
it("ping() returns ok=false with reason when login fails", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response("nope", { status: 401 })
|
||||
: undefined,
|
||||
]);
|
||||
const r = await api.ping();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/401/);
|
||||
});
|
||||
|
||||
it("workItems() returns the items array", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: "T2" }), { status: 200 })
|
||||
: undefined,
|
||||
(url) => url.includes("/api/ea2/work-items")
|
||||
? new Response(JSON.stringify({ items: [{ transaction_id: "tx1", status: "running", definition_key: "k1" }] }), { status: 200 })
|
||||
: undefined,
|
||||
]);
|
||||
const items = await api.workItems();
|
||||
expect(items.length).toBe(1);
|
||||
expect(items[0].transaction_id).toBe("tx1");
|
||||
});
|
||||
|
||||
it("graph() returns null on 404 (typed-safe failure mode)", async () => {
|
||||
mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: "T3" }), { status: 200 })
|
||||
: undefined,
|
||||
(url) => url.includes("/api/ea2/process-definitions/")
|
||||
? new Response("not found", { status: 404 })
|
||||
: undefined,
|
||||
]);
|
||||
const g = await api.graph("does-not-exist");
|
||||
expect(g).toBeNull();
|
||||
});
|
||||
|
||||
it("re-logs in after 401 then succeeds", async () => {
|
||||
let metHits = 0;
|
||||
const calls = mockFetch([
|
||||
(url) => url.endsWith("/api/v1/auth/dev-login")
|
||||
? new Response(JSON.stringify({ access_token: `T${metHits + 1}` }), { status: 200 })
|
||||
: undefined,
|
||||
(url) => {
|
||||
if (url.endsWith("/api/v1/auth/me")) {
|
||||
metHits += 1;
|
||||
if (metHits === 1) return new Response("expired", { status: 401 });
|
||||
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
]);
|
||||
const r = await api.ping();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(calls.filter((c) => c.url.endsWith("/dev-login")).length).toBe(2); // initial + retry login
|
||||
});
|
||||
});
|
||||
174
src/lib/api.ts
Normal file
174
src/lib/api.ts
Normal file
@ -0,0 +1,174 @@
|
||||
// Real in-browser API client for demo.flow-master.ai.
|
||||
// - dev-login → bearer (cached in sessionStorage, refresh on 401)
|
||||
// - typed wrappers for the endpoints Mission Control needs
|
||||
// - all fetches honor an AbortSignal so unmounts cancel cleanly
|
||||
// - never throws into render: returns { ok, error } shapes
|
||||
|
||||
export interface ApiConfig {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
|
||||
// the build is deployed at the same origin as the backend, so an empty
|
||||
// baseUrl is correct.
|
||||
const isDev = import.meta.env.DEV;
|
||||
const DEFAULT_CONFIG: ApiConfig = {
|
||||
baseUrl: import.meta.env.VITE_FM_BASE || (isDev ? "" : "https://demo.flow-master.ai"),
|
||||
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
|
||||
};
|
||||
|
||||
const TOKEN_KEY = "fm.mc.token.v1";
|
||||
|
||||
let inflightLogin: Promise<string> | null = null;
|
||||
|
||||
async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
|
||||
if (inflightLogin) return inflightLogin;
|
||||
inflightLogin = (async () => {
|
||||
const r = await fetch(`${cfg.baseUrl}/api/v1/auth/dev-login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: cfg.email }),
|
||||
signal,
|
||||
});
|
||||
if (!r.ok) throw new Error(`dev-login ${r.status}`);
|
||||
const body = (await r.json()) as { access_token: string };
|
||||
sessionStorage.setItem(TOKEN_KEY, body.access_token);
|
||||
return body.access_token;
|
||||
})();
|
||||
try {
|
||||
return await inflightLogin;
|
||||
} finally {
|
||||
inflightLogin = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function withAuth<T>(
|
||||
cfg: ApiConfig,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
let token = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (!token) token = await login(cfg, signal);
|
||||
const doFetch = async () =>
|
||||
fetch(`${cfg.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
let r = await doFetch();
|
||||
if (r.status === 401) {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
token = await login(cfg, signal);
|
||||
r = await doFetch();
|
||||
}
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => "");
|
||||
throw new Error(`${path} → ${r.status} ${text.slice(0, 120)}`);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export interface WorkItem {
|
||||
transaction_id: string;
|
||||
short_id?: string;
|
||||
status: string;
|
||||
hub?: string;
|
||||
age_days?: number;
|
||||
active_step_display_name?: string;
|
||||
current_node?: string;
|
||||
next_action?: string;
|
||||
definition_key: string;
|
||||
business_subject?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ProcessGraph {
|
||||
process_definition: {
|
||||
_key: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
hub?: string;
|
||||
config: { nodes: unknown[]; edges: unknown[]; org_id?: string };
|
||||
};
|
||||
version_definitions?: Array<{ version: number; approved_at?: string }>;
|
||||
}
|
||||
|
||||
export interface RuntimeTransaction {
|
||||
transaction_id: string;
|
||||
status: string;
|
||||
active_step?: { step_definition_id?: string; display_name?: string };
|
||||
available_actions?: Array<{ display_label: string }>;
|
||||
created_at?: string;
|
||||
business_subject?: string;
|
||||
}
|
||||
|
||||
export interface AuthMe {
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: DEFAULT_CONFIG,
|
||||
|
||||
async me(signal?: AbortSignal): Promise<AuthMe> {
|
||||
return withAuth<AuthMe>(this.config, "/api/v1/auth/me", {}, signal);
|
||||
},
|
||||
|
||||
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
||||
const body = await withAuth<{ items?: WorkItem[] }>(
|
||||
this.config,
|
||||
"/api/ea2/work-items?view=all",
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
return body.items ?? [];
|
||||
},
|
||||
|
||||
async graph(defKey: string, signal?: AbortSignal): Promise<ProcessGraph | null> {
|
||||
try {
|
||||
return await withAuth<ProcessGraph>(
|
||||
this.config,
|
||||
`/api/ea2/process-definitions/${defKey}/graph`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async transaction(txId: string, signal?: AbortSignal): Promise<RuntimeTransaction | null> {
|
||||
try {
|
||||
return await withAuth<RuntimeTransaction>(
|
||||
this.config,
|
||||
`/api/runtime/transactions/${txId}`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Probe whether the backend is reachable. Never throws. */
|
||||
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string }> {
|
||||
try {
|
||||
const me = await this.me(signal);
|
||||
return { ok: true, user: me.email };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
}
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
312
src/lib/buildScenarios.ts
Normal file
312
src/lib/buildScenarios.ts
Normal file
@ -0,0 +1,312 @@
|
||||
// In-browser equivalent of fetch_scenarios.mjs:
|
||||
// pulls live work items → groups by definition_key → fetches each graph and
|
||||
// representative runtime → maps into ProcessScenario[] using the same shape
|
||||
// as the bundled snapshot loader (`data/live.ts`).
|
||||
import { api, type WorkItem, type ProcessGraph, type RuntimeTransaction } from "./api";
|
||||
import type {
|
||||
AgentRun, EvidenceItem, FlowEdge, ProcessScenario, ProcessStep,
|
||||
QueueItem, Rule, RuntimeState, RunSummary, StepAction, StepKind, TourStep,
|
||||
} from "../data/types";
|
||||
|
||||
const KIND_FROM_TYPE: Record<string, StepKind> = {
|
||||
start: "start",
|
||||
end: "end",
|
||||
human_task: "human",
|
||||
agent_task: "agent",
|
||||
service_task: "service",
|
||||
system_task: "service",
|
||||
};
|
||||
const OWNER_FROM_TYPE: Record<string, "human" | "agent" | "system"> = {
|
||||
start: "system",
|
||||
end: "system",
|
||||
human_task: "human",
|
||||
agent_task: "agent",
|
||||
service_task: "system",
|
||||
system_task: "system",
|
||||
};
|
||||
const WAIT_FROM_STATUS: Record<string, QueueItem["waitingOn"]> = {
|
||||
running: "approval",
|
||||
waiting_for_user: "approval",
|
||||
waiting_for_agent: "agent",
|
||||
errored: "input",
|
||||
failed: "input",
|
||||
};
|
||||
|
||||
const shortNode = (defKey: string, id: string | null | undefined): string | null =>
|
||||
id ? id.replace(`${defKey}_`, "") : null;
|
||||
|
||||
interface LiveNode {
|
||||
id: string;
|
||||
type: string;
|
||||
label?: string;
|
||||
display_name?: string;
|
||||
actions?: Array<{ id: string; display_label?: string; label?: string; kind: string }>;
|
||||
}
|
||||
interface LiveEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
function buildScenarioFromGraph(
|
||||
family: ProcessScenario["family"],
|
||||
defKey: string,
|
||||
graph: ProcessGraph,
|
||||
cases: WorkItem[],
|
||||
headlineRt: RuntimeTransaction | null,
|
||||
recent: RuntimeTransaction[],
|
||||
): ProcessScenario {
|
||||
const pd = graph.process_definition;
|
||||
const cfg = pd.config as { nodes: LiveNode[]; edges: LiveEdge[] };
|
||||
const activeNode = headlineRt ? shortNode(defKey, headlineRt.active_step?.step_definition_id) : null;
|
||||
const activeIdx = cfg.nodes.findIndex((n) => n.id === activeNode);
|
||||
const rtStatus = headlineRt?.status ?? "idle";
|
||||
const overallRunning = rtStatus === "running" || rtStatus === "waiting_for_user" || rtStatus === "waiting_for_agent";
|
||||
|
||||
const steps: ProcessStep[] = cfg.nodes.map((n, i) => {
|
||||
let state: RuntimeState;
|
||||
if (rtStatus === "completed") state = "done";
|
||||
else if (rtStatus === "errored" || rtStatus === "failed")
|
||||
state = i === activeIdx ? "errored" : i < activeIdx ? "done" : "idle";
|
||||
else if (overallRunning) {
|
||||
if (n.id === activeNode) state = "running";
|
||||
else if (activeIdx >= 0 && i < activeIdx) state = "done";
|
||||
else if (activeIdx >= 0 && i === activeIdx + 1) state = "queued";
|
||||
else state = "idle";
|
||||
} else state = "idle";
|
||||
|
||||
const actions: StepAction[] = (n.actions || []).map((a) => ({
|
||||
id: a.id,
|
||||
label: a.display_label || a.label || a.kind,
|
||||
kind: (a.kind === "approve" || a.kind === "decline" || a.kind === "fork" ? a.kind : "complete") as StepAction["kind"],
|
||||
}));
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.label || n.display_name || n.id,
|
||||
kind: KIND_FROM_TYPE[n.type] || "service",
|
||||
owner: OWNER_FROM_TYPE[n.type] || "human",
|
||||
governs: n.type === "human_task" ? ["spend-threshold"] : [],
|
||||
state,
|
||||
actions,
|
||||
raw: n,
|
||||
};
|
||||
});
|
||||
|
||||
const stepById = new Map(steps.map((s) => [s.id, s]));
|
||||
const edges: FlowEdge[] = cfg.edges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.outcome,
|
||||
traversed: stepById.get(e.source)?.state === "done",
|
||||
}));
|
||||
|
||||
const rules: Rule[] = steps.some((s) => s.governs.length)
|
||||
? [{ id: "spend-threshold", name: "Spend threshold", expr: "amount > 10_000 → dual approval", isSynthetic: true }]
|
||||
: [];
|
||||
|
||||
const evidence: EvidenceItem[] = activeNode
|
||||
? [
|
||||
{
|
||||
id: "ev-rt",
|
||||
stepId: activeNode,
|
||||
at: (headlineRt?.created_at || "").slice(11, 16) || "now",
|
||||
actor: "runtime",
|
||||
summary: `Transaction ${headlineRt?.transaction_id?.slice(0, 8)} active at ${headlineRt?.active_step?.display_name ?? activeNode}`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const queue: QueueItem[] = cases.slice(0, 8).map((c, i) => ({
|
||||
id: `${c.transaction_id || defKey}-${i}`,
|
||||
stepId: shortNode(defKey, c.current_node) || activeNode || steps[0]?.id || "",
|
||||
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
|
||||
waitingOn: WAIT_FROM_STATUS[c.status] ?? "input",
|
||||
ageDays: c.age_days ?? 0,
|
||||
status: c.status,
|
||||
}));
|
||||
|
||||
const agentRuns: AgentRun[] = activeNode
|
||||
? [
|
||||
{
|
||||
id: "agent-1",
|
||||
stepId: activeNode,
|
||||
status: "awaiting-confirm",
|
||||
intent: `Action "${headlineRt?.available_actions?.[0]?.display_label ?? "Complete"}" via sidekick_on_behalf_of_user`,
|
||||
isSynthetic: true,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const seenRunIds = new Set<string>();
|
||||
const runs: RunSummary[] = [headlineRt, ...recent].filter(Boolean).flatMap((rt) => {
|
||||
const r = rt as RuntimeTransaction;
|
||||
if (seenRunIds.has(r.transaction_id)) return [];
|
||||
seenRunIds.add(r.transaction_id);
|
||||
const started = r.created_at ? new Date(r.created_at).getTime() : Date.now();
|
||||
return [{
|
||||
id: r.transaction_id,
|
||||
shortId: r.transaction_id.slice(0, 8),
|
||||
activeStep: r.active_step?.display_name ?? null,
|
||||
status: r.status,
|
||||
startedAt: r.created_at ?? "",
|
||||
durationSec: Math.max(60, Math.floor((Date.now() - started) / 1000)),
|
||||
}];
|
||||
});
|
||||
|
||||
const statuses: Record<string, number> = {};
|
||||
for (const c of cases) statuses[c.status] = (statuses[c.status] || 0) + 1;
|
||||
|
||||
const kpis = [
|
||||
{ label: "Live cases", value: String(cases.length), trend: "up" as const, trendValue: `${Math.min(3, cases.length)} now` },
|
||||
{ label: "Running", value: String(statuses.running ?? 0), trend: "flat" as const },
|
||||
{ label: "Errored", value: String((statuses.errored ?? 0) + (statuses.failed ?? 0)), trend: (statuses.errored ?? 0) > 0 ? ("up" as const) : ("flat" as const) },
|
||||
{ label: "Avg cycle", value: avgCycle(cases), trend: "down" as const, trendValue: "" },
|
||||
];
|
||||
|
||||
const defaultStepId = activeNode || steps[0]?.id || "";
|
||||
const versionLabel = `v${graph.version_definitions?.[0]?.version ?? 1}`;
|
||||
|
||||
return {
|
||||
id: family.id,
|
||||
family,
|
||||
live: true,
|
||||
defKey,
|
||||
defName: pd.display_name || pd.name,
|
||||
version: versionLabel,
|
||||
headlineTx: headlineRt?.transaction_id ?? null,
|
||||
tagline: `${pd.display_name || pd.name} · ${versionLabel} · live from demo.flow-master.ai`,
|
||||
steps,
|
||||
edges,
|
||||
rules,
|
||||
evidence,
|
||||
queue,
|
||||
agentRuns,
|
||||
runs,
|
||||
kpis,
|
||||
defaultStepId,
|
||||
tour: buildTour(family.id, defaultStepId),
|
||||
raw: { graph, headlineRt },
|
||||
};
|
||||
}
|
||||
|
||||
function avgCycle(cases: WorkItem[]): string {
|
||||
const ages = cases.map((c) => c.age_days ?? 0).filter((a) => a > 0);
|
||||
if (!ages.length) return "—";
|
||||
const avg = ages.reduce((s, a) => s + a, 0) / ages.length;
|
||||
return `${avg.toFixed(1)}d`;
|
||||
}
|
||||
|
||||
function buildTour(familyId: string, defaultStepId: string): TourStep[] {
|
||||
return [
|
||||
{ id: "t1", anchor: "graph", title: "Live process at a glance", body: "This graph is the real, executing definition for this scenario. Each node maps to a runtime step the backend is driving.", selectStep: defaultStepId },
|
||||
{ id: "t2", anchor: "queue", title: "Real cases, real states", body: "The left rail mirrors the live EA2 work-item board for this definition. Counts, statuses, and ages come straight from the runtime API." },
|
||||
{ id: "t3", anchor: "inspector", title: "Typed inspector", body: "Click any step to see its typed fields, governing rules, and evidence trail. The Raw tab gives you the EA2 payload verbatim." },
|
||||
{ id: "t4", anchor: "command", title: "⌘K command palette", body: `Press ⌘K to jump between scenarios, steps, or open the guided tour for ${familyId}.` },
|
||||
{ id: "t5", anchor: "telemetry", title: "Throughput rollup", body: "The bottom strip rolls up running/errored cases across every scenario you have open." },
|
||||
{ id: "t6", anchor: "graph", title: "Explore", body: "That's the full loop. Switch scenarios at the top, or open ⌘K to navigate freely." },
|
||||
];
|
||||
}
|
||||
|
||||
interface CandidateBucket {
|
||||
key: string;
|
||||
cases: WorkItem[];
|
||||
statuses: Record<string, number>;
|
||||
hubs: Set<string>;
|
||||
}
|
||||
|
||||
const FAMILIES = [
|
||||
{ id: "procurement", label: "Procurement to Pay", subtitle: "Requisition → PO → 3-way match", accent: "#3b82f6", re: /procure|purchas|pr_to_po|atlas|requisition|po\b/i },
|
||||
{ id: "ar", label: "Accounts Receivable", subtitle: "Refunds, credits & collections", accent: "#10b981", re: /refund|credit|collect|receivable|invoice/i },
|
||||
{ id: "hcm", label: "People Operations", subtitle: "Onboard · Offboard · Leave", accent: "#a855f7", re: /onboard|offboard|hire|hcm|employee|leave|payroll|hr\b/i },
|
||||
{ id: "gl", label: "GL Close", subtitle: "Accruals, reconciliations, journals", accent: "#f59e0b", re: /close|ledger|journal|accrual|reconcil|gl\b/i },
|
||||
{ id: "service", label: "Service Operations", subtitle: "Tickets, incidents, support", accent: "#ef4444", re: /ticket|incident|support|service|case\b/i },
|
||||
];
|
||||
|
||||
export async function buildLiveScenariosFromApi(signal?: AbortSignal): Promise<{ scenarios: ProcessScenario[]; workItems: WorkItem[]; distinctDefs: number }> {
|
||||
const workItems = await api.workItems(signal);
|
||||
|
||||
// Bucket by definition_key with meta.
|
||||
const byDef = new Map<string, CandidateBucket>();
|
||||
for (const w of workItems) {
|
||||
const k = w.definition_key;
|
||||
if (!k) continue;
|
||||
if (!byDef.has(k)) byDef.set(k, { key: k, cases: [], statuses: {}, hubs: new Set() });
|
||||
const b = byDef.get(k)!;
|
||||
b.cases.push(w);
|
||||
b.statuses[w.status] = (b.statuses[w.status] || 0) + 1;
|
||||
if (w.hub) b.hubs.add(w.hub);
|
||||
}
|
||||
|
||||
// Candidates with ≥ 2 cases OR 1 running.
|
||||
const candidates = [...byDef.values()].filter((d) => d.cases.length >= 2 || (d.statuses.running ?? 0) >= 1);
|
||||
candidates.sort((a, b) => b.cases.length - a.cases.length);
|
||||
|
||||
// For each candidate fetch graph + a couple runtimes.
|
||||
interface Enriched { bucket: CandidateBucket; graph: ProcessGraph; headlineRt: RuntimeTransaction | null; recent: RuntimeTransaction[] }
|
||||
const enriched: Enriched[] = [];
|
||||
for (const c of candidates.slice(0, 20)) {
|
||||
if (signal?.aborted) break;
|
||||
const graph = await api.graph(c.key, signal);
|
||||
if (!graph?.process_definition?.config?.nodes?.length) 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];
|
||||
const headlineRt = headlineCase?.transaction_id ? await api.transaction(headlineCase.transaction_id, signal) : null;
|
||||
const recent: RuntimeTransaction[] = [];
|
||||
for (const w of c.cases.slice(0, 6)) {
|
||||
if (signal?.aborted) break;
|
||||
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
|
||||
const r = await api.transaction(w.transaction_id, signal);
|
||||
if (r) recent.push(r);
|
||||
if (recent.length >= 3) break;
|
||||
}
|
||||
enriched.push({ bucket: c, graph, headlineRt, recent });
|
||||
}
|
||||
|
||||
// Classify into families.
|
||||
const used = new Set<string>();
|
||||
const scenarios: ProcessScenario[] = [];
|
||||
for (const fam of FAMILIES) {
|
||||
const pick = enriched
|
||||
.filter((e) => !used.has(e.bucket.key) && fam.re.test(`${e.graph.process_definition.display_name ?? e.graph.process_definition.name} ${[...e.bucket.hubs].join(" ")}`))
|
||||
.sort((a, b) => b.bucket.cases.length - a.bucket.cases.length)[0];
|
||||
if (!pick) continue;
|
||||
used.add(pick.bucket.key);
|
||||
scenarios.push(
|
||||
buildScenarioFromGraph(
|
||||
{ id: fam.id, label: fam.label, subtitle: fam.subtitle, accent: fam.accent },
|
||||
pick.bucket.key,
|
||||
pick.graph,
|
||||
pick.bucket.cases,
|
||||
pick.headlineRt,
|
||||
pick.recent,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Top-up with remaining largest buckets if we have fewer than 4.
|
||||
const leftovers = enriched.filter((e) => !used.has(e.bucket.key)).sort((a, b) => b.bucket.cases.length - a.bucket.cases.length);
|
||||
while (scenarios.length < 4 && leftovers.length) {
|
||||
const e = leftovers.shift()!;
|
||||
scenarios.push(
|
||||
buildScenarioFromGraph(
|
||||
{
|
||||
id: `extra-${scenarios.length}`,
|
||||
label: e.graph.process_definition.display_name || e.graph.process_definition.name,
|
||||
subtitle: `${e.bucket.cases.length} live cases`,
|
||||
accent: "#64748b",
|
||||
},
|
||||
e.bucket.key,
|
||||
e.graph,
|
||||
e.bucket.cases,
|
||||
e.headlineRt,
|
||||
e.recent,
|
||||
),
|
||||
);
|
||||
used.add(e.bucket.key);
|
||||
}
|
||||
return { scenarios, workItems, distinctDefs: byDef.size };
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
8138
src/scenarios.json
Normal file
8138
src/scenarios.json
Normal file
File diff suppressed because it is too large
Load Diff
132
src/scenes/Landing.tsx
Normal file
132
src/scenes/Landing.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
// Cinematic landing scene: brand, scenarios pick, start tour CTA.
|
||||
import { motion } from "framer-motion";
|
||||
import { useApp } from "../state/store";
|
||||
import { liveMeta } from "../data/scenarios";
|
||||
import { Sparkles, Arrow, Cmd, Bot, Pulse } from "../components/icons";
|
||||
|
||||
export default function Landing() {
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||
const startTour = useApp((s) => s.startTour);
|
||||
const setCmdOpen = useApp((s) => s.setCmdOpen);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveTotals = useApp((s) => s.liveTotals);
|
||||
|
||||
return (
|
||||
<div className="landing">
|
||||
<div className="landing-bg" aria-hidden />
|
||||
<div className="landing-grid" aria-hidden />
|
||||
|
||||
<header className="landing-top">
|
||||
<div className="brand-lock">
|
||||
<span className="brand-mark" />
|
||||
<span className="brand-name">FlowMaster</span>
|
||||
<span className="brand-divider" />
|
||||
<span className="brand-sub">Mission Control</span>
|
||||
</div>
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||
<Cmd size={13} /> Command <kbd>⌘K</kbd>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="landing-main">
|
||||
<motion.div
|
||||
className="landing-hero"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<span className="hero-eyebrow"><Sparkles size={13} /> Business-as-code · Mission Control</span>
|
||||
<h1 className="hero-title">
|
||||
Every process. <span className="hl">One control surface.</span>
|
||||
</h1>
|
||||
<p className="hero-sub">
|
||||
FlowMaster turns the operational map of a company into living,
|
||||
typed processes — backed by humans, agents, and rules — and gives you a
|
||||
single command-center to drive them. Procurement scenarios are real,
|
||||
backed by EA2 on{" "}
|
||||
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>;
|
||||
AR, HCM, GL, and Service are industry blueprints showing how this
|
||||
same shell extends to any process family.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn btn-primary btn-lg" onClick={startTour}>
|
||||
<Sparkles size={14} /> Start guided tour
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-lg" onClick={() => setScene("mission")}>
|
||||
Skip to Mission Control <Arrow size={13} />
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
|
||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
||||
disabled={liveLoading}
|
||||
title="Toggle between bundled snapshot and a live in-browser fetch from demo.flow-master.ai"
|
||||
>
|
||||
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
|
||||
{mode === "live" ? "Live mode · on" : "Go live"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
<div className="stat">
|
||||
<Pulse size={12} />
|
||||
<span className="stat-v mono">{liveTotals?.workItems ?? liveMeta.workItems}</span>
|
||||
<span className="stat-l">{mode === "live" ? "live work items (now)" : "snapshot work items"}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-v mono">{liveTotals?.distinctDefs ?? liveMeta.distinctDefs}</span>
|
||||
<span className="stat-l">process definitions</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<Bot size={12} />
|
||||
<span className="stat-v mono">{scenarios.length}</span>
|
||||
<span className="stat-l">scenarios in catalog</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
<span className="stat-l">data mode</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="landing-cards"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.12, duration: 0.45 }}
|
||||
>
|
||||
{scenarios.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className="sc-card"
|
||||
style={{ ["--sc-accent" as string]: s.family.accent }}
|
||||
onClick={() => { setScenarioId(s.id); setScene("mission"); }}
|
||||
>
|
||||
<div className="sc-card-top">
|
||||
<span className="sc-card-mark" />
|
||||
<span className={`tag ${s.live ? "tag-live" : "tag-syn"}`}>{s.live ? "live" : "blueprint"}</span>
|
||||
</div>
|
||||
<h3 className="sc-card-title">{s.family.label}</h3>
|
||||
<p className="sc-card-sub">{s.family.subtitle}</p>
|
||||
<div className="sc-card-meta">
|
||||
<span>{s.steps.length} steps</span>
|
||||
<span>·</span>
|
||||
<span>{s.queue.length} cases</span>
|
||||
<span>·</span>
|
||||
<span className="mono">{s.version}</span>
|
||||
</div>
|
||||
<div className="sc-card-cta">Open <Arrow size={12} /></div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</main>
|
||||
|
||||
<footer className="landing-foot">
|
||||
<span className="foot-eyebrow">FlowMaster · Mission Control demo · synthesised on top of demo.flow-master.ai</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/scenes/MissionControl.tsx
Normal file
72
src/scenes/MissionControl.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
// MissionControl scene: scenario tabs + graph + rails + telemetry strip.
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import ProcessGraph from "../components/ProcessGraph";
|
||||
import LeftRail from "../components/LeftRail";
|
||||
import Inspector from "../components/Inspector";
|
||||
import Telemetry from "../components/Telemetry";
|
||||
|
||||
export default function MissionControl() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveError = useApp((s) => s.liveError);
|
||||
const sc = scenarioById(scenarioId);
|
||||
|
||||
return (
|
||||
<div className="mc">
|
||||
{liveLoading && (
|
||||
<div className="mc-banner mc-banner-info">
|
||||
<span className="spin" /> Fetching live scenarios from demo.flow-master.ai…
|
||||
</div>
|
||||
)}
|
||||
{liveError && (
|
||||
<div className="mc-banner mc-banner-err">
|
||||
Live mode failed: <span className="mono">{liveError}</span> · showing snapshot
|
||||
</div>
|
||||
)}
|
||||
<div className="mc-strip" role="tablist" aria-label="Scenarios">
|
||||
{scenarios.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
role="tab"
|
||||
aria-selected={s.id === scenarioId}
|
||||
className={`mc-tab${s.id === scenarioId ? " mc-tab-sel" : ""}`}
|
||||
style={{ ["--accent" as string]: s.family.accent }}
|
||||
onClick={() => setScenarioId(s.id)}
|
||||
>
|
||||
<span className="mc-tab-dot" />
|
||||
<span className="mc-tab-label">{s.family.label}</span>
|
||||
<span className={`mc-tab-mark ${s.live ? "is-live" : "is-syn"}`}>{s.live ? "live" : "bp"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mc-hero">
|
||||
<div>
|
||||
<div className="mc-hero-eyebrow">{sc?.family.subtitle}</div>
|
||||
<h2 className="mc-hero-title">{sc?.defName}</h2>
|
||||
<div className="mc-hero-sub">{sc?.tagline}</div>
|
||||
</div>
|
||||
<div className="mc-hero-kpis">
|
||||
{sc?.kpis.slice(0, 4).map((k) => (
|
||||
<div className="mc-kpi" key={k.label}>
|
||||
<div className="mc-kpi-v">{k.value}</div>
|
||||
<div className="mc-kpi-l">{k.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mc-body">
|
||||
<LeftRail />
|
||||
<main className="mc-main">
|
||||
<ProcessGraph />
|
||||
</main>
|
||||
<Inspector />
|
||||
</div>
|
||||
|
||||
<Telemetry />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/scenes/RunHistory.tsx
Normal file
81
src/scenes/RunHistory.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
// RunHistory scene: cross-scenario timeline.
|
||||
import { useMemo, useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { Clock, History } from "../components/icons";
|
||||
|
||||
const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
|
||||
type Status = (typeof STATUS_FILTERS)[number];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
running: "var(--run)",
|
||||
completed: "var(--ok)",
|
||||
done: "var(--ok)",
|
||||
errored: "var(--block)",
|
||||
failed: "var(--block)",
|
||||
queued: "var(--queue)",
|
||||
};
|
||||
|
||||
export default function RunHistory() {
|
||||
const [filter, setFilter] = useState<Status>("all");
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ scenarioId: string; scenarioLabel: string; accent: string; live: boolean; run: (typeof scenarios)[number]["runs"][number] }> = [];
|
||||
for (const s of scenarios) {
|
||||
for (const r of s.runs) {
|
||||
if (filter !== "all" && r.status !== filter && !(filter === "completed" && r.status === "done")) continue;
|
||||
out.push({ scenarioId: s.id, scenarioLabel: s.family.label, accent: s.family.accent, live: s.live, run: r });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => (b.run.startedAt || "").localeCompare(a.run.startedAt || ""));
|
||||
}, [filter]);
|
||||
|
||||
const maxDuration = useMemo(() => Math.max(60, ...rows.map((r) => r.run.durationSec)), [rows]);
|
||||
|
||||
return (
|
||||
<div className="rh">
|
||||
<header className="rh-head">
|
||||
<div>
|
||||
<span className="mc-hero-eyebrow"><History size={12} /> Run history</span>
|
||||
<h2 className="mc-hero-title">All scenarios · all runs</h2>
|
||||
</div>
|
||||
<nav className="rh-filters">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f} className={`rh-chip${filter === f ? " rh-chip-sel" : ""}`} onClick={() => setFilter(f)}>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="rh-list">
|
||||
{rows.length === 0 && <div className="empty">No runs match the current filter.</div>}
|
||||
{rows.map((row) => (
|
||||
<div className="rh-row" key={`${row.scenarioId}-${row.run.id}`}>
|
||||
<div className="rh-row-id">
|
||||
<span className="rh-dot" style={{ background: row.accent }} />
|
||||
<span className="mono">{row.run.shortId}</span>
|
||||
<span className={`tag ${row.live ? "tag-live" : "tag-syn"}`}>{row.live ? "live" : "bp"}</span>
|
||||
</div>
|
||||
<div className="rh-row-scenario">{row.scenarioLabel}</div>
|
||||
<div className="rh-row-step">{row.run.activeStep ?? "—"}</div>
|
||||
<div className="rh-row-bar">
|
||||
<div
|
||||
className="rh-bar-fill"
|
||||
style={{
|
||||
width: `${Math.max(2, (row.run.durationSec / maxDuration) * 100)}%`,
|
||||
background: STATUS_COLOR[row.run.status] ?? "var(--border-strong)",
|
||||
}}
|
||||
title={`${Math.round(row.run.durationSec / 60)}m`}
|
||||
/>
|
||||
</div>
|
||||
<div className="rh-row-meta">
|
||||
<span className="mono"><Clock size={11} /> {Math.round(row.run.durationSec / 60)}m</span>
|
||||
<span className={`pill pill-${row.run.status === "completed" || row.run.status === "done" ? "done" : row.run.status === "running" ? "running" : row.run.status === "errored" || row.run.status === "failed" ? "errored" : "queued"}`}>{row.run.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
src/state/store.ts
Normal file
185
src/state/store.ts
Normal file
@ -0,0 +1,185 @@
|
||||
// Global UI state for Mission Control.
|
||||
import { create } from "zustand";
|
||||
import { liveScenarios as snapshotLive } from "../data/live";
|
||||
import { syntheticScenarios } from "../data/synthetic";
|
||||
import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
|
||||
import { api } from "../lib/api";
|
||||
import type { ProcessScenario } from "../data/types";
|
||||
|
||||
export type SceneId = "landing" | "mission" | "history" | "studio";
|
||||
export type DataMode = "snapshot" | "live";
|
||||
|
||||
interface TourState {
|
||||
active: boolean;
|
||||
index: number;
|
||||
autoplay: boolean;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
kind: "info" | "ok" | "warn" | "err";
|
||||
msg: string;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
scene: SceneId;
|
||||
setScene: (s: SceneId) => void;
|
||||
|
||||
/** snapshot = bundled scenarios.json; live = in-browser API client */
|
||||
mode: DataMode;
|
||||
setMode: (m: DataMode) => Promise<void>;
|
||||
|
||||
liveLoading: boolean;
|
||||
liveError: string | null;
|
||||
liveTotals: { workItems: number; distinctDefs: number } | null;
|
||||
liveFetchedAt: number | null;
|
||||
|
||||
scenarios: ProcessScenario[];
|
||||
|
||||
scenarioId: string;
|
||||
setScenarioId: (id: string) => void;
|
||||
|
||||
selectedStepId: string | null;
|
||||
setSelectedStepId: (id: string | null) => void;
|
||||
|
||||
cmdOpen: boolean;
|
||||
setCmdOpen: (v: boolean) => void;
|
||||
|
||||
tour: TourState;
|
||||
startTour: () => void;
|
||||
endTour: () => void;
|
||||
tourPrev: () => void;
|
||||
tourNext: () => void;
|
||||
tourSetIndex: (i: number) => void;
|
||||
setTourAutoplay: (v: boolean) => void;
|
||||
|
||||
recents: string[];
|
||||
pushRecent: (label: string) => void;
|
||||
|
||||
inspectorTab: "overview" | "rules" | "evidence" | "raw" | "runs";
|
||||
setInspectorTab: (t: AppState["inspectorTab"]) => void;
|
||||
|
||||
toasts: Toast[];
|
||||
pushToast: (kind: Toast["kind"], msg: string) => void;
|
||||
dismissToast: (id: number) => void;
|
||||
}
|
||||
|
||||
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios];
|
||||
const initialScenario = SNAPSHOT_SCENARIOS[0];
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
export const useApp = create<AppState>((set, get) => ({
|
||||
scene: "landing",
|
||||
setScene: (scene) => set({ scene }),
|
||||
|
||||
mode: "snapshot",
|
||||
setMode: async (mode) => {
|
||||
if (mode === get().mode) return;
|
||||
if (mode === "snapshot") {
|
||||
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
||||
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
||||
return;
|
||||
}
|
||||
set({ mode: "live", liveLoading: true, liveError: null });
|
||||
try {
|
||||
const ping = await api.ping();
|
||||
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const merged = [...scenarios, ...syntheticScenarios];
|
||||
const first = merged[0];
|
||||
set({
|
||||
scenarios: merged,
|
||||
liveTotals: { workItems: workItems.length, distinctDefs },
|
||||
liveFetchedAt: Date.now(),
|
||||
liveLoading: false,
|
||||
scenarioId: first?.id ?? get().scenarioId,
|
||||
selectedStepId: first?.defaultStepId ?? null,
|
||||
});
|
||||
get().pushToast("ok", `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`);
|
||||
} catch (e) {
|
||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
||||
}
|
||||
},
|
||||
|
||||
liveLoading: false,
|
||||
liveError: null,
|
||||
liveTotals: null,
|
||||
liveFetchedAt: null,
|
||||
|
||||
scenarios: SNAPSHOT_SCENARIOS,
|
||||
|
||||
scenarioId: initialScenario?.id ?? "",
|
||||
setScenarioId: (id) => {
|
||||
const sc = get().scenarios.find((s) => s.id === id);
|
||||
set({
|
||||
scenarioId: id,
|
||||
selectedStepId: sc?.defaultStepId ?? null,
|
||||
});
|
||||
if (sc) get().pushRecent(`Scenario: ${sc.family.label}`);
|
||||
},
|
||||
|
||||
selectedStepId: initialScenario?.defaultStepId ?? null,
|
||||
setSelectedStepId: (id) => set({ selectedStepId: id }),
|
||||
|
||||
cmdOpen: false,
|
||||
setCmdOpen: (cmdOpen) => set({ cmdOpen }),
|
||||
|
||||
tour: { active: false, index: 0, autoplay: false },
|
||||
startTour: () => {
|
||||
const sc = get().scenarios.find((s) => s.id === get().scenarioId);
|
||||
const first = sc?.tour[0];
|
||||
set({
|
||||
tour: { active: true, index: 0, autoplay: false },
|
||||
scene: "mission",
|
||||
selectedStepId: first?.selectStep ?? get().selectedStepId,
|
||||
});
|
||||
},
|
||||
endTour: () => set({ tour: { active: false, index: 0, autoplay: false } }),
|
||||
tourPrev: () =>
|
||||
set((s) => {
|
||||
const sc = s.scenarios.find((sc) => sc.id === s.scenarioId);
|
||||
const idx = Math.max(0, s.tour.index - 1);
|
||||
const step = sc?.tour[idx];
|
||||
return {
|
||||
tour: { ...s.tour, index: idx },
|
||||
selectedStepId: step?.selectStep ?? s.selectedStepId,
|
||||
};
|
||||
}),
|
||||
tourNext: () =>
|
||||
set((s) => {
|
||||
const sc = s.scenarios.find((sc) => sc.id === s.scenarioId);
|
||||
if (!sc) return s;
|
||||
const last = sc.tour.length - 1;
|
||||
const idx = Math.min(last, s.tour.index + 1);
|
||||
const step = sc.tour[idx];
|
||||
const nextScenarioId = step?.switchToScenario ?? s.scenarioId;
|
||||
return {
|
||||
tour: { ...s.tour, index: idx },
|
||||
scenarioId: nextScenarioId,
|
||||
selectedStepId: step?.selectStep ?? s.selectedStepId,
|
||||
};
|
||||
}),
|
||||
tourSetIndex: (i) => set((s) => ({ tour: { ...s.tour, index: i } })),
|
||||
setTourAutoplay: (v) => set((s) => ({ tour: { ...s.tour, autoplay: v } })),
|
||||
|
||||
recents: [],
|
||||
pushRecent: (label) =>
|
||||
set((s) => ({ recents: [label, ...s.recents.filter((r) => r !== label)].slice(0, 8) })),
|
||||
|
||||
inspectorTab: "overview",
|
||||
setInspectorTab: (inspectorTab) => set({ inspectorTab }),
|
||||
|
||||
toasts: [],
|
||||
pushToast: (kind, msg) => {
|
||||
const id = ++toastSeq;
|
||||
set((s) => ({ toasts: [...s.toasts, { id, kind, msg }] }));
|
||||
setTimeout(() => get().dismissToast(id), 4200);
|
||||
},
|
||||
dismissToast: (id) =>
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
}));
|
||||
|
||||
export const scenarioById = (id: string): ProcessScenario | undefined =>
|
||||
useApp.getState().scenarios.find((s) => s.id === id);
|
||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
20
vite.config.ts
Normal file
20
vite.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Proxy /api/* to the demo.flow-master.ai backend during dev so live-mode
|
||||
// fetches don't hit CORS. In production, the build is deployed under the
|
||||
// same origin as the backend, so no proxy is needed.
|
||||
const TARGET = process.env.VITE_FM_BASE || 'https://demo.flow-master.ai'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: TARGET,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user