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
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
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>
|
||
);
|
||
}
|