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:
@@ -0,0 +1,132 @@
|
||||
// Cinematic landing scene: brand, scenarios pick, start tour CTA.
|
||||
import { motion } from "framer-motion";
|
||||
import { useApp } from "../state/store";
|
||||
import { liveMeta } from "../data/scenarios";
|
||||
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 scenarios = useApp((s) => s.scenarios);
|
||||
const mode = useApp((s) => s.mode);
|
||||
const setMode = useApp((s) => s.setMode);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveTotals = useApp((s) => s.liveTotals);
|
||||
|
||||
return (
|
||||
<div className="landing">
|
||||
<div className="landing-bg" aria-hidden />
|
||||
<div className="landing-grid" aria-hidden />
|
||||
|
||||
<header className="landing-top">
|
||||
<div className="brand-lock">
|
||||
<span className="brand-mark" />
|
||||
<span className="brand-name">FlowMaster</span>
|
||||
<span className="brand-divider" />
|
||||
<span className="brand-sub">Mission Control</span>
|
||||
</div>
|
||||
<button className="link-btn" onClick={() => setCmdOpen(true)}>
|
||||
<Cmd size={13} /> Command <kbd>⌘K</kbd>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="landing-main">
|
||||
<motion.div
|
||||
className="landing-hero"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.45 }}
|
||||
>
|
||||
<span className="hero-eyebrow"><Sparkles size={13} /> Business-as-code · Mission Control</span>
|
||||
<h1 className="hero-title">
|
||||
Every process. <span className="hl">One control surface.</span>
|
||||
</h1>
|
||||
<p className="hero-sub">
|
||||
FlowMaster turns the operational map of a company into living,
|
||||
typed processes — backed by humans, agents, and rules — and gives you a
|
||||
single command-center to drive them. Procurement scenarios are real,
|
||||
backed by EA2 on{" "}
|
||||
<span className="mono">{liveMeta.fetchedFrom?.replace("https://", "") ?? "demo"}</span>;
|
||||
AR, HCM, GL, and Service are industry blueprints showing how this
|
||||
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>
|
||||
<button
|
||||
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
|
||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
||||
disabled={liveLoading}
|
||||
title="Toggle between bundled snapshot and a live in-browser fetch from demo.flow-master.ai"
|
||||
>
|
||||
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
|
||||
{mode === "live" ? "Live mode · on" : "Go live"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
<div className="stat">
|
||||
<Pulse size={12} />
|
||||
<span className="stat-v mono">{liveTotals?.workItems ?? liveMeta.workItems}</span>
|
||||
<span className="stat-l">{mode === "live" ? "live work items (now)" : "snapshot work items"}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-v mono">{liveTotals?.distinctDefs ?? liveMeta.distinctDefs}</span>
|
||||
<span className="stat-l">process definitions</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<Bot size={12} />
|
||||
<span className="stat-v mono">{scenarios.length}</span>
|
||||
<span className="stat-l">scenarios in catalog</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className={`mode-pill mode-${mode}`}>{mode === "live" ? "LIVE" : "SNAPSHOT"}</span>
|
||||
<span className="stat-l">data mode</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="landing-cards"
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.12, duration: 0.45 }}
|
||||
>
|
||||
{scenarios.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
className="sc-card"
|
||||
style={{ ["--sc-accent" as string]: s.family.accent }}
|
||||
onClick={() => { setScenarioId(s.id); setScene("mission"); }}
|
||||
>
|
||||
<div className="sc-card-top">
|
||||
<span className="sc-card-mark" />
|
||||
<span className={`tag ${s.live ? "tag-live" : "tag-syn"}`}>{s.live ? "live" : "blueprint"}</span>
|
||||
</div>
|
||||
<h3 className="sc-card-title">{s.family.label}</h3>
|
||||
<p className="sc-card-sub">{s.family.subtitle}</p>
|
||||
<div className="sc-card-meta">
|
||||
<span>{s.steps.length} steps</span>
|
||||
<span>·</span>
|
||||
<span>{s.queue.length} cases</span>
|
||||
<span>·</span>
|
||||
<span className="mono">{s.version}</span>
|
||||
</div>
|
||||
<div className="sc-card-cta">Open <Arrow size={12} /></div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</main>
|
||||
|
||||
<footer className="landing-foot">
|
||||
<span className="foot-eyebrow">FlowMaster · Mission Control demo · synthesised on top of demo.flow-master.ai</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// MissionControl scene: scenario tabs + graph + rails + telemetry strip.
|
||||
import { useApp, scenarioById } from "../state/store";
|
||||
import ProcessGraph from "../components/ProcessGraph";
|
||||
import LeftRail from "../components/LeftRail";
|
||||
import Inspector from "../components/Inspector";
|
||||
import Telemetry from "../components/Telemetry";
|
||||
|
||||
export default function MissionControl() {
|
||||
const scenarioId = useApp((s) => s.scenarioId);
|
||||
const setScenarioId = useApp((s) => s.setScenarioId);
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
const liveLoading = useApp((s) => s.liveLoading);
|
||||
const liveError = useApp((s) => s.liveError);
|
||||
const sc = scenarioById(scenarioId);
|
||||
|
||||
return (
|
||||
<div className="mc">
|
||||
{liveLoading && (
|
||||
<div className="mc-banner mc-banner-info">
|
||||
<span className="spin" /> Fetching live scenarios from demo.flow-master.ai…
|
||||
</div>
|
||||
)}
|
||||
{liveError && (
|
||||
<div className="mc-banner mc-banner-err">
|
||||
Live mode failed: <span className="mono">{liveError}</span> · showing snapshot
|
||||
</div>
|
||||
)}
|
||||
<div className="mc-strip" role="tablist" aria-label="Scenarios">
|
||||
{scenarios.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
role="tab"
|
||||
aria-selected={s.id === scenarioId}
|
||||
className={`mc-tab${s.id === scenarioId ? " mc-tab-sel" : ""}`}
|
||||
style={{ ["--accent" as string]: s.family.accent }}
|
||||
onClick={() => setScenarioId(s.id)}
|
||||
>
|
||||
<span className="mc-tab-dot" />
|
||||
<span className="mc-tab-label">{s.family.label}</span>
|
||||
<span className={`mc-tab-mark ${s.live ? "is-live" : "is-syn"}`}>{s.live ? "live" : "bp"}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mc-hero">
|
||||
<div>
|
||||
<div className="mc-hero-eyebrow">{sc?.family.subtitle}</div>
|
||||
<h2 className="mc-hero-title">{sc?.defName}</h2>
|
||||
<div className="mc-hero-sub">{sc?.tagline}</div>
|
||||
</div>
|
||||
<div className="mc-hero-kpis">
|
||||
{sc?.kpis.slice(0, 4).map((k) => (
|
||||
<div className="mc-kpi" key={k.label}>
|
||||
<div className="mc-kpi-v">{k.value}</div>
|
||||
<div className="mc-kpi-l">{k.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mc-body">
|
||||
<LeftRail />
|
||||
<main className="mc-main">
|
||||
<ProcessGraph />
|
||||
</main>
|
||||
<Inspector />
|
||||
</div>
|
||||
|
||||
<Telemetry />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// RunHistory scene: cross-scenario timeline.
|
||||
import { useMemo, useState } from "react";
|
||||
import { useApp } from "../state/store";
|
||||
import { Clock, History } from "../components/icons";
|
||||
|
||||
const STATUS_FILTERS = ["all", "running", "completed", "errored", "queued"] as const;
|
||||
type Status = (typeof STATUS_FILTERS)[number];
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
running: "var(--run)",
|
||||
completed: "var(--ok)",
|
||||
done: "var(--ok)",
|
||||
errored: "var(--block)",
|
||||
failed: "var(--block)",
|
||||
queued: "var(--queue)",
|
||||
};
|
||||
|
||||
export default function RunHistory() {
|
||||
const [filter, setFilter] = useState<Status>("all");
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const out: Array<{ scenarioId: string; scenarioLabel: string; accent: string; live: boolean; run: (typeof scenarios)[number]["runs"][number] }> = [];
|
||||
for (const s of scenarios) {
|
||||
for (const r of s.runs) {
|
||||
if (filter !== "all" && r.status !== filter && !(filter === "completed" && r.status === "done")) continue;
|
||||
out.push({ scenarioId: s.id, scenarioLabel: s.family.label, accent: s.family.accent, live: s.live, run: r });
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => (b.run.startedAt || "").localeCompare(a.run.startedAt || ""));
|
||||
}, [filter]);
|
||||
|
||||
const maxDuration = useMemo(() => Math.max(60, ...rows.map((r) => r.run.durationSec)), [rows]);
|
||||
|
||||
return (
|
||||
<div className="rh">
|
||||
<header className="rh-head">
|
||||
<div>
|
||||
<span className="mc-hero-eyebrow"><History size={12} /> Run history</span>
|
||||
<h2 className="mc-hero-title">All scenarios · all runs</h2>
|
||||
</div>
|
||||
<nav className="rh-filters">
|
||||
{STATUS_FILTERS.map((f) => (
|
||||
<button key={f} className={`rh-chip${filter === f ? " rh-chip-sel" : ""}`} onClick={() => setFilter(f)}>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="rh-list">
|
||||
{rows.length === 0 && <div className="empty">No runs match the current filter.</div>}
|
||||
{rows.map((row) => (
|
||||
<div className="rh-row" key={`${row.scenarioId}-${row.run.id}`}>
|
||||
<div className="rh-row-id">
|
||||
<span className="rh-dot" style={{ background: row.accent }} />
|
||||
<span className="mono">{row.run.shortId}</span>
|
||||
<span className={`tag ${row.live ? "tag-live" : "tag-syn"}`}>{row.live ? "live" : "bp"}</span>
|
||||
</div>
|
||||
<div className="rh-row-scenario">{row.scenarioLabel}</div>
|
||||
<div className="rh-row-step">{row.run.activeStep ?? "—"}</div>
|
||||
<div className="rh-row-bar">
|
||||
<div
|
||||
className="rh-bar-fill"
|
||||
style={{
|
||||
width: `${Math.max(2, (row.run.durationSec / maxDuration) * 100)}%`,
|
||||
background: STATUS_COLOR[row.run.status] ?? "var(--border-strong)",
|
||||
}}
|
||||
title={`${Math.round(row.run.durationSec / 60)}m`}
|
||||
/>
|
||||
</div>
|
||||
<div className="rh-row-meta">
|
||||
<span className="mono"><Clock size={11} /> {Math.round(row.run.durationSec / 60)}m</span>
|
||||
<span className={`pill pill-${row.run.status === "completed" || row.run.status === "done" ? "done" : row.run.status === "running" ? "running" : row.run.status === "errored" || row.run.status === "failed" ? "errored" : "queued"}`}>{row.run.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user