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:
27
indexer-ui/package.json
Normal file
27
indexer-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
indexer-ui/postcss.config.js
Normal file
8
indexer-ui/postcss.config.js
Normal 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
58
indexer-ui/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
indexer-ui/src/api/client.ts
Normal file
115
indexer-ui/src/api/client.ts
Normal 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
96
indexer-ui/src/api/sse.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
46
indexer-ui/src/components/CapabilitiesPanel.tsx
Normal file
46
indexer-ui/src/components/CapabilitiesPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
indexer-ui/src/components/EscrowLookup.tsx
Normal file
133
indexer-ui/src/components/EscrowLookup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
indexer-ui/src/components/EventsLog.tsx
Normal file
146
indexer-ui/src/components/EventsLog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
indexer-ui/src/components/SuppliersTable.tsx
Normal file
117
indexer-ui/src/components/SuppliersTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
indexer-ui/src/components/SyncProgress.tsx
Normal file
80
indexer-ui/src/components/SyncProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
indexer-ui/src/hooks/usePolling.ts
Normal file
71
indexer-ui/src/hooks/usePolling.ts
Normal 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 };
|
||||
}
|
||||
69
indexer-ui/src/hooks/useSSE.ts
Normal file
69
indexer-ui/src/hooks/useSSE.ts
Normal 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
3
indexer-ui/src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
13
indexer-ui/src/index.html
Normal file
13
indexer-ui/src/index.html
Normal 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
20
indexer-ui/src/main.tsx
Normal 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>,
|
||||
);
|
||||
14
indexer-ui/tailwind.config.js
Normal file
14
indexer-ui/tailwind.config.js
Normal 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
15
indexer-ui/tsconfig.json
Normal 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
41
indexer-ui/vite.config.ts
Normal 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") },
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user