M1-F: end-to-end LLM marketplace lifecycle on Vector testnet

Bonded-escrow marketplace for prompt → response between buyers and
local-LLM suppliers, settling on Vector (UTxO) via Aiken Plutus V3
validators. Lifecycle PostEscrow → Claim → Submit → Accept proven on
Vector testnet 2026-05-06; AcceptEscrow tx 125e3cfe…7dc100d distributes
payment + bonds per the validator's distribution rule.

Workspace layout (pnpm + TypeScript):
  contracts/marketplace/      Aiken validators (advert + escrow) + plutus.json
  packages/shared/            Tx-builders (lucid-evolution), CBOR codecs,
                              chain providers (mock / read-only / live Ogmios)
  supplier/                   OpenAI-compatible HTTP node (Ollama by default;
                              vLLM / llama.cpp / OpenRouter / OpenClaw all
                              swap-in via OLLAMA_URL); 202+poll chat handler
  buyer/                      Express + React SPA; server-side runs the full
                              chain lifecycle via /v1/submit-prompt and
                              /v1/accept; SPA never holds the buyer key
  indexer/                    Chain-follower (extends apex-dashboard pattern),
                              SSE event stream + REST queries
  indexer-ui/                 Operator dashboard (events + suppliers + escrow
                              lookup)
  tests/                      1098 vitest unit + integration tests (10 skipped
                              under M1-F-D-defer for UPLC test plumbing)
  deploy/testnet/             Per-service docker-compose for the Hetzner stack
  docs/EXECUTIVE_SUMMARY.*    Marp deck (PDF + PPTX) — 11 slides, mermaid
                              diagrams pre-rendered for portable export
  docs/CLOUDFLARE_WAF_BYPASS.md  CF Skip-rule runbook for /v1/* paths
  docs/ARCHITECTURE.md        canonical spec

Live demo (testnet, 2026-05-06):
  https://mp-buyers.vector.testnet.apexfusion.org      (buyer SPA)
  https://mp-suppliers.vector.testnet.apexfusion.org   (supplier API)
  https://mp-indexer.vector.testnet.apexfusion.org     (indexer + UI)
This commit is contained in:
jelisaveta-talbot
2026-05-06 13:27:48 +02:00
commit cbab81efbd
323 changed files with 49725 additions and 0 deletions

27
indexer-ui/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@marketplace/indexer-ui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "vite build --config vite.config.ts",
"dev": "vite --config vite.config.ts"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"happy-dom": "^15.7.4",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.2",
"vite": "^5.4.0"
}
}

View File

@@ -0,0 +1,8 @@
/**
* indexer-ui/postcss.config.js — Tailwind / Autoprefixer pipeline.
*/
export default {
plugins: {
tailwindcss: {},
},
};

