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

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

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

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

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

153 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>
);
}