Files
CurrenciCombo/src/services/orchestrator.ts
nsatoshi b66ec0a78f
Some checks failed
CI / Frontend Lint (push) Failing after 6s
CI / Frontend Type Check (push) Failing after 6s
CI / Frontend Build (push) Failing after 6s
CI / Frontend E2E Tests (push) Failing after 9s
CI / Orchestrator Build (push) Failing after 6s
CI / Contracts Compile (push) Failing after 6s
CI / Contracts Test (push) Failing after 6s
Security Scan / Dependency Vulnerability Scan (push) Failing after 4s
Security Scan / OWASP ZAP Scan (push) Failing after 4s
PR G: portal /transactions page + 12-state machine view (#11)
2026-04-22 17:18:52 +00:00

282 lines
9.3 KiB
TypeScript

/**
* 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<string, unknown>;
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<T>(path: string, init?: RequestInit): Promise<T> {
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<T>;
}
export async function probeOrchestrator(): Promise<OrchestratorProbe> {
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<PlanStateDetail>(`/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,
}));
}