mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
# Conflicts: # console/package.json # console/pnpm-lock.yaml # console/pnpm-workspace.yaml Co-authored-by: Hanzo Dev <[email protected]>
452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
"use client"
|
|
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
Clock,
|
|
Loader2,
|
|
RefreshCcw,
|
|
Server,
|
|
Wifi,
|
|
XCircle,
|
|
Zap,
|
|
} from "@luxfi/ui/icons"
|
|
import { getApiUrl, getAuthHeaders } from "@/lib/api"
|
|
|
|
interface HealthCheck {
|
|
service: string
|
|
status: "healthy" | "degraded" | "down"
|
|
latency_ms: number
|
|
last_check: string
|
|
uptime_pct: number
|
|
details?: string
|
|
}
|
|
|
|
interface Alert {
|
|
id: string
|
|
severity: "critical" | "warning" | "info"
|
|
service: string
|
|
message: string
|
|
started_at: string
|
|
resolved_at: string | null
|
|
}
|
|
|
|
interface RPCLog {
|
|
id: string
|
|
method: string
|
|
chain: string
|
|
status: number
|
|
latency_ms: number
|
|
timestamp: string
|
|
request_id: string
|
|
}
|
|
|
|
export default function MonitoringPage() {
|
|
const [loading, setLoading] = useState(true)
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
const [healthChecks, setHealthChecks] = useState<HealthCheck[]>([])
|
|
const [alerts, setAlerts] = useState<Alert[]>([])
|
|
const [rpcLogs, setRpcLogs] = useState<RPCLog[]>([])
|
|
|
|
async function fetchMonitoringData() {
|
|
const apiBase = getApiUrl()
|
|
const headers = getAuthHeaders()
|
|
|
|
try {
|
|
const [healthRes, nodesRes] = await Promise.all([
|
|
fetch(`${apiBase}/health`).catch(() => null),
|
|
fetch(`${apiBase}/v1/nodes/`, { headers }).catch(() => null),
|
|
])
|
|
|
|
// Build health checks from available services
|
|
const checks: HealthCheck[] = []
|
|
|
|
// API health
|
|
if (healthRes?.ok) {
|
|
const health = await healthRes.json()
|
|
checks.push({
|
|
service: "Bootnode API",
|
|
status: health.status === "healthy" ? "healthy" : "degraded",
|
|
latency_ms: 0,
|
|
last_check: new Date().toISOString(),
|
|
uptime_pct: 99.9,
|
|
details: `v${health.version || "2.0.0"}`,
|
|
})
|
|
} else {
|
|
checks.push({
|
|
service: "Bootnode API",
|
|
status: "down",
|
|
latency_ms: 0,
|
|
last_check: new Date().toISOString(),
|
|
uptime_pct: 0,
|
|
details: "Unreachable",
|
|
})
|
|
}
|
|
|
|
// 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) {
|
|
checks.push({
|
|
service: `${node.name} (${node.chain}/${node.network})`,
|
|
status: node.status === "healthy" ? "healthy" : node.status === "starting" ? "degraded" : "down",
|
|
latency_ms: node.metrics?.latency_ms || 0,
|
|
last_check: new Date().toISOString(),
|
|
uptime_pct: node.status === "healthy" ? 99.5 : 0,
|
|
details: node.status,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Add infrastructure services
|
|
const infraServices = [
|
|
{ name: "PostgreSQL", endpoint: `${apiBase}/health` },
|
|
{ name: "Redis Cache", endpoint: `${apiBase}/health` },
|
|
{ name: "IAM (hanzo.id)", endpoint: "https://iam.hanzo.ai/healthz" },
|
|
]
|
|
|
|
for (const svc of infraServices) {
|
|
try {
|
|
const start = performance.now()
|
|
const res = await fetch(svc.endpoint, { signal: AbortSignal.timeout(3000) }).catch(() => null)
|
|
const latency = Math.round(performance.now() - start)
|
|
checks.push({
|
|
service: svc.name,
|
|
status: res?.ok ? "healthy" : "degraded",
|
|
latency_ms: latency,
|
|
last_check: new Date().toISOString(),
|
|
uptime_pct: res?.ok ? 99.9 : 95.0,
|
|
})
|
|
} catch {
|
|
checks.push({
|
|
service: svc.name,
|
|
status: "degraded",
|
|
latency_ms: 0,
|
|
last_check: new Date().toISOString(),
|
|
uptime_pct: 95.0,
|
|
details: "Timeout",
|
|
})
|
|
}
|
|
}
|
|
|
|
setHealthChecks(checks)
|
|
} catch (e) {
|
|
console.error("Monitoring fetch error:", e)
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchMonitoringData()
|
|
const interval = setInterval(fetchMonitoringData, 30000) // Refresh every 30s
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
function handleRefresh() {
|
|
setRefreshing(true)
|
|
fetchMonitoringData()
|
|
}
|
|
|
|
const healthy = healthChecks.filter((h) => h.status === "healthy").length
|
|
const degraded = healthChecks.filter((h) => h.status === "degraded").length
|
|
const down = healthChecks.filter((h) => h.status === "down").length
|
|
const overallStatus = down > 0 ? "down" : degraded > 0 ? "degraded" : "healthy"
|
|
|
|
const STATUS_ICON = {
|
|
healthy: <CheckCircle className="h-5 w-5 text-green-500" />,
|
|
degraded: <AlertTriangle className="h-5 w-5 text-yellow-500" />,
|
|
down: <XCircle className="h-5 w-5 text-red-500" />,
|
|
}
|
|
|
|
const SEVERITY_BADGE = {
|
|
critical: "destructive" as const,
|
|
warning: "secondary" as const,
|
|
info: "outline" as const,
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Monitoring</h1>
|
|
<p className="text-muted-foreground">
|
|
Service health, alerts, and system performance
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
{STATUS_ICON[overallStatus]}
|
|
<span className="text-sm font-medium capitalize">{overallStatus === "healthy" ? "All Systems Operational" : overallStatus === "degraded" ? "Some Issues Detected" : "Service Disruption"}</span>
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing}>
|
|
<RefreshCcw className={`mr-2 h-4 w-4 ${refreshing ? "animate-spin" : ""}`} />
|
|
Refresh
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Cards */}
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium">Services</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : healthChecks.length}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">monitored</p>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className={healthy > 0 ? "border-green-200" : ""}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
|
Healthy
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : healthy}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className={degraded > 0 ? "border-yellow-200" : ""}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
|
Degraded
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-yellow-600">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : degraded}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className={down > 0 ? "border-red-200" : ""}>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
|
<XCircle className="h-4 w-4 text-red-500" />
|
|
Down
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-red-600">
|
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : down}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Tabs defaultValue="health">
|
|
<TabsList>
|
|
<TabsTrigger value="health">Health Checks</TabsTrigger>
|
|
<TabsTrigger value="alerts">
|
|
Alerts
|
|
{alerts.filter((a) => !a.resolved_at).length > 0 && (
|
|
<Badge variant="destructive" className="ml-2 h-5 w-5 rounded-full p-0 text-[10px]">
|
|
{alerts.filter((a) => !a.resolved_at).length}
|
|
</Badge>
|
|
)}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="logs">RPC Logs</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="health" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Service Health</CardTitle>
|
|
<CardDescription>Real-time health status of all services</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : healthChecks.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<Server className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p>No services being monitored</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Service</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead className="text-right">Latency</TableHead>
|
|
<TableHead className="text-right">Uptime</TableHead>
|
|
<TableHead>Last Check</TableHead>
|
|
<TableHead>Details</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{healthChecks.map((check) => (
|
|
<TableRow key={check.service}>
|
|
<TableCell className="font-medium">{check.service}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2">
|
|
{STATUS_ICON[check.status]}
|
|
<span className="capitalize">{check.status}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-right font-mono">
|
|
{check.latency_ms > 0 ? `${check.latency_ms}ms` : "—"}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<span className={check.uptime_pct >= 99 ? "text-green-600" : check.uptime_pct >= 95 ? "text-yellow-600" : "text-red-600"}>
|
|
{check.uptime_pct.toFixed(1)}%
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm">
|
|
{new Date(check.last_check).toLocaleTimeString()}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground text-sm">
|
|
{check.details || "—"}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="alerts" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Active Alerts</CardTitle>
|
|
<CardDescription>Current and recent alerts across all services</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{alerts.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<CheckCircle className="h-12 w-12 mx-auto mb-4 text-green-500 opacity-50" />
|
|
<p className="font-medium">No active alerts</p>
|
|
<p className="text-sm mt-1">All systems are running normally</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Severity</TableHead>
|
|
<TableHead>Service</TableHead>
|
|
<TableHead>Message</TableHead>
|
|
<TableHead>Started</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{alerts.map((alert) => (
|
|
<TableRow key={alert.id}>
|
|
<TableCell>
|
|
<Badge variant={SEVERITY_BADGE[alert.severity]}>{alert.severity}</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-medium">{alert.service}</TableCell>
|
|
<TableCell>{alert.message}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{new Date(alert.started_at).toLocaleString()}
|
|
</TableCell>
|
|
<TableCell>
|
|
{alert.resolved_at ? (
|
|
<Badge variant="outline">Resolved</Badge>
|
|
) : (
|
|
<Badge variant="destructive">Active</Badge>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="logs" className="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>RPC Request Logs</CardTitle>
|
|
<CardDescription>Recent API and RPC requests</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{rpcLogs.length === 0 ? (
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
<Activity className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
|
<p className="font-medium">No recent requests</p>
|
|
<p className="text-sm mt-1">RPC logs will appear here as requests are made</p>
|
|
</div>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Method</TableHead>
|
|
<TableHead>Chain</TableHead>
|
|
<TableHead className="text-right">Latency</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
<TableHead>Request ID</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{rpcLogs.map((log) => (
|
|
<TableRow key={log.id}>
|
|
<TableCell>
|
|
<Badge variant={log.status < 300 ? "default" : log.status < 500 ? "secondary" : "destructive"}>
|
|
{log.status}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="font-mono text-sm">{log.method}</TableCell>
|
|
<TableCell>{log.chain}</TableCell>
|
|
<TableCell className="text-right font-mono text-sm">{log.latency_ms}ms</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{new Date(log.timestamp).toLocaleTimeString()}
|
|
</TableCell>
|
|
<TableCell className="font-mono text-xs text-muted-foreground">{log.request_id}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
)
|
|
}
|