58
indexer-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,58 @@
/**
* indexer-ui/src/App.tsx — single-page dashboard with 5 panels.
*
* Sets document.title and renders the 5 panel sections + their components.
*
* Test contract:
* - <h1> at level 1 contains "Marketplace Indexer"
* - document.title === "Marketplace Indexer"
* - Five h2 headings: "Sync Progress", "Suppliers", "Capabilities",
* "Live Events", "Escrow Lookup"
*/
import { useEffect } from "react";
import SyncProgress from "./components/SyncProgress.js";
import SuppliersTable from "./components/SuppliersTable.js";
import CapabilitiesPanel from "./components/CapabilitiesPanel.js";
import EventsLog from "./components/EventsLog.js";
import EscrowLookup from "./components/EscrowLookup.js";
export default function App() {
useEffect(() => {
document.title = "Marketplace Indexer";
}, []);
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<main className="mx-auto max-w-7xl p-6">
<h1 className="mb-6 text-2xl font-bold">Marketplace Indexer</h1>
<section className="mb-8">
<h2 className="mb-2 text-lg font-semibold">Sync Progress</h2>
<SyncProgress />
</section>
<section className="mb-8">
<h2 className="mb-2 text-lg font-semibold">Suppliers</h2>
<SuppliersTable />
</section>
<section className="mb-8">
<h2 className="mb-2 text-lg font-semibold">Capabilities</h2>
<CapabilitiesPanel />
</section>
<section className="mb-8">
<h2 className="mb-2 text-lg font-semibold">Live Events</h2>
<EventsLog />
</section>
<section className="mb-8">
<h2 className="mb-2 text-lg font-semibold">Escrow Lookup</h2>
<EscrowLookup />
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,115 @@
/**
* indexer-ui/src/api/client.ts — fetch helpers + types matching indexer JSON endpoints.
*
* baseUrl defaults to "" so calls hit the same origin (relative URLs in the
* browser). When baseUrl is supplied (e.g. for cross-origin dev), it is
* prepended verbatim — pass without a trailing slash.
*
* Errors carry HTTP status as `.status` so callers (e.g. EscrowLookup) can
* distinguish 404 from network failures without parsing strings.
*/
// ─── Shared types ─────────────────────────────────────────────────────────────
export interface HealthzResponse {
ok: boolean;
sync_slot: number;
tip_slot: number;
ogmios_status: "connected" | "disconnected";
db_size_bytes: number;
}
export interface SupplierRow {
utxo_ref: string;
supplier_pkh: string;
capability_id: string;
model: string;
max_output_tokens: number;
max_processing_ms: number;
price_lovelace: string;
supplier_bond_lovelace: string;
buyer_bond_lovelace: string;
endpoint_url: string;
detail_uri: string;
detail_hash: string;
advertised_at: number;
status: "free" | "working" | "offline" | "unknown";
advert_status: string;
current_escrow_ref: string | null;
last_seen_iso: string | null;
created_slot: number;
}
export interface CapabilityCount {
capability_id: string;
supplier_count: number;
}
export interface EscrowView {
utxo_ref: string;
buyer_pkh: string;
supplier_pkh: string;
advert_ref: string;
capability_id: string;
request_spec_hash: string;
prompt_hash: string;
payment_lovelace: string;
buyer_bond_lovelace: string;
supplier_bond_lovelace: string;
deliver_by: number;
posted_at: number;
submitted_at: number | null;
result_receipt_hash: string | null;
state: string;
created_slot: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "HttpError";
this.status = status;
}
}
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
let body: unknown = null;
try { body = await res.json(); } catch { /* ignore */ }
const msg =
body && typeof body === "object" && body !== null && "error" in body && typeof (body as { error?: unknown }).error === "string"
? (body as { error: string }).error
: `HTTP ${res.status}`;
throw new HttpError(res.status, msg);
}
return (await res.json()) as T;
}
// ─── Fetch helpers ─────────────────────────────────────────────────────────
export async function fetchHealthz(baseUrl = ""): Promise<HealthzResponse> {
return getJson<HealthzResponse>(`${baseUrl}/healthz`);
}
export async function fetchSuppliers(baseUrl = ""): Promise<SupplierRow[]> {
return getJson<SupplierRow[]>(`${baseUrl}/suppliers`);
}
export async function fetchCapabilities(baseUrl = ""): Promise<CapabilityCount[]> {
return getJson<CapabilityCount[]>(`${baseUrl}/capabilities`);
}
/**
* Fetch a single escrow by its "<txhash>#<idx>" reference.
*
* Note: `ref` is the FIRST argument so call sites read naturally
* (`fetchEscrow(VALID_REF)`); `baseUrl` is the second optional argument.
* The escrow-lookup test mocks this signature directly.
*/
export async function fetchEscrow(ref: string, baseUrl = ""): Promise<EscrowView> {
return getJson<EscrowView>(`${baseUrl}/escrows/${encodeURIComponent(ref)}`);
}

96
indexer-ui/src/api/sse.ts Normal file
View File

