feat: real-time live data integration
- lib/live-data.ts: Shared TypeScript client for blackroad-live-data Worker - components/live-stats.tsx: LiveStatsBar, RecentRepos, AgentStatusGrid components - app/page.tsx: Import LiveStatsBar in main page header Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,48 +1,168 @@
|
||||
// Raspberry Pi fleet status
|
||||
const PI_FLEET = [
|
||||
{ name: 'aria64', ip: '192.168.4.38', role: 'Primary', capacity: 22500, worlds_dir: 'worlds/' },
|
||||
{ name: 'blackroad-pi', ip: '192.168.4.64', role: 'Secondary', capacity: 7500, worlds_dir: 'worlds/' },
|
||||
{ name: 'alice', ip: '192.168.4.49', role: 'Tertiary', capacity: 5000, worlds_dir: 'alice-worlds/' },
|
||||
]
|
||||
'use client';
|
||||
|
||||
async function getFleetStatus() {
|
||||
try {
|
||||
const res = await fetch('https://blackroad-agents-status.amundsonalexa.workers.dev/fleet',
|
||||
{ next: { revalidate: 30 } })
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch { return null }
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Cpu, Globe, Wifi, WifiOff, Activity, Zap, Server, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Node {
|
||||
name: string; ip: string; role: string; capacity: number;
|
||||
model: string; status: string; latencyMs: number; services: string[];
|
||||
}
|
||||
interface TunnelRoute { hostname: string; service: string; pi: string; group: string; }
|
||||
interface FleetData { nodes: Node[]; summary: { total_nodes: number; online_nodes: number; total_capacity: number; tunnel_routes: number }; }
|
||||
interface TunnelData { routes: TunnelRoute[]; by_pi: Record<string, TunnelRoute[]>; tunnel_id: string; }
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
PRIMARY: '#FF1D6C', SECONDARY: '#2979FF', TERTIARY: '#F5A623', IDENTITY: '#9C27B0',
|
||||
};
|
||||
|
||||
const ROLE_GLOW: Record<string, string> = {
|
||||
PRIMARY: 'shadow-[0_0_20px_rgba(255,29,108,0.3)]',
|
||||
SECONDARY: 'shadow-[0_0_20px_rgba(41,121,255,0.3)]',
|
||||
TERTIARY: 'shadow-[0_0_20px_rgba(245,166,35,0.3)]',
|
||||
IDENTITY: 'shadow-[0_0_20px_rgba(156,39,176,0.3)]',
|
||||
};
|
||||
|
||||
export default function FleetPage() {
|
||||
const [fleet, setFleet] = useState<FleetData | null>(null);
|
||||
const [tunnel, setTunnel] = useState<TunnelData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastChecked, setLastChecked] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const [f, t] = await Promise.all([fetch('/api/fleet'), fetch('/api/tunnel')]);
|
||||
if (f.ok) setFleet(await f.json());
|
||||
if (t.ok) setTunnel(await t.json());
|
||||
setLastChecked(new Date().toLocaleTimeString());
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); const iv = setInterval(load, 30000); return () => clearInterval(iv); }, []);
|
||||
|
||||
const onlineCount = fleet?.summary.online_nodes ?? 0;
|
||||
const totalCapacity = fleet?.summary.total_capacity ?? 30000;
|
||||
const tunnelRoutes = fleet?.summary.tunnel_routes ?? 14;
|
||||
|
||||
export default async function FleetPage() {
|
||||
const status = await getFleetStatus()
|
||||
return (
|
||||
<div className="p-8 max-w-5xl">
|
||||
<h1 className="text-3xl font-bold mb-2">🍓 Pi Fleet</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Raspberry Pi infrastructure — {PI_FLEET.length} nodes · 35,000 agent capacity
|
||||
</p>
|
||||
<div className="grid gap-4">
|
||||
{PI_FLEET.map(pi => (
|
||||
<div key={pi.name} className="rounded-xl border p-6 flex items-start gap-6">
|
||||
<div className="text-4xl">🍓</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="font-bold text-lg">{pi.name}</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted rounded px-2 py-0.5">{pi.role}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground font-mono">{pi.ip}</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
Capacity: {pi.capacity.toLocaleString()} agents
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-right">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 inline-block mr-1"></div>
|
||||
<span className="text-green-600 text-xs">Online</span>
|
||||
<div className="min-h-screen bg-black text-white p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Pi Fleet</h1>
|
||||
<p className="text-gray-400 mt-1">Cloudflare Tunnel → Raspberry Pi Network</p>
|
||||
</div>
|
||||
<button onClick={load} className="flex items-center gap-2 px-4 py-2 rounded-lg bg-white/5 hover:bg-white/10 text-sm text-gray-400 transition-all">
|
||||
<Activity className="w-4 h-4" />
|
||||
Refresh {lastChecked && <span className="text-gray-600">{lastChecked}</span>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||
{[
|
||||
{ label: 'Nodes', value: `${onlineCount}/${fleet?.summary.total_nodes ?? 4}`, icon: Server, color: '#22c55e' },
|
||||
{ label: 'Agent Slots', value: totalCapacity.toLocaleString(), icon: Cpu, color: '#FF1D6C' },
|
||||
{ label: 'Tunnel Routes', value: tunnelRoutes, icon: Globe, color: '#2979FF' },
|
||||
{ label: 'Tunnel ID', value: '8ae67ab0', icon: Zap, color: '#F5A623' },
|
||||
].map(s => (
|
||||
<div key={s.label} className="rounded-2xl bg-white/5 border border-white/10 p-5">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<s.icon className="w-5 h-5" style={{ color: s.color }} />
|
||||
<span className="text-sm text-gray-400">{s.label}</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold font-mono" style={{ color: s.color }}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Node cards */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-8">
|
||||
{(fleet?.nodes ?? []).map(node => {
|
||||
const routes = tunnel?.by_pi[node.name] ?? [];
|
||||
const color = ROLE_COLORS[node.role] ?? '#fff';
|
||||
const glow = ROLE_GLOW[node.role] ?? '';
|
||||
return (
|
||||
<div key={node.name} className={`rounded-2xl bg-white/5 border border-white/10 p-6 ${glow} transition-all`}>
|
||||
{/* Node header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{node.status === 'online'
|
||||
? <Wifi className="w-5 h-5 text-green-400" />
|
||||
: <WifiOff className="w-5 h-5 text-gray-600" />}
|
||||
<div>
|
||||
<h3 className="font-bold text-lg">{node.name}</h3>
|
||||
<p className="text-sm text-gray-500">{node.ip} · {node.model}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="text-xs font-bold px-2 py-1 rounded-full" style={{ backgroundColor: color + '22', color }}>
|
||||
{node.role}
|
||||
</span>
|
||||
{node.capacity > 0 && (
|
||||
<span className="text-xs text-gray-500">{node.capacity.toLocaleString()} slots</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latency */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className={`w-2 h-2 rounded-full ${node.status === 'online' ? 'bg-green-400 animate-pulse' : 'bg-gray-600'}`} />
|
||||
<span className="text-xs text-gray-400">
|
||||
{node.status === 'online'
|
||||
? node.latencyMs > 0 ? `${node.latencyMs}ms` : 'online'
|
||||
: 'offline / unreachable'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Routes */}
|
||||
{routes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-gray-600 uppercase tracking-wider mb-2">Tunnel Routes</p>
|
||||
{routes.map(r => (
|
||||
<div key={r.hostname} className="flex items-center gap-2 text-sm">
|
||||
<ArrowRight className="w-3 h-3 flex-shrink-0" style={{ color }} />
|
||||
<a href={`https://${r.hostname}`} target="_blank" rel="noreferrer"
|
||||
className="hover:underline truncate" style={{ color }}>
|
||||
{r.hostname}
|
||||
</a>
|
||||
<span className="text-gray-600 text-xs ml-auto flex-shrink-0">
|
||||
{r.service.split(':').pop()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* All routes table */}
|
||||
{tunnel && (
|
||||
<div className="rounded-2xl bg-white/5 border border-white/10 p-6">
|
||||
<h2 className="text-lg font-bold mb-4">All Tunnel Routes ({tunnel.routes.length})</h2>
|
||||
<div className="space-y-2">
|
||||
{tunnel.routes.map(r => (
|
||||
<div key={r.hostname} className="flex items-center gap-4 py-2 border-b border-white/5 last:border-0">
|
||||
<div className="w-2 h-2 rounded-full bg-green-400 flex-shrink-0" />
|
||||
<a href={`https://${r.hostname}`} target="_blank" rel="noreferrer"
|
||||
className="text-sm font-mono hover:text-[#FF1D6C] transition-colors flex-1">
|
||||
{r.hostname}
|
||||
</a>
|
||||
<span className="text-sm text-gray-500 font-mono">{r.service}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: (ROLE_COLORS[r.group] ?? '#888') + '22', color: ROLE_COLORS[r.group] ?? '#888' }}>
|
||||
{r.pi}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user