Files
zeekayandHanzo Dev da98c0de75 feat(console): one icon set — every glyph comes from @luxfi/ui/icons
74 files imported `lucide-react` directly, pinned ^1.26.0, while @luxfi/ui —
which this console renders alongside — resolves 1.27. Two Lucide copies from two
resolutions in one bundle is how a set stops being one set. Two more glyphs were
not from any icon library at all: the node list drew its network in/out arrows as
the literal characters "↓" and "↑", and the chain onboarding selector drew its
tick as "✓". Those are font glyphs — they take the text metrics, not the icon
box, so they sit on a different baseline and scale with the font, not `size-*`.

One source now: `@luxfi/ui/icons`, Lucide as @hanzo/gui standardises on it and
@luxfi/ui themes it. `lucide-react` is out of package.json, so per-surface drift
is no longer expressible; @luxfi/ui comes in at 7.4.3, which is where `./icons`
lives. The three text glyphs are ArrowDown / ArrowUp / Check.

All 121 distinct icon names this console uses were checked against the 6014
symbols @luxfi/ui/icons exports before the rewrite — zero missing, with a
negative control. `next build` is clean: 78 routes, Next 16.2.11 + Turbopack.

The d3 chart roots (node-globe, node-map, globe-view, donut-chart, fleet-map)
keep their <svg>: those are plots, not glyphs. The command menu's "↑↓" stays —
it is a keyboard legend inside <kbd>, and it means the keys.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 14:57:55 -07:00

496 lines
18 KiB
TypeScript