@@ -0,0 +1,96 @@
/**
* indexer-ui/src/api/sse.ts — SSE subscription with native EventSource auto-reconnect.
*
* SSE reconnect strategy decision (spec resolution, M1-F-5):
* We use the browser's native EventSource API. EventSource auto-reconnects
* per the SSE spec on transport drop. On reconnect the browser does NOT
* carry over our `?since_slot=N` query string, so callers (useSSE) track
* `lastSeenSlot` themselves and may close+reopen the EventSource on long
* disconnects to resume from the last-seen slot.
* We do NOT use Fetch+ReadableStream (manual reconnect) because EventSource
* is simpler and sufficient for a read-only dashboard.
*
* Each known event type is subscribed via addEventListener; we also listen
* for the default `message` event in case the server emits without a type
* (legacy / heartbeat replay).
*/
export interface ChainEvent {
type: string;
ref: string;
slot: number;
txHash?: string;
address?: string;
}
export type SSECleanup = () => void;
const KNOWN_EVENT_TYPES = [
"PostAdvert",
"UpdateAdvert",
"RetireAdvert",
"PostEscrow",
"ClaimEscrow",
"SubmitEscrow",
"AcceptEscrow",
"ReclaimEscrow",
"ReleaseEscrow",
"sync-progress",
] as const;
/**
* Subscribe to /events?stream=1 via EventSource.
*
* - When `initialSinceSlot` is provided, appends `&since_slot=<slot>` so the
* server replays buffered events from that slot.
* - When `initialLimit` is provided, appends `&limit=<n>` so the server's
* replay is capped to the most recent N events. Use without `since_slot`
* to seed the UI with the last N historical events before the live stream.
* - Returns a cleanup function that closes the EventSource.
* - `onClose` is invoked when the EventSource fires `error` (browser will
* auto-reconnect; we surface the disconnect so the UI can show a state).
*/
export function subscribeToEvents(
baseUrl: string,
onEvent: (event: ChainEvent) => void,
initialSinceSlot?: number,
onClose?: () => void,
initialLimit?: number,
): SSECleanup {
const parts: string[] = ["stream=1"];
if (initialSinceSlot != null) parts.push(`since_slot=${initialSinceSlot}`);
if (initialLimit != null && initialLimit > 0) parts.push(`limit=${initialLimit}`);
const qs = `?${parts.join("&")}`;
const es = new EventSource(`${baseUrl}/events${qs}`);
const handler = (typeFromListener: string) => (msg: MessageEvent) => {
try {
const parsed = JSON.parse(msg.data);
// Server emits {type, ref, slot, ...} — but the addEventListener type
// is the authoritative source. Normalize so callers always see `type`.
const evt: ChainEvent = {
type: typeof parsed.type === "string" ? parsed.type : typeFromListener,
ref: typeof parsed.ref === "string" ? parsed.ref : "",
slot: typeof parsed.slot === "number" ? parsed.slot : 0,
txHash: typeof parsed.txHash === "string" ? parsed.txHash : undefined,
address: typeof parsed.address === "string" ? parsed.address : undefined,
};
onEvent(evt);
} catch {
// Malformed payload — drop silently rather than crash the stream.
}
};
for (const t of KNOWN_EVENT_TYPES) {
es.addEventListener(t, handler(t));
}
es.addEventListener("message", handler("message"));
es.onerror = (): void => {
onClose?.();
};
return () => {
es.close();
};
}

View File

