diff --git a/app/globals.css b/app/globals.css
index ed23c36..d65fe44 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -668,6 +668,11 @@ a.hero-status:hover .arrow {
animation: fleet-pulse 2.4s ease-in-out infinite;
}
+.fleet-pulse-stale {
+ background: color-mix(in srgb, var(--paper) 45%, transparent);
+ border: none;
+}
+
@keyframes fleet-pulse {
0%,
100% {
diff --git a/components/site/FleetStatus.test.ts b/components/site/FleetStatus.test.ts
index 3adb0ca..d228664 100644
--- a/components/site/FleetStatus.test.ts
+++ b/components/site/FleetStatus.test.ts
@@ -51,6 +51,16 @@ const stats: Stats = {
amount: "500000",
epoch: 7,
},
+ {
+ txHash: "0xdef",
+ logIndex: 0,
+ blockNumber: 90,
+ timestamp: 1_790_000_000 - 24 * 60 * 60,
+ operator: "0x00000000000000000000000000000000000000a4",
+ bytesDelivered: "1000000000",
+ amount: "500000",
+ epoch: 6,
+ },
],
nodes: {
"0x01": {
@@ -65,6 +75,10 @@ const stats: Stats = {
operator: "0x00000000000000000000000000000000000000a3",
region: "unknown",
},
+ "0x04": {
+ operator: "0x00000000000000000000000000000000000000a4",
+ region: "US",
+ },
},
};
@@ -108,14 +122,31 @@ describe("FleetPanel", () => {
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("4nodes 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("us0x0000…00a4 · settled 24 h ago");
+ expect(text).toContain("de0x0000…00a2n/a0x0000…00a3Σ served");
+ expect(text).not.toContain("idle");
expect(text).toContain("$1.50");
expect(text).not.toContain("catching up");
});
+ // A node pulses while its newest settlement is inside the 24h window, greys
+ // out once it falls outside, and stays an empty ring with none on file.
+ it("pulses recent nodes and greys out stale ones", () => {
+ const { tree } = panel({ status: "ok", stats, fetchedAt });
+ const pulses = findAll(tree, "span")
+ .map((el) => attrs(el).className)
+ .filter((c): c is string => typeof c === "string")
+ .filter((c) => c.startsWith("fleet-pulse"));
+ expect(pulses).toEqual([
+ "fleet-pulse fleet-pulse-active pulse-0",
+ "fleet-pulse fleet-pulse-stale",
+ "fleet-pulse",
+ "fleet-pulse",
+ ]);
+ });
+
it("flags a backfilling index", () => {
const { text } = panel({
status: "ok",
diff --git a/components/site/FleetStatus.tsx b/components/site/FleetStatus.tsx
index 8506e6d..22feb96 100644
--- a/components/site/FleetStatus.tsx
+++ b/components/site/FleetStatus.tsx
@@ -5,6 +5,7 @@ import { PANEL_CAPTIONS } from "@/lib/copy";
import {
FLEET_WINDOW_HOURS,
fleetView,
+ settledInWindow,
type StatsResult,
timeAgo,
} from "@/lib/stats";
@@ -76,19 +77,21 @@ export function FleetPanel({
{n.region}
{n.operator}
-
- {" · "}
- {n.lastSettled !== null && result.status === "ok"
- ? `settled ${timeAgo(n.lastSettled, result.fetchedAt)}`
- : "idle"}
-
+ {n.lastSettled !== null && result.status === "ok" && (
+
+ {" · settled "}
+ {timeAgo(n.lastSettled, result.fetchedAt)}
+
+ )}
))
diff --git a/lib/stats.test.ts b/lib/stats.test.ts
index 79437f1..152ced1 100644
--- a/lib/stats.test.ts
+++ b/lib/stats.test.ts
@@ -6,6 +6,7 @@ import {
formatBytes,
formatUsdc,
loadStats,
+ settledInWindow,
type Stats,
STATS_URL,
TERMINAL_SESSIONS,
@@ -122,6 +123,19 @@ describe("timeAgo", () => {
});
});
+describe("settledInWindow", () => {
+ const now = 1_790_000_000_000;
+ it.each([
+ [1_790_000_000 - 4 * 60, true],
+ [1_790_000_000 - 24 * 3600 + 1, true],
+ [1_790_000_000 - 24 * 3600, false],
+ [1_790_000_000 - 3 * 86_400, false],
+ [null, false],
+ ])("%s → %s", (lastSettled, inWindow) => {
+ expect(settledInWindow(lastSettled, now)).toBe(inWindow);
+ });
+});
+
describe("terminalSessions", () => {
const stats: Stats = {
...base,
diff --git a/lib/stats.ts b/lib/stats.ts
index a202171..dfafc3f 100644
--- a/lib/stats.ts
+++ b/lib/stats.ts
@@ -227,6 +227,18 @@ export type FleetView = {
caughtUp: boolean;
};
+/** Whether a node's newest settlement falls inside the fleet window as of
+ * `now` (ms): its row pulses while this holds and greys out after. */
+export function settledInWindow(
+ lastSettled: number | null,
+ now: number,
+): boolean {
+ return (
+ lastSettled !== null &&
+ now / 1000 - lastSettled < FLEET_WINDOW_HOURS * 60 * 60
+ );
+}
+
/** 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));