mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-09-30 20:46:32 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user