fix(oracle-r2): same-origin baseUrl + refresh actually re-fetches
Oracle round-2 review caught two real bugs: 1. Production baseUrl bypassed the nginx /api proxy - api.ts defaulted to https://demo.flow-master.ai in prod - Browser would hit cross-origin and CORS-fail - Now: baseUrl='' everywhere; nginx.conf already reverse-proxies /api/* - vite.config.ts proxy still handles dev 2. Refresh button didn't refresh - setMode('live') early-returned when already in live mode - Now: setMode() and refreshLive() share runLiveFetch(); refreshLive ignores the same-mode guard and always re-runs - 4 new vitest regressions in state/store.test.ts cover the contract - Smoke now asserts /api/ea2/work-items is called twice after Refresh Also: - buildScenarios.ts parallelized: cap N=6 candidates, Promise.all per- candidate fetches → live mode now ~3s instead of 30s - CommandBar + LeftRail preview toasts now name the exact endpoint (/api/runtime/transactions/{id}/actions) in the visible text - Landing 'Go live' button rebound to refreshLive() when already live; copy changed to 'Live · refresh' - README: scenario table now renders (added separator row); deploy section points at the real ops PR + the actual overlay path (overlays/demo, not overlays/mc.flow-master.ai); CORS doc clarifies same-origin requirement Constraint: browsers reject cross-origin → same-origin /api/* required Rejected: dev/prod baseUrl divergence | created production bug Confidence: high Scope-risk: narrow Not-tested: production image actually built + served by ops PR (gated by trusted updater + DNS)
This commit is contained in:
parent
2b83e3ad0e
commit
e3b4ed62c0
61
README.md
61
README.md
@ -31,15 +31,15 @@ on Gitea.
|
||||
|
||||
## Scenarios in the catalog
|
||||
|
||||
| id | mode | source |
|
||||
| ----------- | --------- | ------------------------------------------------------------------- |
|
||||
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
|
||||
| extra-1 | live | Atlas F1 Fresh (procurement variant) |
|
||||
| extra-2 | live | Atlas F1 Fresh (procurement variant) |
|
||||
| ar | blueprint | AR · Customer Refund Approval |
|
||||
| hcm | blueprint | HCM · New Hire Onboarding |
|
||||
| gl | blueprint | GL · Period-End Close |
|
||||
| service | blueprint | Service Ops · Customer Incident |
|
||||
| id | mode | source |
|
||||
|-------------|-----------|-------------------------------------------------------------------|
|
||||
| procurement | live | Purchase Requisition → PO (`pr_to_po_def` on demo.flow-master.ai) |
|
||||
| extra-1 | live | Atlas F1 Fresh (procurement variant) |
|
||||
| extra-2 | live | Atlas F1 Fresh (procurement variant) |
|
||||
| ar | blueprint | AR · Customer Refund Approval |
|
||||
| hcm | blueprint | HCM · New Hire Onboarding |
|
||||
| gl | blueprint | GL · Period-End Close |
|
||||
| service | blueprint | Service Ops · Customer Incident |
|
||||
|
||||
**Live** = backed by a real EA2 process definition currently in the demo
|
||||
backend, with real runtime transactions and a real work-item queue.
|
||||
@ -68,15 +68,20 @@ prior version. Safeguards now in place:
|
||||
## Live-mode mechanics (the CORS gotcha)
|
||||
|
||||
`demo.flow-master.ai` does not advertise CORS headers for arbitrary origins.
|
||||
`src/lib/api.ts` therefore uses an **empty `baseUrl` everywhere** (both dev
|
||||
and prod). All `/api/*` requests are same-origin from the browser's
|
||||
perspective; whatever is serving the page is responsible for proxying them
|
||||
to the backend.
|
||||
|
||||
- In **dev** (`pnpm dev`): Vite proxies `/api/*` to
|
||||
`${VITE_FM_BASE:-https://demo.flow-master.ai}` (see `vite.config.ts`). The
|
||||
browser sees a same-origin request, no CORS check. `src/lib/api.ts` uses an
|
||||
empty `baseUrl` in dev for this.
|
||||
- In **production**: deploy the build at the same origin as the backend (or
|
||||
behind a reverse proxy that passes `/api/*` through). Cross-origin
|
||||
deployments fail gracefully — `setMode("live")` catches the error, surfaces
|
||||
it in the topbar banner and a toast, and falls back to snapshot mode.
|
||||
- **Dev** (`pnpm dev`): `vite.config.ts` proxies `/api/*` to
|
||||
`${VITE_FM_BASE:-https://demo.flow-master.ai}`.
|
||||
- **Prod** (Docker image): the bundled `nginx.conf` reverse-proxies `/api/*`
|
||||
to `https://demo.flow-master.ai`. The image is intended to sit behind the
|
||||
`mc.flow-master.ai` ingress (see `FM06/flowmaster-ops` overlay).
|
||||
- **Anywhere else**: set `VITE_FM_BASE=https://your-backend` at build time
|
||||
and accept that browsers will reject the cross-origin call. Live mode then
|
||||
fails gracefully — `setMode("live")` catches the error, raises an
|
||||
`mc-banner-err` banner + error toast, and falls back to snapshot.
|
||||
|
||||
## Run, test, build
|
||||
|
||||
@ -129,15 +134,25 @@ fetch_scenarios.mjs # Node script to refresh src/scenarios.json
|
||||
|
||||
## Deploy
|
||||
|
||||
Production deployment is tracked in `FM06/flowmaster-ops` under
|
||||
`manifests/overlays/mc.flow-master.ai/`. A static nginx serves `dist/`;
|
||||
ingress fronts it at `mc.flow-master.ai`. `/api/*` is reverse-proxied to the
|
||||
same backend `demo.flow-master.ai` talks to — that's what makes live mode
|
||||
work in production without CORS.
|
||||
Production deployment is tracked in
|
||||
[FM06/flowmaster-ops PR #1164](https://gitea.flow-master.ai/FM06/flowmaster-ops/pulls/1164),
|
||||
which adds three resources to `manifests/overlays/demo/`:
|
||||
|
||||
- `mc-deployment.yaml` — 2-replica nginx Deployment in the `demo` namespace
|
||||
- `mc-service.yaml` — ClusterIP service on port 80
|
||||
- `mc-ingress.yaml` — Traefik ingress at `mc.flow-master.ai` with cert-manager DNS-01 cert
|
||||
|
||||
**Status:** the PR is open and mergeable. The live URL `https://mc.flow-master.ai` is **not yet serving** — two pre-merge action items remain for the trusted updater:
|
||||
|
||||
1. Cloudflare A record `mc.flow-master.ai → 65.21.71.186, 91.98.159.56` (same as `hakeem.flow-master.ai`).
|
||||
2. Gitea Actions must have published the first image tag (`gitea.flow-master.ai/shad/mission-control-demo:sha-…`) — `mc-deployment.yaml` pins that explicit SHA, not `:latest`.
|
||||
|
||||
Until the PR merges + DNS is added, the artifact is this repo + the open PR.
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
# dist/ is the static site → ship to nginx referenced by the ops overlay
|
||||
# dist/ is the static site. The Dockerfile bakes it into a tiny nginx
|
||||
# image. CI in .gitea/workflows/build.yml builds + publishes on push to main.
|
||||
```
|
||||
|
||||
## What's intentionally not here
|
||||
|
||||
36
qa/smoke.mjs
36
qa/smoke.mjs
@ -114,7 +114,11 @@ await page.waitForTimeout(450);
|
||||
logOk("tour advances", (await page.locator(".tour-title").first().innerText()).length > 0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Live-mode toggle (real network call to demo.flow-master.ai)
|
||||
// Live-mode toggle (real network call to demo.flow-master.ai via vite proxy)
|
||||
const networkCalls = [];
|
||||
page.on("request", (req) => {
|
||||
if (req.url().includes("/api/ea2/work-items")) networkCalls.push(req.url());
|
||||
});
|
||||
const toggleBtn = page.locator(".mode-toggle").first();
|
||||
await toggleBtn.click();
|
||||
const liveOk = await page.waitForFunction(
|
||||
@ -126,6 +130,36 @@ if (liveOk) {
|
||||
await page.waitForTimeout(400);
|
||||
await page.screenshot({ path: `${OUT}/09-live-mode.png` });
|
||||
logOk("Refresh button appears in live mode", await page.locator(".link-btn", { hasText: "Refresh" }).isVisible());
|
||||
|
||||
// Refresh must trigger ANOTHER /api/ea2/work-items request (Oracle round 2 fix).
|
||||
// Wait for the Refresh button to become enabled (initial fetch may still be settling).
|
||||
await page.locator(".link-btn", { hasText: "Refresh" }).waitFor({ state: "visible" });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const btns = Array.from(document.querySelectorAll(".link-btn"));
|
||||
const btn = btns.find((b) => (b.textContent || "").includes("Refresh"));
|
||||
return btn && !btn.disabled;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
const before = networkCalls.length;
|
||||
await page.locator(".link-btn", { hasText: "Refresh" }).click();
|
||||
await page.waitForTimeout(2500);
|
||||
logOk("Refresh re-fetches /api/ea2/work-items", networkCalls.length > before,
|
||||
`before=${before} after=${networkCalls.length}`);
|
||||
}
|
||||
|
||||
// LeftRail Confirm button: toast must name /api/runtime/transactions/{id}/actions
|
||||
await page.locator(".mc-tab", { hasText: "Accounts Receivable" }).click();
|
||||
await page.waitForTimeout(400);
|
||||
const confirmBtn = page.locator(".agent-acts .btn-primary").first();
|
||||
if (await confirmBtn.count() > 0) {
|
||||
await confirmBtn.click();
|
||||
await page.waitForTimeout(300);
|
||||
const lastToast = (await page.locator(".toast-msg").last().innerText()).trim();
|
||||
logOk("LeftRail Confirm toast names /api/runtime/transactions endpoint",
|
||||
/\/api\/runtime\/transactions\/\{id\}\/actions/.test(lastToast),
|
||||
`toast="${lastToast.slice(0, 80)}"`);
|
||||
}
|
||||
|
||||
// Telemetry honesty (check before leaving MC because RH has no telemetry strip)
|
||||
|
||||
@ -19,6 +19,7 @@ export default function App() {
|
||||
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);
|
||||
|
||||
@ -80,7 +81,7 @@ export default function App() {
|
||||
{mode === "live" && liveAge && <span className="topbar-age">{liveAge}</span>}
|
||||
</button>
|
||||
{mode === "live" && (
|
||||
<button className="link-btn" onClick={() => setMode("live")} disabled={liveLoading} title="Re-fetch live scenarios">
|
||||
<button className="link-btn" onClick={refreshLive} disabled={liveLoading} title="Re-fetch live scenarios">
|
||||
<Refresh size={12} /> Refresh
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -17,12 +17,13 @@ export default function CommandBar() {
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
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 sc = scenarioById(scenarioId);
|
||||
|
||||
const previewAction = (label: string) => {
|
||||
pushToast("info", `${label} is preview-only in this demo. Wire to /api/runtime/transactions to make it real.`);
|
||||
pushToast("info", `${label} is preview-only. Wire to POST /api/runtime/transactions/{id}/actions to make it real.`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@ -112,10 +113,17 @@ export default function CommandBar() {
|
||||
)}
|
||||
|
||||
<Command.Group heading="Data mode">
|
||||
<Command.Item onSelect={() => { setMode("live"); close(); }}>
|
||||
<Refresh size={13} /> {mode === "live" ? "Refresh live scenarios" : "Switch to LIVE mode (fetch demo.flow-master.ai)"}
|
||||
<span className="cmd-hint">{mode === "live" ? "re-fetch" : "in-browser"}</span>
|
||||
</Command.Item>
|
||||
{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>
|
||||
|
||||
@ -76,14 +76,14 @@ export default function LeftRail() {
|
||||
<div className="agent-acts">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => useApp.getState().pushToast("info", `Confirm "${a.intent.slice(0, 50)}…" is preview-only.`)}
|
||||
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"
|
||||
onClick={() => useApp.getState().pushToast("info", `Reject is preview-only.`)}
|
||||
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
|
||||
|
||||
@ -12,9 +12,8 @@ export interface ApiConfig {
|
||||
// In dev, route via vite proxy (same-origin) to dodge CORS; in production
|
||||
// the build is deployed at the same origin as the backend, so an empty
|
||||
// baseUrl is correct.
|
||||
const isDev = import.meta.env.DEV;
|
||||
const DEFAULT_CONFIG: ApiConfig = {
|
||||
baseUrl: import.meta.env.VITE_FM_BASE || (isDev ? "" : "https://demo.flow-master.ai"),
|
||||
baseUrl: import.meta.env.VITE_FM_BASE ?? "",
|
||||
email: import.meta.env.VITE_FM_EMAIL || "dev@flow-master.ai",
|
||||
};
|
||||
|
||||
|
||||
@ -243,29 +243,30 @@ export async function buildLiveScenariosFromApi(signal?: AbortSignal): Promise<{
|
||||
const candidates = [...byDef.values()].filter((d) => d.cases.length >= 2 || (d.statuses.running ?? 0) >= 1);
|
||||
candidates.sort((a, b) => b.cases.length - a.cases.length);
|
||||
|
||||
// For each candidate fetch graph + a couple runtimes.
|
||||
interface Enriched { bucket: CandidateBucket; graph: ProcessGraph; headlineRt: RuntimeTransaction | null; recent: RuntimeTransaction[] }
|
||||
const enriched: Enriched[] = [];
|
||||
for (const c of candidates.slice(0, 20)) {
|
||||
if (signal?.aborted) break;
|
||||
const graph = await api.graph(c.key, signal);
|
||||
if (!graph?.process_definition?.config?.nodes?.length) continue;
|
||||
const headlineCase =
|
||||
c.cases.find((w) => w.status === "running") ||
|
||||
c.cases.find((w) => w.status === "waiting_for_user") ||
|
||||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
|
||||
c.cases[0];
|
||||
const headlineRt = headlineCase?.transaction_id ? await api.transaction(headlineCase.transaction_id, signal) : null;
|
||||
const recent: RuntimeTransaction[] = [];
|
||||
for (const w of c.cases.slice(0, 6)) {
|
||||
if (signal?.aborted) break;
|
||||
if (!w.transaction_id || w.transaction_id === headlineCase?.transaction_id) continue;
|
||||
const r = await api.transaction(w.transaction_id, signal);
|
||||
if (r) recent.push(r);
|
||||
if (recent.length >= 3) break;
|
||||
}
|
||||
enriched.push({ bucket: c, graph, headlineRt, recent });
|
||||
}
|
||||
const TOP_N = 6;
|
||||
const enrichedRaw = await Promise.all(
|
||||
candidates.slice(0, TOP_N).map(async (c): Promise<Enriched | null> => {
|
||||
if (signal?.aborted) return null;
|
||||
const graph = await api.graph(c.key, signal);
|
||||
if (!graph?.process_definition?.config?.nodes?.length) return null;
|
||||
const headlineCase =
|
||||
c.cases.find((w) => w.status === "running") ||
|
||||
c.cases.find((w) => w.status === "waiting_for_user") ||
|
||||
c.cases.find((w) => w.status === "errored" || w.status === "failed") ||
|
||||
c.cases[0];
|
||||
const recentCandidates = c.cases
|
||||
.filter((w) => w.transaction_id && w.transaction_id !== headlineCase?.transaction_id)
|
||||
.slice(0, 3);
|
||||
const [headlineRt, ...recentResults] = await Promise.all([
|
||||
headlineCase?.transaction_id ? api.transaction(headlineCase.transaction_id, signal) : Promise.resolve(null),
|
||||
...recentCandidates.map((w) => api.transaction(w.transaction_id, signal)),
|
||||
]);
|
||||
const recent = recentResults.filter((r): r is RuntimeTransaction => r != null);
|
||||
return { bucket: c, graph, headlineRt, recent };
|
||||
}),
|
||||
);
|
||||
const enriched: Enriched[] = enrichedRaw.filter((e): e is Enriched => e != null);
|
||||
|
||||
// Classify into families.
|
||||
const used = new Set<string>();
|
||||
|
||||
@ -12,6 +12,7 @@ export default function Landing() {
|
||||
const scenarios = useApp((s) => s.scenarios);
|
||||
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 liveTotals = useApp((s) => s.liveTotals);
|
||||
|
||||
@ -61,12 +62,12 @@ export default function Landing() {
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-ghost btn-lg${mode === "live" ? " is-on" : ""}`}
|
||||
onClick={() => setMode(mode === "live" ? "snapshot" : "live")}
|
||||
onClick={() => mode === "live" ? refreshLive() : setMode("live")}
|
||||
disabled={liveLoading}
|
||||
title="Toggle between bundled snapshot and a live in-browser fetch from demo.flow-master.ai"
|
||||
title={mode === "live" ? "Re-fetch live scenarios from demo.flow-master.ai" : "Toggle between bundled snapshot and a live in-browser fetch"}
|
||||
>
|
||||
{liveLoading ? <span className="spin" /> : <Pulse size={13} />}
|
||||
{mode === "live" ? "Live mode · on" : "Go live"}
|
||||
{mode === "live" ? "Live · refresh" : "Go live"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
77
src/state/store.test.ts
Normal file
77
src/state/store.test.ts
Normal file
@ -0,0 +1,77 @@
|
||||
// Regression: refreshLive() must re-fetch when already in live mode,
|
||||
// and setMode("live") repeated must not early-return.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { useApp } from "./store";
|
||||
|
||||
function stubFetch() {
|
||||
const calls: string[] = [];
|
||||
const store = new Map<string, string>();
|
||||
globalThis.sessionStorage = {
|
||||
getItem: (k) => store.get(k) ?? null,
|
||||
setItem: (k, v) => { store.set(k, String(v)); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
} as Storage;
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
calls.push(url);
|
||||
if (url.endsWith("/api/v1/auth/dev-login")) {
|
||||
return new Response(JSON.stringify({ access_token: "T" }), { status: 200 });
|
||||
}
|
||||
if (url.endsWith("/api/v1/auth/me")) {
|
||||
return new Response(JSON.stringify({ user_id: "u", tenant_id: "t", email: "dev@flow-master.ai" }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/ea2/work-items")) {
|
||||
return new Response(JSON.stringify({ items: [] }), { status: 200 });
|
||||
}
|
||||
return new Response("not stubbed", { status: 404 });
|
||||
}) as unknown as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useApp.setState({ mode: "snapshot", liveLoading: false, liveError: null, liveFetchedAt: null });
|
||||
});
|
||||
|
||||
describe("store live-mode + refresh", () => {
|
||||
it("setMode('live') performs a network fetch", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().setMode("live");
|
||||
const meCalls = calls.filter((u) => u.endsWith("/api/v1/auth/me")).length;
|
||||
const wiCalls = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
||||
expect(meCalls).toBeGreaterThanOrEqual(1);
|
||||
expect(wiCalls).toBeGreaterThanOrEqual(1);
|
||||
expect(useApp.getState().mode).toBe("live");
|
||||
expect(useApp.getState().liveFetchedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("refreshLive() triggers a second fetch and bumps liveFetchedAt", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().setMode("live");
|
||||
const initialFetched = useApp.getState().liveFetchedAt!;
|
||||
const initialWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
await useApp.getState().refreshLive();
|
||||
|
||||
const afterWi = calls.filter((u) => u.includes("/api/ea2/work-items")).length;
|
||||
expect(afterWi).toBeGreaterThan(initialWi);
|
||||
expect(useApp.getState().liveFetchedAt!).toBeGreaterThanOrEqual(initialFetched);
|
||||
});
|
||||
|
||||
it("refreshLive() in snapshot mode is a no-op (does NOT fetch)", async () => {
|
||||
const calls = stubFetch();
|
||||
await useApp.getState().refreshLive();
|
||||
expect(calls.length).toBe(0);
|
||||
expect(useApp.getState().mode).toBe("snapshot");
|
||||
});
|
||||
|
||||
it("setMode('snapshot') when already snapshot does not re-toast", async () => {
|
||||
stubFetch();
|
||||
const before = useApp.getState().toasts.length;
|
||||
await useApp.getState().setMode("snapshot");
|
||||
expect(useApp.getState().toasts.length).toBe(before);
|
||||
});
|
||||
});
|
||||
@ -28,6 +28,8 @@ interface AppState {
|
||||
/** 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;
|
||||
liveError: string | null;
|
||||
@ -69,38 +71,56 @@ const initialScenario = SNAPSHOT_SCENARIOS[0];
|
||||
|
||||
let toastSeq = 0;
|
||||
|
||||
async function runLiveFetch(
|
||||
set: (partial: Partial<AppState>) => void,
|
||||
get: () => AppState,
|
||||
) {
|
||||
const prevMode = get().mode;
|
||||
set({ mode: "live", liveLoading: true, liveError: null });
|
||||
try {
|
||||
const ping = await api.ping();
|
||||
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const merged = [...scenarios, ...syntheticScenarios];
|
||||
const first = merged[0];
|
||||
const keepCurrent = get().scenarios.some((s) => s.id === get().scenarioId);
|
||||
set({
|
||||
scenarios: merged,
|
||||
liveTotals: { workItems: workItems.length, distinctDefs },
|
||||
liveFetchedAt: Date.now(),
|
||||
liveLoading: false,
|
||||
scenarioId: keepCurrent ? get().scenarioId : first?.id ?? get().scenarioId,
|
||||
selectedStepId: keepCurrent ? get().selectedStepId : first?.defaultStepId ?? null,
|
||||
});
|
||||
get().pushToast(
|
||||
"ok",
|
||||
prevMode === "live"
|
||||
? `Refreshed · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`
|
||||
: `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`,
|
||||
);
|
||||
} catch (e) {
|
||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
||||
}
|
||||
}
|
||||
|
||||
export const useApp = create<AppState>((set, get) => ({
|
||||
scene: "landing",
|
||||
setScene: (scene) => set({ scene }),
|
||||
|
||||
mode: "snapshot",
|
||||
setMode: async (mode) => {
|
||||
if (mode === get().mode) return;
|
||||
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;
|
||||
}
|
||||
set({ mode: "live", liveLoading: true, liveError: null });
|
||||
try {
|
||||
const ping = await api.ping();
|
||||
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
||||
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
||||
const merged = [...scenarios, ...syntheticScenarios];
|
||||
const first = merged[0];
|
||||
set({
|
||||
scenarios: merged,
|
||||
liveTotals: { workItems: workItems.length, distinctDefs },
|
||||
liveFetchedAt: Date.now(),
|
||||
liveLoading: false,
|
||||
scenarioId: first?.id ?? get().scenarioId,
|
||||
selectedStepId: first?.defaultStepId ?? null,
|
||||
});
|
||||
get().pushToast("ok", `Live mode · ${scenarios.length} live + ${syntheticScenarios.length} blueprint scenarios`);
|
||||
} catch (e) {
|
||||
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
||||
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
||||
}
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
refreshLive: async () => {
|
||||
if (get().mode !== "live") return;
|
||||
await runLiveFetch(set, get);
|
||||
},
|
||||
|
||||
liveLoading: false,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user