fix(web): a failed load is an error, not a zero

Across the dashboard, every data fetch was `.catch(() => null)` followed by
`res?.ok ? await res.json() : []`. A 401 after token expiry, a CORS refusal, a
5xx, or the API being unreachable all became empty data — and the pages rendered
"0 API keys / 0 webhooks / 0 networks / 0 team members" with no error state at
all. A user cannot tell an empty account from a broken one. Only
console.error() recorded anything, where nobody looks.

Adds `load()` / `loadError()` to lib/api.ts — one helper returning a
discriminated `{ok: true, data} | {ok: false, why}` — and routes the dashboard,
analytics, organization and networks pages through it. Each now shows a banner
naming which sources failed and why, while still rendering whatever DID load, so
one dead endpoint no longer blanks a page. The codebase already knew better:
infrastructure/nodes/page.tsx has used setError on both branches all along.

Monitoring needed the opposite treatment and was mostly already correct: it is a
health page, so its `.catch(() => null)` on /health is RIGHT — null falls to an
else branch that pushes an explicit "down / Unreachable" row, and the 3s probe
loop at line 125 likewise reports a failed fetch as "degraded". Not every
swallowed error is a defect; there the failure IS the datum. The one gap was the
nodes list, which silently emitted [] and made an unreachable API look like an
empty fleet — it now pushes a "down" row like the API check above it.

