Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ run = "bun install"
[tasks.test]
description = "Run all tests (Rust unit tests + TS typechecks)"
run = [
"cargo test --manifest-path node/Cargo.toml",
"bun run --cwd apps/coordinator lint",
"bunx --cwd apps/web tsc --noEmit",
"cargo test",
"bun run --cwd apps/web lint",
"bunx --cwd apps/tui tsc --noEmit",
]

[tasks.fmt]
Expand Down
3 changes: 2 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"dev": "bunx next dev",
"build": "bunx next build",
"start": "bunx next start"
"start": "bunx next start",
"lint": "tsc --noEmit"
},
"dependencies": {
"next": "^16.0.0",
Expand Down
79 changes: 5 additions & 74 deletions apps/web/src/app/api/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,10 @@
/**
* GET /api/status — public network health JSON.
*
* Today: returns aggregate-only placeholder data while we stand up the
* status aggregator service (DIP-0014). When deployed, the aggregator
* will be a sibling Railway service running c0mpute in `verifier` mode
* that subscribes to c0mpute/cap/v1 and c0mpute/jobs/* gossipsub
* topics, aggregates, and exposes a JSON endpoint at the internal
* Railway DNS name (status.railway.internal). This handler proxies to
* it.
*
* Env var STATUS_AGGREGATOR_URL points at the aggregator. When unset
* (today), we return a stub payload.
*
* Importantly: only AGGREGATE fields are exposed publicly. Individual
* jobs, customer DIDs, and worker addresses do NOT appear here. See
* DIP-0014 for the wire format and privacy model.
*/

import { NextResponse } from "next/server";

interface StatusPayload {
ok: boolean;
generated_at: string;
network: {
workers_online: number;
workers_with_role: Record<string, number>;
jobs_in_flight: number;
jobs_completed_24h: number;
avg_job_latency_seconds: number | null;
};
// No per-worker, per-job, per-customer fields here.
source: "aggregator" | "stub";
}

const AGGREGATOR_URL = process.env.STATUS_AGGREGATOR_URL ?? null;
import { getStatusPayload } from "@/lib/status";

export async function GET() {
if (AGGREGATOR_URL) {
try {
const r = await fetch(AGGREGATOR_URL, {
// The aggregator is a sibling service on the same private
// network — short timeout, no follow-redirects.
signal: AbortSignal.timeout(2_000),
cache: "no-store",
});
if (r.ok) {
const data = (await r.json()) as Omit<StatusPayload, "source">;
return NextResponse.json(
{ ...data, source: "aggregator" } satisfies StatusPayload,
{ headers: { "cache-control": "public, max-age=15" } },
);
}
} catch {
// fall through to stub
}
}

const stub: StatusPayload = {
ok: true,
generated_at: new Date().toISOString(),
network: {
workers_online: 0,
workers_with_role: {
storage: 0,
transcode: 0,
gateway: 0,
verifier: 0,
},
jobs_in_flight: 0,
jobs_completed_24h: 0,
avg_job_latency_seconds: null,
},
source: "stub",
};
return NextResponse.json(stub, {
headers: { "cache-control": "public, max-age=30" },
const payload = await getStatusPayload();
const maxAge = payload.source === "aggregator" ? 15 : 30;
return NextResponse.json(payload, {
headers: { "cache-control": `public, max-age=${maxAge}` },
});
}
63 changes: 63 additions & 0 deletions apps/web/src/app/api/status/stream/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* GET /api/status/stream — SSE endpoint for live status updates.
*
* Opens a persistent SSE connection. On the server side, polls the
* status source (aggregator or stub) every 15s and pushes payloads to
* the client. When the real aggregator ships with native push support,
* the internal poll can be swapped for a persistent connection.
*
* The client (LiveBadge) subscribes to this stream and triggers
* router.refresh() on each payload so the page re-renders with
* fresh data.
*/

import { NextRequest } from "next/server";
import { getStatusPayload } from "@/lib/status";

const POLL_INTERVAL_MS = 15_000;

export async function GET(_request: NextRequest) {
let aborted = false;

const stream = new ReadableStream({
async start(controller) {
const push = async () => {
if (aborted) return;
try {
const payload = await getStatusPayload();
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`),
);
} catch {
// Silently skip failed polls — the client has a fallback timer.
}
};

await push();

const interval = setInterval(async () => {
if (aborted) {
clearInterval(interval);
return;
}
await push();
}, POLL_INTERVAL_MS);

_request.signal.addEventListener("abort", () => {
aborted = true;
clearInterval(interval);
});
},
cancel() {
aborted = true;
},
});

return new Response(stream, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
},
});
}
4 changes: 4 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
--color-accent-dim: #166534;
--color-rule: #1f2937;
--color-card: #111114;
--color-panel: #0f0f13;
--color-line: #1c1c22;
--color-muted: #555561;
--color-warn: #f6c177;
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", Menlo, Consolas, monospace;
}

Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/app/status/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"use client";

export default function StatusError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="max-w-3xl mx-auto px-6 py-16">
<div className="rounded-2xl border border-red-400/30 bg-red-400/5 p-8">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-red-300">
network status
</p>
<h1 className="mt-2 text-2xl font-bold text-[var(--color-fg)]">
Could not load network snapshot
</h1>
<p className="mt-3 text-sm text-[var(--color-dim)]">
{error?.message ?? "An unknown error occurred."}
</p>
<button
type="button"
onClick={() => reset()}
className="mt-6 rounded-full border border-[var(--color-accent)] px-4 py-2 text-sm text-[var(--color-accent)] hover:bg-[var(--color-accent-dim)]/20 transition-colors"
>
Try again
</button>
</div>
</main>
);
}
39 changes: 39 additions & 0 deletions apps/web/src/app/status/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import DashboardShell from "@/components/dashboard-shell";

export default function StatusLoading() {
return (
<DashboardShell>
<section className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border border-[var(--color-rule)] bg-[var(--color-card)] p-5"
>
<div className="h-3 w-20 animate-pulse rounded bg-[var(--color-rule)]" />
<div className="mt-4 h-8 w-24 animate-pulse rounded bg-[var(--color-line)]" />
<div className="mt-3 h-3 w-32 animate-pulse rounded bg-[var(--color-rule)]" />
</div>
))}
</section>

<section className="grid gap-6 xl:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border border-[var(--color-rule)] bg-[var(--color-card)] p-6"
>
<div className="h-4 w-32 animate-pulse rounded bg-[var(--color-rule)]" />
<div className="mt-4 space-y-3">
{Array.from({ length: 4 }).map((_, j) => (
<div
key={j}
className="h-4 w-full animate-pulse rounded bg-[var(--color-line)]"
/>
))}
</div>
</div>
))}
</section>
</DashboardShell>
);
}
Loading
Loading