feat: move resale dashboard into marketplace
This commit is contained in:
@@ -38,6 +38,9 @@ NETWORK_ID=0
|
||||
# along over loopback. Production behind TLS should leave this unset / "1".
|
||||
# COOKIE_SECURE=1
|
||||
|
||||
# Internal, redacted data source for the authenticated /dashboard page.
|
||||
# SURPLUS_DASHBOARD_URL=http://surplus-seller:8080/internal/resale-dashboard
|
||||
|
||||
# ─── Optional: PDF Book Summarizer (/book page, /v1/pdf-* routes) ─────────
|
||||
# Upload a PDF → chunk it → pay suppliers to summarize each chunk (one escrow
|
||||
# per map + reduce call) → full-book summary. All knobs are optional; defaults
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
* API key" page knows where to POST /signup. When
|
||||
* unset, the SPA falls back to deriving "api." +
|
||||
* current host.
|
||||
* SURPLUS_DASHBOARD_URL — Internal URL of the private Surplus dashboard
|
||||
* snapshot endpoint. When unset, /v1/resale-dashboard
|
||||
* responds 503 while the rest of the buyer stays live.
|
||||
* PDF_ENABLED — "1" (default) serves the PDF book summarizer
|
||||
* (/v1/pdf-* routes). Set "0" to disable the feature
|
||||
* entirely: no PDF job store is opened and every
|
||||
@@ -68,6 +71,7 @@ export interface BuyerConfig {
|
||||
cookieSecure: boolean;
|
||||
pdfEnabled: boolean;
|
||||
gatewayPublicUrl: string;
|
||||
resaleDashboardUrl: string;
|
||||
}
|
||||
|
||||
function requireField(env: Record<string, string | undefined>, name: string): string {
|
||||
@@ -169,6 +173,20 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
// the SPA via the boot block (no trailing slash). Empty string means the SPA
|
||||
// derives the URL from its own host ("api." + location.host).
|
||||
const gatewayPublicUrl = (env.GATEWAY_PUBLIC_URL ?? "").trim().replace(/\/+$/, "");
|
||||
const resaleDashboardUrl = (env.SURPLUS_DASHBOARD_URL ?? "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (resaleDashboardUrl !== "") {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(resaleDashboardUrl);
|
||||
} catch {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must be a valid URL");
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("loadConfig: SURPLUS_DASHBOARD_URL must use http or https");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
privKeyHex,
|
||||
@@ -186,5 +204,6 @@ export function loadConfig(env: Record<string, string | undefined>): BuyerConfig
|
||||
cookieSecure,
|
||||
pdfEnabled,
|
||||
gatewayPublicUrl,
|
||||
resaleDashboardUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -141,6 +141,7 @@ export async function runMain(env: Record<string, string | undefined>): Promise<
|
||||
sessionSecret: config.sessionSecret,
|
||||
cookieSecure: config.cookieSecure,
|
||||
gatewayPublicUrl: config.gatewayPublicUrl,
|
||||
resaleDashboardUrl: config.resaleDashboardUrl,
|
||||
jobStore,
|
||||
pdfCaps,
|
||||
});
|
||||
|
||||
@@ -114,6 +114,9 @@ export interface AppDeps {
|
||||
* `${gatewayUrl}/signup`. Public, non-secret value. When empty/omitted the
|
||||
* SPA derives "api." + its own host. */
|
||||
gatewayPublicUrl?: string;
|
||||
/** Private supplier-network endpoint used to populate the resale dashboard.
|
||||
* The browser only sees the redacted same-origin /v1/resale-dashboard proxy. */
|
||||
resaleDashboardUrl?: string;
|
||||
/** PDF book summarizer job registry. When provided, the /v1/pdf-* routes
|
||||
* are live; when omitted they respond 503 (feature disabled). Built in
|
||||
* runMain from the same marketplace/chain/wallet/archive deps. */
|
||||
@@ -317,6 +320,66 @@ export function createApp(deps: AppDeps): Express {
|
||||
}
|
||||
: null;
|
||||
|
||||
app.get("/v1/resale-dashboard", async (_req: Request, res: Response) => {
|
||||
const dashboardUrl = (deps.resaleDashboardUrl ?? "").replace(/\/+$/, "");
|
||||
if (dashboardUrl === "") {
|
||||
return jsonError(
|
||||
res,
|
||||
503,
|
||||
"service_unavailable",
|
||||
"buyer-app booted without SURPLUS_DASHBOARD_URL",
|
||||
);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const upstream = await (deps.fetchImpl ?? globalThis.fetch)(dashboardUrl, {
|
||||
headers: { accept: "application/json" },
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await upstream.text();
|
||||
if (Buffer.byteLength(text, "utf8") > 1024 * 1024) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_response_too_large",
|
||||
"resale dashboard response exceeded 1 MiB",
|
||||
);
|
||||
}
|
||||
if (!upstream.ok) {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_upstream_error",
|
||||
`resale dashboard upstream returned ${upstream.status}`,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_invalid_json",
|
||||
"resale dashboard upstream did not return JSON",
|
||||
);
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
return res.status(200).json(payload);
|
||||
} catch {
|
||||
return jsonError(
|
||||
res,
|
||||
502,
|
||||
"dashboard_unreachable",
|
||||
"resale dashboard upstream is unavailable",
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/v1/pending-receipts", async (_req: Request, res: Response) => {
|
||||
if (!resolved) {
|
||||
return jsonError(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Routes (all gated by <RequireAuth>):
|
||||
* / → <Dashboard />
|
||||
* /tasks → <TaskHistory />
|
||||
* /dashboard → <ResaleDashboard /> unified Surplus + Vector metrics
|
||||
* /pending → <PendingReceipts /> UX-1: buyer-side Accept button
|
||||
* /wallet → <Wallet />
|
||||
* /api-keys → <ApiKeys /> self-serve gateway API key generation
|
||||
@@ -16,6 +17,7 @@ import Wallet from "./pages/Wallet.js";
|
||||
import PendingReceipts from "./pages/PendingReceipts.js";
|
||||
import BookSummarizer from "./pages/BookSummarizer.js";
|
||||
import ApiKeys from "./pages/ApiKeys.js";
|
||||
import ResaleDashboard from "./pages/ResaleDashboard.js";
|
||||
import { RequireAuth, useAuth } from "./state/AuthContext.js";
|
||||
|
||||
function SignOutButton() {
|
||||
@@ -37,6 +39,7 @@ export default function App() {
|
||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
||||
<nav className="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6">
|
||||
<Link to="/" className="font-medium hover:underline">Dashboard</Link>
|
||||
<Link to="/dashboard" className="font-medium hover:underline">Resale</Link>
|
||||
{/* Book Summarizer tab hidden for now — the /book route and all PDF
|
||||
summarize code remain intact; restore by re-adding this link:
|
||||
<Link to="/book" className="font-medium hover:underline">Book Summarizer</Link> */}
|
||||
@@ -49,6 +52,7 @@ export default function App() {
|
||||
<main className="p-6">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/dashboard" element={<ResaleDashboard />} />
|
||||
<Route path="/book" element={<BookSummarizer />} />
|
||||
<Route path="/tasks" element={<TaskHistory />} />
|
||||
<Route path="/pending" element={<PendingReceipts />} />
|
||||
|
||||
411
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
411
buyer/src/ui/pages/ResaleDashboard.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface DashboardSnapshot {
|
||||
generatedAt: string;
|
||||
controller: {
|
||||
ok: boolean;
|
||||
phase: string;
|
||||
lastError: string | null;
|
||||
};
|
||||
identity: {
|
||||
sellerWallet: string;
|
||||
payoutAddress: string;
|
||||
};
|
||||
provider: {
|
||||
hardLimitUsd: string;
|
||||
remainingUsd: string;
|
||||
usedUsd: string;
|
||||
reserveUsd: string;
|
||||
estimatedDailyExposureUsd: string;
|
||||
};
|
||||
surplus: {
|
||||
offer: {
|
||||
id: string | null;
|
||||
model: string | null;
|
||||
providerModel: string | null;
|
||||
status: string;
|
||||
dailyCapUsd: string | null;
|
||||
costMultiplier: number | null;
|
||||
inputUsdPer1m: string | null;
|
||||
outputUsdPer1m: string | null;
|
||||
rank: number | null;
|
||||
available: boolean;
|
||||
healthy: boolean;
|
||||
trusted: boolean;
|
||||
trades24h: number;
|
||||
volume24hUsd: string;
|
||||
capRemainingUsd: string | null;
|
||||
};
|
||||
earnings: {
|
||||
totalUsd: string;
|
||||
pendingUsd: string;
|
||||
paidUsd: string;
|
||||
requests: number;
|
||||
tokens: number;
|
||||
today: {
|
||||
earnedUsd: number;
|
||||
requests: number;
|
||||
totalTokens: number;
|
||||
} | null;
|
||||
topModel: string | null;
|
||||
payoutHoldReason: string | null;
|
||||
payoutHoldReleasesAt: string | null;
|
||||
};
|
||||
recentSales: Array<{
|
||||
id: string;
|
||||
model: string;
|
||||
createdAt: string | null;
|
||||
settlementStatus: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
revenueUsd: string;
|
||||
transactionHash: string | null;
|
||||
}>;
|
||||
};
|
||||
vector: {
|
||||
status: "retired";
|
||||
model: string;
|
||||
supplierWallet: string;
|
||||
advertRef: string;
|
||||
retirementTransaction: string;
|
||||
retiredOn: string;
|
||||
historicalAp3xEarned: string;
|
||||
historicalSettledJobs: number;
|
||||
historicalUpstreamSpendUsd: string;
|
||||
};
|
||||
}
|
||||
|
||||
function usd(value: string | number | null): string {
|
||||
if (value === null || value === "") return "—";
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return "—";
|
||||
return `$${number.toFixed(number < 0.01 ? 6 : 4)}`;
|
||||
}
|
||||
|
||||
function count(value: number): string {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
}
|
||||
|
||||
function timestamp(value: string | null): string {
|
||||
if (value === null) return "—";
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? "—" : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function compact(value: string): string {
|
||||
if (value.length <= 24) return value;
|
||||
return `${value.slice(0, 12)}…${value.slice(-9)}`;
|
||||
}
|
||||
|
||||
function StatusPill({ label, active }: { label: string; active: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
active ? "bg-green-100 text-green-700" : "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) {
|
||||
return (
|
||||
<div className="rounded border border-gray-200 bg-gray-50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 text-xl font-semibold tracking-tight text-gray-900">{value}</p>
|
||||
<p className="mt-0.5 text-xs text-gray-500">{detail}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowNode({ label, title, detail, mono }: {
|
||||
label: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
mono?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0 flex-1 rounded border border-gray-200 bg-gray-50 p-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{label}</p>
|
||||
<p className="mt-1 font-semibold text-gray-900">{title}</p>
|
||||
<p className="mt-1 text-sm text-gray-600">{detail}</p>
|
||||
{mono && <p className="mt-2 truncate font-mono text-xs text-gray-400">{mono}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ResaleDashboard() {
|
||||
const [data, setData] = useState<DashboardSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch("/v1/resale-dashboard", { cache: "no-store" });
|
||||
const body = (await response.json().catch(() => null)) as DashboardSnapshot | {
|
||||
message?: string;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
body && "message" in body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `dashboard request failed (${response.status})`,
|
||||
);
|
||||
}
|
||||
setData(body as DashboardSnapshot);
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "dashboard data is unavailable");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const interval = setInterval(() => { void refresh(); }, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refresh]);
|
||||
|
||||
if (loading && data === null) {
|
||||
return <p className="text-sm text-gray-500">Loading resale dashboard…</p>;
|
||||
}
|
||||
|
||||
if (data === null) {
|
||||
return (
|
||||
<div className="max-w-3xl rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error ?? "Dashboard data is unavailable."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const offer = data.surplus.offer;
|
||||
const earnings = data.surplus.earnings;
|
||||
const vector = data.vector;
|
||||
const offerLive = offer.status === "active" && offer.available && offer.healthy;
|
||||
const advertHash = vector.advertRef.split("#")[0] ?? "";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6" data-testid="resale-dashboard">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-semibold">Resale Dashboard</h1>
|
||||
<p className="max-w-3xl text-sm text-gray-600">
|
||||
One OpenRouter capacity pool managed by the seller agent. Surplus is the live Base USDC
|
||||
market. Vector preserves the completed AP3X escrow proof.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusPill label={data.controller.phase} active={data.controller.ok} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void refresh(); }}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
Last refresh failed: {error}. Showing the last good snapshot.
|
||||
</div>
|
||||
)}
|
||||
{data.controller.lastError && (
|
||||
<div className="rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
Controller: {data.controller.lastError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Capacity route</h2>
|
||||
<p className="text-sm text-gray-500">The same upstream capacity moved from Vector proof to Surplus production.</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">Updated {timestamp(data.generatedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-stretch gap-2 lg:flex-row lg:items-center">
|
||||
<FlowNode
|
||||
label="Upstream"
|
||||
title="OpenRouter"
|
||||
detail={`${usd(data.provider.remainingUsd)} of ${usd(data.provider.hardLimitUsd)} remaining`}
|
||||
mono={offer.providerModel}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Manager"
|
||||
title="Seller agent"
|
||||
detail="Discovers, prices, caps, and reconciles offers"
|
||||
mono={data.identity.sellerWallet}
|
||||
/>
|
||||
<span className="self-center text-lg text-gray-400">→</span>
|
||||
<FlowNode
|
||||
label="Live settlement"
|
||||
title="Surplus / Base USDC"
|
||||
detail={`${offerLive ? "Available" : "Unavailable"} · ${offer.trusted ? "trusted" : "untrusted"}`}
|
||||
mono={offer.id}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-2">
|
||||
<article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Current market</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Surplus Intelligence</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">Discounted inference with USDC settlement on Base.</p>
|
||||
</div>
|
||||
<StatusPill label={offer.status} active={offerLive} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
<Metric label="Lifetime revenue" value={usd(earnings.totalUsd)} detail={`${usd(earnings.pendingUsd)} pending`} />
|
||||
<Metric label="Requests sold" value={count(earnings.requests)} detail={`${count(earnings.tokens)} tokens`} />
|
||||
<Metric label="Market rank" value={offer.rank === null ? "—" : `#${offer.rank}`} detail={`${offer.trades24h} trades / 24h`} />
|
||||
<Metric label="Daily seller cap" value={usd(offer.dailyCapUsd)} detail={`${usd(offer.capRemainingUsd)} remaining`} />
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-x-6 gap-y-2 border-t border-gray-100 pt-4 text-sm sm:grid-cols-2">
|
||||
<div><dt className="text-gray-500">Marketplace model</dt><dd className="font-mono text-xs text-gray-900">{offer.model ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Provider model</dt><dd className="font-mono text-xs text-gray-900">{offer.providerModel ?? "—"}</dd></div>
|
||||
<div><dt className="text-gray-500">Input / 1M</dt><dd>{usd(offer.inputUsdPer1m)}</dd></div>
|
||||
<div><dt className="text-gray-500">Output / 1M</dt><dd>{usd(offer.outputUsdPer1m)}</dd></div>
|
||||
</dl>
|
||||
{earnings.payoutHoldReason && (
|
||||
<div className="mt-4 rounded border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
Payout hold: {earnings.payoutHoldReason}
|
||||
{earnings.payoutHoldReleasesAt && ` · releases ${timestamp(earnings.payoutHoldReleasesAt)}`}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Migration record</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Vector marketplace</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">AP3X escrow route retired after the cross-market proof.</p>
|
||||
</div>
|
||||
<StatusPill label="retired" active={false} />
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-2 2xl:grid-cols-4">
|
||||
<Metric label="AP3X earned" value={`${vector.historicalAp3xEarned} AP3X`} detail="historical" />
|
||||
<Metric label="Settled jobs" value={count(vector.historicalSettledJobs)} detail="Vector escrows" />
|
||||
<Metric label="Upstream spend" value={usd(vector.historicalUpstreamSpendUsd)} detail="proof route" />
|
||||
<Metric label="Retired on" value={vector.retiredOn} detail="advert closed" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 border-t border-gray-100 pt-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Retired advert</p>
|
||||
<p className="break-all font-mono text-xs text-gray-900">{vector.advertRef}</p>
|
||||
<a
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
href={`https://vector.apexscan.org/en/transaction/${advertHash}/summary/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open advert transaction ↗
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Retirement proof</p>
|
||||
<p className="break-all font-mono text-xs text-gray-900">{vector.retirementTransaction}</p>
|
||||
<a
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
href={`https://vector.apexscan.org/en/transaction/${vector.retirementTransaction}/summary/`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open confirmed transaction ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Economics</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Capacity and price controls</h2>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
The seller cap is discounted revenue. The agent converts it to estimated upstream exposure before opening the route.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="OpenRouter remaining" value={usd(data.provider.remainingUsd)} detail={`${usd(data.provider.usedUsd)} used`} />
|
||||
<Metric label="Protected reserve" value={usd(data.provider.reserveUsd)} detail="fail-closed threshold" />
|
||||
<Metric label="Daily upstream estimate" value={usd(data.provider.estimatedDailyExposureUsd)} detail="from recovery floor" />
|
||||
<Metric label="Current multiplier" value={offer.costMultiplier === null ? "—" : `${(offer.costMultiplier * 100).toFixed(3)}%`} detail={`${usd(offer.inputUsdPer1m)} in · ${usd(offer.outputUsdPer1m)} out`} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">Surplus activity</p>
|
||||
<h2 className="mt-1 text-lg font-semibold">Recent sales</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{count(earnings.requests)} lifetime requests · {count(earnings.tokens)} tokens</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50 text-left text-xs font-medium uppercase tracking-wide text-gray-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Time</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<th className="px-4 py-3">Model</th>
|
||||
<th className="px-4 py-3 text-right">Input</th>
|
||||
<th className="px-4 py-3 text-right">Output</th>
|
||||
<th className="px-4 py-3 text-right">Cache read</th>
|
||||
<th className="px-4 py-3 text-right">Revenue</th>
|
||||
<th className="px-4 py-3">Base settlement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{data.surplus.recentSales.length === 0 ? (
|
||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">No sales returned.</td></tr>
|
||||
) : data.surplus.recentSales.map((sale) => (
|
||||
<tr key={sale.id} className="hover:bg-gray-50">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-gray-600">{timestamp(sale.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${
|
||||
sale.settlementStatus === "confirmed"
|
||||
? "bg-green-100 text-green-700"
|
||||
: "bg-amber-100 text-amber-700"
|
||||
}`}>
|
||||
{sale.settlementStatus}
|
||||
</span>
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 font-mono text-xs">{sale.model}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.inputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.outputTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{count(sale.cacheReadTokens)}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{usd(sale.revenueUsd)}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs">
|
||||
{sale.transactionHash ? (
|
||||
<a
|
||||
className="text-blue-600 hover:underline"
|
||||
href={`https://basescan.org/tx/${sale.transactionHash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{compact(sale.transactionHash)}
|
||||
</a>
|
||||
) : "Accruing"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
Surplus seller {compact(data.identity.sellerWallet)} · payout {compact(data.identity.payoutAddress)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
# gateway must CORS-allow https://marketplace.vector.apexfusion.org (see
|
||||
# GATEWAY_CORS_ORIGINS in docker-compose.gateway.yml).
|
||||
GATEWAY_PUBLIC_URL: https://api.marketplace.vector.apexfusion.org
|
||||
SURPLUS_DASHBOARD_URL: http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard
|
||||
# NETWORK_ID intentionally not pinned — see supplier compose comment.
|
||||
volumes:
|
||||
- buyer-mainnet-archive:/repo/data/archive
|
||||
|
||||
@@ -23,19 +23,7 @@ services:
|
||||
volumes:
|
||||
- surplus-seller-state:/var/lib/surplus-manager
|
||||
networks:
|
||||
- traefik-net
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik-net"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.entrypoints=web"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet.middlewares=mp-openrouter-reseller-mainnet-https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.scheme=https"
|
||||
- "traefik.http.middlewares.mp-openrouter-reseller-mainnet-https.redirectscheme.permanent=true"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.entrypoints=websecure"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.rule=Host(`mp-suppliers-openrouter-reseller.vector.apexfusion.org`)"
|
||||
- "traefik.http.routers.mp-openrouter-reseller-mainnet-tls.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mp-openrouter-reseller-mainnet.loadbalancer.server.port=8080"
|
||||
- marketplace-mainnet-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1"]
|
||||
interval: 20s
|
||||
@@ -53,6 +41,6 @@ volumes:
|
||||
name: marketplace-mainnet-surplus-seller-state
|
||||
|
||||
networks:
|
||||
traefik-net:
|
||||
marketplace-mainnet-net:
|
||||
external: true
|
||||
name: traefik-net
|
||||
name: marketplace-mainnet-net
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
SurplusDashboardService,
|
||||
type SurplusDashboardSnapshot,
|
||||
} from "../surplus/dashboard.js";
|
||||
import { renderSurplusDashboardPage } from "../surplus/dashboardPage.js";
|
||||
|
||||
export async function runSurplusSeller(
|
||||
env: Record<string, string | undefined>,
|
||||
@@ -81,27 +80,6 @@ async function handleRequest(
|
||||
response.setHeader("x-content-type-options", "nosniff");
|
||||
response.setHeader("x-frame-options", "DENY");
|
||||
response.setHeader("referrer-policy", "no-referrer");
|
||||
if (request.method === "GET" && path === "/") {
|
||||
response.statusCode = 302;
|
||||
response.setHeader("location", "/reseller");
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.method === "GET" &&
|
||||
(path === "/reseller" || path === "/reseller/")
|
||||
) {
|
||||
response.statusCode = 200;
|
||||
response.setHeader("content-type", "text/html; charset=utf-8");
|
||||
response.setHeader(
|
||||
"content-security-policy",
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'unsafe-inline'; connect-src 'self'; " +
|
||||
"img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'",
|
||||
);
|
||||
response.end(renderSurplusDashboardPage());
|
||||
return;
|
||||
}
|
||||
const status = controller.snapshot();
|
||||
if (request.method === "GET" && path === "/healthz") {
|
||||
const healthy = controller.healthy();
|
||||
@@ -116,7 +94,7 @@ async function handleRequest(
|
||||
response.end(JSON.stringify(status));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && path === "/dashboard/api") {
|
||||
if (request.method === "GET" && path === "/internal/resale-dashboard") {
|
||||
try {
|
||||
const snapshot = await dashboard.getSnapshot();
|
||||
response.statusCode = 200;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
export function renderSurplusDashboardPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>Inference Resale Control Plane</title>
|
||||
<style>
|
||||
:root{--bg:#07110f;--surface:#0c1916;--surface-2:#10231e;--line:#244039;--text:#eef8f3;--muted:#91aaa1;--green:#57e39b;--teal:#5fd8cd;--blue:#79b8ff;--amber:#f3c969;--red:#ff827c;--shadow:0 24px 80px #0007}
|
||||
*{box-sizing:border-box}html{min-width:320px}body{margin:0;background:radial-gradient(circle at 8% -5%,#17493b 0,transparent 29rem),radial-gradient(circle at 95% 20%,#15344b 0,transparent 34rem),var(--bg);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
||||
body:before{content:"";position:fixed;inset:0;pointer-events:none;background-image:linear-gradient(#fff008 1px,transparent 1px),linear-gradient(90deg,#fff008 1px,transparent 1px);background-size:42px 42px;mask-image:linear-gradient(to bottom,#0008,transparent 55%)}
|
||||
a{color:var(--blue);text-decoration:none}a:hover,a:focus-visible{text-decoration:underline}.shell{width:min(1240px,calc(100% - 32px));margin:auto;padding:38px 0 72px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:32px;margin-bottom:26px}.eyebrow{color:var(--green);font:700 11px/1.2 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.16em;text-transform:uppercase}.top h1{margin:10px 0 8px;font-size:clamp(32px,5.5vw,58px);line-height:.98;letter-spacing:-.055em;max-width:780px}.top p{color:var(--muted);margin:0;max-width:660px;font-size:15px}.live-cluster{text-align:right;min-width:210px}.updated{color:var(--muted);font-variant-numeric:tabular-nums;margin-top:8px}.badge{display:inline-flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:999px;padding:7px 11px;background:#081511cc;font-size:12px;font-weight:700}.badge:before{content:"";width:7px;height:7px;border-radius:50%;background:var(--muted);box-shadow:0 0 0 3px #fff1}.badge.live:before{background:var(--green);box-shadow:0 0 18px var(--green)}.badge.retired:before{background:var(--amber)}
|
||||
.panel{border:1px solid var(--line);border-radius:18px;background:linear-gradient(145deg,#10231ee8,#0a1714f2);box-shadow:var(--shadow)}.flow{display:grid;grid-template-columns:1fr 42px 1.1fr 42px 1.15fr;align-items:stretch;padding:14px;margin-bottom:18px;overflow:hidden}.node{padding:18px;border-radius:13px;background:#091612;border:1px solid #24443a;min-width:0}.node strong{display:block;font-size:18px;margin:5px 0 3px}.node small{color:var(--muted);display:block}.node .sub{font:12px ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.arrow{display:grid;place-items:center;color:var(--teal);font-size:22px}.markets{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:18px}.market-card{padding:22px;position:relative;overflow:hidden}.market-card:after{content:"";position:absolute;width:180px;height:180px;border-radius:50%;right:-90px;top:-110px;background:#57e39b0b}.market-head{display:flex;justify-content:space-between;align-items:flex-start;gap:16px}.market-head h2{margin:3px 0 0;font-size:22px}.market-head p{margin:5px 0 0;color:var(--muted)}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin:16px 0 0}.metric{padding:14px;border:1px solid #254039;border-radius:12px;background:#07130f9c;min-width:0}.label{display:block;color:var(--muted);text-transform:uppercase;letter-spacing:.11em;font-size:10px;font-weight:700}.metric strong{display:block;font-size:clamp(18px,2.2vw,27px);letter-spacing:-.035em;margin-top:5px;font-variant-numeric:tabular-nums}.metric small{color:var(--muted);display:block;margin-top:2px}.good{color:var(--green)}.warn{color:var(--amber)}.retired-copy{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}.fact{padding:12px 14px;border-left:2px solid var(--amber);background:#f3c9690a}.fact strong{display:block;margin-top:3px}
|
||||
.section-head{display:flex;justify-content:space-between;align-items:end;gap:20px;margin:28px 2px 12px}.section-head h2{margin:0;font-size:22px;letter-spacing:-.025em}.section-head p{margin:0;color:var(--muted)}.table-wrap{overflow:auto}.sales{width:100%;border-collapse:collapse;min-width:820px}.sales th,.sales td{text-align:left;padding:12px 15px;border-bottom:1px solid #213a33}.sales th{position:sticky;top:0;background:#0d1b17;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.12em}.sales tbody tr:hover{background:#ffffff05}.sales tbody tr:last-child td{border-bottom:0}.status-pill{display:inline-flex;border-radius:999px;padding:3px 8px;background:#ffffff0b;border:1px solid #ffffff13;font-size:11px}.status-pill.confirmed{color:var(--green);border-color:#57e39b55}.status-pill.accrued{color:var(--amber);border-color:#f3c96955}.empty{padding:34px!important;text-align:center!important;color:var(--muted)}.alert{display:none;margin-top:14px;padding:12px 14px;border-radius:11px;background:#ff827c10;border:1px solid #ff827c44;color:#ffc4bf}.alert.visible{display:block}.foot{display:flex;justify-content:space-between;gap:24px;color:var(--muted);margin-top:24px;font-size:12px}.foot span:last-child{text-align:right}
|
||||
@media(max-width:980px){.metrics{grid-template-columns:repeat(2,1fr)}.markets{grid-template-columns:1fr}.flow{grid-template-columns:1fr}.arrow{transform:rotate(90deg);height:34px}.top{flex-direction:column}.live-cluster{text-align:left}.retired-copy{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:560px){.shell{width:min(100% - 20px,1240px);padding-top:24px}.metrics,.retired-copy{grid-template-columns:1fr}.market-card{padding:17px}.top h1{font-size:38px}.foot{flex-direction:column}.foot span:last-child{text-align:left}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="top"><div><div class="eyebrow">Autonomous inference resale</div><h1>One capacity pool.<br>Two settlement markets.</h1><p>Live OpenRouter capacity sold through Surplus on Base, with the completed Vector AP3X proof preserved as the migration record.</p></div><div class="live-cluster"><span class="badge" id="controller-badge">Loading controller</span><div class="updated" id="updated">Waiting for current state</div></div></header>
|
||||
<section class="panel flow" aria-label="Capacity routing flow">
|
||||
<div class="node"><span class="label">Upstream capacity</span><strong>OpenRouter</strong><small id="flow-allowance">Allowance loading</small><small class="sub" id="flow-provider-model">provider model --</small></div><div class="arrow" aria-hidden="true">→</div>
|
||||
<div class="node"><span class="label">Autonomous manager</span><strong>Seller agent</strong><small>Discovers · prices · caps · reconciles</small><small class="sub" id="flow-wallet">seller --</small></div><div class="arrow" aria-hidden="true">→</div>
|
||||
<div class="node"><span class="label">Current settlement</span><strong>Surplus / Base USDC</strong><small id="flow-surplus">Offer loading</small><small class="sub" id="flow-offer">offer --</small></div>
|
||||
</section>
|
||||
<section class="markets">
|
||||
<article class="panel market-card"><div class="market-head"><div><span class="label">Current market</span><h2>Surplus Intelligence</h2><p>Competitive model routing with USDC settlement on Base.</p></div><span class="badge" id="surplus-badge">Loading</span></div>
|
||||
<div class="metrics"><div class="metric"><span class="label">Lifetime revenue</span><strong id="earned">--</strong><small id="earnings-state">--</small></div><div class="metric"><span class="label">Requests sold</span><strong id="requests">--</strong><small id="tokens">-- tokens</small></div><div class="metric"><span class="label">Market position</span><strong id="rank">--</strong><small id="market-state">--</small></div><div class="metric"><span class="label">Daily seller cap</span><strong id="daily-cap">--</strong><small id="cap-remaining">-- remaining</small></div></div>
|
||||
<div class="alert" id="payout-hold"></div><div class="alert" id="controller-error"></div></article>
|
||||
<article class="panel market-card"><div class="market-head"><div><span class="label">Migration record</span><h2>Vector marketplace</h2><p>AP3X escrow route retired after the cross-market proof.</p></div><span class="badge retired">Retired</span></div>
|
||||
<div class="metrics"><div class="metric"><span class="label">AP3X earned</span><strong id="vector-earned">--</strong><small>historical</small></div><div class="metric"><span class="label">Settled jobs</span><strong id="vector-jobs">--</strong><small>Vector escrows</small></div><div class="metric"><span class="label">Upstream spend</span><strong id="vector-spend">--</strong><small>proof route</small></div><div class="metric"><span class="label">Retired on</span><strong id="vector-date">--</strong><small>advert closed</small></div></div>
|
||||
<div class="retired-copy"><div class="fact"><span class="label">Retired advert</span><strong class="mono truncate" id="vector-advert">--</strong><a id="advert-link" target="_blank" rel="noopener noreferrer">Open creation transaction ↗</a></div><div class="fact"><span class="label">Retirement proof</span><strong class="mono truncate" id="vector-retirement">--</strong><a id="retirement-link" target="_blank" rel="noopener noreferrer">Open confirmed transaction ↗</a></div></div></article>
|
||||
</section>
|
||||
<section class="panel market-card"><div class="market-head"><div><span class="label">Economics</span><h2>Capacity and price controls</h2><p>The seller cap is discounted revenue. The agent converts it to estimated upstream exposure before it opens the route.</p></div><span class="badge live">Bounded</span></div>
|
||||
<div class="metrics"><div class="metric"><span class="label">OpenRouter remaining</span><strong id="provider-remaining">--</strong><small id="provider-used">-- used</small></div><div class="metric"><span class="label">Protected reserve</span><strong id="provider-reserve">--</strong><small>fail-closed threshold</small></div><div class="metric"><span class="label">Daily upstream estimate</span><strong id="provider-exposure">--</strong><small>from recovery floor</small></div><div class="metric"><span class="label">Current multiplier</span><strong id="multiplier">--</strong><small id="prices">--</small></div></div></section>
|
||||
<div class="section-head"><div><span class="label">Surplus activity</span><h2>Recent sales</h2></div><p id="sales-note">Loading settlement records</p></div>
|
||||
<section class="panel table-wrap"><table class="sales"><thead><tr><th>Time</th><th>Status</th><th>Model</th><th>Input</th><th>Output</th><th>Cache read</th><th>Revenue</th><th>Base settlement</th></tr></thead><tbody id="sales-body"><tr><td colspan="8" class="empty">Loading sales</td></tr></tbody></table></section>
|
||||
<footer class="foot"><span id="identity">Seller identity loading</span><span>Vector proves the agent-owned route. Surplus is the current USDC market.</span></footer>
|
||||
</main>
|
||||
<script>
|
||||
(function(){'use strict';
|
||||
var $=function(id){return document.getElementById(id)};
|
||||
var set=function(id,value){$(id).textContent=value==null?'--':String(value)};
|
||||
var usd=function(value){if(value==null||value==='')return'--';var n=Number(value);return Number.isFinite(n)?'$'+n.toFixed(n<.01?6:4):'--'};
|
||||
var count=function(value){return new Intl.NumberFormat().format(Number(value)||0)};
|
||||
var short=function(value){var s=String(value||'');return s.length>18?s.slice(0,10)+'…'+s.slice(-7):s||'--'};
|
||||
var when=function(value){if(!value)return'--';var d=new Date(value);return Number.isNaN(d.getTime())?'--':d.toLocaleString()};
|
||||
function statusBadge(id,label,live){var el=$(id);el.textContent=label;el.className='badge '+(live?'live':'');}
|
||||
function link(id,href){var el=$(id);el.href=href;}
|
||||
function txLink(row,hash){var td=document.createElement('td');if(typeof hash==='string'&&/^0x[0-9a-fA-F]{64}$/.test(hash)){var a=document.createElement('a');a.href='https://basescan.org/tx/'+hash;a.target='_blank';a.rel='noopener noreferrer';a.textContent=short(hash);td.appendChild(a)}else{td.textContent='Accruing'}row.appendChild(td)}
|
||||
function cell(row,value,className){var td=document.createElement('td');if(className)td.className=className;td.textContent=value==null?'--':String(value);row.appendChild(td)}
|
||||
function renderSales(sales){var body=$('sales-body');body.replaceChildren();if(!sales.length){var empty=document.createElement('tr');cell(empty,'No sales returned','empty');empty.firstChild.colSpan=8;body.appendChild(empty);return}sales.forEach(function(sale){var row=document.createElement('tr');cell(row,when(sale.createdAt));var status=document.createElement('td');var pill=document.createElement('span');pill.className='status-pill '+sale.settlementStatus;pill.textContent=sale.settlementStatus;status.appendChild(pill);row.appendChild(status);cell(row,sale.model,'mono');cell(row,count(sale.inputTokens));cell(row,count(sale.outputTokens));cell(row,count(sale.cacheReadTokens));cell(row,usd(sale.revenueUsd));txLink(row,sale.transactionHash);body.appendChild(row)})}
|
||||
function render(data){var c=data.controller,o=data.surplus.offer,e=data.surplus.earnings,p=data.provider,v=data.vector;statusBadge('controller-badge',c.phase,c.ok);statusBadge('surplus-badge',o.status,o.available&&o.healthy);set('updated','Updated '+when(data.generatedAt));set('flow-allowance',usd(p.remainingUsd)+' of '+usd(p.hardLimitUsd)+' remaining');set('flow-provider-model',o.providerModel);set('flow-wallet',short(data.identity.sellerWallet));set('flow-surplus',(o.available?'Available':'Unavailable')+' · '+(o.healthy?'healthy':'not healthy'));set('flow-offer',o.id);set('earned',usd(e.totalUsd));set('earnings-state',usd(e.pendingUsd)+' pending · '+usd(e.paidUsd)+' paid');set('requests',count(e.requests));set('tokens',count(e.tokens)+' tokens');set('rank',o.rank==null?'--':'#'+o.rank);set('market-state',(o.trusted?'trusted':'untrusted')+' · '+count(o.trades24h)+' trades / 24h');set('daily-cap',usd(o.dailyCapUsd));set('cap-remaining',usd(o.capRemainingUsd)+' remaining');set('provider-remaining',usd(p.remainingUsd));set('provider-used',usd(p.usedUsd)+' used of '+usd(p.hardLimitUsd));set('provider-reserve',usd(p.reserveUsd));set('provider-exposure',usd(p.estimatedDailyExposureUsd));set('multiplier',o.costMultiplier==null?'--':(o.costMultiplier*100).toFixed(3)+'%');set('prices',usd(o.inputUsdPer1m)+' in · '+usd(o.outputUsdPer1m)+' out / 1M');set('vector-earned',v.historicalAp3xEarned+' AP3X');set('vector-jobs',count(v.historicalSettledJobs));set('vector-spend',usd(v.historicalUpstreamSpendUsd));set('vector-date',v.retiredOn);set('vector-advert',v.advertRef);set('vector-retirement',v.retirementTransaction);var advertHash=String(v.advertRef).split('#')[0];link('advert-link','https://vector.apexscan.org/en/transaction/'+advertHash+'/summary/');link('retirement-link','https://vector.apexscan.org/en/transaction/'+v.retirementTransaction+'/summary/');set('identity','Surplus '+short(data.identity.sellerWallet)+' · payout '+short(data.identity.payoutAddress));set('sales-note',count(e.requests)+' lifetime requests · '+count(e.tokens)+' tokens');var hold=$('payout-hold');if(e.payoutHoldReason){hold.className='alert visible';hold.textContent='Payout hold: '+e.payoutHoldReason+(e.payoutHoldReleasesAt?' · releases '+when(e.payoutHoldReleasesAt):'')}else{hold.className='alert'}var error=$('controller-error');if(c.lastError){error.className='alert visible';error.textContent='Controller: '+c.lastError}else{error.className='alert'}renderSales(data.surplus.recentSales)}
|
||||
async function refresh(){try{var response=await fetch('/dashboard/api',{cache:'no-store'});if(!response.ok)throw new Error('Dashboard API returned '+response.status);render(await response.json())}catch(error){statusBadge('controller-badge','data unavailable',false);set('updated',error instanceof Error?error.message:'Dashboard data unavailable')}}
|
||||
refresh();setInterval(refresh,15000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
58
tests/unit/buyer-resale-dashboard.test.ts
Normal file
58
tests/unit/buyer-resale-dashboard.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import request from "supertest";
|
||||
import { createApp } from "../../buyer/src/server.js";
|
||||
|
||||
describe("buyer resale dashboard proxy", () => {
|
||||
it("serves the private supplier snapshot from a same-origin buyer route", async () => {
|
||||
const payload = {
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
controller: { ok: true, phase: "active" },
|
||||
surplus: { earnings: { requests: 1_333 } },
|
||||
vector: { status: "retired" },
|
||||
};
|
||||
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get("accept")).toBe("application/json");
|
||||
expect(init?.redirect).toBe("error");
|
||||
return Response.json(payload);
|
||||
});
|
||||
const app = createApp({
|
||||
resaleDashboardUrl:
|
||||
"http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard/",
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
const response = await request(app).get("/v1/resale-dashboard");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual(payload);
|
||||
expect(response.headers["cache-control"]).toBe("no-store");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"http://marketplace-mainnet-surplus-seller:8080/internal/resale-dashboard",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails without exposing an upstream error body", async () => {
|
||||
const app = createApp({
|
||||
resaleDashboardUrl: "http://seller/internal/resale-dashboard",
|
||||
fetchImpl: async () => new Response("secret upstream detail", { status: 500 }),
|
||||
});
|
||||
|
||||
const response = await request(app).get("/v1/resale-dashboard");
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.body.error).toBe("dashboard_upstream_error");
|
||||
expect(JSON.stringify(response.body)).not.toContain("secret upstream detail");
|
||||
});
|
||||
|
||||
it("returns a bounded feature-disabled response when unconfigured", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
const app = createApp({ fetchImpl });
|
||||
|
||||
const response = await request(app).get("/v1/resale-dashboard");
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toBe("service_unavailable");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,24 @@ describe("loadConfig(env)", () => {
|
||||
it("throws when PDF_ENABLED is not 0 or 1", () => {
|
||||
expect(() => loadConfig({ ...VALID_ENV, PDF_ENABLED: "false" })).toThrow(/PDF_ENABLED/);
|
||||
});
|
||||
|
||||
it("normalizes the private resale dashboard URL", () => {
|
||||
const config = loadConfig({
|
||||
...VALID_ENV,
|
||||
SURPLUS_DASHBOARD_URL:
|
||||
"http://surplus-seller:8080/internal/resale-dashboard/",
|
||||
});
|
||||
expect(config.resaleDashboardUrl).toBe(
|
||||
"http://surplus-seller:8080/internal/resale-dashboard",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a resale dashboard URL with an unsupported protocol", () => {
|
||||
expect(() => loadConfig({
|
||||
...VALID_ENV,
|
||||
SURPLUS_DASHBOARD_URL: "file:///tmp/dashboard.json",
|
||||
})).toThrow(/SURPLUS_DASHBOARD_URL/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── createApp tests ──────────────────────────────────────────────────────────
|
||||
|
||||
107
tests/unit/buyer-ui-resale-dashboard.test.tsx
Normal file
107
tests/unit/buyer-ui-resale-dashboard.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import ResaleDashboard from "../../buyer/src/ui/pages/ResaleDashboard.js";
|
||||
|
||||
const DASHBOARD = {
|
||||
generatedAt: "2026-08-28T12:00:00.000Z",
|
||||
controller: { ok: true, phase: "active", lastError: null },
|
||||
identity: {
|
||||
sellerWallet: "0x1111111111111111111111111111111111111111",
|
||||
payoutAddress: "0x1111111111111111111111111111111111111111",
|
||||
},
|
||||
provider: {
|
||||
hardLimitUsd: "20.000000",
|
||||
remainingUsd: "15.157733",
|
||||
usedUsd: "4.842267",
|
||||
reserveUsd: "1.00",
|
||||
estimatedDailyExposureUsd: "1.020408",
|
||||
},
|
||||
surplus: {
|
||||
offer: {
|
||||
id: "offer-1",
|
||||
model: "deepseek-v4-flash",
|
||||
providerModel: "deepseek/deepseek-v4-flash",
|
||||
status: "active",
|
||||
dailyCapUsd: "0.050000",
|
||||
costMultiplier: 0.05065,
|
||||
inputUsdPer1m: "0.004027",
|
||||
outputUsdPer1m: "0.008055",
|
||||
rank: 11,
|
||||
available: true,
|
||||
healthy: true,
|
||||
trusted: true,
|
||||
trades24h: 654,
|
||||
volume24hUsd: "0.112434",
|
||||
capRemainingUsd: "0.000000",
|
||||
},
|
||||
earnings: {
|
||||
totalUsd: "0.239903",
|
||||
pendingUsd: "0.219945",
|
||||
paidUsd: "0.019958",
|
||||
requests: 1_333,
|
||||
tokens: 138_612_442,
|
||||
today: { earnedUsd: 0.053093, requests: 240, totalTokens: 33_913_537 },
|
||||
topModel: "deepseek-v4-flash",
|
||||
payoutHoldReason: "new_seller",
|
||||
payoutHoldReleasesAt: "2026-08-29T11:24:42.552Z",
|
||||
},
|
||||
recentSales: [{
|
||||
id: "sale-1",
|
||||
model: "deepseek-v4-flash",
|
||||
createdAt: "2026-08-28T11:00:00.000Z",
|
||||
settlementStatus: "confirmed",
|
||||
inputTokens: 10,
|
||||
outputTokens: 2,
|
||||
cacheReadTokens: 3,
|
||||
revenueUsd: "0.000083",
|
||||
transactionHash: "0x" + "c".repeat(64),
|
||||
}],
|
||||
},
|
||||
vector: {
|
||||
status: "retired",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
supplierWallet: "addr1test",
|
||||
advertRef: `${"a".repeat(64)}#0`,
|
||||
retirementTransaction: "b".repeat(64),
|
||||
retiredOn: "2026-08-27",
|
||||
historicalAp3xEarned: "1.600000",
|
||||
historicalSettledJobs: 8,
|
||||
historicalUpstreamSpendUsd: "0.000093",
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ResaleDashboard", () => {
|
||||
it("matches the marketplace card system and renders both settlement markets", async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json(DASHBOARD));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
render(<ResaleDashboard />);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Resale Dashboard" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Surplus Intelligence" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: "Vector marketplace" })).toBeInTheDocument();
|
||||
expect(screen.getByText("$0.2399")).toBeInTheDocument();
|
||||
expect(screen.getByText("1.600000 AP3X")).toBeInTheDocument();
|
||||
expect(screen.getByText("confirmed")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("resale-dashboard")).toHaveClass("max-w-7xl");
|
||||
expect(document.querySelectorAll(".bg-white").length).toBeGreaterThan(3);
|
||||
expect(screen.getByRole("link", { name: /Open confirmed transaction/ })).toHaveAttribute(
|
||||
"href",
|
||||
`https://vector.apexscan.org/en/transaction/${"b".repeat(64)}/summary/`,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /0xcccc/ })).toHaveAttribute(
|
||||
"href",
|
||||
`https://basescan.org/tx/0x${"c".repeat(64)}`,
|
||||
);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/v1/resale-dashboard",
|
||||
{ cache: "no-store" },
|
||||
));
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
SurplusDashboardService,
|
||||
type SurplusDashboardSnapshot,
|
||||
} from "../../supplier/src/surplus/dashboard.js";
|
||||
import { renderSurplusDashboardPage } from "../../supplier/src/surplus/dashboardPage.js";
|
||||
import { createSurplusServer } from "../../supplier/src/cli/surplus-seller.js";
|
||||
import type { SurplusControllerStatus } from "../../supplier/src/surplus/controller.js";
|
||||
|
||||
@@ -222,38 +221,21 @@ describe("SurplusDashboardService", () => {
|
||||
expect(getOrderBook).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unified dashboard HTTP surface", () => {
|
||||
it("serves the dashboard, cached API data, and security headers", async () => {
|
||||
describe("private supplier dashboard data surface", () => {
|
||||
it("serves redacted data internally without hosting a dashboard page", async () => {
|
||||
const data = snapshot();
|
||||
const server = createSurplusServer(
|
||||
{ snapshot: () => STATUS, healthy: () => true },
|
||||
{ getSnapshot: async () => data },
|
||||
);
|
||||
|
||||
const page = await request(server).get("/reseller");
|
||||
expect(page.status).toBe(200);
|
||||
expect(page.text).toContain("One capacity pool.");
|
||||
expect(page.text).toContain("Vector marketplace");
|
||||
expect(page.headers["content-security-policy"]).toContain("frame-ancestors 'none'");
|
||||
|
||||
const api = await request(server).get("/dashboard/api");
|
||||
const api = await request(server).get("/internal/resale-dashboard");
|
||||
expect(api.status).toBe(200);
|
||||
expect(api.body).toEqual(data);
|
||||
expect(JSON.stringify(api.body)).not.toContain("test-secret");
|
||||
|
||||
const root = await request(server).get("/");
|
||||
expect(root.status).toBe(302);
|
||||
expect(root.headers.location).toBe("/reseller");
|
||||
expect((await request(server).get("/reseller")).status).toBe(404);
|
||||
expect((await request(server).get("/")).status).toBe(404);
|
||||
server.close();
|
||||
});
|
||||
|
||||
it("renders static HTML without embedding credentials", () => {
|
||||
const html = renderSurplusDashboardPage();
|
||||
expect(html).toContain("/dashboard/api");
|
||||
expect(html).toContain("Surplus Intelligence");
|
||||
expect(html).toContain("Vector marketplace");
|
||||
expect(html).not.toContain("si_seller_");
|
||||
expect(html).not.toContain("sk-or-v1-");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user