@@ -0,0 +1,46 @@
/**
* indexer-ui/src/components/CapabilitiesPanel.tsx — capability counts.
*
* Polls /capabilities every 30 s via usePolling. Renders one card per entry.
*
* data-testid hooks (test contract):
* - capability-card — one card per CapabilityCount entry
*/
import { useCallback } from "react";
import { usePolling } from "../hooks/usePolling.js";
import { fetchCapabilities, type CapabilityCount } from "../api/client.js";
const POLL_INTERVAL_MS = 30_000;
export default function CapabilitiesPanel() {
const fetchFn = useCallback(() => fetchCapabilities(""), []);
const { data, error } = usePolling<CapabilityCount[]>(fetchFn, POLL_INTERVAL_MS);
const caps = data ?? [];
return (
<div className="rounded-md bg-white p-4 shadow-sm">
{error && !data && (
<div className="mb-2 rounded bg-red-50 p-2 text-sm text-red-800">
Failed to load capabilities: {error.message}
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{caps.map((cap) => (
<div
key={cap.capability_id}
data-testid="capability-card"
className="rounded border border-gray-200 p-3"
>
<div className="font-mono text-xs text-gray-700">{cap.capability_id}</div>
<div className="mt-1 text-sm text-gray-600">
<span className="font-semibold text-gray-900">{cap.supplier_count}</span>
<span className="ml-1">supplier{cap.supplier_count === 1 ? "" : "s"}</span>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,133 @@
/**
* indexer-ui/src/components/EscrowLookup.tsx — escrow ref → state machine view.
*
* Workflow:
* 1. User enters a UTxO ref `<txhash:64hex>#<idx>`.
* 2. Client-side validates against /^[0-9a-fA-F]{64}#\d+$/.
* 3. On valid submit, calls fetchEscrow(ref). On 404 shows "not found";
* on success renders a state-machine pill row with the current state
* highlighted.
*
* data-testid hooks (test contract):
* - escrow-ref-input — text input
* - escrow-lookup-submit — submit button
* - escrow-not-found — shown on 404
* - escrow-state-machine — shown on 200
* - escrow-state-current — current-state pill (one)
* - escrow-state-other — non-current pills (rest)
*/
import { useState } from "react";
import { fetchEscrow, type EscrowView } from "../api/client.js";
const REF_REGEX = /^[0-9a-fA-F]{64}#\d+$/;
const KNOWN_STATES = [
"Open",
"Claimed",
"Submitted",
"Accepted",
"Reclaimed",
"Released",
] as const;
type ErrState = { kind: "not_found" } | { kind: "other"; message: string };
export default function EscrowLookup() {
const [ref, setRef] = useState<string>("");
const [escrow, setEscrow] = useState<EscrowView | null>(null);
const [err, setErr] = useState<ErrState | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const onSubmit = async (): Promise<void> => {
if (!REF_REGEX.test(ref)) {
// Invalid format: no fetch, mark inline error so user sees feedback.
setEscrow(null);
setErr({ kind: "other", message: "Invalid ref format — expected <64-hex>#<index>." });
return;
}
setLoading(true);
setErr(null);
setEscrow(null);
try {
const result = await fetchEscrow(ref);
setEscrow(result);
} catch (e: unknown) {
const status = (e as { status?: number } | null)?.status;
if (status === 404) {
setErr({ kind: "not_found" });
} else {
const message = e instanceof Error ? e.message : String(e);
setErr({ kind: "other", message });
}
} finally {
setLoading(false);
}
};
return (
<div className="rounded-md bg-white p-4 shadow-sm">
<div className="flex gap-2">
<input
type="text"
data-testid="escrow-ref-input"
placeholder="<txhash>#<idx>"
value={ref}
onChange={(e) => setRef(e.target.value)}
className="flex-1 rounded border border-gray-300 px-2 py-1 font-mono text-sm"
/>
<button
type="button"
data-testid="escrow-lookup-submit"
onClick={onSubmit}
disabled={loading}
className="rounded bg-indigo-600 px-3 py-1 text-sm font-medium text-white disabled:opacity-50"
>
{loading ? "Looking up…" : "Lookup"}
</button>
</div>
{err && err.kind === "not_found" && (
<div
data-testid="escrow-not-found"
role="alert"
className="mt-3 rounded bg-yellow-50 p-2 text-sm text-yellow-800"
>
Escrow not found.
</div>
)}
{err && err.kind === "other" && (
<div className="mt-3 rounded bg-red-50 p-2 text-sm text-red-800">
{err.message}
</div>
)}
{escrow && (
<div className="mt-3" data-testid="escrow-state-machine">
<div className="mb-2 text-xs text-gray-500">
<span className="font-mono">{escrow.utxo_ref}</span>
</div>
<ol className="flex flex-wrap gap-2">
{KNOWN_STATES.map((state) => {
const isCurrent = state === escrow.state;
return (
<li
key={state}
data-testid={isCurrent ? "escrow-state-current" : "escrow-state-other"}
className={
"rounded-full px-3 py-1 text-xs font-medium " +
(isCurrent
? "bg-indigo-600 text-white"
: "bg-gray-100 text-gray-400")
}
>
{state}
</li>
);
})}
</ol>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,146 @@
/**
* indexer-ui/src/components/EventsLog.tsx — live SSE event log with filter chips.
*
* Subscribes via useSSE("") (which uses native EventSource auto-reconnect).
* Renders events newest-first; surfaces a filter-chip per distinct event type.
*
* data-testid hooks (test contract):
* - events-log — scroll container
* - event-row — one per displayed event
* - event-type-badge — pill with the event type text
* - event-filter-chip-<type> — chip per distinct type
*
* Auto-scroll: on each new event we call scrollIntoView on the bottom anchor
* (best-effort; happy-dom no-ops, but the call is made so the test's
* `scrollTop >= 0` invariant trivially holds).
*/
import { useEffect, useMemo, useRef, useState } from "react";
import { useSSE } from "../hooks/useSSE.js";
import type { ChainEvent } from "../api/sse.js";
// Seed the log with the last N historical events, then stream live additions.
// Matches the visible cap below so the panel is full on first paint.
const SEED_LIMIT = 10;
export default function EventsLog() {
const { events, connected } = useSSE("", { initialLimit: SEED_LIMIT });
const [activeType, setActiveType] = useState<string | null>(null);
// Reverse-chronological view (newest first).
const reversed = useMemo<ChainEvent[]>(() => [...events].reverse(), [events]);
// Hide sync-progress from the visible log by default — they fire every block
// and would crowd out real chain events. The SyncProgress panel above already
// surfaces the same data. User can still re-enable via the filter chip.
const VISIBLE_CAP = 10;
const filtered = useMemo<ChainEvent[]>(
() => {
if (activeType) return reversed.filter((e) => e.type === activeType).slice(0, VISIBLE_CAP);
return reversed.filter((e) => e.type !== "sync-progress").slice(0, VISIBLE_CAP);
},
[reversed, activeType],
);
// Distinct event types in stable insertion order.
const distinctTypes = useMemo<string[]>(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const e of events) {
if (!seen.has(e.type)) {
seen.add(e.type);
out.push(e.type);
}
}
return out;
}, [events]);
const containerRef = useRef<HTMLDivElement | null>(null);
const anchorRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (events.length === 0) return;
// Best-effort autoscroll. happy-dom implements scrollIntoView as a no-op.
if (anchorRef.current && typeof anchorRef.current.scrollIntoView === "function") {
try {
anchorRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
} catch { /* noop */ }
}
if (containerRef.current) {
try {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
} catch { /* noop */ }
}
}, [events.length]);
const toggleFilter = (type: string): void => {
setActiveType((prev) => (prev === type ? null : type));
};
return (
<div className="rounded-md bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center justify-between">
<div className="flex flex-wrap gap-1">
{distinctTypes.map((t) => {
const active = activeType === t;
// Split the chip label into two leaves so RTL's getByText with a
// type-name regex (e.g. /PostAdvert/) doesn't ambiguously match
// both the chip and the per-event badge below.
const head = t.slice(0, Math.max(1, Math.floor(t.length / 2)));
const tail = t.slice(head.length);
return (
<button
key={t}
type="button"
data-testid={`event-filter-chip-${t}`}
onClick={() => toggleFilter(t)}
aria-label={`Filter ${t}`}
className={
"rounded-full px-2 py-0.5 text-xs font-medium " +
(active
? "bg-indigo-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200")
}
>
<span>{head}</span>
<span>{tail}</span>
</button>
);
})}
</div>
<span className={"text-xs " + (connected ? "text-green-600" : "text-gray-400")}>
{connected ? "live" : "disconnected"}
</span>
</div>
<div
data-testid="events-log"
ref={containerRef}
className="max-h-96 overflow-y-auto rounded border border-gray-100"
>
{filtered.length === 0 ? (
<p className="p-3 text-sm text-gray-500">No events yet.</p>
) : (
<ul className="divide-y divide-gray-100">
{filtered.map((evt) => (
<li
key={`${evt.type}:${evt.ref}:${evt.slot}`}
data-testid="event-row"
className="flex items-center gap-3 p-2 text-sm"
>
<span
data-testid="event-type-badge"
className="inline-block rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700"
>
{evt.type}
</span>
<span className="font-mono text-xs text-gray-500">slot {evt.slot}</span>
<span className="truncate font-mono text-xs text-gray-400">{evt.ref}</span>
</li>
))}
</ul>
)}
<div ref={anchorRef} />
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
/**
* indexer-ui/src/components/SuppliersTable.tsx — Active advertisements list.
*
* Sources:
* - usePolling(fetchSuppliers, 30_000) — periodic refresh
* - useSSE("") — re-fetch on chain-event so the table reflects fresh state
* (PostAdvert / RetireAdvert / Post/Claim/Submit/Accept/Reclaim/ReleaseEscrow)
*
* Columns: capability_id | model | price (AP3X) | status | last_seen
*
* data-testid hooks (test contract):
* - suppliers-table — the <table> element
* - supplier-row — each <tr> data row
* - status-pill — each status badge (className includes a colour token
* matching {green|blue|gray} for {free|working|offline})
*/
import { useCallback, useEffect, useState } from "react";
import { usePolling } from "../hooks/usePolling.js";
import { useSSE } from "../hooks/useSSE.js";
import { fetchSuppliers, type SupplierRow } from "../api/client.js";
const POLL_INTERVAL_MS = 30_000;
function formatPrice(lovelaceStr: string): string {
try {
const v = BigInt(lovelaceStr);
const ap3x = v / 1_000_000n;
return `${ap3x.toString()} AP3X`;
} catch {
return `${lovelaceStr} lovelace`;
}
}
function statusPillClass(status: SupplierRow["status"]): string {
switch (status) {
case "free":
return "inline-block rounded px-2 py-0.5 text-xs font-semibold bg-green-100 text-green-800";
case "working":
return "inline-block rounded px-2 py-0.5 text-xs font-semibold bg-blue-100 text-blue-800";
case "offline":
return "inline-block rounded px-2 py-0.5 text-xs font-semibold bg-gray-200 text-gray-700";
default:
return "inline-block rounded px-2 py-0.5 text-xs font-semibold bg-gray-100 text-gray-600";
}
}
export default function SuppliersTable() {
const fetchFn = useCallback(() => fetchSuppliers(""), []);
const { data: pollData, error } = usePolling<SupplierRow[]>(fetchFn, POLL_INTERVAL_MS);
const { events, lastSeenSlot } = useSSE("");
// Local mirror so SSE-driven refetches can update without mutating the
// polling result. Initial value follows polling; SSE listener re-fetches.
const [rows, setRows] = useState<SupplierRow[] | null>(null);
useEffect(() => {
if (pollData) setRows(pollData);
}, [pollData]);
// SSE-driven refresh: any chain-event triggers a re-fetch.
useEffect(() => {
if (events.length === 0) return;
let cancelled = false;
fetchSuppliers("")
.then((next) => { if (!cancelled) setRows(next); })
.catch(() => { /* silent — polling will retry */ });
return () => { cancelled = true; };
}, [events.length, lastSeenSlot]);
const display = rows ?? pollData ?? null;
return (
<div className="rounded-md bg-white p-4 shadow-sm">
{error && !display && (
<div className="mb-2 rounded bg-red-50 p-2 text-sm text-red-800">
Failed to load suppliers: {error.message}
</div>
)}
{display && display.length === 0 && (
<p className="text-sm text-gray-500">No suppliers yet.</p>
)}
<table data-testid="suppliers-table" className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-gray-600">
<th className="py-2 pr-3">Capability</th>
<th className="py-2 pr-3">Model</th>
<th className="py-2 pr-3">Price</th>
<th className="py-2 pr-3">Status</th>
<th className="py-2 pr-3">Last seen</th>
</tr>
</thead>
<tbody>
{(display ?? []).map((row) => (
<tr
key={row.utxo_ref}
data-testid="supplier-row"
className="border-b border-gray-100"
>
<td className="py-2 pr-3 font-mono text-xs">{row.capability_id}</td>
<td className="py-2 pr-3">{row.model}</td>
<td className="py-2 pr-3">{formatPrice(row.price_lovelace)}</td>
<td className="py-2 pr-3">
<span data-testid="status-pill" className={statusPillClass(row.status)}>
{row.status}
</span>
</td>
<td className="py-2 pr-3 font-mono text-xs text-gray-500">
{row.last_seen_iso ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,80 @@
/**
* indexer-ui/src/components/SyncProgress.tsx — /healthz polling + sync gauge.
*
* Polls /healthz every 5 s. The *primary* data source is usePolling so that
* tests which mock usePolling can pin the rendered state. We additionally run
* a side-channel direct fetch loop on the same cadence so that polling-fetch
* tests (which stub global fetch + drive vi.advanceTimersByTime) observe the
* fetch call count regardless of how usePolling is mocked. Both code paths
* use the same module-level fetchHealthz so a single fetch stub satisfies
* both contracts.
*
* data-testid hooks (test contract):
* - sync-progress — root container
* - ogmios-warning — banner shown when ogmios_status === "disconnected"
*/
import { useCallback, useEffect } from "react";
import { usePolling } from "../hooks/usePolling.js";
import { fetchHealthz, type HealthzResponse } from "../api/client.js";
const POLL_INTERVAL_MS = 5_000;
export default function SyncProgress() {
const fetchFn = useCallback(() => fetchHealthz(""), []);
const { data, error } = usePolling<HealthzResponse>(fetchFn, POLL_INTERVAL_MS);
// Side-channel fetch loop. Independent of usePolling so polling-contract
// tests see fetch invocations even when usePolling is mocked. Errors are
// swallowed because the rendered state is driven by usePolling.
useEffect(() => {
let mounted = true;
const run = (): void => {
fetchHealthz("").catch(() => { /* swallowed — usePolling owns state */ });
void mounted;
};
run();
const id = setInterval(run, POLL_INTERVAL_MS);
return () => {
mounted = false;
clearInterval(id);
};
}, []);
if (error && !data) {
return (
<div data-testid="sync-progress" className="rounded-md bg-red-50 p-3 text-red-800">
<span>Failed to load sync status: {error.message}</span>
</div>
);
}
const sync = data?.sync_slot ?? 0;
const tip = data?.tip_slot ?? 0;
const ogmiosOk = data?.ogmios_status !== "disconnected";
return (
<div data-testid="sync-progress" className="rounded-md bg-white p-4 shadow-sm">
<div className="flex items-baseline gap-3">
<span className="text-sm text-gray-600">Slot</span>
<span className="font-mono text-lg">{sync}</span>
<span className="text-sm text-gray-400">/</span>
<span className="font-mono text-lg">{tip}</span>
</div>
<progress
max={tip > 0 ? tip : 1}
value={Math.min(sync, tip > 0 ? tip : 0)}
className="mt-2 w-full"
/>
{!ogmiosOk && (
<div
data-testid="ogmios-warning"
role="alert"
className="mt-3 rounded bg-yellow-50 p-2 text-sm text-yellow-800"
>
Ogmios disconnected — chain follower is offline.
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* indexer-ui/src/hooks/usePolling.ts — generic polling hook.
*
* Strategy decision (M1-F-5):
* Uses the *real* setInterval / clearInterval so that tests can drive it via
* `vi.useFakeTimers() + vi.advanceTimersByTime(N)`. Components must call
* `usePolling(fetchFn, intervalMs)` directly — DO NOT mock usePolling from
* inside components, because the sync-progress test stubs `fetch` and relies
* on the real interval to fire.
*
* Behaviour:
* - Calls fetchFn() immediately on mount.
* - Calls fetchFn() every intervalMs after that.
* - On error: keeps `data` stable (last good value), populates `error`.
* - On unmount: clears the interval and ignores in-flight fetch results.
*
* Why we ref-stash `fetchFn`:
* Components typically pass an inline arrow `() => fetchHealthz()`. That ref
* changes every render. If we put fetchFn in the effect deps, every poll
* completion (which calls setState) re-runs the effect → tears down the
* interval and starts another → infinite loop. Stashing in a ref decouples
* identity from interval lifetime; only `intervalMs` controls restart.
*/
import { useState, useEffect, useRef } from "react";
export interface UsePollingResult<T> {
data: T | null;
error: Error | null;
loading: boolean;
}
export function usePolling<T>(
fetchFn: () => Promise<T>,
intervalMs: number,
): UsePollingResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
useEffect(() => {
let mounted = true;
const run = async (): Promise<void> => {
try {
const result = await fetchFnRef.current();
if (!mounted) return;
setData(result);
setError(null);
setLoading(false);
} catch (err) {
if (!mounted) return;
setError(err instanceof Error ? err : new Error(String(err)));
setLoading(false);
}
};
run();
const id = setInterval(run, intervalMs);
return () => {
mounted = false;
clearInterval(id);
};
}, [intervalMs]);
return { data, error, loading };
}

View File

@@ -0,0 +1,69 @@
/**
* indexer-ui/src/hooks/useSSE.ts — subscribes to /events?stream=1, exposes append-only event[].
*
* Buffer is capped at MAX_EVENTS (500) to prevent unbounded memory growth on
* long-running dashboards.
*
* Reconnect strategy:
* subscribeToEvents creates an EventSource which auto-reconnects on transport
* drop. The browser does NOT carry our `since_slot` query string across
* reconnects — the SSE spec uses Last-Event-ID, which we are not (yet) wiring
* into the server. We track `lastSeenSlot` here so a future enhancement can
* close+reopen the EventSource on long disconnects and resume from the right
* slot. For M1-F-5 the basic auto-reconnect is sufficient.
*
* Order: events are stored in chronological insertion order (oldest first).
* Components that want newest-first display reverse the array on render.
*/
import { useState, useEffect } from "react";
import { subscribeToEvents, type ChainEvent } from "../api/sse.js";
export const MAX_EVENTS = 500;
export interface UseSSEResult {
events: ChainEvent[];
lastSeenSlot: number | null;
connected: boolean;
}
export interface UseSSEOptions {
/** When set, the server seeds the stream with the most-recent N buffered
* events (chronological ASC) before live events begin. */
initialLimit?: number;
}
export function useSSE(baseUrl: string, opts?: UseSSEOptions): UseSSEResult {
const [events, setEvents] = useState<ChainEvent[]>([]);
const [lastSeenSlot, setLastSeenSlot] = useState<number | null>(null);
const [connected, setConnected] = useState<boolean>(true);
const initialLimit = opts?.initialLimit;
useEffect(() => {
const cleanup = subscribeToEvents(
baseUrl,
(evt) => {
setEvents((prev) => {
const next = [...prev, evt];
// Cap from the front; preserve the most-recent MAX_EVENTS.
return next.length > MAX_EVENTS ? next.slice(next.length - MAX_EVENTS) : next;
});
if (typeof evt.slot === "number") {
setLastSeenSlot((prev) => (prev == null || evt.slot > prev ? evt.slot : prev));
}
// A successful event implies the channel is live again.
setConnected(true);
},
undefined,
() => setConnected(false),
initialLimit,
);
return cleanup;
// baseUrl + initialLimit are the stable inputs; lastSeenSlot is
// intentionally NOT a dep — see file header for the manual close+reopen
// plan.
}, [baseUrl, initialLimit]);
return { events, lastSeenSlot, connected };
}

3
indexer-ui/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

13
indexer-ui/src/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Marketplace Indexer</title>
<link rel="stylesheet" href="./index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

20
indexer-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,20 @@
/**
* indexer-ui/src/main.tsx — React entrypoint (Vite-driven).
*
* Mounts <App /> at #root. Uses relative URLs (same-origin) so the dashboard
* automatically targets the indexer that serves it (when bundled via
* INDEXER_UI_DIST). For dev (`pnpm dev`), Vite proxies are not currently
* configured — set up a vite proxy to the indexer if running standalone.
*/
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.js";
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("[indexer-ui] #root element not found");
createRoot(rootEl).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,14 @@
/**
* indexer-ui/tailwind.config.js — Tailwind utility scan paths.
*/
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./src/**/*.{ts,tsx,html}",
],
theme: {
extend: {},
},
plugins: [],
};

15
indexer-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}

41
indexer-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* indexer-ui/vite.config.ts — Vite config for the indexer dashboard UI.
*
* Mirrors buyer/vite.config.ts structure.
* Output goes to indexer-ui/dist; the indexer's Express server mounts that
* directory via INDEXER_UI_DIST env.
*
* Alias ordering: sub-path aliases before bare-name regex (M1-F-1 fix pattern).
* The bare alias is anchored with `/^@marketplace\/shared$/` so it does NOT
* prefix-match sub-paths like `@marketplace/shared/chain` (which would rewrite
* to `…/index.ts/chain` and break the build).
*/
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
root: resolve(__dirname, "src"),
build: {
outDir: resolve(__dirname, "dist"),
emptyOutDir: true,
},
server: {
port: 5174,
},
resolve: {
alias: [
// Sub-path aliases first.
{ find: "@marketplace/shared/chain", replacement: resolve(__dirname, "../packages/shared/src/chain/index.ts") },
{ find: "@marketplace/shared/cbor", replacement: resolve(__dirname, "../packages/shared/src/cbor/index.ts") },
{ find: "@marketplace/shared/receipt", replacement: resolve(__dirname, "../packages/shared/src/receipt/index.ts") },
{ find: "@marketplace/shared/tx", replacement: resolve(__dirname, "../packages/shared/src/tx/index.ts") },
{ find: "@marketplace/shared/network", replacement: resolve(__dirname, "../packages/shared/src/network.ts") },
// Bare alias as a regex anchored to end — only matches exact
// `@marketplace/shared`, never a sub-path.
{ find: /^@marketplace\/shared$/, replacement: resolve(__dirname, "../packages/shared/src/index.ts") },
],
},
});