diff --git a/AGENTS.md b/AGENTS.md index 604ad92..38972b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,8 @@ pnpm check:og-image # postbuild social-card guard over ./out (runs inside `pnp - `components/site/` — page sections composed by `app/page.tsx` (Hero, Compare, Method, Faq, Contact, …) plus chrome (`Chrome`, `Footer`, `ScrollReveal`, …). Section components are named after their section `id` (e.g. `Contact.tsx` for `id="contact"`); `Hero` is the idiomatic exception for the top `id="intro"` section. - `components/ui/` — low-level primitives (Frame, SectionHeader, Prose, Figure, …). - `lib/` — shared helpers (`links.ts`, `blog.ts`, `faq.ts`, `legal.ts`, `jsonld.tsx`, `schema.ts`, …). `lib/schema.ts` holds the site-level JSON-LD nodes; the per-post `BlogPosting` node is `blogPostingNode` in `lib/blog.ts` because it derives from a post rather than from the site — `blogNode` nests it and `app/blog/[slug]/page.tsx` emits it top-level, so they cannot diverge under the same `@id`. -- `lib/copy.ts` — the site's prose, as plain data. New or edited homepage copy goes here, not inline in a component: the comparison table maps over `COMPARE_ROWS` and `app/llms-full.txt/route.ts` serialises the same strings as markdown, so copy inlined in JSX silently drifts from the mirror. This is not hypothetical — the hero and method stat strips were inline `
` attributes restated by hand in the mirror, and had already drifted. Values as well as sentences: `HERO_FIGURES`, `STACK`, `DEMO_CAPTIONS`. Render prose through `highlightBrand` (`components/ui/brand.tsx`) wherever the sentence names the product. +- `lib/copy.ts` — the site's prose, as plain data. New or edited homepage copy goes here, not inline in a component: the comparison table maps over `COMPARE_ROWS` and `app/llms-full.txt/route.ts` serialises the same strings as markdown, so copy inlined in JSX silently drifts from the mirror. This is not hypothetical — the hero and method stat strips were inline `
` attributes restated by hand in the mirror, and had already drifted. Values as well as sentences: `HERO_FIGURES`, `STACK`, `PANEL_CAPTIONS`. Render prose through `highlightBrand` (`components/ui/brand.tsx`) wherever the sentence names the product. +- `lib/stats.ts` — the live testnet figures behind the hero terminal and the Contact fleet panel. The page stays a static export: both panels render a loading state into the HTML and fill in after mount from `https://data.decdn.org/stats-421614.json`, which the `decdn/stats` indexer rewrites every five minutes (schema: `worker/src/stats.ts` there). Shape check, formatting and view models are pure and unit-tested; `lib/use-stats.ts` is the one-fetch-per-page client hook. The CSP in `public/_headers` allowlists the origin under `connect-src` — change both together. - `test-utils/` — test-only helpers (`react-tree.ts` walks the element tree a server component returns). Nothing under `app/` or `components/` imports from here. - `scripts/` — `check-out.mjs`, the postbuild assertions `pnpm build` runs against `./out`. - `content/blog/` — MDX posts loaded by `lib/blog.ts` and rendered via `app/blog/[slug]/page.tsx`. diff --git a/app/globals.css b/app/globals.css index b403ebd..6c98b89 100644 --- a/app/globals.css +++ b/app/globals.css @@ -303,10 +303,11 @@ a.cta-primary:focus-visible { } /* --- hero terminal ----------------------------------------------------- - Mocks a single decdn CLI session: probe → stream → verify → settle. + Replays recent on-chain settlements from the live stats file + (lib/stats.ts), one per cycle: tx → block → operator → bytes → paid. All lines share one 4.5s loop; each line fades in at its own delay. - Progress bar fills during the "streaming" phase. Cursor blinks - independently. One terminal session restarts every cycle. */ + Progress bar fills during the "delivered" line. Cursor blinks + independently. The cascade restarts every cycle. */ .terminal { font-family: @@ -498,9 +499,11 @@ a.cta-primary:focus-visible { /* --- fleet status panel (§05) ----------------------------------------- Sits on the paper Contact section as an inverted panel. Same "tool panel" vocabulary as .terminal — square corners, dashed border, hard - offset shadow — with an ops-dashboard layout instead of a CLI log: a - list of nodes with pulsing serving indicators plus two aggregate stat - bars that breathe. Not interactive, so no hover transform. */ + offset shadow — with an ops-dashboard layout instead of a CLI log: + registered nodes (region, operator, last settlement) with a pulse for + each one that settled recently, plus two 24h + aggregates over hourly spark strips. Filled from the live stats file + (lib/stats.ts). Not interactive, so no hover transform. */ .fleet { font-family: @@ -663,68 +666,42 @@ a.cta-primary:focus-visible { font-variant-numeric: tabular-nums; } -.fleet-bar { +/* Full-width line for a status or overflow note in place of node rows. */ +.fleet-note { + padding: 2px 0; + opacity: 0.5; +} + +/* Hourly spark strip under each aggregate: one cell per hour of the 24h + window, oldest left, each cell's height its share of the busiest hour + (--level, 0–1). A zero hour keeps a hairline so the strip reads as a + timeline rather than a gap. */ +.fleet-spark { + display: flex; + align-items: flex-end; + gap: 2px; width: 100%; - height: 5px; - border: 1px solid color-mix(in srgb, var(--paper) 22%, transparent); - position: relative; - overflow: hidden; + height: 14px; margin: 4px 0 4px; + border-bottom: 1px solid color-mix(in srgb, var(--paper) 22%, transparent); } -.fleet-bar-fill { - position: absolute; - inset: 0; - width: 70%; +.fleet-spark-cell { + flex: 1; + height: max(1px, calc(var(--level, 0) * 100%)); background: var(--whisper); -} - -.fb-throughput { - animation: fleet-wave 7s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -.fb-revenue { - animation: fleet-wave 8.5s cubic-bezier(0.4, 0, 0.6, 1) infinite; - animation-delay: 2.3s; -} - -/* subtle live-data jitter around a steady-state value — not a dramatic - breathe. Bar stays in a 72–82% band; the two bars run on different - durations + a phase offset so they never sync. */ -@keyframes fleet-wave { - 0%, - 100% { - width: 74%; - } - 15% { - width: 79%; - } - 32% { - width: 73%; - } - 48% { - width: 81%; - } - 63% { - width: 76%; - } - 80% { - width: 78%; - } + transition: height 0.6s cubic-bezier(0.3, 0, 0.2, 1); } @media (prefers-reduced-motion: reduce) { - .fleet-pulse-active, - .fleet-bar-fill { - animation: none; - } .fleet-pulse-active { + animation: none; opacity: 1; transform: none; box-shadow: none; } - .fleet-bar-fill { - width: 80%; + .fleet-spark-cell { + transition: none; } } diff --git a/app/llms-full.txt/route.test.ts b/app/llms-full.txt/route.test.ts index 186c9d3..8845e60 100644 --- a/app/llms-full.txt/route.test.ts +++ b/app/llms-full.txt/route.test.ts @@ -16,6 +16,7 @@ import { } from "@/lib/copy"; import { FAQ_ITEMS } from "@/lib/faq"; import { getLegalDoc, LEGAL_SLUGS } from "@/lib/legal"; +import { STATS_URL } from "@/lib/stats"; const response = GET(); const body = await response.text(); @@ -161,19 +162,10 @@ describe("homepage mirror", () => { expect(body).toContain("the three legal documents"); }); - // The terminal and fleet-dashboard figures are invented sample values. In - // HTML they sit inside a captioned
; plain text strips that framing - // entirely, so mirroring them would be worse than omitting them. - it.each([ - "15.1 GB/s", - "$0.151 /s", - "$0.1309", - "$0.0055", - "$0.0411", - "550 MB", - "probing 18 peers", - ])("omits the demo figure %s", (figure) => { - expect(body).not.toContain(figure); + // The settlement feed and fleet panel are live readings; a mirror built at + // deploy time would freeze them, so it points at their source instead. + it("points at the live stats file rather than mirroring the panels", () => { + expect(body).toContain(STATS_URL); }); }); diff --git a/app/llms-full.txt/route.ts b/app/llms-full.txt/route.ts index 7a448a2..546f478 100644 --- a/app/llms-full.txt/route.ts +++ b/app/llms-full.txt/route.ts @@ -17,6 +17,7 @@ import { import { FAQ_ITEMS } from "@/lib/faq"; import { getLegalDoc, LEGAL_SLUGS, legalUrl } from "@/lib/legal"; import { DOCS_ORIGIN, EMAIL, links, SITE_URL } from "@/lib/links"; +import { STATS_URL } from "@/lib/stats"; // The whole origin as one plain-text document, so an agent grounding on deCDN // fetches this instead of stripping HTML from the homepage, the blog index, @@ -32,9 +33,9 @@ import { DOCS_ORIGIN, EMAIL, links, SITE_URL } from "@/lib/links"; // Post and legal bodies come from the same loaders the routes use. // // Deliberately excluded: the HeroTerminal and FleetStatus figures. They are -// invented sample values, and a plain-text mirror strips exactly the visual -// framing that marks them as a demo, so quoting them here would be worse than -// not mirroring them at all. The tests assert their absence. +// live testnet readings fetched in the browser, and a mirror built once at +// deploy time would freeze a stale copy of them. The mirror points at the +// stats file instead. // Required by Next 16 under `output: "export"` for route handlers that emit // static files at build time. GET-only is the only verb supported in export. @@ -193,7 +194,7 @@ This is every page on decdn.org as one document: the homepage copy, the traditio ${statusBlock("throughout")} -Two notes on reading this file. Field notes and legal documents are reproduced verbatim except that their headings are demoted to nest under the \`###\` each is given here, so the outline of this document is its own. And the homepage's terminal and fleet-dashboard widgets are not mirrored at all: their figures are invented sample values for demonstration, and plain text strips the framing that marks them as a demo. +Two notes on reading this file. Field notes and legal documents are reproduced verbatim except that their headings are demoted to nest under the \`###\` each is given here, so the outline of this document is its own. And the homepage's settlement feed and fleet panel are not mirrored: they show live Arbitrum Sepolia testnet readings, fetched in the browser from ${STATS_URL}, which the deCDN indexer rewrites every five minutes. Read that file for current figures. ## Homepage diff --git a/components/site/FleetStatus.test.ts b/components/site/FleetStatus.test.ts new file mode 100644 index 0000000..3adb0ca --- /dev/null +++ b/components/site/FleetStatus.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { FleetPanel } from "./FleetStatus"; +import { PANEL_CAPTIONS } from "@/lib/copy"; +import type { Stats, StatsResult } from "@/lib/stats"; +import { attrs, findAll, findOne, textOf } from "@/test-utils/react-tree"; + +// Both panels delegate the
/
structure to +// components/ui/PanelFigure, whose own test asserts that the caption sits +// outside the aria-hidden subtree. What is left to check here is that the +// fleet panel composes it with the right caption, and renders each state. +// +// FleetStatus itself calls useStats (useState), so the tree walker can't +// invoke it; FleetPanel is its render half. HeroTerminal is all client state — +// scripts/check-out.mjs greps the built HTML for both captions, which is the +// only check that sees HeroTerminal render. + +const stats: Stats = { + chainId: 421614, + updatedAt: "2026-09-27T12:00:00.000Z", + lastBlock: 100, + caughtUp: true, + totals: { + valueSettled: "1500000", + bytesServed: "3000000000", + settlementCount: 2, + }, + hourly: [ + { + hour: "2026-09-27T11", + valueSettled: "500000", + bytesServed: "1000000000", + settlementCount: 1, + registeredNodes: 3, + }, + { + hour: "2026-09-27T12", + valueSettled: "1000000", + bytesServed: "2000000000", + settlementCount: 1, + registeredNodes: 3, + }, + ], + settlements: [ + { + txHash: "0xabc", + logIndex: 0, + blockNumber: 100, + timestamp: 1_790_000_000 - 4 * 60, + operator: "0x00000000000000000000000000000000000000a1", + bytesDelivered: "1000000000", + amount: "500000", + epoch: 7, + }, + ], + nodes: { + "0x01": { + operator: "0x00000000000000000000000000000000000000a1", + region: "DE", + }, + "0x02": { + operator: "0x00000000000000000000000000000000000000a2", + region: "DE", + }, + "0x03": { + operator: "0x00000000000000000000000000000000000000a3", + region: "unknown", + }, + }, +}; + +const fetchedAt = 1_790_000_000_000; + +const panel = (result: StatsResult) => { + const tree = FleetPanel({ result, className: "block w-full" }); + const [hidden] = findAll(tree, "div").filter( + (el) => attrs(el)["aria-hidden"] === true, + ); + return { tree, text: textOf(hidden) }; +}; + +describe("FleetPanel", () => { + it("renders through PanelFigure", () => { + const { tree } = panel({ status: "loading" }); + const figure = findOne(tree, "figure"); + expect(attrs(figure).className).toBe("block w-full"); + const panels = findAll(tree, "div").filter( + (el) => attrs(el)["aria-hidden"] === true, + ); + expect(panels).toHaveLength(1); + expect(attrs(panels[0]).className).toBe("fleet"); + }); + + it("captions the panel with the fleet caption", () => { + const { tree } = panel({ status: "loading" }); + expect(textOf(findOne(tree, "figcaption"))).toBe(PANEL_CAPTIONS.fleet); + }); + + // The static HTML is this state: it must carry no figures at all. + it("renders no figures while loading", () => { + const { text } = panel({ status: "loading" }); + expect(text).toContain("loading"); + expect(text.replaceAll("24h", "")).not.toMatch(/\d/); + }); + + it("says so when the stats file is unavailable", () => { + expect(panel({ status: "error" }).text).toContain("stats unavailable"); + }); + + it("renders one row per registered node and the 24h aggregates", () => { + const { text } = panel({ status: "ok", stats, fetchedAt }); + expect(text).toContain("3nodes registered"); + expect(text).toContain("de0x0000…00a1 · settled 4 min ago"); + expect(text).toContain("de0x0000…00a2 · idle"); + expect(text).toContain("n/a0x0000…00a3 · idle"); + expect(text).toContain("$1.50"); + expect(text).not.toContain("catching up"); + }); + + it("flags a backfilling index", () => { + const { text } = panel({ + status: "ok", + stats: { ...stats, caughtUp: false }, + fetchedAt, + }); + expect(text).toContain("catching up"); + }); +}); diff --git a/components/site/FleetStatus.tsx b/components/site/FleetStatus.tsx index a18e074..8506e6d 100644 --- a/components/site/FleetStatus.tsx +++ b/components/site/FleetStatus.tsx @@ -1,83 +1,131 @@ -import { DemoFigure } from "@/components/ui/DemoFigure"; -import { DEMO_CAPTIONS } from "@/lib/copy"; +"use client"; -type Node = { - id: string; - active: boolean; - rate: string; -}; +import { PanelFigure } from "@/components/ui/PanelFigure"; +import { PANEL_CAPTIONS } from "@/lib/copy"; +import { + FLEET_WINDOW_HOURS, + fleetView, + type StatsResult, + timeAgo, +} from "@/lib/stats"; +import { useStats } from "@/lib/use-stats"; -const NODES: readonly Node[] = [ - { id: "ber-14", active: true, rate: "2.4 GB/s" }, - { id: "icn-03", active: true, rate: "1.8 GB/s" }, - { id: "sfo-22", active: true, rate: "3.1 GB/s" }, - { id: "tyo-05", active: true, rate: "4.2 GB/s" }, - { id: "fra-11", active: true, rate: "2.1 GB/s" }, - { id: "sgp-02", active: true, rate: "1.5 GB/s" }, - { id: "nyc-07", active: false, rate: "idle" }, - { id: "lax-17", active: false, rate: "idle" }, -] as const; +const EMPTY_LABEL = { + loading: "loading", + error: "stats unavailable", +} as const; -// Every node id, per-node rate and Σ total below is invented; DemoFigure -// carries the caption that says so, outside the aria-hidden subtree. -export function FleetStatus({ className }: { className?: string }) { - const active = NODES.filter((n) => n.active).length; +// Flat strips until the stats load. +const FLAT = Array(FLEET_WINDOW_HOURS).fill(0); + +/** One hourly spark strip: a cell per hour, its height the hour's share of + * the window's busiest hour. */ +function Spark({ levels }: { levels: number[] }) { + return ( +
+ {levels.map((level, i) => ( + + ))} +
+ ); +} + +/** + * The panel itself, from a stats result. Split from `FleetStatus` so the + * rendering is a plain function a test can walk without a React renderer; + * `FleetStatus` only adds the fetch. + */ +export function FleetPanel({ + result, + className, +}: { + result: StatsResult; + className?: string; +}) { + const view = result.status === "ok" ? fleetView(result.stats) : null; return ( -
- decdn · fleet · test + + decdn · fleet · arbitrum sepolia + {view && !view.caughtUp ? " · catching up" : ""} +
- - {active} - / {NODES.length} - - nodes serving + {view ? view.registered : "—"} + nodes registered
- {NODES.map((n, i) => ( -
- {n.id} - - - {n.rate} - + {view ? ( + view.nodes.length > 0 ? ( + view.nodes.map((n, i) => ( +
+ {n.region} + + + {n.operator} + + {" · "} + {n.lastSettled !== null && result.status === "ok" + ? `settled ${timeAgo(n.lastSettled, result.fetchedAt)}` + : "idle"} + + +
+ )) + ) : ( +
no nodes registered yet
+ ) + ) : ( +
+ {EMPTY_LABEL[result.status as keyof typeof EMPTY_LABEL]}
- ))} + )} + {view && view.moreNodes > 0 && ( +
+ + {view.moreNodes} more {view.moreNodes === 1 ? "node" : "nodes"} +
+ )}
- Σ throughput - 15.1 GB/s -
-
- + Σ served · 24h + {view ? view.served24h : "—"}
+
- Σ revenue - $0.151 /s -
-
- + Σ settled · 24h + {view ? view.settled24h : "—"}
+
- + ); } + +// Every figure is read from the live stats file (lib/stats.ts) after mount; +// the static HTML carries the loading state. +export function FleetStatus({ className }: { className?: string }) { + return ; +} diff --git a/components/site/HeroTerminal.tsx b/components/site/HeroTerminal.tsx index ec266f2..16080f2 100644 --- a/components/site/HeroTerminal.tsx +++ b/components/site/HeroTerminal.tsx @@ -1,132 +1,127 @@ "use client"; import { useEffect, useState } from "react"; -import { DemoFigure } from "@/components/ui/DemoFigure"; -import { DEMO_CAPTIONS } from "@/lib/copy"; - -type Session = { - hash: string; - peerCount: string; - probeMs: string; - peers: string; - size: string; - chunks: string; - price: string; - duration: string; -}; - -const SESSIONS: readonly Session[] = [ - { - hash: "c4a8f93142e1…9b37", - peerCount: "18", - probeMs: "41", - peers: "fra-11, ams-04, lhr-06", - size: "13.4 GB", - chunks: "1712", - price: "$0.1309", - duration: "1.27s", - }, - { - hash: "3f8c1ab4e92d…d55a", - peerCount: "12", - probeMs: "34", - peers: "nyc-07, iad-03, ord-12", - size: "550 MB", - chunks: "86", - price: "$0.0055", - duration: "0.48s", - }, - { - hash: "9c21f088a774…3bde", - peerCount: "14", - probeMs: "28", - peers: "sgp-02, kul-05, cgk-09", - size: "4.2 GB", - chunks: "672", - price: "$0.0411", - duration: "0.89s", - }, -]; +import { PanelFigure } from "@/components/ui/PanelFigure"; +import { PANEL_CAPTIONS } from "@/lib/copy"; +import { STATS_URL, terminalSessions, timeAgo } from "@/lib/stats"; +import { useStats } from "@/lib/use-stats"; const CYCLE_MS = 4500; export function HeroTerminal({ className }: { className?: string }) { + const result = useStats(); + const sessions = result.status === "ok" ? terminalSessions(result.stats) : []; const [index, setIndex] = useState(0); useEffect(() => { + if (sessions.length < 2) return; const id = window.setInterval(() => { - setIndex((i) => (i + 1) % SESSIONS.length); + setIndex((i) => (i + 1) % sessions.length); }, CYCLE_MS); return () => window.clearInterval(id); - }, []); + }, [sessions.length]); - // Defensive: if SESSIONS length shrinks across an HMR reload while - // index is stale, modulo keeps it within bounds. - const s = SESSIONS[index % SESSIONS.length]; + const s = sessions.length > 0 ? sessions[index % sessions.length] : null; + const catchingUp = result.status === "ok" && !result.stats.caughtUp; return ( - // Every hash, peer id, latency and settled amount below is invented; - // DemoFigure carries the caption that says so, outside the aria-hidden - // subtree. -
- decdn@node-001 ~ fetch + + arbitrum sepolia ~ settled{catchingUp ? " · catching up" : ""} +
{/* key forces a remount every cycle so the CSS line cascade and progress bar restart cleanly when the session flips. */} -
-
- $ - decdn fetch - blake3:{s.hash} -
+ {s ? ( +
+
+ $ + settled + tx {s.tx} +
-
- → - probing {s.peerCount} peers - · {s.probeMs} ms -
+
+ → + block {s.block} + · epoch {s.epoch} +
-
- → - selected: {s.peers} -
+
+ → + operator {s.operator} + {s.region && · {s.region}} +
-
- → - streaming {s.size} - - - -
+
+ → + delivered {s.size} + + + +
-
- ✓ - verified {s.chunks} chunks - blake3 -
+
+ ✓ + paid + {s.amount} + usdc +
-
- ✓ - settled - {s.price} - in - {s.duration} -
+
+ ✓ + on-chain + + {result.status === "ok" && timeAgo(s.timestamp, result.fetchedAt)} + +
-
- $ - +
+ $ + +
-
- + ) : ( +
+
+ $ + read + {STATS_URL.replace("https://", "")} +
+
+ → + + {result.status === "loading" + ? " loading" + : result.status === "error" + ? " stats unavailable" + : " no settlements indexed yet"} + +
+ {/* Blank lines hold the panel at its seven-line height, so the + hero doesn't shift when the first settlement lands. */} + {[2, 3, 4, 5].map((n) => ( +
+ {"\u00a0"} +
+ ))} +
+ $ + +
+
+ )} + ); } diff --git a/components/site/demo-caption.test.ts b/components/site/demo-caption.test.ts deleted file mode 100644 index 155a3f8..0000000 --- a/components/site/demo-caption.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { FleetStatus } from "./FleetStatus"; -import { DEMO_CAPTIONS } from "@/lib/copy"; -import { attrs, findAll, findOne, textOf } from "@/test-utils/react-tree"; - -// Both demo widgets delegate the
/
structure to -// components/ui/DemoFigure, whose own test asserts that the caption sits -// outside the aria-hidden subtree. What is left to check here is that each -// widget actually composes it, with the right caption. -// -// FleetStatus is a server component and can be walked. HeroTerminal is -// "use client" and calls useState, so it cannot be invoked as a plain -// function — scripts/check-out.mjs greps the built HTML for both captions, -// which is the only check that sees HeroTerminal render. - -const tree = FleetStatus({ className: "block w-full" }); - -describe("FleetStatus", () => { - it("renders through DemoFigure", () => { - const figure = findOne(tree, "figure"); - expect(attrs(figure).className).toBe("block w-full"); - const panels = findAll(tree, "div").filter( - (el) => attrs(el)["aria-hidden"] === true, - ); - expect(panels).toHaveLength(1); - expect(attrs(panels[0]).className).toBe("fleet"); - }); - - it("captions the panel with the fleet hedge", () => { - expect(textOf(findOne(tree, "figcaption"))).toBe(DEMO_CAPTIONS.fleet); - }); - - // The numbers themselves stay — they are part of the design, and they stay - // hidden from assistive tech. Only the framing is new. - it("keeps the invented aggregates inside the hidden panel", () => { - const [panel] = findAll(tree, "div").filter( - (el) => attrs(el)["aria-hidden"] === true, - ); - const text = textOf(panel); - expect(text).toContain("15.1 GB/s"); - expect(text).toContain("$0.151 /s"); - }); -}); diff --git a/components/ui/DemoFigure.test.ts b/components/ui/PanelFigure.test.ts similarity index 82% rename from components/ui/DemoFigure.test.ts rename to components/ui/PanelFigure.test.ts index d67bb33..3f3d88b 100644 --- a/components/ui/DemoFigure.test.ts +++ b/components/ui/PanelFigure.test.ts @@ -1,6 +1,6 @@ import { createElement } from "react"; import { describe, expect, it } from "vitest"; -import { DemoFigure } from "./DemoFigure"; +import { PanelFigure } from "./PanelFigure"; import { asElement, attrs, @@ -9,16 +9,16 @@ import { textOf, } from "@/test-utils/react-tree"; -const CAPTION = "Illustrative deCDN thing. Sample values, not telemetry."; +const CAPTION = "Live deCDN thing. Testnet readings."; -const tree = DemoFigure({ +const tree = PanelFigure({ panelClassName: "fleet", caption: CAPTION, className: "block w-full", children: createElement("div", { className: "inner" }, "15.1 GB/s"), }); -describe("DemoFigure", () => { +describe("PanelFigure", () => { it("wraps the panel in a figure carrying the caller's className", () => { const figure = asElement(tree); expect(figure.type).toBe("figure"); @@ -33,9 +33,9 @@ describe("DemoFigure", () => { expect(attrs(panel[0]).className).toBe("fleet"); }); - // The entire reason this component exists. aria-hidden hides the invented - // numbers from assistive tech but not from text extractors, so the hedge has - // to sit outside the hidden subtree to travel with them. Getting this wrong + // The entire reason this component exists. aria-hidden hides the figures + // from assistive tech but not from text extractors, so the caption has to + // sit outside the hidden subtree to travel with them. Getting this wrong // is a one-line mistake in either widget and invisible in review. it("captions the panel from outside the aria-hidden subtree", () => { const caption = findOne(tree, "figcaption"); diff --git a/components/ui/DemoFigure.tsx b/components/ui/PanelFigure.tsx similarity index 53% rename from components/ui/DemoFigure.tsx rename to components/ui/PanelFigure.tsx index 0660232..0116578 100644 --- a/components/ui/DemoFigure.tsx +++ b/components/ui/PanelFigure.tsx @@ -1,26 +1,25 @@ import type { ReactNode } from "react"; /** - * A decorative demo panel, captioned as illustrative. + * A decorative data panel with a plain-language caption. * - * The panel is `aria-hidden` because its animated figures are noise for a - * screen reader. Text extractors ignore `aria-hidden` entirely, though, and - * every value in both panels is invented — so the caption has to sit *outside* - * the hidden subtree, where a crawler reads it next to the figures it - * qualifies. + * The panel is `aria-hidden` because its cycling, animated figures are noise + * for a screen reader. Text extractors ignore `aria-hidden` entirely, though, + * so the caption — which says where the figures come from and that they are + * testnet readings — has to sit *outside* the hidden subtree, where a crawler + * reads it next to the figures it qualifies. * * That placement is the whole reason this component exists rather than each - * widget writing its own `
`: it is a one-line mistake to make in - * either of them, HeroTerminal is a client component that unit tests can't - * walk, and components/ui/DemoFigure.test.ts asserts the structure once for - * both. The captions themselves live in `DEMO_CAPTIONS` (lib/copy.ts), and - * scripts/check-out.mjs greps the built HTML for them. + * panel writing its own `
`: it is a one-line mistake to make in + * either of them, and components/ui/PanelFigure.test.ts asserts the structure + * once for both. The captions themselves live in `PANEL_CAPTIONS` + * (lib/copy.ts), and scripts/check-out.mjs greps the built HTML for them. * * `className` lands on the `
` rather than the panel; both call sites * pass "block w-full", which behaves identically there (preflight zeroes * figure margin). */ -export function DemoFigure({ +export function PanelFigure({ panelClassName, caption, className, diff --git a/lib/copy.test.ts b/lib/copy.test.ts index 651a264..5c422f7 100644 --- a/lib/copy.test.ts +++ b/lib/copy.test.ts @@ -6,11 +6,11 @@ import { CONTACT_HEADLINE, CONTACT_LEAD, type CompareRow, - DEMO_CAPTIONS, HERO_FIGURES, HERO_HEADLINE, HERO_LEAD, METHOD_STEPS, + PANEL_CAPTIONS, SITE_DESCRIPTION, SITE_TITLE, STACK, @@ -58,8 +58,8 @@ const ALL_STRINGS: readonly [string, string][] = [ [`STACK.${s.name}.name`, s.name], [`STACK.${s.name}.role`, s.role], ]), - ["DEMO_CAPTIONS.terminal", DEMO_CAPTIONS.terminal], - ["DEMO_CAPTIONS.fleet", DEMO_CAPTIONS.fleet], + ["PANEL_CAPTIONS.terminal", PANEL_CAPTIONS.terminal], + ["PANEL_CAPTIONS.fleet", PANEL_CAPTIONS.fleet], ...CONTACT_HEADLINE.map((s, i): [string, string] => [ `CONTACT_HEADLINE[${i}]`, s, @@ -160,16 +160,16 @@ describe("statusBlock", () => { }); }); -describe("DEMO_CAPTIONS", () => { - // Both captions hedge invented figures, so both have to say so and point at - // the disclaimer. HeroTerminal is a client component the tree walker can't - // invoke, which is why its caption is asserted here and its *rendering* is - // asserted by scripts/check-out.mjs against the built HTML. - it.each(Object.entries(DEMO_CAPTIONS))( - "%s names the figures as illustrative and links the disclaimer", +describe("PANEL_CAPTIONS", () => { + // Both captions describe live testnet figures, so both have to say they are + // testnet readings and point at the disclaimer. scripts/check-out.mjs + // asserts each one reaches the built HTML. + it.each(Object.entries(PANEL_CAPTIONS))( + "%s names the figures as live testnet readings and links the disclaimer", (_name, caption) => { - expect(caption).toMatch(/^Illustrative deCDN /); - expect(caption).toContain("not live network telemetry"); + expect(caption).toMatch(/^Live deCDN /); + expect(caption).toContain("Arbitrum Sepolia testnet"); + expect(caption).toContain("not mainnet revenue"); expect(caption).toContain("/legal/disclaimer/"); }, ); diff --git a/lib/copy.ts b/lib/copy.ts index 9bd97c5..8b4c04d 100644 --- a/lib/copy.ts +++ b/lib/copy.ts @@ -188,22 +188,23 @@ export const statusBlock = (scope: string): string => `Status: testnet v0. The protocol runs end-to-end in a test environment; a public testnet and the open-source release are targeted for Q3 2026. The ${TARGET_RATE} figure quoted ${scope} is a public target rate, not a protocol-enforced price.`; /** - * The hedges on the two homepage demo widgets. + * The captions on the two homepage data panels. * - * `aria-hidden` hides the invented figures from assistive tech but does - * nothing to text extractors, so each panel's caption has to sit outside the - * hidden subtree and say the numbers are samples. They live here rather than - * inline so components/ui/DemoFigure.tsx's structural test covers the text - * too, and so a build-output check can grep for them. + * Both panels read the live stats file (lib/stats.ts) in the browser. They are + * `aria-hidden` because they cycle and animate, and that does nothing to text + * extractors, so each caption sits outside the hidden subtree and says what + * the figures are: testnet readings, not mainnet revenue. They live here + * rather than inline so components/ui/PanelFigure.tsx's structural test covers + * the text too, and so a build-output check can grep for them. * - * Deliberately absent from the llms-full.txt mirror: it excludes the widgets - * entirely, so there is nothing there for these to caption. + * Deliberately absent from the llms-full.txt mirror: it points at the stats + * file instead of freezing a reading of it into a static document. */ -export const DEMO_CAPTIONS = { +export const PANEL_CAPTIONS = { terminal: - "Illustrative deCDN fetch session. The BLAKE3 hash, peer identifiers, peer count, latency, payload size, chunk count, settled amount, and duration shown are sample values for demonstration, not live network telemetry. See the disclaimer at /legal/disclaimer/ for forward-looking statements.", + "Live deCDN settlement feed. The most recent Settled events from the FeeRouter contract on the Arbitrum Sepolia testnet: transaction, block, epoch, operator, bytes delivered and USDC paid, read in the browser from the public stats file the deCDN indexer publishes every five minutes. Testnet figures, not mainnet revenue. See the disclaimer at /legal/disclaimer/ for forward-looking statements.", fleet: - "Illustrative deCDN fleet dashboard. The node identifiers, per-node rates, and aggregate throughput and revenue figures shown are sample values for demonstration, not live network telemetry. See the disclaimer at /legal/disclaimer/ for forward-looking statements.", + "Live deCDN fleet panel. Nodes registered in the CapacityBond contract on the Arbitrum Sepolia testnet, each with its self-declared region, operator address and most recent settlement, and the bytes served and USDC settled over the last 24 hours, read in the browser from the public stats file the deCDN indexer publishes every five minutes. Testnet figures, not mainnet revenue. See the disclaimer at /legal/disclaimer/ for forward-looking statements.", } as const; /** Blog index identity. Read by app/blog/page.tsx for its metadata, by diff --git a/lib/stats.test.ts b/lib/stats.test.ts new file mode 100644 index 0000000..79437f1 --- /dev/null +++ b/lib/stats.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + asStats, + FLEET_ROWS, + fleetView, + formatBytes, + formatUsdc, + loadStats, + type Stats, + STATS_URL, + TERMINAL_SESSIONS, + terminalSessions, + timeAgo, + truncateHex, +} from "./stats"; + +const settlement = (i: number) => ({ + txHash: `0x${String(i).padStart(64, "a")}` as const, + logIndex: 0, + blockNumber: 212_345_678 + i, + timestamp: 1_790_000_000 - i * 60, + operator: "0x00000000000000000000000000000000000000a1" as const, + bytesDelivered: "13400000000", + amount: "130900", + epoch: 42, +}); + +const base: Stats = { + chainId: 421614, + updatedAt: "2026-09-27T12:00:00.000Z", + lastBlock: 212_345_700, + caughtUp: true, + totals: { valueSettled: "0", bytesServed: "0", settlementCount: 0 }, + hourly: [], + settlements: [], + nodes: {}, +}; + +describe("STATS_URL", () => { + it("names the Arbitrum Sepolia file on the public bucket", () => { + expect(STATS_URL).toBe("https://data.decdn.org/stats-421614.json"); + }); +}); + +describe("asStats", () => { + it("accepts the indexer's shape, extra fields and all", () => { + const file = { ...base, feeRouter: "0x1", daily: [], poolOwners: {} }; + expect(asStats(file)).toBe(file); + }); + + it("accepts a file with settlements", () => { + expect(asStats({ ...base, settlements: [settlement(0)] })).not.toBeNull(); + }); + + it.each([ + ["null", null], + ["an array", []], + ["another chain", { ...base, chainId: 1 }], + ["an unparseable updatedAt", { ...base, updatedAt: "soon" }], + [ + "a float total", + { ...base, totals: { ...base.totals, bytesServed: "1.5" } }, + ], + ["no nodes", { ...base, nodes: undefined }], + [ + "a malformed settlement", + { ...base, settlements: [{ ...settlement(0), amount: 5 }] }, + ], + [ + "a settlement without a logIndex", + { ...base, settlements: [{ ...settlement(0), logIndex: undefined }] }, + ], + ])("rejects %s", (_name, value) => { + expect(asStats(value)).toBeNull(); + }); +}); + +describe("formatBytes", () => { + it.each([ + ["0", "0 B"], + ["999", "999 B"], + ["550000000", "550.0 MB"], + ["13400000000", "13.4 GB"], + ["2000000000000000000", "2000.0 PB"], + ])("%s → %s", (bytes, text) => { + expect(formatBytes(bytes)).toBe(text); + }); +}); + +describe("formatUsdc", () => { + it.each([ + ["0", "$0.00"], + ["130900", "$0.1309"], + ["318", "$0.000318"], + ["12500000", "$12.50"], + ["40485136", "$40.48"], + ["1234567000000", "$1,234,567.00"], + ])("%s → %s", (base, text) => { + expect(formatUsdc(base)).toBe(text); + }); +}); + +describe("truncateHex", () => { + it("keeps the head and tail", () => { + expect(truncateHex("0x0123456789abcdef")).toBe("0x0123…cdef"); + }); + + it("leaves short values alone", () => { + expect(truncateHex("0x1234")).toBe("0x1234"); + }); +}); + +describe("timeAgo", () => { + const now = 1_790_000_000_000; + it.each([ + [1_790_000_000 - 30, "just now"], + [1_790_000_000 - 4 * 60, "4 min ago"], + [1_790_000_000 - 3 * 3600, "3 h ago"], + [1_790_000_000 - 3 * 86_400, "3 d ago"], + ])("%i → %s", (timestamp, text) => { + expect(timeAgo(timestamp, now)).toBe(text); + }); +}); + +describe("terminalSessions", () => { + const stats: Stats = { + ...base, + settlements: Array.from({ length: 20 }, (_, i) => settlement(i)), + nodes: { + "0x01": { + operator: "0x00000000000000000000000000000000000000A1", + region: "DE", + }, + }, + }; + + it("takes the newest settlements, newest first", () => { + const sessions = terminalSessions(stats); + expect(sessions).toHaveLength(TERMINAL_SESSIONS); + expect(sessions[0].block).toBe("212,345,678"); + }); + + it("formats one settlement for the terminal", () => { + const [s] = terminalSessions(stats); + expect(s).toMatchObject({ + epoch: "42", + operator: "0x0000…00a1", + region: "de", + size: "13.4 GB", + amount: "$0.1309", + }); + expect(s.tx).toMatch(/^0xaaaaaaaa…aaa0$/); + }); + + it("leaves the region out for an operator with no registered node", () => { + const [s] = terminalSessions({ ...stats, nodes: {} }); + expect(s.region).toBeNull(); + }); +}); + +describe("fleetView", () => { + it("lists registered nodes, most recently settled first", () => { + const op = (n: number) => + `0x${String(n).padStart(40, "0")}` as `0x${string}`; + const view = fleetView({ + ...base, + settlements: [ + { ...settlement(0), operator: op(2), timestamp: 300 }, + { ...settlement(1), operator: op(3), timestamp: 200 }, + // An older row for the same operator doesn't move its last settlement. + { ...settlement(2), operator: op(2), timestamp: 100 }, + ], + nodes: { + "0x01": { operator: op(1), region: "unknown" }, + "0x02": { operator: op(2), region: "US" }, + "0x03": { operator: op(3), region: "DE" }, + "0x04": { operator: op(4), region: "SG" }, + }, + }); + expect(view.registered).toBe(4); + expect( + view.nodes.map((n) => [n.region, n.operator, n.lastSettled]), + ).toEqual([ + ["us", "0x0000…0002", 300], + ["de", "0x0000…0003", 200], + ["sg", "0x0000…0004", null], + ["n/a", "0x0000…0001", null], + ]); + }); + + it("folds nodes past the row limit into a count", () => { + const nodes = Object.fromEntries( + Array.from({ length: FLEET_ROWS + 3 }, (_, i) => [ + `0x${i}`, + { operator: `0xa${i}`, region: "DE" }, + ]), + ) as Stats["nodes"]; + const view = fleetView({ ...base, nodes }); + expect(view.nodes).toHaveLength(FLEET_ROWS); + expect(view.moreNodes).toBe(3); + }); + + it("sums the last 24 hourly buckets and scales the sparks", () => { + const hourly = Array.from({ length: 25 }, (_, i) => ({ + hour: `h${i}`, + // The oldest bucket is outside the window and must not count. + valueSettled: i === 0 ? "999000000" : i === 24 ? "2000000" : "0", + bytesServed: i === 24 ? "4000" : i === 23 ? "1000" : "0", + settlementCount: 0, + registeredNodes: 0, + })); + const view = fleetView({ ...base, hourly }); + expect(view.settled24h).toBe("$2.00"); + expect(view.served24h).toBe("5.0 KB"); + expect(view.servedSpark).toHaveLength(24); + expect(view.servedSpark.at(-1)).toBe(1); + expect(view.servedSpark.at(-2)).toBe(0.25); + }); + + it("keeps an idle window flat", () => { + const view = fleetView(base); + expect(view.servedSpark).toEqual(Array(24).fill(0)); + expect(view.served24h).toBe("0 B"); + expect(view.settled24h).toBe("$0.00"); + }); +}); + +describe("loadStats", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("resolves an error result, and retries on the next call", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response("nope", { status: 404 })) + .mockResolvedValueOnce(Response.json(base)); + vi.stubGlobal("fetch", fetch); + + expect(await loadStats()).toEqual({ status: "error" }); + const loaded = await loadStats(); + expect(loaded).toMatchObject({ status: "ok", stats: base }); + // Once loaded, the result is shared: no third request. + expect(await loadStats()).toBe(loaded); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][0]).toBe(STATS_URL); + }); +}); diff --git a/lib/stats.ts b/lib/stats.ts new file mode 100644 index 0000000..a202171 --- /dev/null +++ b/lib/stats.ts @@ -0,0 +1,325 @@ +// Live testnet figures for the two homepage panels (HeroTerminal, FleetStatus). +// +// The decdn/stats Worker indexes the FeeRouter, CapacityBond and PaymentPool +// logs on Arbitrum Sepolia every five minutes and writes the result to a +// public R2 bucket as `stats-.json`. The site stays a static export: +// the panels render an honest "loading" state into the HTML and fill in from +// that file in the browser. The file's CORS policy allows any origin; our own +// CSP has to allow it too (`connect-src` in public/_headers). +// +// Everything in this module is pure except `loadStats`, so the shape check, +// the formatting and the view models are unit-tested without a network. + +export const STATS_CHAIN_ID = 421614; +export const STATS_ORIGIN = "https://data.decdn.org"; +export const STATS_URL = `${STATS_ORIGIN}/stats-${STATS_CHAIN_ID}.json`; + +type Hex = `0x${string}`; + +/** One FeeRouter `Settled` log. Amounts and byte counts are decimal strings: + * USDC in 6-decimal base units, bytes as a raw count. */ +export type SettlementRow = { + txHash: Hex; + logIndex: number; + blockNumber: number; + timestamp: number; + operator: Hex; + bytesDelivered: string; + amount: string; + epoch: number; +}; + +export type HourlyPoint = { + /** UTC hour, "2026-09-23T14". */ + hour: string; + valueSettled: string; + bytesServed: string; + settlementCount: number; + registeredNodes: number; +}; + +/** + * The subset of the stats file the panels read. The full shape (and the + * indexer that writes it) lives in decdn/stats `worker/src/stats.ts`; fields + * we don't use are left out rather than restated. + */ +export type Stats = { + chainId: number; + updatedAt: string; + lastBlock: number; + /** False while the indexer is backfilling: totals are partial and the + * hourly series ends in the past. */ + caughtUp: boolean; + totals: { + valueSettled: string; + bytesServed: string; + settlementCount: number; + }; + hourly: HourlyPoint[]; + /** Newest first. */ + settlements: SettlementRow[]; + /** The CapacityBond registered set, keyed by nodeId. `region` is an + * upper-case ISO 3166-1 alpha-2 code, or `UNKNOWN_REGION`. */ + nodes: Record; +}; + +/** Where the indexer counts a node with no valid region hint. */ +export const UNKNOWN_REGION = "unknown"; + +const isUint = (value: unknown) => + typeof value === "string" && /^\d+$/.test(value); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** The parsed file as `Stats` when it has the shape the panels read, else + * null — an older schema, the wrong chain, or not stats at all. */ +export function asStats(value: unknown): Stats | null { + if (!isRecord(value)) return null; + const s = value as Partial; + const ok = + s.chainId === STATS_CHAIN_ID && + typeof s.updatedAt === "string" && + !Number.isNaN(Date.parse(s.updatedAt)) && + typeof s.lastBlock === "number" && + typeof s.caughtUp === "boolean" && + isRecord(s.totals) && + isUint(s.totals.valueSettled) && + isUint(s.totals.bytesServed) && + typeof s.totals.settlementCount === "number" && + Array.isArray(s.hourly) && + s.hourly.every( + (p) => isRecord(p) && isUint(p.valueSettled) && isUint(p.bytesServed), + ) && + Array.isArray(s.settlements) && + s.settlements.every( + (r) => + isRecord(r) && + typeof r.txHash === "string" && + typeof r.logIndex === "number" && + typeof r.operator === "string" && + typeof r.blockNumber === "number" && + typeof r.timestamp === "number" && + typeof r.epoch === "number" && + isUint(r.bytesDelivered) && + isUint(r.amount), + ) && + isRecord(s.nodes); + return ok ? (s as Stats) : null; +} + +// --- formatting ----------------------------------------------------------- + +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"]; + +/** Raw byte count (decimal string) → "13.4 GB", base-1000 like the stats + * dashboard. Whole bytes stay whole: "0 B", not "0.0 B". */ +export function formatBytes(bytes: string): string { + let value = Number(bytes); + let unit = 0; + while (value >= 1000 && unit < BYTE_UNITS.length - 1) { + value /= 1000; + unit += 1; + } + return `${value.toFixed(unit === 0 ? 0 : 1)} ${BYTE_UNITS[unit]}`; +} + +/** 6-decimal USDC base units → "$0.1309", "$0.000318", "$40.48". Exact (no + * float). Below a dollar every significant digit stays — testnet settlements + * are often fractions of a cent, and rounding them to "$0.00" would say + * nothing; from a dollar up, cents (truncated, never rounded up). */ +export function formatUsdc(base: string): string { + const value = BigInt(base); + const scale = BigInt(1_000_000); + const whole = value / scale; + const digits = (value % scale).toString().padStart(6, "0"); + const frac = + whole > BigInt(0) + ? digits.slice(0, 2) + : digits.replace(/0+$/, "").padEnd(2, "0"); + return `$${whole.toLocaleString("en-US")}.${frac}`; +} + +/** "0x12ab34…cd56". */ +export function truncateHex(hex: string, lead = 6, tail = 4): string { + if (hex.length <= lead + tail + 1) return hex; + return `${hex.slice(0, lead)}…${hex.slice(-tail)}`; +} + +/** Unix seconds relative to `now` (ms) → "just now", "4 min ago", "3 h ago", + * "2 d ago". */ +export function timeAgo(timestamp: number, now: number): string { + const minutes = Math.floor((now / 1000 - timestamp) / 60); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes} min ago`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours} h ago`; + return `${Math.floor(hours / 24)} d ago`; +} + +const sum = (values: string[]) => + values.reduce((acc, v) => acc + BigInt(v), BigInt(0)).toString(); + +// --- view models ---------------------------------------------------------- + +/** How many recent settlements the hero terminal cycles through. */ +export const TERMINAL_SESSIONS = 8; + +export type TerminalSession = { + key: string; + tx: string; + block: string; + epoch: string; + operator: string; + /** The operator's registered region, lower-case, or null if unknown. */ + region: string | null; + size: string; + amount: string; + timestamp: number; +}; + +/** The newest settlements, formatted for the hero terminal. */ +export function terminalSessions(stats: Stats): TerminalSession[] { + const regionOf = new Map(); + for (const node of Object.values(stats.nodes)) { + if (node.region !== UNKNOWN_REGION) { + regionOf.set(node.operator.toLowerCase(), node.region.toLowerCase()); + } + } + return stats.settlements.slice(0, TERMINAL_SESSIONS).map((row) => ({ + key: `${row.txHash}:${row.logIndex}`, + tx: truncateHex(row.txHash, 10, 4), + block: row.blockNumber.toLocaleString("en-US"), + epoch: String(row.epoch), + operator: truncateHex(row.operator), + region: regionOf.get(row.operator.toLowerCase()) ?? null, + size: formatBytes(row.bytesDelivered), + amount: formatUsdc(row.amount), + timestamp: row.timestamp, + })); +} + +/** How many node rows the fleet panel lists. */ +export const FLEET_ROWS = 8; +/** The fleet panel's aggregate window, in hourly buckets. */ +export const FLEET_WINDOW_HOURS = 24; + +export type FleetNode = { + key: string; + /** Lower-case alpha-2 code, or "n/a" for a node with no valid region. */ + region: string; + operator: string; + /** Unix seconds of the operator's newest settlement in the file's recent + * window, or null when it has none there. */ + lastSettled: number | null; +}; + +export type FleetView = { + registered: number; + nodes: FleetNode[]; + /** Nodes beyond FLEET_ROWS, folded into one count. */ + moreNodes: number; + served24h: string; + settled24h: string; + /** Per-hour levels in [0, 1] for the two spark strips, oldest first. */ + servedSpark: number[]; + settledSpark: number[]; + caughtUp: boolean; +}; + +/** Each value as a fraction of the series max; all zeros stay zeros. */ +function levels(values: string[]): number[] { + const big = values.map((v) => BigInt(v)); + const max = big.reduce((a, b) => (b > a ? b : a), BigInt(0)); + if (max === BigInt(0)) return big.map(() => 0); + return big.map((v) => Number((v * BigInt(1000)) / max) / 1000); +} + +/** The registered nodes, most recently settled first, plus the last 24 + * hours of bytes and USDC. */ +export function fleetView(stats: Stats): FleetView { + // `settlements` is newest first, so the first row seen per operator is its + // newest. It holds only the indexer's recent window, so a node absent from + // it has not settled recently — not necessarily never. + const lastSettled = new Map(); + for (const row of stats.settlements) { + const operator = row.operator.toLowerCase(); + if (!lastSettled.has(operator)) lastSettled.set(operator, row.timestamp); + } + const all: FleetNode[] = Object.entries(stats.nodes) + .map(([nodeId, node]) => ({ + key: nodeId, + region: + node.region === UNKNOWN_REGION ? "n/a" : node.region.toLowerCase(), + operator: truncateHex(node.operator), + lastSettled: lastSettled.get(node.operator.toLowerCase()) ?? null, + })) + .sort((a, b) => { + if (a.lastSettled !== b.lastSettled) { + return (b.lastSettled ?? -1) - (a.lastSettled ?? -1); + } + if (a.region !== b.region) { + if (a.region === "n/a") return 1; + if (b.region === "n/a") return -1; + return a.region.localeCompare(b.region); + } + return a.operator.localeCompare(b.operator); + }); + + // Left-padded with empty hours, so the strips always span the full window + // — a fresh index has fewer buckets than that. + const window = stats.hourly.slice(-FLEET_WINDOW_HOURS); + const pad = Array(FLEET_WINDOW_HOURS - window.length).fill("0"); + const served = [...pad, ...window.map((p) => p.bytesServed)]; + const settled = [...pad, ...window.map((p) => p.valueSettled)]; + + return { + registered: Object.keys(stats.nodes).length, + nodes: all.slice(0, FLEET_ROWS), + moreNodes: Math.max(0, all.length - FLEET_ROWS), + served24h: formatBytes(sum(served)), + settled24h: formatUsdc(sum(settled)), + servedSpark: levels(served), + settledSpark: levels(settled), + caughtUp: stats.caughtUp, + }; +} + +// --- loading -------------------------------------------------------------- + +export type StatsResult = + | { status: "loading" } + /** `fetchedAt` (ms) is when the file arrived: the "now" relative times + * are measured from, so rendering stays pure. */ + | { status: "ok"; stats: Stats; fetchedAt: number } + /** No usable file: a network or CORS failure, a timeout, a 404 before the + * first cron tick, or a schema this module doesn't read. */ + | { status: "error" }; + +const FETCH_TIMEOUT_MS = 15_000; + +let pending: Promise | null = null; + +/** + * Fetches and checks the stats file, once per page load. Both panels call + * this; the shared promise means one request between them. Never rejects — + * a failure resolves to `{ status: "error" }`, and a later call retries. + */ +export function loadStats(): Promise { + pending ??= (async (): Promise => { + try { + const res = await fetch(STATS_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!res.ok) throw new Error(`${STATS_URL}: ${res.status}`); + const stats = asStats(await res.json()); + if (!stats) throw new Error(`${STATS_URL}: unexpected shape`); + return { status: "ok", stats, fetchedAt: Date.now() }; + } catch (error) { + console.warn(error); + pending = null; + return { status: "error" }; + } + })(); + return pending; +} diff --git a/lib/use-stats.ts b/lib/use-stats.ts new file mode 100644 index 0000000..d062a26 --- /dev/null +++ b/lib/use-stats.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { loadStats, type StatsResult } from "@/lib/stats"; + +/** The live stats file, as the panels read it. "loading" on the server and on + * first paint, so the static HTML carries the loading state and hydration + * matches; the fetch happens after mount. */ +export function useStats(): StatsResult { + const [result, setResult] = useState({ status: "loading" }); + useEffect(() => { + let live = true; + void loadStats().then((next) => { + if (live) setResult(next); + }); + return () => { + live = false; + }; + }, []); + return result; +} diff --git a/public/_headers b/public/_headers index de5728d..534638c 100644 --- a/public/_headers +++ b/public/_headers @@ -1,5 +1,5 @@ /* - Content-Security-Policy: default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'; upgrade-insecure-requests + Content-Security-Policy: default-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self' https://data.decdn.org; frame-ancestors 'none'; base-uri 'self'; form-action 'none'; object-src 'none'; upgrade-insecure-requests Strict-Transport-Security: max-age=63072000; includeSubDomains; preload X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin diff --git a/scripts/check-out.mjs b/scripts/check-out.mjs index 9fa002f..62dfe91 100644 --- a/scripts/check-out.mjs +++ b/scripts/check-out.mjs @@ -94,17 +94,18 @@ for (const surface of ["llms.txt", "llms-full.txt"]) { for (const url of unique) assertResolves(url, surface); } -// 3. Both demo captions survive into the rendered homepage. FleetStatus is -// covered by a unit test; HeroTerminal is only observable here. +// 3. Both panel captions survive into the rendered homepage. They are the +// only text-extractor-visible statement of what the aria-hidden live +// figures are. const home = read("index.html"); if (home) { const captions = { - "HeroTerminal (fetch session)": "Illustrative deCDN fetch session.", - "FleetStatus (fleet dashboard)": "Illustrative deCDN fleet dashboard.", + "HeroTerminal (settlement feed)": "Live deCDN settlement feed.", + "FleetStatus (fleet panel)": "Live deCDN fleet panel.", }; for (const [name, caption] of Object.entries(captions)) { if (!home.includes(caption)) { - fail(`index.html: ${name} caption missing — invented figures ship bare`); + fail(`index.html: ${name} caption missing — live figures ship bare`); } } diff --git a/types/css.d.ts b/types/css.d.ts index 59f1d85..b469101 100644 --- a/types/css.d.ts +++ b/types/css.d.ts @@ -5,5 +5,6 @@ declare module "react" { "--reveal-delay"?: `${number}ms`; "--mm-i"?: number; "--faq-n"?: number; + "--level"?: number; } }