feat: fleet page showing Pi node status
This commit is contained in:
@@ -1,136 +1,68 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense } from "react"
|
||||
|
||||
interface PiNode {
|
||||
hostname: string; ip: string; role: "primary" | "secondary" | "backup";
|
||||
agent_capacity: number; agents_active: number;
|
||||
services: string[]; tunnel_active: boolean;
|
||||
ollama_models: string[]; uptime_pct: number;
|
||||
const PI_NODES = [
|
||||
{ name: "aria64", ip: "192.168.4.38", user: "alexa", role: "Primary", capacity: 22500 },
|
||||
{ name: "alice", ip: "192.168.4.49", user: "blackroad", role: "Secondary", capacity: 7500 },
|
||||
{ name: "lucidia", ip: "192.168.4.99", user: "pi", role: "Backup", capacity: 2000 },
|
||||
]
|
||||
|
||||
async function getFleetStatus() {
|
||||
try {
|
||||
const res = await fetch("https://worlds.blackroad.io/stats", { next: { revalidate: 30 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
const PI_NODES: PiNode[] = [
|
||||
{ hostname: "aria64", ip: "192.168.4.38", role: "primary", agent_capacity: 22500, agents_active: 0,
|
||||
services: ["ollama", "agent-runtime", "cloudflared"], tunnel_active: true,
|
||||
ollama_models: ["qwen2.5:7b", "deepseek-r1:7b"], uptime_pct: 99.9 },
|
||||
{ hostname: "blackroad-pi", ip: "192.168.4.64", role: "secondary", agent_capacity: 7500, agents_active: 0,
|
||||
services: ["ollama", "agent-runtime"], tunnel_active: false,
|
||||
ollama_models: ["llama3.2:3b"], uptime_pct: 99.5 },
|
||||
{ hostname: "lucidia", ip: "192.168.4.99", role: "backup", agent_capacity: 0, agents_active: 0,
|
||||
services: ["agent-runtime"], tunnel_active: false,
|
||||
ollama_models: [], uptime_pct: 98.2 },
|
||||
];
|
||||
|
||||
const ROLE_COLOR: Record<string, string> = {
|
||||
primary: "text-green-400 border-green-700", secondary: "text-blue-400 border-blue-700",
|
||||
backup: "text-zinc-400 border-zinc-700",
|
||||
};
|
||||
|
||||
function PingDot({ active }: { active: boolean }) {
|
||||
return (
|
||||
<span className="relative inline-flex h-2.5 w-2.5">
|
||||
{active && <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />}
|
||||
<span className={`relative inline-flex rounded-full h-2.5 w-2.5 ${active ? "bg-green-400" : "bg-zinc-600"}`} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FleetPage() {
|
||||
const [nodes, setNodes] = useState(PI_NODES);
|
||||
const [reachable, setReachable] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
// Poll /api/fleet for live status
|
||||
const poll = async () => {
|
||||
try {
|
||||
const r = await fetch("/api/fleet");
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
setNodes(data.nodes ?? PI_NODES);
|
||||
setReachable(data.reachable ?? {});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
poll();
|
||||
const id = setInterval(poll, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const totalCapacity = nodes.reduce((s, n) => s + n.agent_capacity, 0);
|
||||
const totalActive = nodes.reduce((s, n) => s + n.agents_active, 0);
|
||||
export default async function FleetPage() {
|
||||
const stats = await getFleetStatus()
|
||||
const nodeWorlds = stats?.by_node ?? {}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-5xl mx-auto space-y-6">
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Pi Fleet</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">
|
||||
{nodes.length} nodes · {totalCapacity.toLocaleString()} agent capacity · {totalActive.toLocaleString()} active
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-white">🖥️ Fleet</h1>
|
||||
<p className="text-gray-400 mt-1">Raspberry Pi agent nodes powering BlackRoad OS</p>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{[
|
||||
{ label: "Total Capacity", value: totalCapacity.toLocaleString(), sub: "agents" },
|
||||
{ label: "Active Agents", value: totalActive.toLocaleString(), sub: "running" },
|
||||
{ label: "Fleet Health", value: `${Math.round(nodes.reduce((s,n)=>s+n.uptime_pct,0)/nodes.length)}%`, sub: "avg uptime" },
|
||||
].map(({ label, value, sub }) => (
|
||||
<div key={label} className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider">{label}</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{value}</p>
|
||||
<p className="text-xs text-zinc-500">{sub}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="grid gap-4">
|
||||
{PI_NODES.map((node) => {
|
||||
const worlds = nodeWorlds[node.name] ?? 0
|
||||
const active = worlds > 0
|
||||
return (
|
||||
<div key={node.name} className={`bg-gray-900 border rounded-lg p-5 ${active ? "border-green-700" : "border-gray-700"}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${active ? "bg-green-400 animate-pulse" : "bg-gray-600"}`}></span>
|
||||
<span className="text-white font-bold text-lg font-mono">{node.name}</span>
|
||||
<span className="text-xs text-gray-500 bg-gray-800 px-2 py-0.5 rounded">{node.role}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm mt-1 font-mono">{node.user}@{node.ip}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-amber-400">{worlds}</div>
|
||||
<div className="text-xs text-gray-500">worlds</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="bg-gray-800 rounded p-2">
|
||||
<div className="text-gray-400">Capacity</div>
|
||||
<div className="text-white font-mono">{node.capacity.toLocaleString()} agents</div>
|
||||
</div>
|
||||
<div className="bg-gray-800 rounded p-2">
|
||||
<div className="text-gray-400">Status</div>
|
||||
<div className={active ? "text-green-400" : "text-gray-500"}>{active ? "Online" : "Offline"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Node cards */}
|
||||
<div className="space-y-4">
|
||||
{nodes.map((node) => (
|
||||
<div key={node.hostname} className={`bg-zinc-900 border rounded-xl p-5 ${ROLE_COLOR[node.role].split(" ")[1]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<PingDot active={reachable[node.ip] !== false} />
|
||||
<h3 className="text-white font-bold font-mono text-lg">{node.hostname}</h3>
|
||||
<span className={`text-xs uppercase font-semibold px-2 py-0.5 rounded-full border ${ROLE_COLOR[node.role]}`}>
|
||||
{node.role}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-zinc-400 font-mono text-sm mt-1">{node.ip}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-white text-lg font-bold">{node.agent_capacity.toLocaleString()}</p>
|
||||
<p className="text-zinc-500 text-xs">agent capacity</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-zinc-500 text-xs uppercase mb-2">Services</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{node.services.map((s) => (
|
||||
<span key={s} className="bg-zinc-800 text-zinc-300 text-xs px-2 py-0.5 rounded font-mono">{s}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-500 text-xs uppercase mb-2">Ollama Models</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{node.ollama_models.length > 0
|
||||
? node.ollama_models.map((m) => (
|
||||
<span key={m} className="bg-violet-900/40 text-violet-300 text-xs px-2 py-0.5 rounded font-mono">{m}</span>
|
||||
))
|
||||
: <span className="text-zinc-600 text-xs">None configured</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4 text-xs text-zinc-500">
|
||||
<span>Uptime: <strong className="text-white">{node.uptime_pct}%</strong></span>
|
||||
{node.tunnel_active && <span className="text-cyan-400">⚡ Cloudflare Tunnel active</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg p-4 text-sm text-gray-400">
|
||||
<p className="font-semibold text-white mb-1">Total Fleet Capacity</p>
|
||||
<p className="text-2xl font-bold text-white">{PI_NODES.reduce((s,n) => s + n.capacity, 0).toLocaleString()} <span className="text-sm text-gray-400">agents</span></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user