mirror of
https://github.com/hanzoai/bootnode.git
synced 2026-10-25 02:31:30 +00:00
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:
committed by
GitHub
co-authored by
Hanzo Dev
parent
766da81cc6
commit
852bb7cf15
@@ -1,3 +1,5 @@
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export function GET() {
|
||||
return Response.json({ status: "ok" })
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/admin/* /dashboard/:splat 301
|
||||
Reference in New Issue
Block a user