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:
@@ -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);
|
||||
Reference in New Issue
Block a user