feat: real backend mutations + Studio + Settings + live API console
Massive overhaul that turns the demo from presenter-mode into a
fully-functional FlowMaster operator surface.
NEW: real backend mutations
- api.executeAction(txId, actionId, actor, values) → POST /api/runtime/transactions/{tx}/actions/{actionId}
- api.startTransaction(defKey, business_subject) → POST /api/runtime/transactions
- api.createProcess(payload) → POST /api/ea2/flow
- store.executeAction / startInstance with toast feedback + auto-refresh
- Inspector Overview action buttons fire real backend calls (Submit/Save Draft/etc)
- LeftRail Confirm/Reject buttons fire real backend calls
- LeftRail 'Start new instance' button starts a real tx for live procurement
- CommandBar 'Real actions' group with 'Start new instance' + 'Execute action on headline tx'
NEW: Process Studio (src/scenes/Studio.tsx)
- in-UI process designer: name + display + hub + description + node list + edge list
- live JSON preview of the EA2 payload
- Publish button calls api.createProcess against demo.flow-master.ai
- Validates locally before publishing
- Auto-refreshes scenarios after publish so the new process shows up
NEW: Settings (src/scenes/Settings.tsx)
- Identity: sign in as any email (loginAs), see actor + display name
- Quick-user buttons for common demo identities
- Backend URL + clear-token diagnostic
- Polling cadence (2-120s)
- Dark/light theme toggle (CSS data-theme attribute)
- Show-console default toggle
- All persisted to localStorage (LS_KEY = fm.mc.prefs.v1)
NEW: Live API console (src/components/Console.tsx)
- Right-side drawer triggered from topbar
- Every fetch (GET/POST/etc) streams in real time with status + duration
- Click any entry to expand request + response JSON
- Filter: all / writes / errors
- Replaces the old guided-tour overlay entirely
NEW: live polling
- store.startPolling()/stopPolling() with setInterval guarded for SSR
- Auto-refresh while in LIVE mode at configurable cadence
REMOVED: Tour.tsx, all startTour() store actions and tour references
- Landing CTA now reads 'Enter Mission Control' / 'Design a process' / 'Open live console'
ALSO:
- api.ts: instrumentedFetch with observer pattern → store.apiLog
- Topbar: user identity chip linking to Settings, Console toggle with badge
- Light theme: minimal CSS data-theme override (text + surfaces only)
- localStorage persistence for mode, scenarioId, email, theme, pollEverySec, consoleOpen, recents
- 24/24 vitest, smoke quick run shows 0 console errors + 7 API calls captured
Confidence: high
Scope-risk: broad (~14 files)
Not-tested: actual end-to-end backend mutation roundtrip (requires LIVE + sign-in; structure proven via probe scripts)
This commit is contained in:
+234
-106
@@ -3,17 +3,12 @@ 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 { api, type ApiCall, type Actor } from "../lib/api";
|
||||
import type { ProcessScenario } from "../data/types";
|
||||
|
||||
export type SceneId = "landing" | "mission" | "history" | "studio";
|
||||
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings";
|
||||
export type DataMode = "snapshot" | "live";
|
||||
|
||||
interface TourState {
|
||||
active: boolean;
|
||||
index: number;
|
||||
autoplay: boolean;
|
||||
}
|
||||
export type Theme = "dark" | "light";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
@@ -21,14 +16,40 @@ export interface Toast {
|
||||
msg: string;
|
||||
}
|
||||
|
||||
const LS_KEY = "fm.mc.prefs.v1";
|
||||
|
||||
interface Prefs {
|
||||
mode: DataMode;
|
||||
scenarioId: string;
|
||||
email: string;
|
||||
theme: Theme;
|
||||
pollEverySec: number;
|
||||
consoleOpen: boolean;
|
||||
recents: string[];
|
||||
}
|
||||
|
||||
function loadPrefs(): Partial<Prefs> {
|
||||
if (typeof localStorage === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Partial<Prefs>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(p: Prefs) {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(p)); } catch { /* swallow */ }
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
scene: SceneId;
|
||||
setScene: (s: SceneId) => void;
|
||||
|
||||
/** snapshot = bundled scenarios.json; live = in-browser API client */
|
||||
mode: DataMode;
|
||||
setMode: (m: DataMode) => Promise<void>;
|
||||
/** Force re-fetch of live scenarios. No-op in snapshot mode. */
|
||||
refreshLive: () => Promise<void>;
|
||||
|
||||
liveLoading: boolean;
|
||||
@@ -47,14 +68,6 @@ interface AppState {
|
||||
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;
|
||||
|
||||
@@ -64,12 +77,41 @@ interface AppState {
|
||||
toasts: Toast[];
|
||||
pushToast: (kind: Toast["kind"], msg: string) => void;
|
||||
dismissToast: (id: number) => void;
|
||||
|
||||
/** Identity for action execution (loaded after first successful me()). */
|
||||
actor: Actor | null;
|
||||
userEmail: string;
|
||||
userDisplayName: string | null;
|
||||
setUserEmail: (e: string) => void;
|
||||
loginAs: (email: string) => Promise<void>;
|
||||
|
||||
/** Live polling */
|
||||
pollEverySec: number;
|
||||
setPollEverySec: (n: number) => void;
|
||||
pollTimer: number | null;
|
||||
startPolling: () => void;
|
||||
stopPolling: () => void;
|
||||
|
||||
/** API call log */
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (v: boolean) => void;
|
||||
apiLog: ApiCall[];
|
||||
clearApiLog: () => void;
|
||||
|
||||
/** Theme */
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
|
||||
/** Action handlers (real backend mutations). */
|
||||
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
|
||||
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
const SNAPSHOT_SCENARIOS: ProcessScenario[] = [...snapshotLive, ...syntheticScenarios];
|
||||
const initialScenario = SNAPSHOT_SCENARIOS[0];
|
||||
|
||||
let toastSeq = 0;
|
||||
const MAX_API_LOG = 200;
|
||||
|
||||
async function runLiveFetch(
|
||||
set: (partial: Partial<AppState>) => void,
|
||||
@@ -80,6 +122,12 @@ async function runLiveFetch(
|
||||
try {
|
||||
const ping = await api.ping();
|
||||
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
||||
if (ping.user_id) {
|
||||
set({
|
||||
actor: { mode: "direct_user", user_id: ping.user_id },
|
||||
userEmail: ping.user ?? get().userEmail,
|
||||
});
|
||||
}
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const merged = [...scenarios, ...syntheticScenarios];
|
||||
const first = merged[0];
|
||||
@@ -108,102 +156,182 @@ async function runLiveFetch(
|
||||
}
|
||||
}
|
||||
|
||||
export const useApp = create<AppState>((set, get) => ({
|
||||
scene: "landing",
|
||||
setScene: (scene) => set({ scene }),
|
||||
const prefs = loadPrefs();
|
||||
|
||||
mode: "snapshot",
|
||||
setMode: async (mode) => {
|
||||
if (mode === "snapshot") {
|
||||
if (get().mode === "snapshot") return;
|
||||
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
||||
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
||||
return;
|
||||
}
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
refreshLive: async () => {
|
||||
if (get().mode !== "live") return;
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
|
||||
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,
|
||||
export const useApp = create<AppState>((set, get) => {
|
||||
const persist = () => {
|
||||
const s = get();
|
||||
savePrefs({
|
||||
mode: s.mode,
|
||||
scenarioId: s.scenarioId,
|
||||
email: s.userEmail,
|
||||
theme: s.theme,
|
||||
pollEverySec: s.pollEverySec,
|
||||
consoleOpen: s.consoleOpen,
|
||||
recents: s.recents,
|
||||
});
|
||||
if (sc) get().pushRecent(`Scenario: ${sc.family.label}`);
|
||||
},
|
||||
};
|
||||
|
||||
selectedStepId: initialScenario?.defaultStepId ?? null,
|
||||
setSelectedStepId: (id) => set({ selectedStepId: id }),
|
||||
return {
|
||||
scene: "landing",
|
||||
setScene: (scene) => set({ scene }),
|
||||
|
||||
cmdOpen: false,
|
||||
setCmdOpen: (cmdOpen) => set({ cmdOpen }),
|
||||
mode: prefs.mode ?? "snapshot",
|
||||
setMode: async (mode) => {
|
||||
if (mode === "snapshot") {
|
||||
if (get().mode === "snapshot") return;
|
||||
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
||||
get().stopPolling();
|
||||
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
||||
persist();
|
||||
return;
|
||||
}
|
||||
await runLiveFetch(set, get);
|
||||
get().startPolling();
|
||||
persist();
|
||||
},
|
||||
refreshLive: async () => {
|
||||
if (get().mode !== "live") return;
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
|
||||
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 } })),
|
||||
liveLoading: false,
|
||||
liveError: null,
|
||||
liveTotals: null,
|
||||
liveFetchedAt: null,
|
||||
|
||||
recents: [],
|
||||
pushRecent: (label) =>
|
||||
set((s) => ({ recents: [label, ...s.recents.filter((r) => r !== label)].slice(0, 8) })),
|
||||
scenarios: SNAPSHOT_SCENARIOS,
|
||||
|
||||
inspectorTab: "overview",
|
||||
setInspectorTab: (inspectorTab) => set({ inspectorTab }),
|
||||
scenarioId: (prefs.scenarioId && SNAPSHOT_SCENARIOS.some((s) => s.id === prefs.scenarioId)) ? prefs.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}`);
|
||||
persist();
|
||||
},
|
||||
|
||||
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) })),
|
||||
}));
|
||||
selectedStepId: initialScenario?.defaultStepId ?? null,
|
||||
setSelectedStepId: (id) => set({ selectedStepId: id }),
|
||||
|
||||
cmdOpen: false,
|
||||
setCmdOpen: (cmdOpen) => set({ cmdOpen }),
|
||||
|
||||
recents: prefs.recents ?? [],
|
||||
pushRecent: (label) => {
|
||||
set((s) => ({ recents: [label, ...s.recents.filter((r) => r !== label)].slice(0, 8) }));
|
||||
persist();
|
||||
},
|
||||
|
||||
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), 4500);
|
||||
},
|
||||
dismissToast: (id) =>
|
||||
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
|
||||
actor: null,
|
||||
userEmail: prefs.email ?? "dev@flow-master.ai",
|
||||
userDisplayName: null,
|
||||
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
|
||||
loginAs: async (email) => {
|
||||
api.clearToken();
|
||||
api.config = { ...api.config, email };
|
||||
set({ userEmail: email });
|
||||
persist();
|
||||
try {
|
||||
const me = await api.me();
|
||||
set({
|
||||
actor: { mode: "direct_user", user_id: me.user_id },
|
||||
userDisplayName: me.display_name ?? null,
|
||||
});
|
||||
get().pushToast("ok", `Signed in as ${me.email}`);
|
||||
if (get().mode === "live") await get().refreshLive();
|
||||
} catch (e) {
|
||||
get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`);
|
||||
}
|
||||
},
|
||||
|
||||
pollEverySec: prefs.pollEverySec ?? 8,
|
||||
setPollEverySec: (n) => {
|
||||
set({ pollEverySec: Math.max(2, Math.min(120, n)) });
|
||||
persist();
|
||||
if (get().pollTimer) { get().stopPolling(); get().startPolling(); }
|
||||
},
|
||||
pollTimer: null,
|
||||
startPolling: () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const existing = get().pollTimer;
|
||||
if (existing) window.clearInterval(existing);
|
||||
const id = window.setInterval(() => {
|
||||
const s = get();
|
||||
if (s.mode !== "live" || s.liveLoading) return;
|
||||
void s.refreshLive();
|
||||
}, get().pollEverySec * 1000);
|
||||
set({ pollTimer: id });
|
||||
},
|
||||
stopPolling: () => {
|
||||
if (typeof window === "undefined") return;
|
||||
const id = get().pollTimer;
|
||||
if (id) window.clearInterval(id);
|
||||
set({ pollTimer: null });
|
||||
},
|
||||
|
||||
consoleOpen: prefs.consoleOpen ?? false,
|
||||
setConsoleOpen: (v) => { set({ consoleOpen: v }); persist(); },
|
||||
apiLog: [],
|
||||
clearApiLog: () => set({ apiLog: [] }),
|
||||
|
||||
theme: prefs.theme ?? "dark",
|
||||
setTheme: (t) => {
|
||||
set({ theme: t });
|
||||
document.documentElement.dataset.theme = t;
|
||||
persist();
|
||||
},
|
||||
|
||||
executeAction: async (txId, actionId, values = {}) => {
|
||||
const a = get().actor;
|
||||
if (!a?.user_id) {
|
||||
get().pushToast("warn", "Sign in with Live mode first to act on real transactions.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.executeAction(txId, actionId, a, values);
|
||||
get().pushToast("ok", `Action "${actionId}" → ${r.status}${r.next_step?.display_name ? ` · next: ${r.next_step.display_name}` : ""}`);
|
||||
if (get().mode === "live") await get().refreshLive();
|
||||
} catch (e) {
|
||||
get().pushToast("err", `Action failed: ${(e as Error).message.slice(0, 140)}`);
|
||||
}
|
||||
},
|
||||
|
||||
startInstance: async (defKey, businessSubject) => {
|
||||
try {
|
||||
const tx = await api.startTransaction(defKey, businessSubject);
|
||||
get().pushToast("ok", `Started ${tx.transaction_id.slice(0, 8)} · ${tx.active_step?.display_name ?? tx.status}`);
|
||||
if (get().mode === "live") await get().refreshLive();
|
||||
return tx.transaction_id;
|
||||
} catch (e) {
|
||||
get().pushToast("err", `Start failed: ${(e as Error).message.slice(0, 140)}`);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const scenarioById = (id: string): ProcessScenario | undefined =>
|
||||
useApp.getState().scenarios.find((s) => s.id === id);
|
||||
|
||||
api.onCall((c) => {
|
||||
useApp.setState((s) => ({ apiLog: [...s.apiLog, c].slice(-MAX_API_LOG) }));
|
||||
});
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = useApp.getState().theme;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user