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:
parent
49639a0857
commit
cb9291b225
43
qa/smoke_quick.mjs
Normal file
43
qa/smoke_quick.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
import { chromium } from "playwright";
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const p = await b.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
const errors = [];
|
||||
p.on("pageerror", e => errors.push(`pageerror ${e.message}`));
|
||||
p.on("console", m => { if (m.type() === "error") errors.push(`console.error ${m.text().slice(0, 200)}`); });
|
||||
|
||||
await p.goto("http://127.0.0.1:5173", { waitUntil: "networkidle" });
|
||||
await p.locator(".sc-card").first().click();
|
||||
await p.waitForSelector(".mc"); await p.waitForTimeout(400);
|
||||
|
||||
// Open console first
|
||||
await p.locator(".link-btn", { hasText: "Console" }).first().click();
|
||||
await p.waitForSelector(".console");
|
||||
|
||||
// Toggle live
|
||||
await p.locator(".mode-toggle").first().click();
|
||||
await p.waitForTimeout(6000); // let all calls land
|
||||
|
||||
const callCount = await p.locator(".call").count();
|
||||
console.log("api calls logged:", callCount);
|
||||
const firstCall = await p.locator(".call-head").first();
|
||||
if (await firstCall.count()) {
|
||||
console.log("first call:", await firstCall.innerText());
|
||||
}
|
||||
|
||||
// Click a call to expand
|
||||
if (callCount > 0) {
|
||||
await p.locator(".call-head").first().click();
|
||||
await p.waitForTimeout(300);
|
||||
console.log("expanded has body:", await p.locator(".call-body").first().isVisible());
|
||||
}
|
||||
|
||||
// Sign in via settings
|
||||
await p.locator(".tab", { hasText: "Settings" }).click();
|
||||
await p.waitForSelector(".settings"); await p.waitForTimeout(300);
|
||||
const userBtn = p.locator(".quick-users .link-btn", { hasText: "dev@flow-master.ai" }).first();
|
||||
console.log("settings quick-user:", await userBtn.isVisible());
|
||||
|
||||
console.log("\nconsole errors:", errors.length);
|
||||
errors.slice(0, 5).forEach(e => console.log(" -", e));
|
||||
await p.screenshot({ path: "qa/screenshots/v3-console-open.png", fullPage: false });
|
||||
await b.close();
|
||||
55
src/App.tsx
55
src/App.tsx
@ -1,12 +1,15 @@
|
||||
// App shell — scene switcher + top bar + command palette + tour overlay.
|
||||
// App shell — scene switcher + top bar + command palette + console + toaster.
|
||||
import { useEffect } from "react";
|
||||
import { useApp, scenarioById } from "./state/store";
|
||||
import Landing from "./scenes/Landing";
|
||||
import MissionControl from "./scenes/MissionControl";
|
||||
import RunHistory from "./scenes/RunHistory";
|
||||
import Studio from "./scenes/Studio";
|
||||
import Settings from "./scenes/Settings";
|
||||
import CommandBar from "./components/CommandBar";
|
||||
import Tour from "./components/Tour";
|
||||
import Toaster from "./components/Toaster";
|
||||
import { Cmd, Home, Layers, History as HistoryIcon, Sparkles, Pulse, Refresh } from "./components/icons";
|
||||
import Console from "./components/Console";
|
||||
import { Cmd, Home, Layers, History as HistoryIcon, Pulse, Refresh, Branch, Cog, User } from "./components/icons";
|
||||
import { liveMeta } from "./data/scenarios";
|
||||
|
||||
export default function App() {
|
||||
@ -15,13 +18,23 @@ export default function App() {
|
||||
const setCmdOpen = useApp((s) => s.setCmdOpen);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const sc = scenarioById(scenarioId);
|
||||
const startTour = useApp((s) => s.startTour);
|
||||
const tourActive = useApp((s) => s.tour.active);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveFetchedAt = useApp((s) => s.liveFetchedAt);
|
||||
const consoleOpen = useApp((s) => s.consoleOpen);
|
||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||
const apiLogCount = useApp((s) => s.apiLog.length);
|
||||
const actor = useApp((s) => s.actor);
|
||||
const userEmail = useApp((s) => s.userEmail);
|
||||
const startPolling = useApp((s) => s.startPolling);
|
||||
const stopPolling = useApp((s) => s.stopPolling);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "live") startPolling();
|
||||
return () => stopPolling();
|
||||
}, [mode, startPolling, stopPolling]);
|
||||
|
||||
const liveAge = liveFetchedAt ? `${Math.max(0, Math.floor((Date.now() - liveFetchedAt) / 1000))}s ago` : null;
|
||||
|
||||
@ -43,13 +56,19 @@ export default function App() {
|
||||
<button role="tab" aria-selected={scene === "history"} className={`tab${scene === "history" ? " tab-sel" : ""}`} onClick={() => setScene("history")}>
|
||||
<HistoryIcon size={13} /> Runs
|
||||
</button>
|
||||
<button role="tab" aria-selected={scene === "studio"} className={`tab${scene === "studio" ? " tab-sel" : ""}`} onClick={() => setScene("studio")}>
|
||||
<Branch size={13} /> Studio
|
||||
</button>
|
||||
<button role="tab" aria-selected={scene === "settings"} className={`tab${scene === "settings" ? " tab-sel" : ""}`} onClick={() => setScene("settings")}>
|
||||
<Cog size={13} /> Settings
|
||||
</button>
|
||||
<button role="tab" aria-selected={false} className="tab" onClick={() => setScene("landing")}>
|
||||
<Home size={13} /> Home
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="topbar-mid">
|
||||
{sc && (
|
||||
{sc && scene === "mission" && (
|
||||
<div className="topbar-context">
|
||||
<span className="topbar-chip">
|
||||
<span className="dot dot-running" /> {sc.family.label}
|
||||
@ -68,6 +87,15 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
<button
|
||||
className="link-btn user-btn"
|
||||
onClick={() => setScene("settings")}
|
||||
title={`Signed in as ${userEmail}${actor?.user_id ? ` (${actor.user_id.slice(0, 8)})` : ""}`}
|
||||
>
|
||||
<User size={12} />
|
||||
<span className="user-email">{userEmail.split("@")[0]}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`link-btn mode-toggle mode-${mode}`}
|
||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
||||
@ -85,11 +113,16 @@ export default function App() {
|
||||
<Refresh size={12} /> Refresh
|
||||
</button>
|
||||
)}
|
||||
<button className="link-btn" onClick={startTour} disabled={tourActive}>
|
||||
<Sparkles size={13} /> Tour
|
||||
<button
|
||||
className={`link-btn${consoleOpen ? " is-on" : ""}`}
|
||||
onClick={() => setConsoleOpen(!consoleOpen)}
|
||||
title="Toggle live API console"
|
||||
>
|
||||
<Layers size={12} /> Console
|
||||
{apiLogCount > 0 && <span className="badge mono">{apiLogCount}</span>}
|
||||
</button>
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||
<Cmd size={13} /> Command <kbd>⌘K</kbd>
|
||||
<Cmd size={13} /> <kbd>⌘K</kbd>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@ -99,10 +132,12 @@ export default function App() {
|
||||
{scene === "landing" && <Landing />}
|
||||
{scene === "mission" && <MissionControl />}
|
||||
{scene === "history" && <RunHistory />}
|
||||
{scene === "studio" && <Studio />}
|
||||
{scene === "settings" && <Settings />}
|
||||
</div>
|
||||
|
||||
<CommandBar />
|
||||
<Tour />
|
||||
<Console />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
// Command palette v2 — scenarios, steps, tour, scenes, recents.
|
||||
// Command palette — scenarios, steps, scenes, recents, real actions.
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Play, Branch, Bot, Check, Sparkles, Home, History as HistoryIcon, Layers, Search, Refresh } from "./icons";
|
||||
import { Play, Branch, Bot, Check, Home, History as HistoryIcon, Layers, Search, Refresh, Cog, User } from "./icons";
|
||||
|
||||
export default function CommandBar() {
|
||||
const open = useApp((s) => s.cmdOpen);
|
||||
const setOpen = useApp((s) => s.setCmdOpen);
|
||||
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||
const setSelectedStepId = useApp((s) => s.setSelectedStepId);
|
||||
const startTour = useApp((s) => s.startTour);
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
const recents = useApp((s) => s.recents);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
@ -18,14 +17,16 @@ export default function CommandBar() {
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const startInstance = useApp((s) => s.startInstance);
|
||||
const executeAction = useApp((s) => s.executeAction);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||
const setTheme = useApp((s) => s.setTheme);
|
||||
const theme = useApp((s) => s.theme);
|
||||
const actor = useApp((s) => s.actor);
|
||||
|
||||
const sc = scenarioById(scenarioId);
|
||||
|
||||
const previewAction = (label: string) => {
|
||||
pushToast("info", `${label} is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to make it real.`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
|
||||
@ -44,12 +45,15 @@ export default function CommandBar() {
|
||||
|
||||
const close = () => setOpen(false);
|
||||
|
||||
const canActLive = mode === "live" && actor?.user_id && sc?.live && sc.headlineTx;
|
||||
const firstActionId = sc?.steps.find((s) => s.state === "running")?.actions?.[0]?.id;
|
||||
|
||||
return (
|
||||
<div className="cmd-overlay" onClick={close}>
|
||||
<Command className="cmd" onClick={(e) => e.stopPropagation()} label="Command bar">
|
||||
<div className="cmd-input-row">
|
||||
<Search size={14} />
|
||||
<Command.Input autoFocus placeholder="Switch scenario · jump to step · start tour…" />
|
||||
<Command.Input autoFocus placeholder="Switch scenario · jump to step · start instance · execute action…" />
|
||||
<kbd className="kbd-hint">esc</kbd>
|
||||
</div>
|
||||
<Command.List>
|
||||
@ -75,11 +79,70 @@ export default function CommandBar() {
|
||||
<Command.Item onSelect={() => { setScene("history"); close(); }}>
|
||||
<HistoryIcon size={13} /> Run History
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setScene("studio"); close(); }}>
|
||||
<Branch size={13} /> Process Studio
|
||||
<span className="cmd-hint">design & publish a new process</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setScene("settings"); close(); }}>
|
||||
<Cog size={13} /> Settings
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Tour & demo">
|
||||
<Command.Item onSelect={() => { startTour(); close(); }}>
|
||||
<Sparkles size={13} /> Start guided tour <span className="cmd-hint">presenter mode</span>
|
||||
<Command.Group heading="Data mode">
|
||||
{mode === "live" ? (
|
||||
<Command.Item onSelect={() => { refreshLive(); close(); }}>
|
||||
<Refresh size={13} /> Refresh live scenarios
|
||||
<span className="cmd-hint">re-fetch demo.flow-master.ai</span>
|
||||
</Command.Item>
|
||||
) : (
|
||||
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
||||
<Refresh size={13} /> Switch to LIVE mode (fetch demo.flow-master.ai)
|
||||
<span className="cmd-hint">in-browser</span>
|
||||
</Command.Item>
|
||||
)}
|
||||
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
|
||||
<Layers size={13} /> Switch to SNAPSHOT mode
|
||||
<span className="cmd-hint">bundled JSON</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setConsoleOpen(true); close(); }}>
|
||||
<Layers size={13} /> Open live API console
|
||||
<span className="cmd-hint">stream every fetch</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { setTheme(theme === "dark" ? "light" : "dark"); close(); }}>
|
||||
<Cog size={13} /> Toggle theme
|
||||
<span className="cmd-hint">{theme === "dark" ? "→ light" : "→ dark"}</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Real actions">
|
||||
{sc?.live ? (
|
||||
<Command.Item
|
||||
onSelect={async () => {
|
||||
close();
|
||||
if (mode !== "live") { pushToast("warn", "Switch to LIVE mode to start a real instance."); return; }
|
||||
await startInstance(sc.defKey, `MC demo · ${new Date().toLocaleString()}`);
|
||||
}}
|
||||
>
|
||||
<Play size={13} /> Start new instance of "{sc.defName}"
|
||||
<span className="cmd-hint">POST /api/runtime/transactions</span>
|
||||
</Command.Item>
|
||||
) : (
|
||||
<Command.Item disabled>
|
||||
<Play size={13} /> Start new instance
|
||||
<span className="cmd-hint">switch to a LIVE scenario first</span>
|
||||
</Command.Item>
|
||||
)}
|
||||
{canActLive && firstActionId && (
|
||||
<Command.Item
|
||||
onSelect={async () => { close(); await executeAction(sc!.headlineTx!, firstActionId); }}
|
||||
>
|
||||
<Check size={13} /> Execute "{firstActionId}" on headline tx
|
||||
<span className="cmd-hint">{sc?.headlineTx?.slice(0, 8)} · real</span>
|
||||
</Command.Item>
|
||||
)}
|
||||
<Command.Item onSelect={() => { setScene("settings"); close(); }}>
|
||||
<User size={13} /> Sign in as different user
|
||||
<span className="cmd-hint">Settings → identity</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
@ -112,33 +175,15 @@ export default function CommandBar() {
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
<Command.Group heading="Data mode">
|
||||
{mode === "live" ? (
|
||||
<Command.Item onSelect={() => { refreshLive(); close(); }}>
|
||||
<Refresh size={13} /> Refresh live scenarios
|
||||
<span className="cmd-hint">re-fetch demo.flow-master.ai</span>
|
||||
</Command.Item>
|
||||
) : (
|
||||
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
||||
<Refresh size={13} /> Switch to LIVE mode (fetch demo.flow-master.ai)
|
||||
<span className="cmd-hint">in-browser</span>
|
||||
</Command.Item>
|
||||
)}
|
||||
<Command.Item onSelect={() => { setMode("snapshot"); close(); }}>
|
||||
<Layers size={13} /> Switch to SNAPSHOT mode
|
||||
<span className="cmd-hint">bundled JSON</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Actions (preview-only)">
|
||||
<Command.Item onSelect={() => { previewAction("Start runtime instance"); close(); }}>
|
||||
<Play size={13} /> Start runtime instance <span className="cmd-hint">preview</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { previewAction("Dispatch sidekick agent"); close(); }}>
|
||||
<Bot size={13} /> Dispatch sidekick agent <span className="cmd-hint">preview</span>
|
||||
</Command.Item>
|
||||
<Command.Item onSelect={() => { previewAction("Confirm awaiting agent runs"); close(); }}>
|
||||
<Check size={13} /> Confirm awaiting agent runs <span className="cmd-hint">{sc?.agentRuns.length ?? 0} pending · preview</span>
|
||||
<Command.Group heading="Agent">
|
||||
<Command.Item
|
||||
onSelect={async () => {
|
||||
close();
|
||||
pushToast("info", `Sidekick dispatch endpoint not yet wired — see Studio for process-level changes.`);
|
||||
}}
|
||||
>
|
||||
<Bot size={13} /> Dispatch sidekick agent
|
||||
<span className="cmd-hint">coming soon</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
|
||||
116
src/components/Console.tsx
Normal file
116
src/components/Console.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
// Live API call log. Every fetch the app makes shows up here in real time.
|
||||
// Replaces the old guided-tour overlay.
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useApp } from "../state/store";
|
||||
import { Close, Refresh, Layers, Pulse } from "./icons";
|
||||
|
||||
const METHOD_COLOR: Record<string, string> = {
|
||||
GET: "var(--text-2)",
|
||||
POST: "#7eb0ff",
|
||||
PUT: "#f5b755",
|
||||
PATCH: "#f5b755",
|
||||
DELETE: "#ff8a8a",
|
||||
};
|
||||
|
||||
function statusClass(s: number): string {
|
||||
if (s === 0) return "err";
|
||||
if (s >= 500) return "err";
|
||||
if (s >= 400) return "warn";
|
||||
if (s >= 200) return "ok";
|
||||
return "info";
|
||||
}
|
||||
|
||||
export default function Console() {
|
||||
const open = useApp((s) => s.consoleOpen);
|
||||
const setOpen = useApp((s) => s.setConsoleOpen);
|
||||
const log = useApp((s) => s.apiLog);
|
||||
const clear = useApp((s) => s.clearApiLog);
|
||||
const [expanded, setExpanded] = useState<number | null>(null);
|
||||
const [filter, setFilter] = useState<"all" | "writes" | "errors">("all");
|
||||
const tailRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
tailRef.current?.scrollTo({ top: tailRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [log, open]);
|
||||
|
||||
const filtered = log.filter((c) => {
|
||||
if (filter === "writes") return c.method !== "GET";
|
||||
if (filter === "errors") return c.status === 0 || c.status >= 400;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.aside
|
||||
className="console"
|
||||
initial={{ x: 360, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: 360, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
aria-label="API call console"
|
||||
>
|
||||
<header className="console-head">
|
||||
<Pulse size={13} />
|
||||
<span className="console-title">Live console</span>
|
||||
<span className="console-count mono">{log.length}</span>
|
||||
<div className="console-filters" role="tablist">
|
||||
<button className={`console-filter${filter === "all" ? " on" : ""}`} onClick={() => setFilter("all")}>all</button>
|
||||
<button className={`console-filter${filter === "writes" ? " on" : ""}`} onClick={() => setFilter("writes")}>writes</button>
|
||||
<button className={`console-filter${filter === "errors" ? " on" : ""}`} onClick={() => setFilter("errors")}>errors</button>
|
||||
</div>
|
||||
<button className="console-x" title="Clear" onClick={clear}><Refresh size={11} /></button>
|
||||
<button className="console-x" title="Close" onClick={() => setOpen(false)}><Close size={12} /></button>
|
||||
</header>
|
||||
<div className="console-body" ref={tailRef}>
|
||||
{filtered.length === 0 && (
|
||||
<div className="empty">No API calls yet. Flip to LIVE mode or perform an action.</div>
|
||||
)}
|
||||
{filtered.map((c) => {
|
||||
const isExpanded = expanded === c.id;
|
||||
const shortPath = c.path.replace(/^https?:\/\/[^/]+/, "");
|
||||
return (
|
||||
<div key={c.id} className={`call ${statusClass(c.status)}`}>
|
||||
<button
|
||||
className="call-head"
|
||||
onClick={() => setExpanded(isExpanded ? null : c.id)}
|
||||
title="Toggle body"
|
||||
>
|
||||
<span className="call-method mono" style={{ color: METHOD_COLOR[c.method] || "var(--text-2)" }}>{c.method}</span>
|
||||
<span className="call-status mono">{c.status || "ERR"}</span>
|
||||
<span className="call-path mono" title={c.path}>{shortPath}</span>
|
||||
<span className="call-dur mono">{c.durationMs}ms</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="call-body">
|
||||
{c.reqBody !== undefined && (
|
||||
<>
|
||||
<div className="call-label"><Layers size={10} /> request</div>
|
||||
<pre className="call-json"><code>{JSON.stringify(c.reqBody, null, 2)}</code></pre>
|
||||
</>
|
||||
)}
|
||||
{c.error && (
|
||||
<>
|
||||
<div className="call-label call-err"><Layers size={10} /> network error</div>
|
||||
<pre className="call-json"><code>{c.error}</code></pre>
|
||||
</>
|
||||
)}
|
||||
{c.resBody !== undefined && (
|
||||
<>
|
||||
<div className="call-label"><Layers size={10} /> response</div>
|
||||
<pre className="call-json"><code>{typeof c.resBody === "string" ? c.resBody : JSON.stringify(c.resBody, null, 2)}</code></pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// Tabbed Inspector for the selected step.
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Shield, Doc, Layers, Pulse, History } from "./icons";
|
||||
|
||||
@ -74,16 +74,36 @@ function Empty({ msg }: { msg: string }) {
|
||||
return <div className="empty">{msg}</div>;
|
||||
}
|
||||
|
||||
function PreviewActionButton({ kind, label }: { kind: "complete" | "approve" | "decline" | "fork"; label: string }) {
|
||||
function ActionButton({ kind, label, actionId }: { kind: "complete" | "approve" | "decline" | "fork"; label: string; actionId: string }) {
|
||||
const mode = useApp((s) => s.mode);
|
||||
const actor = useApp((s) => s.actor);
|
||||
const executeAction = useApp((s) => s.executeAction);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const sc = scenarioById(scenarioId);
|
||||
const txId = sc?.headlineTx ?? null;
|
||||
const isLive = mode === "live" && txId && actor?.user_id;
|
||||
const cls = kind === "approve" ? "btn btn-primary" : kind === "decline" ? "btn btn-ghost btn-decline" : "btn btn-ghost";
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
const onClick = async () => {
|
||||
if (!isLive) {
|
||||
pushToast("info", `Switch to LIVE mode + sign in to execute "${label}" against /api/runtime/transactions/{id}/actions/{actionId}.`);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
try {
|
||||
await executeAction(txId, actionId);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cls}
|
||||
onClick={() => pushToast("info", `"${label}" is preview-only — wire to /api/runtime/transactions/{id}/actions to ship it.`)}
|
||||
title="Preview-only action — does not call the backend"
|
||||
>
|
||||
{label} <span className="preview-marker">preview</span>
|
||||
<button className={cls} onClick={onClick} disabled={running}>
|
||||
{running && <span className="spin" />}
|
||||
{label}
|
||||
{!isLive && <span className="preview-marker">preview</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -99,10 +119,10 @@ function OverviewTab({ step, sc }: { step: import("../data/types").ProcessStep;
|
||||
<div className="i-field"><span>Process</span><span>{sc.defName} · {sc.version}</span></div>
|
||||
{step.actions.length > 0 && (
|
||||
<>
|
||||
<h4 className="i-h">Available actions <span className="tag tag-syn" title="These buttons demonstrate the action surface; wiring them to /api/runtime/transactions/{id}/actions would make them real.">preview</span></h4>
|
||||
<h4 className="i-h">Available actions</h4>
|
||||
<div className="i-actions">
|
||||
{step.actions.map((a: import("../data/types").StepAction) => (
|
||||
<PreviewActionButton key={a.id} kind={a.kind} label={a.label} />
|
||||
<ActionButton key={a.id} kind={a.kind} label={a.label} actionId={a.id} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
// LeftRail: queues + agent runs for the active scenario.
|
||||
import { useState } from "react";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Inbox, Bot, Clock, Check, Close, ArrowUp, ArrowDown, Pulse } from "./icons";
|
||||
import { Inbox, Bot, Clock, Check, Close, ArrowUp, ArrowDown, Pulse, Play } from "./icons";
|
||||
|
||||
const WAIT_LABEL: Record<string, string> = { approval: "Approval", agent: "Agent", input: "Input" };
|
||||
|
||||
@ -28,6 +29,7 @@ export default function LeftRail() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StartInstanceButton defKey={sc.defKey} live={sc.live} />
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
@ -73,25 +75,66 @@ export default function LeftRail() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-intent">{a.intent}</div>
|
||||
<div className="agent-acts">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => useApp.getState().pushToast("info", `Confirm "${a.intent.slice(0, 40)}…" is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to ship.`)}
|
||||
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
|
||||
>
|
||||
<Check size={12} /> Confirm <span className="preview-marker">preview</span>
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost btn-decline"
|
||||
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to ship.`)}
|
||||
title="Preview-only — wire to /api/runtime/transactions/{id}/actions to ship"
|
||||
>
|
||||
<Close size={12} /> Reject
|
||||
</button>
|
||||
</div>
|
||||
<AgentActions txId={sc.headlineTx} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActions({ txId }: { txId: string | null }) {
|
||||
const mode = useApp((s) => s.mode);
|
||||
const actor = useApp((s) => s.actor);
|
||||
const executeAction = useApp((s) => s.executeAction);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const isLive = mode === "live" && !!txId && !!actor?.user_id;
|
||||
const [running, setRunning] = useState<"confirm" | "reject" | null>(null);
|
||||
const onConfirm = async () => {
|
||||
if (!isLive) { pushToast("info", "Switch to LIVE mode + sign in to confirm a real agent run."); return; }
|
||||
setRunning("confirm");
|
||||
try { await executeAction(txId!, "submit"); } finally { setRunning(null); }
|
||||
};
|
||||
const onReject = async () => {
|
||||
if (!isLive) { pushToast("info", "Switch to LIVE mode + sign in to reject a real agent run."); return; }
|
||||
setRunning("reject");
|
||||
try { await executeAction(txId!, "save_draft"); } finally { setRunning(null); }
|
||||
};
|
||||
return (
|
||||
<div className="agent-acts">
|
||||
<button className="btn btn-primary" onClick={onConfirm} disabled={running !== null}>
|
||||
{running === "confirm" ? <span className="spin" /> : <Check size={12} />} Confirm
|
||||
{!isLive && <span className="preview-marker">preview</span>}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-decline" onClick={onReject} disabled={running !== null}>
|
||||
{running === "reject" ? <span className="spin" /> : <Close size={12} />} Reject
|
||||
{!isLive && <span className="preview-marker">preview</span>}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartInstanceButton({ defKey, live }: { defKey: string; live: boolean }) {
|
||||
const mode = useApp((s) => s.mode);
|
||||
const startInstance = useApp((s) => s.startInstance);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const canStart = mode === "live" && live;
|
||||
const onClick = async () => {
|
||||
if (!canStart) {
|
||||
pushToast("info", "Live procurement scenarios can be started for real. Switch to LIVE mode first.");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await startInstance(defKey, `MC demo · ${new Date().toLocaleString()}`);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<button className="start-btn" onClick={onClick} disabled={busy}>
|
||||
{busy ? <span className="spin" /> : <Play size={12} />}
|
||||
Start new instance
|
||||
{!canStart && <span className="preview-marker">live only</span>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
// Guided tour overlay. Reads the active scenario's tour script and
|
||||
// anchors a step-card to the named region.
|
||||
import { useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import { Sparkles, ChevL, ChevR, Close } from "./icons";
|
||||
|
||||
export default function Tour() {
|
||||
const tour = useApp((s) => s.tour);
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const endTour = useApp((s) => s.endTour);
|
||||
const tourPrev = useApp((s) => s.tourPrev);
|
||||
const tourNext = useApp((s) => s.tourNext);
|
||||
|
||||
const sc = scenarioById(scenarioId);
|
||||
const step = sc?.tour[tour.index] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!tour.active) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "ArrowRight" || e.key === "Enter") { e.preventDefault(); tourNext(); }
|
||||
if (e.key === "ArrowLeft") { e.preventDefault(); tourPrev(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); endTour(); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [tour.active, tourPrev, tourNext, endTour]);
|
||||
|
||||
if (!tour.active || !step || !sc) return null;
|
||||
|
||||
const last = tour.index === sc.tour.length - 1;
|
||||
const first = tour.index === 0;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
key={step.id}
|
||||
className={`tour-card tour-anchor-${step.anchor}`}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
>
|
||||
<div className="tour-head">
|
||||
<Sparkles size={14} />
|
||||
<span className="tour-eyebrow">Guided tour · {tour.index + 1} / {sc.tour.length}</span>
|
||||
<button className="tour-close" onClick={endTour} aria-label="End tour"><Close size={13} /></button>
|
||||
</div>
|
||||
<h3 className="tour-title">{step.title}</h3>
|
||||
<p className="tour-body">{step.body}</p>
|
||||
<div className="tour-actions">
|
||||
<button className="btn btn-ghost" onClick={tourPrev} disabled={first}>
|
||||
<ChevL size={13} /> Back
|
||||
</button>
|
||||
{last ? (
|
||||
<button className="btn btn-primary" onClick={endTour}>Finish</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={tourNext}>
|
||||
Next <ChevR size={13} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
154
src/index.css
154
src/index.css
@ -680,3 +680,157 @@ kbd { font-family: var(--mono); font-size: 11px; background: var(--surface-2); p
|
||||
.toast-ok { border-left: 3px solid var(--ok); }
|
||||
.toast-warn { border-left: 3px solid var(--queue); }
|
||||
.toast-err { border-left: 3px solid var(--block); }
|
||||
|
||||
/* =====================================================================
|
||||
Light theme — minimal token overrides, everything else inherits dark
|
||||
===================================================================== */
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f4f6fb;
|
||||
--bg-deep: #ffffff;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f0f3f9;
|
||||
--surface-3: #e2e8f3;
|
||||
--border: #d8dee9;
|
||||
--border-strong: #b6c1d4;
|
||||
--border-glow: #3b82f655;
|
||||
--text: #0c1322;
|
||||
--text-2: #4b5872;
|
||||
--text-3: #7a87a5;
|
||||
--text-soft: #1c2538;
|
||||
}
|
||||
:root[data-theme="light"] body { background: var(--bg); }
|
||||
:root[data-theme="light"] .landing-bg {
|
||||
background:
|
||||
radial-gradient(circle at 12% 18%, rgba(59,130,246,0.18) 0%, transparent 40%),
|
||||
radial-gradient(circle at 86% 76%, rgba(168,85,247,0.14) 0%, transparent 45%),
|
||||
var(--bg);
|
||||
}
|
||||
:root[data-theme="light"] .landing-grid { mask-image: radial-gradient(circle at 50% 40%, black 0%, transparent 70%); opacity: 0.5; }
|
||||
:root[data-theme="light"] .topbar { background: linear-gradient(180deg, #fff 0%, var(--bg) 100%); }
|
||||
:root[data-theme="light"] .mc-main { background:
|
||||
radial-gradient(circle at 1px 1px, var(--border) 1px, transparent 0) 0 0 / 28px 28px,
|
||||
var(--bg); }
|
||||
|
||||
/* =====================================================================
|
||||
Console panel (right side drawer)
|
||||
===================================================================== */
|
||||
.console {
|
||||
position: fixed; right: 0; top: 56px; bottom: 0;
|
||||
width: min(420px, 95vw);
|
||||
background: var(--bg-deep);
|
||||
border-left: 1px solid var(--border);
|
||||
display: grid; grid-template-rows: auto 1fr;
|
||||
z-index: 180;
|
||||
box-shadow: -8px 0 40px rgba(0,0,0,0.45);
|
||||
}
|
||||
.console-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 9px 12px; border-bottom: 1px solid var(--border);
|
||||
font-size: 12px; color: var(--text-2);
|
||||
}
|
||||
.console-title { color: var(--text); font-weight: 600; }
|
||||
.console-count { background: var(--surface-2); padding: 1px 7px; border-radius: 999px; font-size: 11px; color: var(--text-2); }
|
||||
.console-filters { margin-left: auto; display: inline-flex; gap: 3px; background: var(--surface-2); padding: 2px; border-radius: 7px; border: 1px solid var(--border); }
|
||||
.console-filter { padding: 3px 8px; border-radius: 5px; font-size: 11px; color: var(--text-2); text-transform: uppercase; letter-spacing: 0.4px; font-weight: 600; }
|
||||
.console-filter:hover { color: var(--text); }
|
||||
.console-filter.on { background: var(--surface); color: var(--text); box-shadow: 0 0 0 1px var(--border-strong) inset; }
|
||||
.console-x { padding: 4px 6px; border-radius: 6px; color: var(--text-3); }
|
||||
.console-x:hover { color: var(--text); background: var(--surface-2); }
|
||||
.console-body { overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 3px; font-size: 12px; }
|
||||
|
||||
.call { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; border-left-width: 3px; }
|
||||
.call.ok { border-left-color: var(--ok); }
|
||||
.call.warn { border-left-color: var(--queue); }
|
||||
.call.err { border-left-color: var(--block); }
|
||||
.call.info { border-left-color: var(--border-strong); }
|
||||
.call-head {
|
||||
display: grid; grid-template-columns: 50px 44px 1fr auto;
|
||||
align-items: center; gap: 8px;
|
||||
width: 100%; padding: 7px 10px; text-align: left;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.call-head:hover { background: var(--surface-2); }
|
||||
.call-method { font-weight: 700; }
|
||||
.call-status { color: var(--text-2); }
|
||||
.call-path { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.call-dur { color: var(--text-3); font-size: 11px; }
|
||||
.call-body { padding: 6px 10px 10px; border-top: 1px dashed var(--border); display: flex; flex-direction: column; gap: 6px; }
|
||||
.call-label { display: inline-flex; align-items: center; gap: 5px; color: var(--text-3); font-size: 10px; letter-spacing: 0.6px; text-transform: uppercase; font-weight: 600; }
|
||||
.call-err { color: var(--block); }
|
||||
.call-json { margin: 0; padding: 8px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-2); font-family: var(--mono); font-size: 11px; line-height: 1.45; overflow-x: auto; max-height: 260px; overflow-y: auto; }
|
||||
|
||||
/* =====================================================================
|
||||
Topbar badge + user button
|
||||
===================================================================== */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
margin-left: 6px;
|
||||
font-size: 10px; font-weight: 700;
|
||||
padding: 1px 6px; border-radius: 999px;
|
||||
background: var(--primary-deep); color: #fff;
|
||||
}
|
||||
.user-btn .user-email { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* =====================================================================
|
||||
Studio
|
||||
===================================================================== */
|
||||
.studio { height: 100%; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
|
||||
.studio-head { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 18px 24px; border-bottom: 1px solid var(--border); background: var(--bg); }
|
||||
.studio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(380px, 1fr));
|
||||
gap: 16px; padding: 20px 24px 40px;
|
||||
}
|
||||
.studio-panel {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 16px 18px;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.studio-panel > .panel-h { margin: -2px 0 4px; }
|
||||
.studio-field { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--text-2); }
|
||||
.studio-field span { font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-3); font-weight: 600; }
|
||||
.studio-input {
|
||||
font-family: var(--sans);
|
||||
background: var(--bg-deep); border: 1px solid var(--border);
|
||||
border-radius: 7px; padding: 7px 10px; color: var(--text); font-size: 13px;
|
||||
}
|
||||
.studio-input:focus { outline: 2px solid var(--primary); outline-offset: 1px; }
|
||||
.studio-input.mono { font-family: var(--mono); }
|
||||
textarea.studio-input { resize: vertical; min-height: 64px; }
|
||||
|
||||
.studio-rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.studio-row { display: flex; align-items: center; gap: 8px; }
|
||||
.studio-row .studio-input { flex: 1; }
|
||||
.studio-row-id { color: var(--text-3); font-size: 11px; min-width: 32px; }
|
||||
|
||||
.studio-result {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 12px; border-radius: 8px;
|
||||
font-size: 12.5px; font-weight: 500;
|
||||
}
|
||||
.studio-result.ok { background: rgba(52,211,153,0.12); color: var(--ok); border: 1px solid rgba(52,211,153,0.4); }
|
||||
.studio-result.err { background: rgba(240,82,82,0.12); color: var(--block); border: 1px solid rgba(240,82,82,0.4); }
|
||||
|
||||
.start-btn {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
margin-top: 4px; padding: 9px 12px; border-radius: 9px;
|
||||
background: linear-gradient(180deg, var(--primary), var(--primary-deep)); color: #fff;
|
||||
font-size: 12.5px; font-weight: 600;
|
||||
transition: filter .15s;
|
||||
}
|
||||
.start-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.start-btn:disabled { opacity: 0.65; cursor: not-allowed; }
|
||||
|
||||
/* =====================================================================
|
||||
Settings
|
||||
===================================================================== */
|
||||
.settings { height: 100%; display: flex; flex-direction: column; overflow-y: auto; background: var(--bg); }
|
||||
.settings-id { display: flex; flex-direction: column; gap: 4px; }
|
||||
.settings-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.settings-hint { color: var(--text-3); font-size: 11.5px; margin-top: 4px; }
|
||||
.settings-toggle { display: inline-flex; align-items: center; gap: 9px; font-size: 13px; color: var(--text); }
|
||||
.settings-toggle input { accent-color: var(--primary); width: 14px; height: 14px; }
|
||||
.settings-text { font-size: 13px; color: var(--text-2); line-height: 1.55; }
|
||||
.settings-link { color: var(--primary); text-decoration: underline; }
|
||||
.quick-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
.quick-users .link-btn { font-family: var(--mono); font-size: 11px; }
|
||||
|
||||
211
src/lib/api.ts
211
src/lib/api.ts
@ -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 };
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ import { Sparkles, Arrow, Cmd, Bot, Pulse } from "../components/icons";
|
||||
export default function Landing() {
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||
const startTour = useApp((s) => s.startTour);
|
||||
const setCmdOpen = useApp((s) => s.setCmdOpen);
|
||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
@ -54,11 +54,8 @@ export default function Landing() {
|
||||
same shell extends to any process family.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<button className="btn btn-primary btn-lg" onClick={startTour}>
|
||||
<Sparkles size={14} /> Start guided tour
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-lg" onClick={() => setScene("mission")}>
|
||||
Skip to Mission Control <Arrow size={13} />
|
||||
<button className="btn btn-primary btn-lg" onClick={() => setScene("mission")}>
|
||||
<Arrow size={14} /> Enter Mission Control
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
|
||||
@ -69,6 +66,12 @@ export default function Landing() {
|
||||
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
|
||||
{mode === "live" ? "Live · refresh" : "Go live"}
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-lg" onClick={() => { setScene("studio"); }}>
|
||||
<Sparkles size={13} /> Design a process
|
||||
</button>
|
||||
<button className="btn btn-ghost btn-lg" onClick={() => setConsoleOpen(true)}>
|
||||
<Bot size={13} /> Open live console
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
|
||||
164
src/scenes/Settings.tsx
Normal file
164
src/scenes/Settings.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
// Settings scene — identity, backend URL, polling cadence, theme.
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { api } from "../lib/api";
|
||||
import { User, Refresh, Cog, Pulse, Layers, Check } from "../components/icons";
|
||||
|
||||
const COMMON_EMAILS = [
|
||||
"dev@flow-master.ai",
|
||||
"procurement.operator@flowmaster.local",
|
||||
"finance.lead@flowmaster.local",
|
||||
"ops.admin@flowmaster.local",
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
const userEmail = useApp((s) => s.userEmail);
|
||||
const actor = useApp((s) => s.actor);
|
||||
const userDisplayName = useApp((s) => s.userDisplayName);
|
||||
const loginAs = useApp((s) => s.loginAs);
|
||||
const pollEverySec = useApp((s) => s.pollEverySec);
|
||||
const setPollEverySec = useApp((s) => s.setPollEverySec);
|
||||
const theme = useApp((s) => s.theme);
|
||||
const setTheme = useApp((s) => s.setTheme);
|
||||
const consoleOpen = useApp((s) => s.consoleOpen);
|
||||
const setConsoleOpen = useApp((s) => s.setConsoleOpen);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
|
||||
const [emailInput, setEmailInput] = useState(userEmail);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
|
||||
const signIn = async (email: string) => {
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await loginAs(email);
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearToken = () => {
|
||||
api.clearToken();
|
||||
pushToast("info", "Cleared bearer token. Next request will re-login.");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings">
|
||||
<header className="studio-head">
|
||||
<div>
|
||||
<div className="mc-hero-eyebrow"><Cog size={12} /> Settings</div>
|
||||
<h2 className="mc-hero-title">Identity, backend & UI</h2>
|
||||
<div className="mc-hero-sub">Everything here is persisted to localStorage and survives reloads.</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="studio-grid">
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h"><User size={12} /> Identity</h3>
|
||||
<div className="settings-id">
|
||||
<div className="i-field">
|
||||
<span>Current</span>
|
||||
<span className="mono">{actor?.user_id?.slice(0, 12) ?? "—"}</span>
|
||||
</div>
|
||||
<div className="i-field">
|
||||
<span>Email</span>
|
||||
<span>{userEmail}</span>
|
||||
</div>
|
||||
<div className="i-field">
|
||||
<span>Display name</span>
|
||||
<span>{userDisplayName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="i-field">
|
||||
<span>Backend</span>
|
||||
<span className="mono">{api.config.baseUrl || "(same-origin via /api)"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="i-h" style={{ marginTop: 16 }}>Sign in as</h4>
|
||||
<div className="studio-rows">
|
||||
<div className="studio-row">
|
||||
<input
|
||||
className="studio-input"
|
||||
placeholder="someone@flow-master.ai"
|
||||
value={emailInput}
|
||||
onChange={(e) => setEmailInput(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={() => signIn(emailInput)} disabled={signingIn || !emailInput.includes("@")}>
|
||||
{signingIn ? <span className="spin" /> : <Check size={12} />} Sign in
|
||||
</button>
|
||||
</div>
|
||||
<div className="quick-users">
|
||||
{COMMON_EMAILS.map((e) => (
|
||||
<button key={e} className="link-btn" onClick={() => { setEmailInput(e); signIn(e); }} disabled={signingIn}>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button className="link-btn" style={{ marginTop: 10 }} onClick={clearToken}>
|
||||
<Refresh size={12} /> Clear bearer token
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h"><Pulse size={12} /> Data mode & polling</h3>
|
||||
<div className="i-field">
|
||||
<span>Current mode</span>
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
</div>
|
||||
<div className="settings-row">
|
||||
<button className="btn btn-primary" onClick={() => setMode(mode === "live" ? "snapshot" : "live")} disabled={signingIn}>
|
||||
<Refresh size={12} /> {mode === "live" ? "Switch to snapshot" : "Switch to live"}
|
||||
</button>
|
||||
{mode === "live" && (
|
||||
<button className="btn btn-ghost" onClick={refreshLive}>
|
||||
<Refresh size={12} /> Refresh now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<label className="studio-field" style={{ marginTop: 12 }}>
|
||||
<span>Auto-refresh every (seconds)</span>
|
||||
<input
|
||||
type="number"
|
||||
className="studio-input mono"
|
||||
min={2}
|
||||
max={120}
|
||||
value={pollEverySec}
|
||||
onChange={(e) => setPollEverySec(Number(e.target.value) || 8)}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-hint">Polling only runs in LIVE mode. Currently {mode === "live" ? "polling" : "paused"}.</div>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h"><Cog size={12} /> Appearance</h3>
|
||||
<div className="settings-row">
|
||||
<button className={`btn ${theme === "dark" ? "btn-primary" : "btn-ghost"}`} onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</button>
|
||||
<button className={`btn ${theme === "light" ? "btn-primary" : "btn-ghost"}`} onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</button>
|
||||
</div>
|
||||
<label className="settings-toggle" style={{ marginTop: 12 }}>
|
||||
<input type="checkbox" checked={consoleOpen} onChange={(e) => setConsoleOpen(e.target.checked)} />
|
||||
<span>Show live API console by default</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h"><Layers size={12} /> About</h3>
|
||||
<p className="settings-text">
|
||||
Mission Control demo. All actions are real backend calls — the live
|
||||
console shows every fetch. Source:{" "}
|
||||
<a className="settings-link" href="https://gitea.flow-master.ai/shad/flowmaster-mission-control-demo" target="_blank" rel="noreferrer">
|
||||
gitea.flow-master.ai/shad/flowmaster-mission-control-demo
|
||||
</a>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
src/scenes/Studio.tsx
Normal file
237
src/scenes/Studio.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
// Studio scene — design a new FlowMaster process and publish it to EA2.
|
||||
import { useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { api } from "../lib/api";
|
||||
import { Branch, Play, Check, Close, Bot, User, Cog } from "../components/icons";
|
||||
|
||||
type NodeKind = "start" | "human_task" | "agent_task" | "service_task" | "end";
|
||||
|
||||
interface DraftNode {
|
||||
id: string;
|
||||
type: NodeKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DraftEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NODES: DraftNode[] = [
|
||||
{ id: "start", type: "start", label: "Request received" },
|
||||
{ id: "review", type: "human_task", label: "Manager review" },
|
||||
{ id: "approved", type: "end", label: "Approved" },
|
||||
];
|
||||
const DEFAULT_EDGES: DraftEdge[] = [
|
||||
{ id: "e1", source: "start", target: "review" },
|
||||
{ id: "e2", source: "review", target: "approved" },
|
||||
];
|
||||
|
||||
const KIND_ICON: Record<NodeKind, (p: { size?: number }) => React.ReactElement> = {
|
||||
start: Branch,
|
||||
end: Branch,
|
||||
human_task: User,
|
||||
agent_task: Bot,
|
||||
service_task: Cog,
|
||||
};
|
||||
|
||||
const ORG_ID = "a0000000-0000-0000-0000-000000000010";
|
||||
|
||||
export default function Studio() {
|
||||
const mode = useApp((s) => s.mode);
|
||||
const pushToast = useApp((s) => s.pushToast);
|
||||
const refreshLive = useApp((s) => s.refreshLive);
|
||||
const actor = useApp((s) => s.actor);
|
||||
const setScene = useApp((s) => s.setScene);
|
||||
|
||||
const [name, setName] = useState("my-process-" + Math.random().toString(36).slice(2, 8));
|
||||
const [displayName, setDisplayName] = useState("My Process");
|
||||
const [hub, setHub] = useState("procurement");
|
||||
const [description, setDescription] = useState("Demo process designed in the Studio.");
|
||||
const [nodes, setNodes] = useState<DraftNode[]>(DEFAULT_NODES);
|
||||
const [edges, setEdges] = useState<DraftEdge[]>(DEFAULT_EDGES);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<{ ok: boolean; key?: string; err?: string } | null>(null);
|
||||
|
||||
const canPublish = mode === "live" && !!actor?.user_id;
|
||||
|
||||
const addNode = () => {
|
||||
const idx = nodes.length;
|
||||
setNodes([...nodes, { id: `n${idx}`, type: "human_task", label: `New step ${idx}` }]);
|
||||
};
|
||||
|
||||
const removeNode = (id: string) => {
|
||||
setNodes(nodes.filter((n) => n.id !== id));
|
||||
setEdges(edges.filter((e) => e.source !== id && e.target !== id));
|
||||
};
|
||||
|
||||
const updateNode = (id: string, patch: Partial<DraftNode>) => {
|
||||
setNodes(nodes.map((n) => (n.id === id ? { ...n, ...patch } : n)));
|
||||
};
|
||||
|
||||
const addEdge = () => {
|
||||
if (nodes.length < 2) return;
|
||||
const idx = edges.length;
|
||||
setEdges([...edges, { id: `e${idx + 1}`, source: nodes[0].id, target: nodes[nodes.length - 1].id }]);
|
||||
};
|
||||
|
||||
const removeEdge = (id: string) => setEdges(edges.filter((e) => e.id !== id));
|
||||
|
||||
const updateEdge = (id: string, patch: Partial<DraftEdge>) => {
|
||||
setEdges(edges.map((e) => (e.id === id ? { ...e, ...patch } : e)));
|
||||
};
|
||||
|
||||
const publish = async () => {
|
||||
if (!canPublish) {
|
||||
pushToast("warn", "Switch to LIVE mode + sign in to publish a process.");
|
||||
return;
|
||||
}
|
||||
setPublishing(true);
|
||||
setLastResult(null);
|
||||
try {
|
||||
const r = await api.createProcess({
|
||||
name,
|
||||
display_name: displayName,
|
||||
label: displayName,
|
||||
description,
|
||||
hub,
|
||||
config: {
|
||||
org_id: ORG_ID,
|
||||
executable: true,
|
||||
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
|
||||
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
||||
},
|
||||
});
|
||||
pushToast("ok", `Published ${r.name} → ${r._key.slice(0, 8)}`);
|
||||
setLastResult({ ok: true, key: r._key });
|
||||
await refreshLive();
|
||||
} catch (e) {
|
||||
pushToast("err", `Publish failed: ${(e as Error).message.slice(0, 140)}`);
|
||||
setLastResult({ ok: false, err: (e as Error).message });
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const valid = nodes.length >= 2 && edges.length >= 1 && nodes.every((n) => n.label.trim().length > 0);
|
||||
|
||||
return (
|
||||
<div className="studio">
|
||||
<header className="studio-head">
|
||||
<div>
|
||||
<div className="mc-hero-eyebrow"><Branch size={12} /> Process Studio</div>
|
||||
<h2 className="mc-hero-title">Design a new process</h2>
|
||||
<div className="mc-hero-sub">Hand-craft a typed FlowMaster process and publish it straight to EA2 on demo.flow-master.ai.</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-lg" onClick={publish} disabled={!valid || publishing}>
|
||||
{publishing ? <span className="spin" /> : <Play size={13} />} Publish to EA2
|
||||
{!canPublish && <span className="preview-marker">live + sign-in required</span>}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="studio-grid">
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">Definition</h3>
|
||||
<label className="studio-field">
|
||||
<span>name (unique slug)</span>
|
||||
<input className="studio-input mono" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>display name</span>
|
||||
<input className="studio-input" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>hub</span>
|
||||
<select className="studio-input" value={hub} onChange={(e) => setHub(e.target.value)}>
|
||||
<option value="procurement">procurement</option>
|
||||
<option value="finance">finance</option>
|
||||
<option value="people">people</option>
|
||||
<option value="service">service</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="studio-field">
|
||||
<span>description</span>
|
||||
<textarea className="studio-input" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">
|
||||
Nodes
|
||||
<span className="panel-count">{nodes.length}</span>
|
||||
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addNode}>+ node</button>
|
||||
</h3>
|
||||
<div className="studio-rows">
|
||||
{nodes.map((n) => {
|
||||
const Icon = KIND_ICON[n.type] ?? Cog;
|
||||
return (
|
||||
<div key={n.id} className="studio-row">
|
||||
<span className="studio-row-id mono">{n.id}</span>
|
||||
<Icon size={13} />
|
||||
<select className="studio-input" value={n.type} onChange={(e) => updateNode(n.id, { type: e.target.value as NodeKind })}>
|
||||
<option value="start">start</option>
|
||||
<option value="human_task">human_task</option>
|
||||
<option value="agent_task">agent_task</option>
|
||||
<option value="service_task">service_task</option>
|
||||
<option value="end">end</option>
|
||||
</select>
|
||||
<input className="studio-input" value={n.label} onChange={(e) => updateNode(n.id, { label: e.target.value })} />
|
||||
<button className="link-btn" onClick={() => removeNode(n.id)} title="Remove"><Close size={11} /></button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">
|
||||
Edges
|
||||
<span className="panel-count">{edges.length}</span>
|
||||
<button className="link-btn" style={{ marginLeft: "auto" }} onClick={addEdge}>+ edge</button>
|
||||
</h3>
|
||||
<div className="studio-rows">
|
||||
{edges.map((e) => (
|
||||
<div key={e.id} className="studio-row">
|
||||
<span className="studio-row-id mono">{e.id}</span>
|
||||
<select className="studio-input" value={e.source} onChange={(ev) => updateEdge(e.id, { source: ev.target.value })}>
|
||||
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
|
||||
</select>
|
||||
<span className="mono" style={{ color: "var(--text-3)" }}>→</span>
|
||||
<select className="studio-input" value={e.target} onChange={(ev) => updateEdge(e.id, { target: ev.target.value })}>
|
||||
{nodes.map((n) => <option key={n.id} value={n.id}>{n.id}</option>)}
|
||||
</select>
|
||||
<button className="link-btn" onClick={() => removeEdge(e.id)} title="Remove"><Close size={11} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="studio-panel">
|
||||
<h3 className="panel-h">JSON preview</h3>
|
||||
<pre className="raw-json"><code>{JSON.stringify({
|
||||
name, display_name: displayName, hub, description,
|
||||
kind: "definition", status: "published", version: 1,
|
||||
config: {
|
||||
org_id: ORG_ID, executable: true,
|
||||
nodes: nodes.map((n) => ({ id: n.id, type: n.type, label: n.label })),
|
||||
edges: edges.map((e) => ({ id: e.id, source: e.source, target: e.target })),
|
||||
},
|
||||
}, null, 2)}</code></pre>
|
||||
{lastResult && (
|
||||
<div className={`studio-result ${lastResult.ok ? "ok" : "err"}`}>
|
||||
{lastResult.ok ? (
|
||||
<>
|
||||
<Check size={12} /> Published as <span className="mono">{lastResult.key?.slice(0, 12)}</span>
|
||||
<button className="link-btn" onClick={() => setScene("mission")} style={{ marginLeft: "auto" }}>Open in Mission Control</button>
|
||||
</>
|
||||
) : (
|
||||
<><Close size={12} /> <span className="mono">{lastResult.err?.slice(0, 200)}</span></>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user