feat: real backend mutations + Studio + Settings + live API console
build-and-publish / test (push) Has been cancelled
build-and-publish / image (push) Has been cancelled

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:
2026-06-14 01:19:36 +04:00
parent 49639a0857
commit cb9291b225
13 changed files with 1334 additions and 305 deletions
+234 -106
View File
@@ -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;
}