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:
2026-06-14 00:09:32 +04:00
commit 3ffd0e68a7
45 changed files with 15603 additions and 0 deletions
+185
View 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);