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
153 lines
5.4 KiB
TypeScript
153 lines
5.4 KiB
TypeScript
// 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>
|
||
);
|
||
}
|