Lux/Zoo Cloud: static-export build + Zoo brand IAM clientId (⚠️ SSR→export config change) (#2)

* web: static-export build + Zoo/Lux brand IAM clientId fixes

Two source-only changes for the Lux/Zoo Cloud console (web/), with no
committed build output.

next.config.ts (SSR -> static export):
- output "standalone" -> "export" + images.unoptimized for Cloudflare Pages.
- Drop headers()/redirects() (unsupported in export); moved to public/_headers
  and public/_redirects for Pages. Keep poweredByHeader: false.

Static-export compatibility (no SSR/server runtime):
- layout.tsx: bake brand from NEXT_PUBLIC_BRAND/BRAND; drop force-dynamic +
  headers() host detection (client still auto-detects brand from
  window.location.hostname via lib/brand.ts).
- app/api/health, app/llms.txt: mark route handlers force-static.
- Remove app/dashboard/api/keys route handler (backend proxy unsupported in
  export; dashboard talks to brand.apiUrl directly).
- nodes/[id]: split into server page.tsx (generateStaticParams) + client
  node-detail-client.tsx so the dynamic segment is export-compatible.
- unified-navigation.tsx: point API Keys link at the real /dashboard/api-keys
  page (the removed route handler is gone).

IAM clientId fixes (real Casdoor apps):
- lux: "lux-app-client-id" -> "lux-cloud".
- zoo: "zoo-app-client-id" -> "zoo-console" (the old value is not a registered
  Casdoor client and returns "Invalid client_id", breaking zoo.cloud login).

* web: restore skillsOrg field on BrandConfig (fixes broken export)

The /agents page references brand.skillsOrg at 4 sites, but the field was
dropped from BrandConfig during the consolidate-bootnode merge (it had been
added in 3ef7773). This broke `next build` (output: export) on this branch
AND on main. Restores the field + each brand's GitHub skills org, exactly as
originally introduced.

---------

Co-authored-by: Antje Worring <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
antje
2026-06-16 23:26:25 -07:00
committed by GitHub
co-authored by Hanzo Dev
parent 766da81cc6
commit 852bb7cf15
11 changed files with 424 additions and 556 deletions
+2
View File
@@ -1,3 +1,5 @@
export const dynamic = "force-static"
export function GET() {
return Response.json({ status: "ok" })
}
-95
View File
@@ -1,95 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
try {
// Forward request to the API backend
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/v1/auth/api-keys/keys`, {
headers: {
'Authorization': request.headers.get('authorization') || '',
'Content-Type': 'application/json',
},
})
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to fetch API keys' },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
// Forward request to the API backend
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/v1/auth/api-keys/keys`, {
method: 'POST',
headers: {
'Authorization': request.headers.get('authorization') || '',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to create API key' },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const keyId = searchParams.get('id')
if (!keyId) {
return NextResponse.json(
{ error: 'Key ID is required' },
{ status: 400 }
)
}
// Forward request to the API backend
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/v1/auth/api-keys/keys/${keyId}`, {
method: 'DELETE',
headers: {
'Authorization': request.headers.get('authorization') || '',
},
})
if (!response.ok) {
return NextResponse.json(
{ error: 'Failed to delete API key' },
{ status: response.status }
)
}
return NextResponse.json({ success: true })
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}
@@ -0,0 +1,365 @@
"use client"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Server, ArrowLeft, Play, Square, Trash2, RefreshCcw, Copy, Check,
Activity, HardDrive, Cpu, MemoryStick, Network, Loader2, Terminal
} from "lucide-react"
interface NodeDetails {
id: string
name: string
chain: string
network: string
provider: string
status: string
endpoint: string | null
ws_endpoint?: string | null
beacon_api?: string | null
validator_api?: string | null
execution_client?: string
consensus_client?: string
mev_enabled?: boolean
containers?: string[]
created_at: string
metrics?: {
current_block?: number
highest_block?: number
sync_progress?: number
syncing?: boolean
peer_count?: number
chain_id?: number
cpu_usage?: number
memory_usage?: number
disk_usage?: number
network_rx?: number
network_tx?: number
}
error?: string
}
export default function NodeDetailClient() {
const params = useParams()
const router = useRouter()
const nodeId = params.id as string
const [node, setNode] = useState<NodeDetails | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const [rpcInput, setRpcInput] = useState('{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}')
const [rpcResult, setRpcResult] = useState<string | null>(null)
const [rpcLoading, setRpcLoading] = useState(false)
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 fetchNode() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/${nodeId}`,
{ headers: getAuthHeaders() }
)
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
const data = await res.json()
setNode(data)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchNode()
const interval = setInterval(fetchNode, 5000)
return () => clearInterval(interval)
}, [nodeId])
async function handleAction(action: "start" | "stop" | "delete") {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
try {
if (action === "delete") {
if (!confirm("Are you sure you want to delete this node?")) return
await fetch(`${apiUrl}/v1/nodes/${nodeId}`, {
method: "DELETE",
headers: getAuthHeaders()
})
router.push("/dashboard/infrastructure/nodes")
} else {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}/${action}`, {
method: "POST",
headers: getAuthHeaders()
})
if (res.ok) fetchNode()
}
} catch (e) {
console.error(`Failed to ${action}:`, e)
}
}
async function sendRpcRequest() {
if (!node?.endpoint) return
setRpcLoading(true)
try {
const payload = JSON.parse(rpcInput)
const res = await fetch(node.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
const data = await res.json()
setRpcResult(JSON.stringify(data, null, 2))
} catch (e) {
setRpcResult(`Error: ${e}`)
} finally {
setRpcLoading(false)
}
}
function copyToClipboard(text: string, key: string) {
navigator.clipboard.writeText(text)
setCopied(key)
setTimeout(() => setCopied(null), 2000)
}
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)
}
if (error || !node) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back
</Button>
<Card className="border-destructive">
<CardContent className="p-6">
<p className="text-destructive">{error || "Node not found"}</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="h-6 w-6" />
{node.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={node.status === "running" ? "default" : "secondary"}>
{node.status}
</Badge>
<Badge variant="outline">{node.chain}</Badge>
<Badge variant="outline">{node.network}</Badge>
{node.execution_client && <Badge variant="secondary">{node.execution_client}</Badge>}
{node.consensus_client && <Badge variant="secondary">{node.consensus_client}</Badge>}
{node.mev_enabled && <Badge className="bg-purple-500">MEV</Badge>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchNode}>
<RefreshCcw className="h-4 w-4 mr-2" /> Refresh
</Button>
{node.status === "running" ? (
<Button variant="outline" size="sm" onClick={() => handleAction("stop")}>
<Square className="h-4 w-4 mr-2" /> Stop
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => handleAction("start")}>
<Play className="h-4 w-4 mr-2" /> Start
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => handleAction("delete")}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
</div>
</div>
{/* Endpoints */}
<Card>
<CardHeader>
<CardTitle>Endpoints</CardTitle>
<CardDescription>Connection URLs for your node</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{node.endpoint && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">RPC Endpoint</p>
<code className="text-sm text-muted-foreground">{node.endpoint}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.endpoint!, "rpc")}>
{copied === "rpc" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{node.ws_endpoint && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">WebSocket Endpoint</p>
<code className="text-sm text-muted-foreground">{node.ws_endpoint}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.ws_endpoint!, "ws")}>
{copied === "ws" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{node.beacon_api && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">Beacon API</p>
<code className="text-sm text-muted-foreground">{node.beacon_api}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.beacon_api!, "beacon")}>
{copied === "beacon" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Metrics */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Activity className="h-4 w-4" /> Sync Status
</CardTitle>
</CardHeader>
<CardContent>
{node.metrics?.syncing ? (
<>
<p className="text-2xl font-bold">{node.metrics.sync_progress?.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">
Block {node.metrics.current_block?.toLocaleString()} / {node.metrics.highest_block?.toLocaleString()}
</p>
</>
) : node.metrics?.current_block ? (
<>
<p className="text-2xl font-bold text-green-500">Synced</p>
<p className="text-xs text-muted-foreground">Block {node.metrics.current_block?.toLocaleString()}</p>
</>
) : (
<p className="text-2xl font-bold text-muted-foreground">-</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Network className="h-4 w-4" /> Peers
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.peer_count ?? "-"}</p>
<p className="text-xs text-muted-foreground">Connected peers</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" /> CPU
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.cpu_usage?.toFixed(1) ?? "-"}%</p>
<p className="text-xs text-muted-foreground">Usage</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<MemoryStick className="h-4 w-4" /> Memory
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.memory_usage?.toFixed(1) ?? "-"} GB</p>
<p className="text-xs text-muted-foreground">Used</p>
</CardContent>
</Card>
</div>
{/* RPC Console */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> RPC Console
</CardTitle>
<CardDescription>Send JSON-RPC requests to your node</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">Request</label>
<textarea
value={rpcInput}
onChange={(e) => setRpcInput(e.target.value)}
className="w-full h-24 mt-1 p-3 font-mono text-sm border rounded-lg bg-muted"
placeholder='{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
/>
</div>
<Button onClick={sendRpcRequest} disabled={rpcLoading || !node.endpoint}>
{rpcLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Send Request
</Button>
{rpcResult && (
<div>
<label className="text-sm font-medium">Response</label>
<pre className="mt-1 p-3 font-mono text-sm bg-muted rounded-lg overflow-auto max-h-48">
{rpcResult}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Containers */}
{node.containers && node.containers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Containers</CardTitle>
<CardDescription>Docker containers for this node</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{node.containers.map((cid) => (
<div key={cid} className="flex items-center gap-2 p-2 bg-muted rounded font-mono text-sm">
<Server className="h-4 w-4" />
{cid}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}
@@ -1,365 +1,14 @@
"use client"
// Server wrapper for the dynamic node-detail route.
// `output: "export"` requires generateStaticParams for dynamic segments. The
// page itself is fully client-rendered (reads the id via useParams and fetches
// node data at runtime), so we prerender a single placeholder shell and let the
// client resolve any real node id at runtime.
import NodeDetailClient from "./node-detail-client"
import { useState, useEffect } from "react"
import { useParams, useRouter } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Server, ArrowLeft, Play, Square, Trash2, RefreshCcw, Copy, Check,
Activity, HardDrive, Cpu, MemoryStick, Network, Loader2, Terminal
} from "lucide-react"
interface NodeDetails {
id: string
name: string
chain: string
network: string
provider: string
status: string
endpoint: string | null
ws_endpoint?: string | null
beacon_api?: string | null
validator_api?: string | null
execution_client?: string
consensus_client?: string
mev_enabled?: boolean
containers?: string[]
created_at: string
metrics?: {
current_block?: number
highest_block?: number
sync_progress?: number
syncing?: boolean
peer_count?: number
chain_id?: number
cpu_usage?: number
memory_usage?: number
disk_usage?: number
network_rx?: number
network_tx?: number
}
error?: string
export function generateStaticParams() {
return [{ id: "node" }]
}
export default function NodeDetailPage() {
const params = useParams()
const router = useRouter()
const nodeId = params.id as string
const [node, setNode] = useState<NodeDetails | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<string | null>(null)
const [rpcInput, setRpcInput] = useState('{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}')
const [rpcResult, setRpcResult] = useState<string | null>(null)
const [rpcLoading, setRpcLoading] = useState(false)
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 fetchNode() {
try {
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/v1/nodes/${nodeId}`,
{ headers: getAuthHeaders() }
)
if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`)
const data = await res.json()
setNode(data)
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchNode()
const interval = setInterval(fetchNode, 5000)
return () => clearInterval(interval)
}, [nodeId])
async function handleAction(action: "start" | "stop" | "delete") {
const apiUrl = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"
try {
if (action === "delete") {
if (!confirm("Are you sure you want to delete this node?")) return
await fetch(`${apiUrl}/v1/nodes/${nodeId}`, {
method: "DELETE",
headers: getAuthHeaders()
})
router.push("/dashboard/infrastructure/nodes")
} else {
const res = await fetch(`${apiUrl}/v1/nodes/${nodeId}/${action}`, {
method: "POST",
headers: getAuthHeaders()
})
if (res.ok) fetchNode()
}
} catch (e) {
console.error(`Failed to ${action}:`, e)
}
}
async function sendRpcRequest() {
if (!node?.endpoint) return
setRpcLoading(true)
try {
const payload = JSON.parse(rpcInput)
const res = await fetch(node.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
const data = await res.json()
setRpcResult(JSON.stringify(data, null, 2))
} catch (e) {
setRpcResult(`Error: ${e}`)
} finally {
setRpcLoading(false)
}
}
function copyToClipboard(text: string, key: string) {
navigator.clipboard.writeText(text)
setCopied(key)
setTimeout(() => setCopied(null), 2000)
}
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
)
}
if (error || !node) {
return (
<div className="space-y-4">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back
</Button>
<Card className="border-destructive">
<CardContent className="p-6">
<p className="text-destructive">{error || "Node not found"}</p>
</CardContent>
</Card>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Server className="h-6 w-6" />
{node.name}
</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant={node.status === "running" ? "default" : "secondary"}>
{node.status}
</Badge>
<Badge variant="outline">{node.chain}</Badge>
<Badge variant="outline">{node.network}</Badge>
{node.execution_client && <Badge variant="secondary">{node.execution_client}</Badge>}
{node.consensus_client && <Badge variant="secondary">{node.consensus_client}</Badge>}
{node.mev_enabled && <Badge className="bg-purple-500">MEV</Badge>}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchNode}>
<RefreshCcw className="h-4 w-4 mr-2" /> Refresh
</Button>
{node.status === "running" ? (
<Button variant="outline" size="sm" onClick={() => handleAction("stop")}>
<Square className="h-4 w-4 mr-2" /> Stop
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => handleAction("start")}>
<Play className="h-4 w-4 mr-2" /> Start
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => handleAction("delete")}>
<Trash2 className="h-4 w-4 mr-2" /> Delete
</Button>
</div>
</div>
{/* Endpoints */}
<Card>
<CardHeader>
<CardTitle>Endpoints</CardTitle>
<CardDescription>Connection URLs for your node</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{node.endpoint && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">RPC Endpoint</p>
<code className="text-sm text-muted-foreground">{node.endpoint}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.endpoint!, "rpc")}>
{copied === "rpc" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{node.ws_endpoint && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">WebSocket Endpoint</p>
<code className="text-sm text-muted-foreground">{node.ws_endpoint}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.ws_endpoint!, "ws")}>
{copied === "ws" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{node.beacon_api && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div>
<p className="text-sm font-medium">Beacon API</p>
<code className="text-sm text-muted-foreground">{node.beacon_api}</code>
</div>
<Button variant="ghost" size="icon" onClick={() => copyToClipboard(node.beacon_api!, "beacon")}>
{copied === "beacon" ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
</CardContent>
</Card>
{/* Metrics */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Activity className="h-4 w-4" /> Sync Status
</CardTitle>
</CardHeader>
<CardContent>
{node.metrics?.syncing ? (
<>
<p className="text-2xl font-bold">{node.metrics.sync_progress?.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">
Block {node.metrics.current_block?.toLocaleString()} / {node.metrics.highest_block?.toLocaleString()}
</p>
</>
) : node.metrics?.current_block ? (
<>
<p className="text-2xl font-bold text-green-500">Synced</p>
<p className="text-xs text-muted-foreground">Block {node.metrics.current_block?.toLocaleString()}</p>
</>
) : (
<p className="text-2xl font-bold text-muted-foreground">-</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Network className="h-4 w-4" /> Peers
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.peer_count ?? "-"}</p>
<p className="text-xs text-muted-foreground">Connected peers</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" /> CPU
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.cpu_usage?.toFixed(1) ?? "-"}%</p>
<p className="text-xs text-muted-foreground">Usage</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<MemoryStick className="h-4 w-4" /> Memory
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{node.metrics?.memory_usage?.toFixed(1) ?? "-"} GB</p>
<p className="text-xs text-muted-foreground">Used</p>
</CardContent>
</Card>
</div>
{/* RPC Console */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" /> RPC Console
</CardTitle>
<CardDescription>Send JSON-RPC requests to your node</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm font-medium">Request</label>
<textarea
value={rpcInput}
onChange={(e) => setRpcInput(e.target.value)}
className="w-full h-24 mt-1 p-3 font-mono text-sm border rounded-lg bg-muted"
placeholder='{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}'
/>
</div>
<Button onClick={sendRpcRequest} disabled={rpcLoading || !node.endpoint}>
{rpcLoading ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
Send Request
</Button>
{rpcResult && (
<div>
<label className="text-sm font-medium">Response</label>
<pre className="mt-1 p-3 font-mono text-sm bg-muted rounded-lg overflow-auto max-h-48">
{rpcResult}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Containers */}
{node.containers && node.containers.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Containers</CardTitle>
<CardDescription>Docker containers for this node</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{node.containers.map((cid) => (
<div key={cid} className="flex items-center gap-2 p-2 bg-muted rounded font-mono text-sm">
<Server className="h-4 w-4" />
{cid}
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
return <NodeDetailClient />
}
+10 -38
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next"
import { headers } from "next/headers"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { ThemeProvider } from "@/components/theme-provider"
@@ -8,7 +7,10 @@ import { Providers } from "@/app/providers"
import { Toaster } from "@/components/ui/sonner"
import "@/app/globals.css"
// Runtime brand detection for SSR metadata (reads BRAND env var, not baked NEXT_PUBLIC_*)
// Brand baked at build time from NEXT_PUBLIC_BRAND/BRAND (default lux for this
// deployment). Static export: no request-time host detection. The client still
// auto-detects the brand from window.location.hostname (see lib/brand.ts), so
// runtime CSS/logos remain host-correct; only SSR metadata is baked here.
const brands: Record<string, { name: string; tagline: string; domain: string; icon: string; defaultTheme: string }> = {
bootnode: { name: "Bootnode", tagline: "Blockchain Infrastructure", domain: "bootno.de", icon: "/logo/bootnode-icon.svg", defaultTheme: "light" },
hanzo: { name: "Hanzo Web3", tagline: "Web3 Infrastructure", domain: "web3.hanzo.ai", icon: "/logo/hanzo-icon.svg", defaultTheme: "dark" },
@@ -17,43 +19,13 @@ const brands: Record<string, { name: string; tagline: string; domain: string; ic
zoo: { name: "Zoo Labs", tagline: "Decentralized AI Infrastructure", domain: "web3.zoo.ngo", icon: "/logo/zoo-icon.svg", defaultTheme: "dark" },
}
// Detect brand from Host header. Returns null when no host match (caller
// then falls back to BRAND/NEXT_PUBLIC_BRAND env vars).
// IMPORTANT: Do NOT bake the env-var fallback in here — `dynamic = "force-dynamic"`
// makes layout requests runtime, so callers should read process.env directly
// at request time and avoid relying on this helper for the env-var path.
function getServerBrandKeyFromHost(host: string | null): string | null {
if (host) {
if (host.includes("lux.network") || host.includes("lux.cloud")) return "lux"
if (host.includes("pars.network") || host.includes("pars.id")) return "pars"
if (host.includes("zoo.ngo") || host.includes("zoo.network") || host.includes("zoo.cloud")) return "zoo"
if (host.includes("hanzo.ai")) return "hanzo"
if (host.includes("bootno.de") || host.includes("bootnode.io")) return "bootnode"
}
return null
}
async function getServerBrand() {
// Prefer Host header; fall back to BRAND or NEXT_PUBLIC_BRAND env vars.
// dynamic = "force-dynamic" plus headers() forces request-time evaluation,
// so process.env reads pick up runtime values, not the Dockerfile default.
const hdrs = await headers()
const host = hdrs.get("host")
const key = getServerBrandKeyFromHost(host) ||
(process.env.BRAND || process.env.NEXT_PUBLIC_BRAND || "bootnode").toLowerCase()
function getServerBrand() {
const key = (process.env.BRAND || process.env.NEXT_PUBLIC_BRAND || "bootnode").toLowerCase()
return brands[key] || brands.bootnode
}
// Force dynamic rendering so BRAND env var is read at runtime, not build time
export const dynamic = "force-dynamic"
export async function generateMetadata(): Promise<Metadata> {
// Calling headers() forces Next.js to evaluate this at request time, never at build time.
const hdrs = await headers()
const host = hdrs.get("host")
const key = getServerBrandKeyFromHost(host) ||
(process.env.BRAND || process.env.NEXT_PUBLIC_BRAND || "bootnode").toLowerCase()
const b = brands[key] || brands.bootnode
export function generateMetadata(): Metadata {
const b = getServerBrand()
return {
title: {
default: `${b.name} - ${b.tagline}`,
@@ -83,12 +55,12 @@ export async function generateMetadata(): Promise<Metadata> {
}
}
export default async function RootLayout({
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
const b = await getServerBrand()
const b = getServerBrand()
return (
<html
lang="en"
+2
View File
@@ -1,5 +1,7 @@
import { getBrand } from "@/lib/brand"
export const dynamic = "force-static"
export async function GET() {
const brand = getBrand()
const apiDomain = brand.name === "Hanzo" ? "api.hanzo.ai" : `api.${brand.domain}`
+1 -1
View File
@@ -104,7 +104,7 @@ export function UnifiedNavigation() {
icon: Code,
description: "API keys, webhooks, and developer tools",
children: [
{ name: "API Keys", href: "/dashboard/api/keys", icon: Key },
{ name: "API Keys", href: "/dashboard/api-keys", icon: Key },
{ name: "Webhooks", href: "/dashboard/api/webhooks", icon: Webhook },
{ name: "Documentation", href: "/dashboard/api/docs", icon: FileText },
{ name: "Playground", href: "/dashboard/api/playground", icon: Zap },
+12 -2
View File
@@ -13,6 +13,7 @@ export type BrandConfig = {
apiUrl: string
wsUrl: string
statusUrl: string
skillsOrg: string // GitHub org for skills repo (e.g., "bootnode", "hanzoai")
defaultTheme: "light" | "dark"
colors: {
primary: string
@@ -59,6 +60,7 @@ const brands: Record<string, BrandConfig> = {
apiUrl: "https://api.bootno.de",
wsUrl: "wss://ws.bootno.de",
statusUrl: "https://status.bootno.de",
skillsOrg: "bootnode",
defaultTheme: "light",
colors: {
primary: "#000000",
@@ -93,6 +95,7 @@ const brands: Record<string, BrandConfig> = {
apiUrl: "https://api.cloud.hanzo.ai",
wsUrl: "wss://ws.cloud.hanzo.ai",
statusUrl: "https://status.hanzo.ai",
skillsOrg: "hanzoai",
defaultTheme: "dark",
colors: {
primary: "#fd4444",
@@ -134,6 +137,7 @@ const brands: Record<string, BrandConfig> = {
apiUrl: "https://api.cloud.lux.network",
wsUrl: "wss://ws.cloud.lux.network",
statusUrl: "https://status.lux.network",
skillsOrg: "luxfi",
defaultTheme: "dark",
colors: {
primary: "#ffffff",
@@ -147,7 +151,7 @@ const brands: Record<string, BrandConfig> = {
},
iam: {
url: "https://lux.id",
clientId: "lux-app-client-id",
clientId: "lux-cloud",
domain: "lux.id",
},
footerLinks: [
@@ -184,6 +188,7 @@ const brands: Record<string, BrandConfig> = {
apiUrl: "https://api.cloud.pars.network",
wsUrl: "wss://ws.cloud.pars.network",
statusUrl: "https://status.pars.network",
skillsOrg: "parsnetwork",
defaultTheme: "dark",
colors: {
primary: "#00abff",
@@ -226,6 +231,7 @@ const brands: Record<string, BrandConfig> = {
apiUrl: "https://api.web3.zoo.ngo",
wsUrl: "wss://ws.web3.zoo.ngo",
statusUrl: "https://status.zoo.ngo",
skillsOrg: "zooai",
defaultTheme: "dark",
colors: {
primary: "#00cc66",
@@ -238,8 +244,12 @@ const brands: Record<string, BrandConfig> = {
github: "https://github.com/zoolabs",
},
iam: {
// Casdoor app for Zoo (org "zoo"). Resolves via get-app-login and its
// redirectUris already include https://zoo.cloud/* and zoolabs.id/callback.
// The previous value "zoo-app-client-id" is not a registered Casdoor
// client and returns "Invalid client_id", breaking the zoo.cloud login.
url: "https://zoolabs.id",
clientId: "zoo-app-client-id",
clientId: "zoo-console",
domain: "zoolabs.id",
},
footerLinks: [
+14 -60
View File
@@ -4,59 +4,20 @@ const nextConfig: NextConfig = {
// Modern React & Next.js features
reactStrictMode: true,
poweredByHeader: false,
// Production-ready output
output: "standalone",
// Static export for Cloudflare Pages (lux.cloud).
// The app is client-rendered (auth via localStorage, brand auto-detected from
// window.location.hostname in lib/brand.ts), so a static export serves the full
// console as static assets on Pages' CDN — no edge/Node server required.
output: "export",
devIndicators: false,
// Turbopack configuration (required for Next.js 16)
turbopack: {},
// Security and cache headers
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'X-Frame-Options',
value: 'DENY'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'Referrer-Policy',
value: 'strict-origin-when-cross-origin'
},
{
// Prevent aggressive edge caching for HTML pages
key: 'CDN-Cache-Control',
value: 'max-age=60'
}
]
},
{
// Static assets can be cached longer
source: '/_next/static/(.*)',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable'
}
]
}
]
},
// Environment variables
env: {
CUSTOM_PORT: '3001'
},
// Image optimization for modern formats
// Image optimization is unavailable in static export; serve images as-is.
images: {
unoptimized: true,
remotePatterns: [
{
protocol: "https",
@@ -65,17 +26,10 @@ const nextConfig: NextConfig = {
],
formats: ['image/avif', 'image/webp']
},
// Redirects from legacy admin paths
async redirects() {
return [
{
source: '/admin/:path*',
destination: '/dashboard/:path*',
permanent: true,
},
]
}
// NOTE: headers() and redirects() are not supported by `output: "export"`.
// Security headers are applied by Cloudflare Pages via public/_headers, and
// the legacy /admin -> /dashboard redirect is handled by public/_redirects.
}
export default nextConfig
+8
View File
@@ -0,0 +1,8 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
CDN-Cache-Control: max-age=60
/_next/static/*
Cache-Control: public, max-age=31536000, immutable
+1
View File
@@ -0,0 +1 @@
/admin/* /dashboard/:splat 301