mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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]>
678 lines
31 KiB
TypeScript
678 lines
31 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import {
|
|
Activity,
|
|
AlertTriangle,
|
|
CheckCircle,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Code2,
|
|
GripHorizontal,
|
|
Maximize2,
|
|
Minimize2,
|
|
RefreshCcw,
|
|
Send,
|
|
Terminal,
|
|
X,
|
|
XCircle,
|
|
} from "@luxfi/ui/icons"
|
|
import { getApiUrl, getAuthHeaders as getAuth } from "@/lib/api"
|
|
|
|
// ─── Types ─────────────────────────────────────────────
|
|
|
|
interface LogEntry {
|
|
id: string
|
|
status: number
|
|
method: string
|
|
path: string
|
|
latency_ms: number
|
|
timestamp: string
|
|
request_id: string
|
|
ip?: string
|
|
body?: string
|
|
response?: string
|
|
}
|
|
|
|
interface EventEntry {
|
|
id: string
|
|
type: string
|
|
description: string
|
|
timestamp: string
|
|
source: string
|
|
api_version?: string
|
|
data?: Record<string, unknown>
|
|
}
|
|
|
|
interface HealthStatus {
|
|
service: string
|
|
status: "healthy" | "degraded" | "down"
|
|
latency_ms: number
|
|
details?: string
|
|
}
|
|
|
|
interface ShellHistoryItem {
|
|
command: string
|
|
output: string
|
|
timestamp: string
|
|
error?: boolean
|
|
}
|
|
|
|
// ─── Helpers ───────────────────────────────────────────
|
|
|
|
const getAPIURL = () => getApiUrl()
|
|
const getAuthHeaders = () => getAuth()
|
|
|
|
function statusColor(status: number): string {
|
|
if (status < 300) return "text-green-400"
|
|
if (status < 400) return "text-yellow-400"
|
|
return "text-red-400"
|
|
}
|
|
|
|
function statusBg(status: number): string {
|
|
if (status < 300) return "bg-green-500/20 text-green-400 border-green-500/30"
|
|
if (status < 400) return "bg-yellow-500/20 text-yellow-400 border-yellow-500/30"
|
|
return "bg-red-500/20 text-red-400 border-red-500/30"
|
|
}
|
|
|
|
function timeAgo(timestamp: string): string {
|
|
const diff = Date.now() - new Date(timestamp).getTime()
|
|
if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`
|
|
return new Date(timestamp).toLocaleTimeString()
|
|
}
|
|
|
|
// ─── Component ─────────────────────────────────────────
|
|
|
|
export function DeveloperWorkbench() {
|
|
const [open, setOpen] = useState(false)
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [height, setHeight] = useState(350)
|
|
const [tab, setTab] = useState("overview")
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
// Data
|
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
|
const [events, setEvents] = useState<EventEntry[]>([])
|
|
const [health, setHealth] = useState<HealthStatus[]>([])
|
|
const [selectedLog, setSelectedLog] = useState<LogEntry | null>(null)
|
|
const [selectedEvent, setSelectedEvent] = useState<EventEntry | null>(null)
|
|
|
|
// Shell
|
|
const [shellInput, setShellInput] = useState("")
|
|
const [shellHistory, setShellHistory] = useState<ShellHistoryItem[]>([])
|
|
const shellEndRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Overview stats
|
|
const [overviewStats, setOverviewStats] = useState({
|
|
totalRequests: 0,
|
|
errorRate: 0,
|
|
avgLatency: 0,
|
|
activeAlerts: 0,
|
|
})
|
|
|
|
// Drag resize
|
|
const dragRef = useRef<{ startY: number; startHeight: number } | null>(null)
|
|
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
dragRef.current = { startY: e.clientY, startHeight: height }
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!dragRef.current) return
|
|
const delta = dragRef.current.startY - ev.clientY
|
|
const newHeight = Math.max(200, Math.min(window.innerHeight - 100, dragRef.current.startHeight + delta))
|
|
setHeight(newHeight)
|
|
}
|
|
const onUp = () => {
|
|
dragRef.current = null
|
|
document.removeEventListener("mousemove", onMove)
|
|
document.removeEventListener("mouseup", onUp)
|
|
}
|
|
document.addEventListener("mousemove", onMove)
|
|
document.addEventListener("mouseup", onUp)
|
|
}, [height])
|
|
|
|
// ─── Data Fetching ─────────────────────────────────
|
|
|
|
const fetchData = useCallback(async () => {
|
|
const headers = getAuthHeaders()
|
|
|
|
// Fetch health
|
|
try {
|
|
const res = await fetch(`${getAPIURL()}/health`)
|
|
const data = res.ok ? await res.json() : null
|
|
const checks: HealthStatus[] = [{
|
|
service: "Bootnode API",
|
|
status: data?.status === "healthy" ? "healthy" : "down",
|
|
latency_ms: 0,
|
|
details: data ? `v${data.version}` : "Unreachable",
|
|
}]
|
|
|
|
// Check nodes
|
|
const nodesRes = await fetch(`${getAPIURL()}/v1/nodes/`, { headers }).catch(() => null)
|
|
if (nodesRes?.ok) {
|
|
const nodes = await nodesRes.json()
|
|
if (Array.isArray(nodes)) {
|
|
for (const node of nodes.slice(0, 5)) {
|
|
checks.push({
|
|
service: `${node.name}`,
|
|
status: node.status === "running" ? "healthy" : "down",
|
|
latency_ms: 0,
|
|
details: `${node.chain}/${node.network}`,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
setHealth(checks)
|
|
|
|
// Build overview stats
|
|
setOverviewStats({
|
|
totalRequests: logs.length,
|
|
errorRate: logs.length > 0 ? (logs.filter(l => l.status >= 400).length / logs.length) * 100 : 0,
|
|
avgLatency: logs.length > 0 ? Math.round(logs.reduce((a, l) => a + l.latency_ms, 0) / logs.length) : 0,
|
|
activeAlerts: checks.filter(c => c.status !== "healthy").length,
|
|
})
|
|
} catch {
|
|
setHealth([{
|
|
service: "Bootnode API",
|
|
status: "down",
|
|
latency_ms: 0,
|
|
details: "Connection refused",
|
|
}])
|
|
}
|
|
}, [logs])
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
fetchData()
|
|
const interval = setInterval(fetchData, 10000)
|
|
return () => clearInterval(interval)
|
|
}, [open, fetchData])
|
|
|
|
const handleRefresh = useCallback(() => {
|
|
setRefreshing(true)
|
|
fetchData().finally(() => setRefreshing(false))
|
|
}, [fetchData])
|
|
|
|
// ─── Shell ─────────────────────────────────────────
|
|
|
|
async function handleShellSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!shellInput.trim()) return
|
|
|
|
const cmd = shellInput.trim()
|
|
setShellInput("")
|
|
|
|
try {
|
|
let output = ""
|
|
// Parse simple bootnode commands
|
|
if (cmd === "help" || cmd === "bootnode help") {
|
|
output = `Available commands:
|
|
bootnode chains list - List supported chains
|
|
bootnode nodes list - List deployed nodes
|
|
bootnode keys list - List API keys
|
|
bootnode health - Check API health
|
|
bootnode webhooks list - List webhooks
|
|
curl <endpoint> - Make API request`
|
|
} else if (cmd === "bootnode health" || cmd === "health") {
|
|
const res = await fetch(`${getAPIURL()}/health`)
|
|
output = JSON.stringify(await res.json(), null, 2)
|
|
} else if (cmd === "bootnode chains list" || cmd === "chains") {
|
|
const res = await fetch(`${getAPIURL()}/v1/chains`, { headers: getAuthHeaders() })
|
|
const data = res.ok ? await res.json() : { error: res.statusText }
|
|
output = JSON.stringify(data, null, 2)
|
|
} else if (cmd === "bootnode nodes list" || cmd === "nodes") {
|
|
const res = await fetch(`${getAPIURL()}/v1/nodes/`, { headers: getAuthHeaders() })
|
|
const data = res.ok ? await res.json() : { error: res.statusText }
|
|
output = JSON.stringify(data, null, 2)
|
|
} else if (cmd === "bootnode keys list" || cmd === "keys") {
|
|
const res = await fetch(`${getAPIURL()}/v1/auth/keys`, { headers: getAuthHeaders() })
|
|
const data = res.ok ? await res.json() : { error: res.statusText }
|
|
output = JSON.stringify(data, null, 2)
|
|
} else if (cmd === "bootnode webhooks list" || cmd === "webhooks") {
|
|
const res = await fetch(`${getAPIURL()}/v1/webhooks`, { headers: getAuthHeaders() })
|
|
const data = res.ok ? await res.json() : { error: res.statusText }
|
|
output = JSON.stringify(data, null, 2)
|
|
} else if (cmd.startsWith("curl ")) {
|
|
const endpoint = cmd.replace("curl ", "").trim()
|
|
const url = endpoint.startsWith("http") ? endpoint : `${getAPIURL()}${endpoint.startsWith("/") ? "" : "/"}${endpoint}`
|
|
const res = await fetch(url, { headers: getAuthHeaders() })
|
|
const data = await res.json().catch(() => res.text())
|
|
output = typeof data === "string" ? data : JSON.stringify(data, null, 2)
|
|
} else {
|
|
output = `Unknown command: ${cmd}\nType "help" for available commands.`
|
|
}
|
|
|
|
setShellHistory(prev => [...prev, { command: cmd, output, timestamp: new Date().toISOString() }])
|
|
} catch (err) {
|
|
setShellHistory(prev => [...prev, {
|
|
command: cmd,
|
|
output: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
timestamp: new Date().toISOString(),
|
|
error: true,
|
|
}])
|
|
}
|
|
|
|
setTimeout(() => shellEndRef.current?.scrollIntoView({ behavior: "smooth" }), 50)
|
|
}
|
|
|
|
// ─── Toggle Button (when closed) ──────────────────
|
|
|
|
if (!open) {
|
|
return (
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="fixed bottom-0 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 rounded-t-lg border border-b-0 border-zinc-700 bg-zinc-900 px-4 py-1.5 text-sm text-zinc-300 shadow-lg transition hover:bg-zinc-800 hover:text-white"
|
|
>
|
|
<Code2 className="h-4 w-4" />
|
|
Developers
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ─── Panel ─────────────────────────────────────────
|
|
|
|
const panelHeight = expanded ? "100vh" : `${height}px`
|
|
|
|
return (
|
|
<div
|
|
className="fixed bottom-0 left-0 right-0 z-50 flex flex-col border-t border-zinc-700 bg-zinc-950 text-zinc-100 shadow-2xl"
|
|
style={{ height: panelHeight }}
|
|
>
|
|
{/* Drag Handle */}
|
|
{!expanded && (
|
|
<div
|
|
className="flex h-2 cursor-row-resize items-center justify-center hover:bg-zinc-800"
|
|
onMouseDown={handleMouseDown}
|
|
>
|
|
<GripHorizontal className="h-4 w-4 text-zinc-600" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-zinc-800 px-4 py-1">
|
|
<Tabs value={tab} onValueChange={setTab} className="flex-1">
|
|
<div className="flex items-center justify-between">
|
|
<TabsList className="h-8 bg-transparent p-0 gap-0">
|
|
<TabsTrigger
|
|
value="overview"
|
|
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
|
|
>
|
|
Overview
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="logs"
|
|
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
|
|
>
|
|
Logs
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="events"
|
|
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
|
|
>
|
|
Events
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="health"
|
|
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
|
|
>
|
|
Health
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="shell"
|
|
className="h-8 rounded-none border-b-2 border-transparent px-3 text-xs data-[state=active]:border-white data-[state=active]:bg-transparent data-[state=active]:text-white text-zinc-400"
|
|
>
|
|
Shell
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-800" onClick={handleRefresh}>
|
|
<RefreshCcw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-800" onClick={() => setExpanded(!expanded)}>
|
|
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-zinc-400 hover:text-white hover:bg-zinc-800" onClick={() => { setOpen(false); setExpanded(false) }}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ─── Overview Tab ─────────────────────── */}
|
|
<TabsContent value="overview" className="mt-0 flex-1 overflow-auto p-4">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<p className="text-xs text-zinc-500 mb-1">API Requests</p>
|
|
<p className="text-2xl font-bold">{overviewStats.totalRequests}</p>
|
|
<p className="text-xs text-zinc-500">this session</p>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<p className="text-xs text-zinc-500 mb-1">Error Rate</p>
|
|
<p className={`text-2xl font-bold ${overviewStats.errorRate > 5 ? "text-red-400" : "text-green-400"}`}>
|
|
{overviewStats.errorRate.toFixed(1)}%
|
|
</p>
|
|
<p className="text-xs text-zinc-500">of requests</p>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<p className="text-xs text-zinc-500 mb-1">Avg Latency</p>
|
|
<p className="text-2xl font-bold">{overviewStats.avgLatency}ms</p>
|
|
<p className="text-xs text-zinc-500">response time</p>
|
|
</div>
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<p className="text-xs text-zinc-500 mb-1">Health</p>
|
|
<div className="flex items-center gap-2">
|
|
{health.every(h => h.status === "healthy") ? (
|
|
<>
|
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
|
<span className="text-green-400 font-medium">All healthy</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
|
<span className="text-yellow-400 font-medium">{overviewStats.activeAlerts} issues</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
|
<h3 className="text-sm font-medium mb-3">Quick Actions</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => setTab("logs")}>
|
|
View Logs
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => setTab("health")}>
|
|
Health Check
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => setTab("shell")}>
|
|
Open Shell
|
|
</Button>
|
|
<Button size="sm" variant="outline" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800" onClick={() => window.open(`${getAPIURL()}/docs`, "_blank")}>
|
|
API Docs
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ─── Logs Tab ─────────────────────────── */}
|
|
<TabsContent value="logs" className="mt-0 flex-1 overflow-auto">
|
|
<div className="flex h-full">
|
|
<div className={`flex-1 overflow-auto ${selectedLog ? "border-r border-zinc-800" : ""}`}>
|
|
{logs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-500">
|
|
<Activity className="h-8 w-8 mb-3 opacity-50" />
|
|
<p className="text-sm font-medium">No logs yet</p>
|
|
<p className="text-xs mt-1">API request logs will appear here as requests are made</p>
|
|
<p className="text-xs mt-3 text-zinc-600">Try running commands in the Shell tab</p>
|
|
</div>
|
|
) : (
|
|
<table className="w-full text-xs">
|
|
<thead className="sticky top-0 bg-zinc-950 border-b border-zinc-800">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left text-zinc-500 font-medium">Status</th>
|
|
<th className="px-3 py-2 text-left text-zinc-500 font-medium">Method</th>
|
|
<th className="px-3 py-2 text-left text-zinc-500 font-medium">Path</th>
|
|
<th className="px-3 py-2 text-right text-zinc-500 font-medium">Latency</th>
|
|
<th className="px-3 py-2 text-right text-zinc-500 font-medium">Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.map((log) => (
|
|
<tr
|
|
key={log.id}
|
|
className={`cursor-pointer border-b border-zinc-900 hover:bg-zinc-900/50 ${selectedLog?.id === log.id ? "bg-zinc-900" : ""}`}
|
|
onClick={() => setSelectedLog(log)}
|
|
>
|
|
<td className="px-3 py-2">
|
|
<span className={`inline-flex items-center rounded border px-1.5 py-0.5 font-mono text-[10px] font-medium ${statusBg(log.status)}`}>
|
|
{log.status} {log.status < 300 ? "OK" : log.status < 500 ? "ERR" : "FAIL"}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 font-mono text-zinc-300">{log.method}</td>
|
|
<td className="px-3 py-2 font-mono text-zinc-400 max-w-[300px] truncate">{log.path}</td>
|
|
<td className="px-3 py-2 text-right font-mono text-zinc-400">{log.latency_ms}ms</td>
|
|
<td className="px-3 py-2 text-right text-zinc-500">{timeAgo(log.timestamp)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{selectedLog && (
|
|
<div className="w-96 overflow-auto p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium">Request Details</h3>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-zinc-400" onClick={() => setSelectedLog(null)}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-3 text-xs">
|
|
<div>
|
|
<span className="text-zinc-500">Method</span>
|
|
<p className="font-mono text-zinc-200">{selectedLog.method} {selectedLog.path}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Status</span>
|
|
<p className={statusColor(selectedLog.status)}>{selectedLog.status}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Request ID</span>
|
|
<p className="font-mono text-zinc-400">{selectedLog.request_id}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Latency</span>
|
|
<p className="font-mono text-zinc-300">{selectedLog.latency_ms}ms</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Time</span>
|
|
<p className="text-zinc-300">{new Date(selectedLog.timestamp).toLocaleString()}</p>
|
|
</div>
|
|
{selectedLog.response && (
|
|
<div>
|
|
<span className="text-zinc-500">Response</span>
|
|
<pre className="mt-1 rounded bg-zinc-900 p-2 text-[10px] text-zinc-400 overflow-auto max-h-48">
|
|
{selectedLog.response}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ─── Events Tab ───────────────────────── */}
|
|
<TabsContent value="events" className="mt-0 flex-1 overflow-auto">
|
|
<div className="flex h-full">
|
|
<div className={`flex-1 overflow-auto ${selectedEvent ? "border-r border-zinc-800" : ""}`}>
|
|
{events.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-500">
|
|
<Activity className="h-8 w-8 mb-3 opacity-50" />
|
|
<p className="text-sm font-medium">No events yet</p>
|
|
<p className="text-xs mt-1">Webhook events and chain events will appear here</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-zinc-900">
|
|
{events.map((event) => (
|
|
<div
|
|
key={event.id}
|
|
className={`px-4 py-3 cursor-pointer hover:bg-zinc-900/50 ${selectedEvent?.id === event.id ? "bg-zinc-900" : ""}`}
|
|
onClick={() => setSelectedEvent(event)}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium text-zinc-200">{event.type}</span>
|
|
<span className="text-[10px] text-zinc-500">{timeAgo(event.timestamp)}</span>
|
|
</div>
|
|
<p className="text-xs text-zinc-500 mt-0.5">{event.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{selectedEvent && (
|
|
<div className="w-96 overflow-auto p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-medium">Event</h3>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-zinc-400" onClick={() => setSelectedEvent(null)}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
<div className="space-y-3 text-xs">
|
|
<div>
|
|
<span className="text-zinc-500">Type</span>
|
|
<p className="font-mono text-zinc-200">{selectedEvent.type}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Event ID</span>
|
|
<p className="font-mono text-zinc-400">{selectedEvent.id}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Source</span>
|
|
<p className="text-zinc-300">{selectedEvent.source}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-zinc-500">Time</span>
|
|
<p className="text-zinc-300">{new Date(selectedEvent.timestamp).toLocaleString()}</p>
|
|
</div>
|
|
{selectedEvent.data && (
|
|
<div>
|
|
<span className="text-zinc-500">Event Data</span>
|
|
<pre className="mt-1 rounded bg-zinc-900 p-2 text-[10px] text-zinc-400 overflow-auto max-h-48">
|
|
{JSON.stringify(selectedEvent.data, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* ─── Health Tab ────────────────────────── */}
|
|
<TabsContent value="health" className="mt-0 flex-1 overflow-auto p-4">
|
|
<div className="grid grid-cols-4 gap-3 mb-4">
|
|
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-3 text-center">
|
|
<p className="text-xs text-zinc-500">All</p>
|
|
<p className="text-lg font-bold">{health.length}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-red-900/30 bg-red-950/20 p-3 text-center">
|
|
<p className="text-xs text-zinc-500">Critical</p>
|
|
<p className="text-lg font-bold text-red-400">{health.filter(h => h.status === "down").length}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-yellow-900/30 bg-yellow-950/20 p-3 text-center">
|
|
<p className="text-xs text-zinc-500">Degraded</p>
|
|
<p className="text-lg font-bold text-yellow-400">{health.filter(h => h.status === "degraded").length}</p>
|
|
</div>
|
|
<div className="rounded-lg border border-green-900/30 bg-green-950/20 p-3 text-center">
|
|
<p className="text-xs text-zinc-500">Healthy</p>
|
|
<p className="text-lg font-bold text-green-400">{health.filter(h => h.status === "healthy").length}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{health.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-8 text-zinc-500">
|
|
<CheckCircle className="h-8 w-8 mb-3 opacity-50" />
|
|
<p className="text-sm">Checking services...</p>
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-zinc-800 overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead className="bg-zinc-900">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left text-zinc-500 font-medium">Service</th>
|
|
<th className="px-4 py-2 text-left text-zinc-500 font-medium">Status</th>
|
|
<th className="px-4 py-2 text-right text-zinc-500 font-medium">Latency</th>
|
|
<th className="px-4 py-2 text-left text-zinc-500 font-medium">Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{health.map((check) => (
|
|
<tr key={check.service} className="border-t border-zinc-800">
|
|
<td className="px-4 py-2 font-medium text-zinc-200">{check.service}</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center gap-1.5">
|
|
{check.status === "healthy" ? (
|
|
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
|
|
) : check.status === "degraded" ? (
|
|
<AlertTriangle className="h-3.5 w-3.5 text-yellow-500" />
|
|
) : (
|
|
<XCircle className="h-3.5 w-3.5 text-red-500" />
|
|
)}
|
|
<span className="capitalize">{check.status}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2 text-right font-mono text-zinc-400">
|
|
{check.latency_ms > 0 ? `${check.latency_ms}ms` : "—"}
|
|
</td>
|
|
<td className="px-4 py-2 text-zinc-500">{check.details || "—"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* ─── Shell Tab ─────────────────────────── */}
|
|
<TabsContent value="shell" className="mt-0 flex flex-col flex-1 overflow-hidden">
|
|
<div className="flex-1 overflow-auto p-4 font-mono text-xs">
|
|
{shellHistory.length === 0 && (
|
|
<div className="text-zinc-600 mb-4">
|
|
<pre className="text-zinc-500 mb-2">{`
|
|
____ _ _
|
|
| __ ) ___ ___ | |_ _ __ ___ __| | ___
|
|
| _ \\ / _ \\ / _ \\| __| '_ \\ / _ \\ / _\` |/ _ \\
|
|
| |_) | (_) | (_) | |_| | | | (_) | (_| | __/
|
|
|____/ \\___/ \\___/ \\__|_| |_|\\___/ \\__,_|\\___|
|
|
`}</pre>
|
|
<p>Welcome to Bootnode Shell!</p>
|
|
<p>Type <span className="text-zinc-300">help</span> to see available commands.</p>
|
|
<p>Use <span className="text-zinc-300">curl /v1/health</span> to test API endpoints.</p>
|
|
</div>
|
|
)}
|
|
|
|
{shellHistory.map((item, i) => (
|
|
<div key={i} className="mb-3">
|
|
<div className="flex items-center gap-2 text-zinc-300">
|
|
<span className="text-green-500">$</span>
|
|
<span>{item.command}</span>
|
|
</div>
|
|
<pre className={`mt-1 whitespace-pre-wrap ${item.error ? "text-red-400" : "text-zinc-500"}`}>
|
|
{item.output}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
<div ref={shellEndRef} />
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleShellSubmit}
|
|
className="flex items-center gap-2 border-t border-zinc-800 px-4 py-2"
|
|
>
|
|
<span className="text-green-500 text-xs font-mono">$</span>
|
|
<Input
|
|
value={shellInput}
|
|
onChange={(e) => setShellInput(e.target.value)}
|
|
placeholder="Enter a command..."
|
|
className="flex-1 h-8 border-0 bg-transparent text-xs font-mono text-zinc-200 placeholder:text-zinc-600 focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
autoFocus={tab === "shell"}
|
|
/>
|
|
<Button type="submit" variant="ghost" size="icon" className="h-7 w-7 text-zinc-500 hover:text-white">
|
|
<Send className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</form>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|