PR G: portal /transactions page + 12-state machine view (#11)
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

This commit was merged in pull request #11.
This commit is contained in:
2026-04-22 17:18:52 +00:00
parent 3ef71332dc
commit b66ec0a78f
7 changed files with 714 additions and 2 deletions

View File

@@ -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 <span className={`source-badge source-badge--${source}`}>{label}</span>;
}
export default function TransactionsPage() {
const { planId } = useParams<{ planId?: string }>();
return planId ? <TransactionDetail planId={planId} /> : <TransactionsList />;
}
function TransactionsList() {
const navigate = useNavigate();
const [plans, setPlans] = useState<PlanSummary[] | null>(null);
const [source, setSource] = useState<Source>('mocked');
const [error, setError] = useState<string | null>(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 (
<div className="transactions-page">
<div className="page-header">
<h1>Transactions</h1>
<p className="page-subtitle">
Multi-layer atomic settlement plans. State machine per architecture note §8.
{!endpoints.orchestrator.deployed && (
<span className="muted">
{' '}Orchestrator not deployed showing demo plans.
</span>
)}
</p>
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Recent plans</h3>
<SourceBadge source={source} />
</div>
{error && <div className="error-banner">{error}</div>}
{plans === null ? (
<div className="loading-row">Loading</div>
) : plans.length === 0 ? (
<div className="empty-row">No plans yet.</div>
) : (
<table className="portal-table" data-testid="transactions-table">
<thead>
<tr>
<th>Plan ID</th>
<th>State</th>
<th>Instrument</th>
<th>Owner</th>
<th>Updated</th>
<th aria-label="open" />
</tr>
</thead>
<tbody>
{plans.map((p) => (
<tr
key={p.plan_id}
className="portal-table-row"
onClick={() => navigate(`/transactions/${encodeURIComponent(p.plan_id)}`)}
data-testid={`plan-row-${p.plan_id}`}
>
<td className="mono">{p.plan_id}</td>
<td>
<span className={`state-chip state-chip--${p.status.toLowerCase()}`}>
{p.status.replace(/_/g, ' ')}
</span>
</td>
<td>{p.instrument_hint ?? '—'}</td>
<td>{p.actor_id ?? '—'}</td>
<td>{new Date(p.updated_at).toLocaleString()}</td>
<td className="row-chevron"></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
function TransactionDetail({ planId }: { planId: string }) {
const navigate = useNavigate();
const [detail, setDetail] = useState<PlanStateDetail | null>(null);
const [events, setEvents] = useState<PlanEvent[] | null>(null);
const [source, setSource] = useState<Source>('mocked');
const [error, setError] = useState<string | null>(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 (
<div className="transactions-page">
<div className="page-header">
<button className="back-button" onClick={() => navigate('/transactions')}>
All transactions
</button>
<h1>
Plan <span className="mono">{planId}</span>
</h1>
<p className="page-subtitle">
{detail ? (
<>Current state: <strong>{detail.current_state.replace(/_/g, ' ')}</strong></>
) : (
'Loading plan state…'
)}
</p>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="dashboard-card">
<div className="card-header">
<h3>12-state machine</h3>
<SourceBadge source={source} />
</div>
{detail ? (
<StateMachineView current={detail.current_state} transitions={detail.transitions} />
) : (
<div className="loading-row">Loading</div>
)}
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Audit trail</h3>
</div>
{detail === null ? (
<div className="loading-row">Loading</div>
) : detail.transitions.length === 0 ? (
<div className="empty-row">No transitions recorded.</div>
) : (
<table className="portal-table" data-testid="audit-trail">
<thead>
<tr>
<th>#</th>
<th>From To</th>
<th>Actor</th>
<th>Role</th>
<th>Reason</th>
<th>At</th>
</tr>
</thead>
<tbody>
{detail.transitions.map((t, i) => (
<tr key={i}>
<td>{i + 1}</td>
<td className="mono">
{t.from_state ?? '∅'} {t.to_state}
</td>
<td>{t.actor_id}</td>
<td>
<span className={`role-chip role-chip--${t.actor_role}`}>{t.actor_role}</span>
</td>
<td>{t.reason ?? '—'}</td>
<td>{new Date(t.occurred_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="dashboard-card">
<div className="card-header">
<h3>Signed event stream</h3>
</div>
{events === null ? (
<div className="loading-row">Loading</div>
) : events.length === 0 ? (
<div className="empty-row">No events.</div>
) : (
<table className="portal-table" data-testid="event-stream">
<thead>
<tr>
<th>#</th>
<th>Type</th>
<th>Signature</th>
<th>Prev hash</th>
<th>At</th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.id}>
<td>{e.id}</td>
<td className="mono">{e.type}</td>
<td className="mono truncate">{e.signature}</td>
<td className="mono truncate">{e.prev_hash ?? '∅'}</td>
<td>{new Date(e.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}