From 0bda0b76f1270fe7ac32b98bddb5ff7aab38958f Mon Sep 17 00:00:00 2001 From: Devin Date: Wed, 22 Apr 2026 16:49:53 +0000 Subject: [PATCH] PR G: portal /transactions page + 12-state machine view (arch step 8) Adds the first portal page backed by the CurrenciCombo orchestrator (PR A-F). Renders: - /transactions \u2014 list of recent plans with state chip, instrument hint, owner, last-updated. - /transactions/:planId \u2014 detail view with three cards: 1. 12-state machine grid highlighting current + visited states. 2. Audit trail table: from\u2192to, actor, actor_role (SoD), reason, timestamp \u2014 straight from state_transitions. 3. Signed event stream: id, type, signature, prev_hash, at \u2014 straight from events (PR D). Backend wiring (src/services/orchestrator.ts): - listPlans() GET /api/plans - getPlanState(id) GET /api/plans/:id/state - getPlanEvents(id) GET /api/plans/:id/events - probeOrchestrator() GET /health If VITE_ORCHESTRATOR_URL is unset (orchestrator not deployed yet), every call returns deterministic demo data so the 12-state view and audit trail still render. Mirrors how proxmox/dbisCore fall back. Badge on every card shows LIVE / DEGRADED / DEMO. Also registers the orchestrator in backendCatalog so the existing BackendStatusBar on the Dashboard picks it up automatically. PortalLayout gets a new 'Transactions' nav item (GitBranch icon). Portal code from PR #2 is untouched except for the new route + nav entry. --- src/Portal.tsx | 23 ++ src/components/portal/PortalLayout.tsx | 3 +- src/components/portal/StateMachineView.tsx | 51 ++++ src/config/endpoints.ts | 22 +- src/index.css | 93 +++++++ src/pages/TransactionsPage.tsx | 243 ++++++++++++++++++ src/services/orchestrator.ts | 281 +++++++++++++++++++++ 7 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 src/components/portal/StateMachineView.tsx create mode 100644 src/pages/TransactionsPage.tsx create mode 100644 src/services/orchestrator.ts diff --git a/src/Portal.tsx b/src/Portal.tsx index 8f37d5b..5913bb0 100644 --- a/src/Portal.tsx +++ b/src/Portal.tsx @@ -7,6 +7,7 @@ import TreasuryPage from './pages/TreasuryPage'; import ReportingPage from './pages/ReportingPage'; import CompliancePage from './pages/CompliancePage'; import SettlementsPage from './pages/SettlementsPage'; +import TransactionsPage from './pages/TransactionsPage'; import PortalLayout from './components/portal/PortalLayout'; import LiveChainBanner from './components/portal/LiveChainBanner'; import App from './App'; @@ -131,6 +132,28 @@ export default function Portal() { } /> + + + + + + } + /> + + + + + + + } + /> + (transitions.map((t) => t.to_state)); + if (transitions.length > 0 && transitions[0].from_state === null) { + visited.add(transitions[0].to_state); + } + + return ( +
+
+ {TRANSACTION_STATES.map((state) => { + const isCurrent = state === current; + const isVisited = visited.has(state); + const isTerminal = state === 'COMMITTED' || state === 'ABORTED' || state === 'CLOSED'; + const classes = [ + 'state-pill', + isCurrent ? 'state-pill--current' : '', + !isCurrent && isVisited ? 'state-pill--visited' : '', + !isVisited ? 'state-pill--pending' : '', + isTerminal ? 'state-pill--terminal' : '', + ] + .filter(Boolean) + .join(' '); + return ( +
+
+ ); + })} +
+
+ current + visited + not yet reached +
+
+ ); +} diff --git a/src/config/endpoints.ts b/src/config/endpoints.ts index 3dcbc94..68b9788 100644 --- a/src/config/endpoints.ts +++ b/src/config/endpoints.ts @@ -40,6 +40,13 @@ export interface EndpointConfig { * banking API is stood up. */ mocked: true; }; + orchestrator: { + /** CurrenciCombo/orchestrator base URL (plan-state + event stream + * for /transactions page). Empty string means "not deployed — + * fall back to mock demo data". */ + baseUrl: string; + deployed: boolean; + }; } const env = (import.meta as unknown as { env?: Record }).env ?? {}; @@ -66,12 +73,16 @@ export const endpoints: EndpointConfig = { apiBaseUrl: env.VITE_DBIS_CORE_API_BASE_URL || 'https://api.dbis-core.d-bis.org', mocked: true, }, + orchestrator: { + baseUrl: env.VITE_ORCHESTRATOR_URL || '', + deployed: Boolean(env.VITE_ORCHESTRATOR_URL), + }, }; export type BackendStatus = 'live' | 'bff-required' | 'mocked' | 'degraded'; export interface BackendDescriptor { - id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore'; + id: 'chain138' | 'explorer' | 'proxmox' | 'dbisCore' | 'orchestrator'; name: string; status: BackendStatus; url: string; @@ -107,4 +118,13 @@ export const backendCatalog: BackendDescriptor[] = [ url: endpoints.dbisCore.apiBaseUrl, note: 'No public deployment yet. UI falls back to sample portal data.', }, + { + id: 'orchestrator', + name: 'Transaction Orchestrator', + status: endpoints.orchestrator.deployed ? 'live' : 'mocked', + url: endpoints.orchestrator.baseUrl || '(not deployed)', + note: endpoints.orchestrator.deployed + ? 'CurrenciCombo orchestrator — plan state + event stream.' + : 'Orchestrator not yet deployed. /transactions page renders demo plans.', + }, ]; diff --git a/src/index.css b/src/index.css index 857f633..e9a8b04 100644 --- a/src/index.css +++ b/src/index.css @@ -3851,3 +3851,96 @@ html, body, #root { border-radius: 4px; color: var(--accent); } + +/* ================================================================= */ +/* /transactions page (PR G — arch step 8) */ +/* ================================================================= */ + +.transactions-page { padding: 24px; display: flex; flex-direction: column; gap: 20px; } +.transactions-page .back-button { + background: none; border: none; color: var(--accent); + cursor: pointer; font-size: 13px; padding: 0; margin-bottom: 8px; +} +.transactions-page .back-button:hover { text-decoration: underline; } + +.source-badge { + font-size: 10px; letter-spacing: 0.08em; padding: 2px 8px; + border-radius: 10px; font-weight: 600; text-transform: uppercase; +} +.source-badge--live { background: rgba(34,197,94,0.15); color: #22c55e; } +.source-badge--degraded { background: rgba(239,68,68,0.15); color: #ef4444; } +.source-badge--mocked { background: rgba(148,163,184,0.20); color: #94a3b8; } + +.portal-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.portal-table th, .portal-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid rgba(148,163,184,0.12); } +.portal-table th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #94a3b8; font-weight: 600; } +.portal-table tbody tr { transition: background 0.12s ease; } +.portal-table .portal-table-row { cursor: pointer; } +.portal-table .portal-table-row:hover { background: rgba(99,102,241,0.06); } +.portal-table .mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; } +.portal-table .truncate { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.portal-table .row-chevron { color: #64748b; } + +.state-chip, .role-chip { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 10px; border-radius: 999px; font-size: 11px; + font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; + background: rgba(99,102,241,0.14); color: #a5b4fc; +} +.state-chip--committed { background: rgba(34,197,94,0.15); color: #22c55e; } +.state-chip--aborted { background: rgba(239,68,68,0.15); color: #ef4444; } +.state-chip--validating, +.state-chip--executing, +.state-chip--partially_executed { background: rgba(245,158,11,0.15); color: #f59e0b; } +.state-chip--draft { background: rgba(148,163,184,0.18); color: #cbd5e1; } +.state-chip--closed { background: rgba(148,163,184,0.25); color: #e2e8f0; } + +.role-chip--submitter { background: rgba(99,102,241,0.14); color: #a5b4fc; } +.role-chip--approver { background: rgba(245,158,11,0.14); color: #f59e0b; } +.role-chip--releaser { background: rgba(14,165,233,0.14); color: #38bdf8; } +.role-chip--validator { background: rgba(168,85,247,0.14); color: #c084fc; } +.role-chip--coordinator{ background: rgba(148,163,184,0.18); color: #cbd5e1; } + +.state-machine-view { padding: 12px 8px 4px; } +.state-machine-grid { + display: grid; gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); +} +.state-pill { + display: flex; align-items: center; gap: 10px; + padding: 10px 14px; border-radius: 10px; + border: 1px solid rgba(148,163,184,0.18); + background: rgba(15,23,42,0.35); color: #e2e8f0; + font-size: 12px; font-weight: 500; letter-spacing: 0.03em; +} +.state-pill-dot { + width: 10px; height: 10px; border-radius: 50%; + background: rgba(148,163,184,0.45); +} +.state-pill--visited { border-color: rgba(99,102,241,0.35); } +.state-pill--visited .state-pill-dot { background: #818cf8; } +.state-pill--current { + border-color: #22c55e; + box-shadow: 0 0 0 2px rgba(34,197,94,0.18); + background: rgba(34,197,94,0.08); +} +.state-pill--current .state-pill-dot { background: #22c55e; } +.state-pill--pending { opacity: 0.55; } +.state-pill--terminal.state-pill--visited { border-color: #f59e0b; } +.state-machine-legend { + display: flex; gap: 16px; padding: 12px 4px 0; + font-size: 11px; color: #94a3b8; +} +.legend-item { display: inline-flex; align-items: center; gap: 6px; } +.legend-item .dot { width: 8px; height: 8px; border-radius: 50%; } +.legend-item .dot--current { background: #22c55e; } +.legend-item .dot--visited { background: #818cf8; } +.legend-item .dot--pending { background: rgba(148,163,184,0.45); } + +.loading-row, .empty-row { padding: 20px; color: #94a3b8; text-align: center; font-size: 13px; } +.error-banner { + padding: 10px 14px; border-radius: 8px; font-size: 12px; + background: rgba(239,68,68,0.10); color: #fca5a5; + border: 1px solid rgba(239,68,68,0.25); margin: 8px 0; +} +.muted { color: #94a3b8; } diff --git a/src/pages/TransactionsPage.tsx b/src/pages/TransactionsPage.tsx new file mode 100644 index 0000000..cced565 --- /dev/null +++ b/src/pages/TransactionsPage.tsx @@ -0,0 +1,243 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { listPlans, getPlanState, getPlanEvents, type PlanSummary, type PlanStateDetail, type PlanEvent } from '../services/orchestrator'; +import StateMachineView from '../components/portal/StateMachineView'; +import { endpoints } from '../config/endpoints'; + +type Source = 'live' | 'mocked' | 'degraded'; + +function SourceBadge({ source }: { source: Source }) { + const label = source === 'live' ? 'LIVE' : source === 'degraded' ? 'DEGRADED' : 'DEMO'; + return {label}; +} + +export default function TransactionsPage() { + const { planId } = useParams<{ planId?: string }>(); + return planId ? : ; +} + +function TransactionsList() { + const navigate = useNavigate(); + const [plans, setPlans] = useState(null); + const [source, setSource] = useState('mocked'); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setPlans(null); + setError(null); + listPlans() + .then((res) => { + if (cancelled) return; + setPlans(res.plans); + setSource(res.source); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+
+

Transactions

+

+ Multi-layer atomic settlement plans. State machine per architecture note §8. + {!endpoints.orchestrator.deployed && ( + + {' '}Orchestrator not deployed — showing demo plans. + + )} +

+
+ +
+
+

Recent plans

+ +
+ {error &&
{error}
} + {plans === null ? ( +
Loading…
+ ) : plans.length === 0 ? ( +
No plans yet.
+ ) : ( + + + + + + + + + + + + {plans.map((p) => ( + navigate(`/transactions/${encodeURIComponent(p.plan_id)}`)} + data-testid={`plan-row-${p.plan_id}`} + > + + + + + + + + ))} + +
Plan IDStateInstrumentOwnerUpdated +
{p.plan_id} + + {p.status.replace(/_/g, ' ')} + + {p.instrument_hint ?? '—'}{p.actor_id ?? '—'}{new Date(p.updated_at).toLocaleString()}
+ )} +
+
+ ); +} + +function TransactionDetail({ planId }: { planId: string }) { + const navigate = useNavigate(); + const [detail, setDetail] = useState(null); + const [events, setEvents] = useState(null); + const [source, setSource] = useState('mocked'); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setDetail(null); + setEvents(null); + setError(null); + Promise.all([getPlanState(planId), getPlanEvents(planId)]) + .then(([s, e]) => { + if (cancelled) return; + setDetail(s.detail); + setEvents(e.events); + setSource(s.source === 'live' && e.source === 'live' ? 'live' : s.source); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, [planId]); + + return ( +
+
+ +

+ Plan {planId} +

+

+ {detail ? ( + <>Current state: {detail.current_state.replace(/_/g, ' ')} + ) : ( + 'Loading plan state…' + )} +

+
+ + {error &&
{error}
} + +
+
+

12-state machine

+ +
+ {detail ? ( + + ) : ( +
Loading…
+ )} +
+ +
+
+

Audit trail

+
+ {detail === null ? ( +
Loading…
+ ) : detail.transitions.length === 0 ? ( +
No transitions recorded.
+ ) : ( + + + + + + + + + + + + + {detail.transitions.map((t, i) => ( + + + + + + + + + ))} + +
#From → ToActorRoleReasonAt
{i + 1} + {t.from_state ?? '∅'} → {t.to_state} + {t.actor_id} + {t.actor_role} + {t.reason ?? '—'}{new Date(t.occurred_at).toLocaleString()}
+ )} +
+ +
+
+

Signed event stream

+
+ {events === null ? ( +
Loading…
+ ) : events.length === 0 ? ( +
No events.
+ ) : ( + + + + + + + + + + + + {events.map((e) => ( + + + + + + + + ))} + +
#TypeSignaturePrev hashAt
{e.id}{e.type}{e.signature}{e.prev_hash ?? '∅'}{new Date(e.created_at).toLocaleString()}
+ )} +
+
+ ); +} diff --git a/src/services/orchestrator.ts b/src/services/orchestrator.ts new file mode 100644 index 0000000..b733b05 --- /dev/null +++ b/src/services/orchestrator.ts @@ -0,0 +1,281 @@ +/** + * CurrenciCombo/orchestrator API client — consumed by the portal's + * /transactions page. + * + * When `VITE_ORCHESTRATOR_URL` is unset or the endpoint is unreachable, + * every call falls back to deterministic demo data so the page still + * renders something meaningful. This mirrors how the other services + * treat unreachable backends (chain138, explorer, dbisCore). + * + * The shape of the data matches the orchestrator's API (see + * orchestrator/src/api/plans.ts + orchestrator/src/api/eventBus.ts). + * Re-sync if the orchestrator evolves. + */ + +import { endpoints } from '../config/endpoints'; + +export type TransactionState = + | 'DRAFT' + | 'INITIATED' + | 'PRECONDITIONS_PENDING' + | 'READY_FOR_PREPARE' + | 'PREPARED' + | 'EXECUTING' + | 'PARTIALLY_EXECUTED' + | 'VALIDATING' + | 'COMMITTED' + | 'ABORTED' + | 'UNWIND_PENDING' + | 'CLOSED'; + +export const TRANSACTION_STATES: TransactionState[] = [ + 'DRAFT', + 'INITIATED', + 'PRECONDITIONS_PENDING', + 'READY_FOR_PREPARE', + 'PREPARED', + 'EXECUTING', + 'PARTIALLY_EXECUTED', + 'VALIDATING', + 'COMMITTED', + 'ABORTED', + 'UNWIND_PENDING', + 'CLOSED', +]; + +export interface PlanSummary { + plan_id: string; + status: TransactionState; + actor_id: string | null; + created_at: string; + updated_at: string; + instrument_hint?: string; +} + +export interface StateTransition { + from_state: TransactionState | null; + to_state: TransactionState; + actor_id: string; + actor_role: string; + reason: string | null; + occurred_at: string; +} + +export interface PlanStateDetail { + plan_id: string; + current_state: TransactionState; + transitions: StateTransition[]; +} + +export interface PlanEvent { + id: number; + plan_id: string; + type: string; + payload: Record; + signature: string; + prev_hash: string | null; + created_at: string; +} + +type OrchestratorStatus = 'live' | 'mocked' | 'degraded'; + +export interface OrchestratorProbe { + status: OrchestratorStatus; + latencyMs: number | null; + error?: string; +} + +const base = endpoints.orchestrator.baseUrl; +const deployed = endpoints.orchestrator.deployed; + +async function fetchJson(path: string, init?: RequestInit): Promise { + const res = await fetch(`${base}${path}`, { + ...init, + headers: { Accept: 'application/json', ...(init?.headers ?? {}) }, + }); + if (!res.ok) { + throw new Error(`orchestrator ${res.status} on ${path}`); + } + return res.json() as Promise; +} + +export async function probeOrchestrator(): Promise { + if (!deployed) return { status: 'mocked', latencyMs: null }; + const t0 = performance.now(); + try { + const res = await fetch(`${base}/health`, { method: 'GET' }); + const latencyMs = Math.round(performance.now() - t0); + if (!res.ok) return { status: 'degraded', latencyMs, error: `HTTP ${res.status}` }; + return { status: 'live', latencyMs }; + } catch (err) { + return { + status: 'degraded', + latencyMs: null, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export async function listPlans(): Promise<{ plans: PlanSummary[]; source: OrchestratorStatus }> { + if (!deployed) return { plans: demoPlans(), source: 'mocked' }; + try { + const data = await fetchJson<{ plans: PlanSummary[] }>('/api/plans'); + return { plans: data.plans ?? [], source: 'live' }; + } catch { + return { plans: demoPlans(), source: 'degraded' }; + } +} + +export async function getPlanState( + planId: string, +): Promise<{ detail: PlanStateDetail; source: OrchestratorStatus }> { + if (!deployed) return { detail: demoPlanState(planId), source: 'mocked' }; + try { + const detail = await fetchJson(`/api/plans/${encodeURIComponent(planId)}/state`); + return { detail, source: 'live' }; + } catch { + return { detail: demoPlanState(planId), source: 'degraded' }; + } +} + +export async function getPlanEvents( + planId: string, +): Promise<{ events: PlanEvent[]; source: OrchestratorStatus }> { + if (!deployed) return { events: demoPlanEvents(planId), source: 'mocked' }; + try { + const data = await fetchJson<{ events: PlanEvent[] }>( + `/api/plans/${encodeURIComponent(planId)}/events`, + ); + return { events: data.events ?? [], source: 'live' }; + } catch { + return { events: demoPlanEvents(planId), source: 'degraded' }; + } +} + +// --------------------------------------------------------------------- +// Demo data — used only when VITE_ORCHESTRATOR_URL is unset. Lets the +// /transactions page demonstrate the 12-state machine visualisation +// without needing a deployed orchestrator. +// --------------------------------------------------------------------- + +function demoPlans(): PlanSummary[] { + const now = Date.now(); + return [ + { + plan_id: 'demo-sblc-001', + status: 'VALIDATING', + actor_id: 'ops.alice', + created_at: new Date(now - 1000 * 60 * 55).toISOString(), + updated_at: new Date(now - 1000 * 30).toISOString(), + instrument_hint: 'MT760 / EIB beneficiary format', + }, + { + plan_id: 'demo-pay-014', + status: 'COMMITTED', + actor_id: 'ops.bob', + created_at: new Date(now - 1000 * 60 * 60 * 3).toISOString(), + updated_at: new Date(now - 1000 * 60 * 7).toISOString(), + instrument_hint: 'pacs.009 FI-to-FI', + }, + { + plan_id: 'demo-sblc-003', + status: 'ABORTED', + actor_id: 'ops.alice', + created_at: new Date(now - 1000 * 60 * 60 * 8).toISOString(), + updated_at: new Date(now - 1000 * 60 * 60 * 2).toISOString(), + instrument_hint: 'MT202 COV', + }, + { + plan_id: 'demo-draft-029', + status: 'DRAFT', + actor_id: null, + created_at: new Date(now - 1000 * 60 * 4).toISOString(), + updated_at: new Date(now - 1000 * 60 * 4).toISOString(), + instrument_hint: 'Pending review', + }, + ]; +} + +function demoPlanState(planId: string): PlanStateDetail { + const plan = demoPlans().find((p) => p.plan_id === planId) ?? demoPlans()[0]; + const base = new Date(plan.created_at).getTime(); + const mk = (i: number, from: TransactionState | null, to: TransactionState, role: string, actor: string, reason: string) => ({ + from_state: from, + to_state: to, + actor_id: actor, + actor_role: role, + reason, + occurred_at: new Date(base + i * 1000 * 60 * 5).toISOString(), + }); + + if (plan.status === 'COMMITTED') { + return { + plan_id: plan.plan_id, + current_state: 'COMMITTED', + transitions: [ + mk(0, null, 'DRAFT', 'submitter', 'ops.bob', 'plan created'), + mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.bob', 'initiation'), + mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'), + mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'), + mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'), + mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'), + mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'), + mk(7, 'VALIDATING', 'COMMITTED', 'validator', 'ops.eve', 'reconciled + committed (SoD)'), + ], + }; + } + if (plan.status === 'ABORTED') { + return { + plan_id: plan.plan_id, + current_state: 'ABORTED', + transitions: [ + mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'), + mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'), + mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'), + mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'), + mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'), + mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'), + mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched'), + mk(7, 'VALIDATING', 'ABORTED', 'validator', 'ops.eve', 'amount mismatch on camt.054'), + ], + }; + } + if (plan.status === 'DRAFT') { + return { + plan_id: plan.plan_id, + current_state: 'DRAFT', + transitions: [mk(0, null, 'DRAFT', 'submitter', 'ops.frank', 'plan created')], + }; + } + return { + plan_id: plan.plan_id, + current_state: 'VALIDATING', + transitions: [ + mk(0, null, 'DRAFT', 'submitter', 'ops.alice', 'plan created'), + mk(1, 'DRAFT', 'INITIATED', 'submitter', 'ops.alice', 'initiation'), + mk(2, 'INITIATED', 'PRECONDITIONS_PENDING', 'coordinator', 'system', 'await controls'), + mk(3, 'PRECONDITIONS_PENDING', 'READY_FOR_PREPARE', 'coordinator', 'system', 'preconditions satisfied'), + mk(4, 'READY_FOR_PREPARE', 'PREPARED', 'approver', 'ops.chen', 'approve — prepare (SoD)'), + mk(5, 'PREPARED', 'EXECUTING', 'releaser', 'ops.dey', 'release — execute (SoD)'), + mk(6, 'EXECUTING', 'VALIDATING', 'coordinator', 'system', 'both legs dispatched, awaiting reconciliation'), + ], + }; +} + +function demoPlanEvents(planId: string): PlanEvent[] { + const detail = demoPlanState(planId); + return detail.transitions.map((t, i) => ({ + id: i + 1, + plan_id: planId, + type: `state.${t.to_state.toLowerCase()}`, + payload: { + from: t.from_state, + to: t.to_state, + actor_role: t.actor_role, + reason: t.reason, + }, + signature: `demo-sig-${i.toString(16).padStart(4, '0')}`, + prev_hash: i === 0 ? null : `demo-hash-${(i - 1).toString(16).padStart(4, '0')}`, + created_at: t.occurred_at, + })); +} -- 2.34.1