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:
+174
@@ -0,0 +1,174 @@
|
||||
// Real in-browser API client for demo.flow-master.ai.
|
||||
// - dev-login → bearer (cached in sessionStorage, refresh on 401)
|
||||
// - typed wrappers for the endpoints Mission Control needs
|
||||
// - all fetches honor an AbortSignal so unmounts cancel cleanly
|
||||
// - never throws into render: returns { ok, error } shapes
|
||||
|
||||
export interface ApiConfig {
|
||||
baseUrl: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
|
||||
// the build is deployed at the same origin as the backend, so an empty
|
||||
// baseUrl is correct.
|
||||
const isDev = import.meta.env.DEV;
|
||||
const DEFAULT_CONFIG: ApiConfig = {
|
||||
baseUrl: import.meta.env.VITE_FM_BASE || (isDev ? "" : "https://demo.flow-master.ai"),
|
||||
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
|
||||
};
|
||||
|
||||
const TOKEN_KEY = "fm.mc.token.v1";
|
||||
|
||||
let inflightLogin: Promise<string> | null = null;
|
||||
|
||||
async function login(cfg: ApiConfig, signal?: AbortSignal): Promise<string> {
|
||||
if (inflightLogin) return inflightLogin;
|
||||
inflightLogin = (async () => {
|
||||
const r = await fetch(`${cfg.baseUrl}/api/v1/auth/dev-login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: cfg.email }),
|
||||
signal,
|
||||
});
|
||||
if (!r.ok) throw new Error(`dev-login ${r.status}`);
|
||||
const body = (await r.json()) as { access_token: string };
|
||||
sessionStorage.setItem(TOKEN_KEY, body.access_token);
|
||||
return body.access_token;
|
||||
})();
|
||||
try {
|
||||
return await inflightLogin;
|
||||
} finally {
|
||||
inflightLogin = null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
status: string;
|
||||
hub?: string;
|
||||
age_days?: number;
|
||||
active_step_display_name?: string;
|
||||
current_node?: string;
|
||||
next_action?: string;
|
||||
definition_key: string;
|
||||
business_subject?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ProcessGraph {
|
||||
process_definition: {
|
||||
_key: string;
|
||||
name: string;
|
||||
display_name?: string;
|
||||
hub?: string;
|
||||
config: { nodes: unknown[]; edges: unknown[]; org_id?: string };
|
||||
};
|
||||
version_definitions?: Array<{ version: number; approved_at?: string }>;
|
||||
}
|
||||
|
||||
export interface RuntimeTransaction {
|
||||
transaction_id: string;
|
||||
status: string;
|
||||
active_step?: { step_definition_id?: string; display_name?: string };
|
||||
available_actions?: Array<{ display_label: string }>;
|
||||
created_at?: string;
|
||||
business_subject?: string;
|
||||
}
|
||||
|
||||
export interface AuthMe {
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
config: DEFAULT_CONFIG,
|
||||
|
||||
async me(signal?: AbortSignal): Promise<AuthMe> {
|
||||
return withAuth<AuthMe>(this.config, "/api/v1/auth/me", {}, signal);
|
||||
},
|
||||
|
||||
async workItems(signal?: AbortSignal): Promise<WorkItem[]> {
|
||||
const body = await withAuth<{ items?: WorkItem[] }>(
|
||||
this.config,
|
||||
"/api/ea2/work-items?view=all",
|
||||
{},
|
||||
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,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async transaction(txId: string, signal?: AbortSignal): Promise<RuntimeTransaction | null> {
|
||||
try {
|
||||
return await withAuth<RuntimeTransaction>(
|
||||
this.config,
|
||||
`/api/runtime/transactions/${txId}`,
|
||||
{},
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Probe whether the backend is reachable. Never throws. */
|
||||
async ping(signal?: AbortSignal): Promise<{ ok: boolean; reason?: string; user?: string }> {
|
||||
try {
|
||||
const me = await this.me(signal);
|
||||
return { ok: true, user: me.email };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
}
|
||||
},
|
||||
|
||||
clearToken() {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user