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:
+159
-52
@@ -42,37 +42,6 @@ async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function withAuth<T>(
|
||||
cfg: ApiConfig,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
let token = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (!token) token = await login(cfg, signal);
|
||||
const doFetch = async () =>
|
||||
fetch(`${cfg.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
let r = await doFetch();
|
||||
if (r.status === 401) {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
token = await login(cfg, signal);
|
||||
r = await doFetch();
|
||||
}
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => "");
|
||||
throw new Error(`${path} → ${r.status} ${text.slice(0, 120)}`);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export interface WorkItem {
|
||||
transaction_id: string;
|
||||
short_id?: string;
|
||||
@@ -114,31 +83,115 @@ export interface AuthMe {
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface Actor {
|
||||
mode: "direct_user" | "agent" | "system";
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
transaction_id: string;
|
||||
action_id: string;
|
||||
status: string;
|
||||
previous_step?: { display_name?: string; step_run_id?: string };
|
||||
next_step?: { display_name?: string; step_definition_id?: string };
|
||||
active_step?: { display_name?: string; step_definition_id?: string };
|
||||
}
|
||||
|
||||
export type ApiCallObserver = (call: ApiCall) => void;
|
||||
export interface ApiCall {
|
||||
id: number;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
reqBody?: unknown;
|
||||
resBody?: unknown;
|
||||
error?: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
let callSeq = 0;
|
||||
const observers = new Set<ApiCallObserver>();
|
||||
const emit = (c: ApiCall) => { for (const o of observers) try { o(c); } catch { /* swallow */ } };
|
||||
|
||||
async function instrumentedFetch(
|
||||
method: string,
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
reqBody?: unknown,
|
||||
): Promise<Response> {
|
||||
const id = ++callSeq;
|
||||
const at = Date.now();
|
||||
try {
|
||||
const r = await fetch(url, init);
|
||||
const cloned = r.clone();
|
||||
let resBody: unknown;
|
||||
try {
|
||||
const text = await cloned.text();
|
||||
try { resBody = JSON.parse(text); } catch { resBody = text.slice(0, 500); }
|
||||
} catch { /* swallow */ }
|
||||
emit({ id, method, path: url, status: r.status, durationMs: Date.now() - at, reqBody, resBody, at });
|
||||
return r;
|
||||
} catch (e) {
|
||||
emit({ id, method, path: url, status: 0, durationMs: Date.now() - at, reqBody, error: (e as Error).message, at });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function authedRequest<T>(
|
||||
cfg: ApiConfig,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
let token = sessionStorage.getItem(TOKEN_KEY);
|
||||
if (!token) token = await login(cfg, signal);
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
signal,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
let r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
|
||||
if (r.status === 401) {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
token = await login(cfg, signal);
|
||||
init.headers = { ...init.headers, Authorization: `Bearer ${token}` };
|
||||
r = await instrumentedFetch(method, `${cfg.baseUrl}${path}`, init, body);
|
||||
}
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => "");
|
||||
throw new Error(`${path} → ${r.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
return (await r.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: DEFAULT_CONFIG,
|
||||
|
||||
/** Subscribe to all API calls. Returns unsubscribe. */
|
||||
onCall(fn: ApiCallObserver): () => void {
|
||||
observers.add(fn);
|
||||
return () => observers.delete(fn);
|
||||
},
|
||||
|
||||
async me(signal?: AbortSignal): Promise<AuthMe> {
|
||||
return withAuth<AuthMe>(this.config, "/api/v1/auth/me", {}, signal);
|
||||
return authedRequest<AuthMe>(this.config, "GET", "/api/v1/auth/me", undefined, signal);
|
||||
},
|
||||
|
||||
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
||||
const body = await withAuth<{ items?: WorkItem[] }>(
|
||||
this.config,
|
||||
"/api/ea2/work-items?view=all",
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
const body = await authedRequest<{ items?: WorkItem[] }>(this.config, "GET", "/api/ea2/work-items?view=all", undefined, signal);
|
||||
return body.items ?? [];
|
||||
},
|
||||
|
||||
async graph(defKey: string, signal?: AbortSignal): Promise<ProcessGraph | null> {
|
||||
try {
|
||||
return await withAuth<ProcessGraph>(
|
||||
this.config,
|
||||
`/api/ea2/process-definitions/${defKey}/graph`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
return await authedRequest<ProcessGraph>(this.config, "GET", `/api/ea2/process-definitions/${defKey}/graph`, undefined, signal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -146,22 +199,76 @@ export const api = {
|
||||
|
||||
async transaction(txId: string, signal?: AbortSignal): Promise<RuntimeTransaction | null> {
|
||||
try {
|
||||
return await withAuth<RuntimeTransaction>(
|
||||
this.config,
|
||||
`/api/runtime/transactions/${txId}`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
return await authedRequest<RuntimeTransaction>(this.config, "GET", `/api/runtime/transactions/${txId}`, undefined, signal);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Start a fresh runtime instance of a process definition. */
|
||||
async startTransaction(
|
||||
process_definition_id: string,
|
||||
business_subject?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RuntimeTransaction> {
|
||||
return authedRequest<RuntimeTransaction>(
|
||||
this.config,
|
||||
"POST",
|
||||
"/api/runtime/transactions",
|
||||
{ process_definition_id, business_subject: business_subject ?? null },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
/** Execute an action against the active step of a transaction. */
|
||||
async executeAction(
|
||||
txId: string,
|
||||
actionId: string,
|
||||
actor: Actor,
|
||||
values: Record<string, unknown> = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<ActionResult> {
|
||||
return authedRequest<ActionResult>(
|
||||
this.config,
|
||||
"POST",
|
||||
`/api/runtime/transactions/${txId}/actions/${actionId}`,
|
||||
{ actor, values },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
/** Create a new published process definition (writes to /api/ea2/flow). */
|
||||
async createProcess(
|
||||
payload: {
|
||||
name: string;
|
||||
display_name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
hub?: string;
|
||||
config: { nodes: unknown[]; edges: unknown[]; org_id: string; executable?: boolean };
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ _key: string; name: string }> {
|
||||
return authedRequest<{ _key: string; name: string }>(
|
||||
this.config,
|
||||
"POST",
|
||||
"/api/ea2/flow",
|
||||
{
|
||||
kind: "definition",
|
||||
status: "published",
|
||||
source_context: "mc-demo-studio",
|
||||
version: 1,
|
||||
...payload,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
|
||||
/** Probe whether the backend is reachable. Never throws. */
|
||||
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string }> {
|
||||
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string; user_id?: string }> {
|
||||
try {
|
||||
const me = await this.me(signal);
|
||||
return { ok: true, user: me.email };
|
||||
return { ok: true, user: me.email, user_id: me.user_id };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user