tsc 0 errors, production build exits 0, 0 chunks contain localhost:8000.
This commit is contained in:
2026-07-26 13:21:23 -07:00
parent 4bd5ae988f
commit 74c6042671
6 changed files with 220 additions and 36 deletions
+51 -9
View File
@@ -42,6 +42,7 @@ export default function AnalyticsPage() {
const [loading, setLoading] = useState(true)
const [data, setData] = useState<AnalyticsData | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const [error, setError] = useState<string | null>(null)
function getAuthHeaders(): Record<string, string> {
if (typeof window === "undefined") return {}
@@ -59,17 +60,42 @@ export default function AnalyticsPage() {
// Fetch real data from multiple endpoints
const apiUrl = getApiUrl()
const [nodesRes, chainsRes, keysRes, webhooksRes] = await Promise.all([
fetch(`${apiUrl}/v1/nodes/`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/chains`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/webhooks`, { headers }).catch(() => null),
// Same defect as the dashboard: `.catch(() => null)` plus
// `res?.ok ? json : []` turned a 401, a CORS refusal or a 5xx into empty
// data, and the charts then rendered zeros as though that were measured.
// Failures are surfaced; whatever loaded still renders.
const load = (path: string) =>
fetch(`${apiUrl}${path}`, { headers })
.then(async (r) =>
r.ok
? { ok: true as const, data: await r.json() }
: { ok: false as const, why: `${path} → HTTP ${r.status}` }
)
.catch((e) => ({
ok: false as const,
why: `${path} → ${e instanceof Error ? e.message : String(e)}`,
}))
const [nodesR, chainsR, keysR, hooksR] = await Promise.all([
load("/v1/nodes/"),
load("/v1/chains"),
load("/v1/auth/keys"),
load("/v1/webhooks"),
])
const nodes = nodesRes?.ok ? await nodesRes.json() : []
const chains = chainsRes?.ok ? await chainsRes.json() : []
const keys = keysRes?.ok ? await keysRes.json() : []
const webhooks = webhooksRes?.ok ? await webhooksRes.json() : []
const failed = [nodesR, chainsR, keysR, hooksR].filter((r) => !r.ok)
setError(
failed.length
? `Could not load ${failed.length} of 4 data sources — these figures are incomplete. ${failed
.map((f) => (f as { why: string }).why)
.join("; ")}`
: null
)
const nodes = nodesR.ok ? nodesR.data : []
const chains = chainsR.ok ? chainsR.data : []
const keys = keysR.ok ? keysR.data : []
const webhooks = hooksR.ok ? hooksR.data : []
// Build chain breakdown from actual deployed nodes (not fake data)
const chainColors: Record<string, string> = {
@@ -169,6 +195,7 @@ export default function AnalyticsPage() {
setLastUpdated(new Date())
} catch (err) {
console.error("Failed to fetch analytics:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
@@ -182,6 +209,21 @@ export default function AnalyticsPage() {
return (
<div className="space-y-6">
{/* Failures are stated, not charted as zeros. Without this an expired
token or a 5xx rendered a confident set of empty metrics. */}
{error && (
<Card className="border-destructive/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-medium">Some data could not be loaded</p>
<p className="text-sm text-muted-foreground break-words">{error}</p>
</div>
</div>
</CardContent>
</Card>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Analytics</h1>
@@ -12,9 +12,9 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Globe, Loader2, RefreshCcw, ExternalLink, Activity, Zap, CheckCircle, AlertTriangle, XCircle } from "lucide-react"
import { Globe, Loader2, RefreshCcw, ExternalLink, Activity, Zap, CheckCircle, AlertTriangle, XCircle, AlertCircle } from "lucide-react"
import Link from "next/link"
import { getApiUrl, getAuthHeaders } from "@/lib/api"
import { getApiUrl, getAuthHeaders, load, loadError } from "@/lib/api"
interface NetworkInfo {
id: string
@@ -39,18 +39,20 @@ const STATUS_CONFIG = {
export default function NetworksPage() {
const [networks, setNetworks] = useState<NetworkInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
async function fetchNetworks() {
const apiBase = getApiUrl()
try {
const [chainsRes, nodesRes] = await Promise.all([
fetch(`${apiBase}/v1/chains`, { headers: getAuthHeaders() }).catch(() => null),
fetch(`${apiBase}/v1/nodes/`, { headers: getAuthHeaders() }).catch(() => null),
load("/v1/chains"),
load("/v1/nodes/"),
])
const chainsData = chainsRes?.ok ? await chainsRes.json() : []
const nodesData = nodesRes?.ok ? await nodesRes.json() : []
const chainsData = chainsRes.ok ? (chainsRes.data as any) : []
const nodesData = nodesRes.ok ? (nodesRes.data as any) : []
setError(loadError([chainsRes, nodesRes]))
// Build network list from chains registry + running nodes
const chainEntries = Array.isArray(chainsData) ? chainsData : Object.entries(chainsData?.chains || {}).map(([id, info]: [string, any]) => ({
@@ -108,6 +110,19 @@ export default function NetworksPage() {
return (
<div className="space-y-8">
{error && (
<Card className="border-destructive/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-medium">Some data could not be loaded</p>
<p className="text-sm text-muted-foreground break-words">{error}</p>
</div>
</div>
</CardContent>
</Card>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Networks</h1>
+17 -1
View File
@@ -97,7 +97,23 @@ export default function MonitoringPage() {
})
}
// Node health from running nodes
// Node health from running nodes.
//
// A failed fetch is NOT "you have no nodes". On a health page that
// distinction is the whole product: silently emitting [] made an
// unreachable API look like an empty fleet. The API check above already
// gets this right (it pushes an explicit "down" row), so do the same here
// rather than dropping the section.
if (!nodesRes?.ok) {
checks.push({
service: "Nodes",
status: "down",
latency_ms: 0,
last_check: new Date().toISOString(),
uptime_pct: 0,
details: nodesRes ? `Unreachable (HTTP ${nodesRes.status})` : "Unreachable",
})
}
const nodesData = nodesRes?.ok ? await nodesRes.json() : []
if (Array.isArray(nodesData)) {
for (const node of nodesData) {
+21 -6
View File
@@ -1,11 +1,11 @@
"use client"
import { getApiUrl } from "@/lib/api"
import { getApiUrl, load, loadError } from "@/lib/api"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Building, Users, Settings, Key, Loader2, CreditCard } from "lucide-react"
import { Building, Users, Settings, Key, Loader2, CreditCard, AlertCircle } from "lucide-react"
import Link from "next/link"
import { useAuth } from "@/lib/auth"
@@ -20,6 +20,7 @@ interface OrgStats {
export default function OrganizationPage() {
const { user } = useAuth()
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [stats, setStats] = useState<OrgStats | null>(null)
function getAuthHeaders(): Record<string, string> {
@@ -38,12 +39,13 @@ export default function OrganizationPage() {
const apiUrl = getApiUrl()
const [teamRes, keysRes] = await Promise.all([
fetch(`${apiUrl}/v1/team`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
load("/v1/team"),
load("/v1/auth/keys"),
])
const teamData = teamRes?.ok ? await teamRes.json() : { members: [], total: 0 }
const keysData = keysRes?.ok ? await keysRes.json() : []
const teamData = teamRes.ok ? (teamRes.data as any) : { members: [], total: 0 }
const keysData = keysRes.ok ? (keysRes.data as any) : []
setError(loadError([teamRes, keysRes]))
// Get project name from user context or default
const projectName = user?.name ? `${user.name}'s Project` : "My Project"
@@ -67,6 +69,19 @@ export default function OrganizationPage() {
return (
<div className="space-y-8">
{error && (
<Card className="border-destructive/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-medium">Some data could not be loaded</p>
<p className="text-sm text-muted-foreground break-words">{error}</p>
</div>
</div>
</CardContent>
</Card>
)}
<div>
<h1 className="text-3xl font-bold">Organization</h1>
<p className="text-muted-foreground">
+72 -14
View File
@@ -16,7 +16,8 @@ import {
Webhook,
TrendingUp,
Building,
Loader2
Loader2,
AlertCircle
} from "lucide-react"
interface DashboardStats {
@@ -45,6 +46,7 @@ export default function DashboardPage() {
const [mounted, setMounted] = useState(false)
const [loading, setLoading] = useState(true)
const [stats, setStats] = useState<DashboardStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [networks, setNetworks] = useState<NetworkStatus[]>([])
function getAuthHeaders(): Record<string, string> {
@@ -66,22 +68,50 @@ export default function DashboardPage() {
try {
const headers = getAuthHeaders()
// Fetch multiple endpoints in parallel
// Fetch multiple endpoints in parallel.
//
// Every one of these used to be `.catch(() => null)` followed by
// `res?.ok ? json : []`, so a 401 after token expiry, a CORS refusal, a
// 5xx or the API being down all became empty data — and the page then
// rendered "0 API keys / 0 webhooks / 0 networks / 0 team members" as if
// that were the truth. A user cannot tell an empty account from a broken
// one. Failures are now collected and surfaced; whatever DID load still
// renders, so one dead endpoint does not blank the page.
const apiUrl = getApiUrl()
const [teamRes, keysRes, webhooksRes, chainsRes, nodesRes] = await Promise.all([
fetch(`${apiUrl}/v1/team`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/auth/keys`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/webhooks`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/chains`, { headers }).catch(() => null),
fetch(`${apiUrl}/v1/nodes/`, { headers }).catch(() => null),
const load = (path: string) =>
fetch(`${apiUrl}${path}`, { headers })
.then(async (r) =>
r.ok
? { ok: true as const, data: await r.json() }
: { ok: false as const, why: `${path} → HTTP ${r.status}` }
)
.catch((e) => ({
ok: false as const,
why: `${path} → ${e instanceof Error ? e.message : String(e)}`,
}))
const [team, keys, hooks, chains, nodes] = await Promise.all([
load("/v1/team"),
load("/v1/auth/keys"),
load("/v1/webhooks"),
load("/v1/chains"),
load("/v1/nodes/"),
])
// Parse responses
const teamData = teamRes?.ok ? await teamRes.json() : { members: [], total: 0 }
const keysData = keysRes?.ok ? await keysRes.json() : []
const webhooksData = webhooksRes?.ok ? await webhooksRes.json() : []
const chainsData = chainsRes?.ok ? await chainsRes.json() : []
const nodesData = nodesRes?.ok ? await nodesRes.json() : []
const failed = [team, keys, hooks, chains, nodes].filter((r) => !r.ok)
setError(
failed.length
? `Could not load ${failed.length} of 5 data sources — the numbers below are incomplete. ${failed
.map((f) => (f as { why: string }).why)
.join("; ")}`
: null
)
const teamData = team.ok ? team.data : { members: [], total: 0 }
const keysData = keys.ok ? keys.data : []
const webhooksData = hooks.ok ? hooks.data : []
const chainsData = chains.ok ? chains.data : []
const nodesData = nodes.ok ? nodes.data : []
// Build stats from real data
setStats({
@@ -108,7 +138,10 @@ export default function DashboardPage() {
})))
}
} catch (err) {
// A throw here means the whole load failed, not one endpoint. Record it
// so the page says so instead of showing a confident row of zeros.
console.error("Failed to fetch dashboard data:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
@@ -140,6 +173,31 @@ export default function DashboardPage() {
</p>
</div>
{/* Load failures are stated, not rendered as zeros. Without this the page
showed "0 API keys / 0 webhooks / 0 networks" for an expired token,
a CORS refusal or a dead API — indistinguishable from a new account. */}
{error && (
<Card className="border-destructive/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-sm font-medium">Some data could not be loaded</p>
<p className="text-sm text-muted-foreground break-words">{error}</p>
</div>
<Button
variant="outline"
size="sm"
className="ml-auto shrink-0"
onClick={() => window.location.reload()}
>
Retry
</Button>
</div>
</CardContent>
</Card>
)}
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
+38
View File
@@ -40,6 +40,44 @@ export function getApiUrl(): string {
return getApiConfig().API_URL
}
/**
* Fetch one endpoint and report success or failure HONESTLY.
*
* The pattern this replaces was `fetch(...).catch(() => null)` followed by
* `res?.ok ? await res.json() : []`, repeated across the dashboard. It turned a
* 401 after token expiry, a CORS refusal, a 5xx and an unreachable API all into
* empty data, and the pages rendered "0 API keys / 0 webhooks / 0 networks" as
* though that were measured. A caller cannot distinguish an empty account from
* a broken one, which is the definition of a partial function pretending to be
* total.
*
* Callers get a discriminated result: use the data when `ok`, and surface `why`
* when not. Nothing is invented on failure.
*/
export type Loaded<T = unknown> =
| { ok: true; data: T }
| { ok: false; why: string }
export async function load<T = unknown>(path: string, init?: RequestInit): Promise<Loaded<T>> {
const url = `${getApiUrl()}${path}`
try {
const r = await fetch(url, { headers: getAuthHeaders(), ...init })
if (!r.ok) return { ok: false, why: `${path} → HTTP ${r.status}` }
return { ok: true, data: (await r.json()) as T }
} catch (e) {
return { ok: false, why: `${path} → ${e instanceof Error ? e.message : String(e)}` }
}
}
/** Join the `why` of every failed result into one sentence, or null if all ok. */
export function loadError(results: Loaded[], total = results.length): string | null {
const failed = results.filter((r): r is { ok: false; why: string } => !r.ok)
if (!failed.length) return null
return `Could not load ${failed.length} of ${total} data sources — the figures shown are incomplete. ${failed
.map((f) => f.why)
.join("; ")}`
}
/** Auth headers from token or API key in localStorage */
export function getAuthHeaders(): Record<string, string> {
const token = getAuthToken()