"use client"
import { getApiUrl } from "@/lib/api"
import { useEffect, useState } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { NodeMap } from "@/components/d3/node-map"
import { GlobeView } from "@/components/d3/globe-view"
import { DonutChart } from "@/components/d3/donut-chart"
import {
Activity,
Server,
Zap,
Clock,
CheckCircle2,
AlertCircle,
Loader2,
RefreshCcw,
TrendingUp,
Globe
} from "@luxfi/ui/icons"
interface AnalyticsData {
totalRequests: number
computeUnits: number
avgResponseTime: number
successRate: number
chainBreakdown: { label: string; value: number; color: string }[]
methodBreakdown: { method: string; count: number; percentage: number }[]
nodeLocations: {
id: string
name: string
point: [number, number]
count: number
status: "running" | "stopped" | "syncing" | "error"
chain?: string
}[]
}
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 {}
const token = localStorage.getItem("bootnode_token")
const apiKey = localStorage.getItem("bootnode_api_key")
if (token) return { "Authorization": `Bearer ${token}` }
if (apiKey) return { "X-API-Key": apiKey }
return {}
}
async function fetchAnalytics() {
setLoading(true)
try {
const headers = getAuthHeaders()
// Fetch real data from multiple endpoints
const apiUrl = getApiUrl()
// 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 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> = {
ethereum: "#627EEA",
polygon: "#8247E5",
arbitrum: "#28A0F0",
optimism: "#FF0420",
base: "#0052FF",
avalanche: "#E84142",
bsc: "#F0B90B",
lux: "#9333EA",
bitcoin: "#F7931A",
solana: "#14F195",
}
// Count nodes by chain for real data
const chainCounts: Record<string, number> = {}
if (Array.isArray(nodes)) {
nodes.forEach((node: any) => {
const chain = node.chain || "unknown"
chainCounts[chain] = (chainCounts[chain] || 0) + 1
})
}
const chainBreakdown = Object.entries(chainCounts).map(([chain, count]) => ({
label: chain.charAt(0).toUpperCase() + chain.slice(1),
value: count,
color: chainColors[chain] || "#6b7280"
}))
// Build node locations from nodes data
// For local Docker nodes, detect user's location via IP geolocation
const getNodeLocation = async (): Promise<[number, number]> => {
try {
// Use free IP geolocation API to get user's location
const geoRes = await fetch("https://ipapi.co/json/", {
signal: AbortSignal.timeout(3000)
})
if (geoRes.ok) {
const geo = await geoRes.json()
if (geo.longitude && geo.latitude) {
return [geo.longitude, geo.latitude]
}
}
} catch {
// Fallback if geolocation fails
}
// Default to San Francisco
return [-122.4194, 37.7749]
}
const defaultLocation = await getNodeLocation()
const nodeLocations = Array.isArray(nodes)
? nodes.map((node: any, idx: number) => {
// For local Docker nodes (provider === "docker"), use local coordinates
// Add small offset per node to prevent exact overlap
const offset: [number, number] = [
(idx % 3 - 1) * 2,
Math.floor(idx / 3) * 2
]
const coords: [number, number] = node.region
? (node.region === "us-west" ? [-122.4194, 37.7749] :
node.region === "us-east" ? [-74.006, 40.7128] :
node.region === "eu-west" ? [-0.1276, 51.5074] :
node.region === "eu-central" ? [13.405, 52.52] :
node.region === "asia-east" ? [139.6917, 35.6895] :
defaultLocation)
: defaultLocation
return {
id: node.id || `node-${idx}`,
name: node.name || `Node ${idx + 1}`,
point: [coords[0] + offset[0], coords[1] + offset[1]] as [number, number],
count: 1,
status: (node.status === "running" ? "running" :
node.status === "syncing" || node.status === "starting" ? "syncing" :
node.status === "error" ? "error" : "stopped") as "running" | "stopped" | "syncing" | "error",
chain: node.chain || node.network
}
})
: []
// Only show method breakdown when we have real usage data from DataStore
// Empty array means "no data" which shows the placeholder message
const methodBreakdown: { method: string; count: number; percentage: number }[] = []
setData({
totalRequests: 0, // Would come from usage tracking API
computeUnits: 0,
avgResponseTime: 0,
successRate: 0,
chainBreakdown,
methodBreakdown,
nodeLocations
})
setLastUpdated(new Date())
} catch (err) {
console.error("Failed to fetch analytics:", err)
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchAnalytics()
}, [])
const hasData = data && (data.totalRequests > 0 || data.nodeLocations.length > 0 || data.chainBreakdown.length > 0)
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>
<p className="text-muted-foreground">
Monitor your API usage, performance metrics, and blockchain data.
</p>
</div>
<div className="flex items-center gap-2">
{lastUpdated && (
<span className="text-sm text-muted-foreground">
Updated {lastUpdated.toLocaleTimeString()}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={fetchAnalytics}
disabled={loading}
>
<RefreshCcw className={`h-4 w-4 mr-2 ${loading ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : data?.totalRequests ? (
<>
<div className="text-2xl font-bold">
{data.totalRequests.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
<TrendingUp className="inline h-3 w-3 mr-1" />
API calls this period
</p>
</>
) : (
<>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground">No data yet</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Compute Units</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : data?.computeUnits ? (
<>
<div className="text-2xl font-bold">
{(data.computeUnits / 1000000).toFixed(2)}M
</div>
<p className="text-xs text-muted-foreground">
Units consumed
</p>
</>
) : (
<>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground">No usage data</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Response Time</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : data?.avgResponseTime ? (
<>
<div className="text-2xl font-bold">{data.avgResponseTime}ms</div>
<p className="text-xs text-muted-foreground">
Average latency
</p>
</>
) : (
<>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground">No latency data</p>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Success Rate</CardTitle>
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{loading ? (
<Loader2 className="h-6 w-6 animate-spin" />
) : data?.successRate ? (
<>
<div className="text-2xl font-bold">{data.successRate}%</div>
<p className="text-xs text-muted-foreground">
Request success rate
</p>
</>
) : (
<>
<div className="text-2xl font-bold text-muted-foreground">-</div>
<p className="text-xs text-muted-foreground">No success data</p>
</>
)}
</CardContent>
</Card>
</div>
{/* Node Map */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Global Node Distribution
</CardTitle>
<CardDescription>
Geographic distribution of your blockchain nodes
</CardDescription>
</div>
{data?.nodeLocations && data.nodeLocations.length > 0 && (
<Badge variant="secondary">
{data.nodeLocations.length} node{data.nodeLocations.length !== 1 ? "s" : ""}
</Badge>
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-[400px] flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : data?.nodeLocations && data.nodeLocations.length > 0 ? (
<div className="flex justify-center">
<GlobeView
data={data.nodeLocations}
width={450}
height={450}
className="mx-auto"
/>
</div>
) : (
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground">
<Server className="h-12 w-12 mb-4 opacity-50" />
<p className="font-medium">No nodes deployed yet</p>
<p className="text-sm">Deploy nodes to see them on the map</p>
<Button variant="outline" className="mt-4" asChild>
<a href="/dashboard/infrastructure/nodes">Deploy a Node</a>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Charts Row */}
<div className="grid gap-6 md:grid-cols-2">
{/* Chain Distribution */}
<Card>
<CardHeader>
<CardTitle>Chain Distribution</CardTitle>
<CardDescription>API requests by blockchain network</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-[250px] flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : data?.chainBreakdown && data.chainBreakdown.length > 0 ? (
<DonutChart
data={data.chainBreakdown}
width={200}
height={200}
showLegend={true}
postfix="%"
centerValue={data.chainBreakdown.length.toString()}
centerLabel="chains"
/>
) : (
<div className="h-[200px] flex flex-col items-center justify-center text-muted-foreground">
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
<p>No chain data available</p>
<p className="text-sm">Start making API calls to see distribution</p>
</div>
)}
</CardContent>
</Card>
{/* Top API Methods */}
<Card>
<CardHeader>
<CardTitle>Top API Methods</CardTitle>
<CardDescription>Most frequently called RPC methods</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-[250px] flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : data?.methodBreakdown && data.totalRequests > 0 ? (
<div className="space-y-3">
{data.methodBreakdown.map((method, idx) => (
<div key={method.method} className="space-y-1">
<div className="flex justify-between text-sm">
<span className="font-mono">{method.method}</span>
<span className="text-muted-foreground">{method.percentage}%</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${method.percentage}%`,
backgroundColor: `hsl(${220 - idx * 20}, 70%, 50%)`
}}
/>
</div>
</div>
))}
</div>
) : (
<div className="h-[200px] flex flex-col items-center justify-center text-muted-foreground">
<AlertCircle className="h-8 w-8 mb-2 opacity-50" />
<p>No method data available</p>
<p className="text-sm">API usage will appear here</p>
</div>
)}
</CardContent>
</Card>
</div>
{/* Info Banner */}
<Card className="bg-muted/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-muted-foreground mt-0.5" />
<div>
<p className="font-medium">Analytics Data</p>
<p className="text-sm text-muted-foreground">
Detailed usage analytics require connection to Hanzo Datastore.
Configure your datastore connection in settings to enable full analytics tracking.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)
}