spb/maclustr-console
Public
TypeScript 96.1%
CSS 3.1%
JavaScript 0.8%
1"use client";23import { CLUSTER_NODES, TOTAL_CORES, TOTAL_MEMORY, TOTAL_GPU, TIER_COLORS } from "@/lib/cluster";4import type { NodeMetrics } from "@/lib/ssh";56interface TailscaleStatus {7 online: boolean;8 lastSeen: string;9 ip: string;10}1112/** Format a byte count, auto-switching GB -> TB above 1024 GB. */13function formatStorage(bytes: number): string {14 const gb = bytes / (1024 * 1024 * 1024);15 if (gb >= 1024) return `${(gb / 1024).toFixed(2)} TB`;16 return `${Math.round(gb)} GB`;17}1819export default function ClusterOverview({20 statusMap,21 metricsMap = {},22}: {23 statusMap: Record<string, TailscaleStatus>;24 metricsMap?: Record<string, NodeMetrics>;25}) {26 const onlineCount = Object.values(statusMap).filter((s) => s.online).length;27 const total = CLUSTER_NODES.length;28 const pct = total ? Math.round((onlineCount / total) * 100) : 0;29 const tiers = ["ultra", "max", "pro", "base"] as const;3031 // Cluster-wide storage totals (internal SSD + external drives) across reporting nodes.32 const liveMetrics = Object.values(metricsMap).filter((m) => m.online);33 const storageUsed = liveMetrics.reduce((a, m) => a + m.diskUsed + (m.extDiskUsed || 0), 0);34 const storageTotal = liveMetrics.reduce((a, m) => a + m.diskTotal + (m.extDiskTotal || 0), 0);35 const extTotal = liveMetrics.reduce((a, m) => a + (m.extDiskTotal || 0), 0);3637 return (38 <div className="space-y-5 fade-in">39 {/* Hero stat cards */}40 <div className="grid grid-cols-2 md:grid-cols-5 gap-4">41 <StatCard label="Nodes Online" value={`${onlineCount}/${total}`} sub={`${pct}% reachable`} accent="#16a34a" icon={<ServerIcon />}>42 <div className="mt-3 h-1.5 w-full overflow-hidden rounded-full bg-zinc-200">43 <div className="h-full rounded-full" style={{ width: `${pct}%`, background: "linear-gradient(90deg,#16a34a,#4ade80)" }} />44 </div>45 </StatCard>46 <StatCard label="CPU Cores" value={TOTAL_CORES.toString()} sub="across cluster" accent="#2563eb" icon={<CpuIcon />} />47 <StatCard label="Memory" value={`${TOTAL_MEMORY}`} unit="GB" sub="unified" accent="#7c3aed" icon={<MemIcon />} />48 <StatCard label="GPU Cores" value={TOTAL_GPU.toString()} sub="Metal" accent="#d97706" icon={<GpuIcon />} />49 <StatCard50 label="Storage"51 value={storageTotal > 0 ? formatStorage(storageUsed) : "—"}52 sub={storageTotal > 0 ? `of ${formatStorage(storageTotal)}${extTotal > 0 ? ` · +${formatStorage(extTotal)} ext` : ""}` : "internal + external"}53 accent="#0891b2"54 icon={<DiskIcon />}55 />56 </div>5758 {/* Tier breakdown */}59 <div className="grid grid-cols-2 md:grid-cols-4 gap-4">60 {tiers.map((tier) => {61 const nodes = CLUSTER_NODES.filter((n) => n.tier === tier);62 const online = nodes.filter((n) => statusMap[n.id]?.online).length;63 const tpct = nodes.length ? Math.round((online / nodes.length) * 100) : 0;64 const color = TIER_COLORS[tier];65 return (66 <div key={tier} className="glass glass-hover rounded-2xl p-4">67 <div className="mb-2 flex items-center justify-between">68 <div className="flex items-center gap-2">69 <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color, boxShadow: `0 0 8px ${color}66` }} />70 <span className="text-xs font-semibold uppercase tracking-wider text-zinc-600">{tier}</span>71 </div>72 <span className="text-xs text-zinc-500">{online}/{nodes.length}</span>73 </div>74 <div className="text-2xl font-bold text-zinc-900 tabular-nums">75 {nodes.reduce((a, n) => a + n.cpuCores, 0)}76 <span className="ml-1 text-sm font-normal text-zinc-500">cores</span>77 </div>78 <div className="mb-2 text-xs text-zinc-500">{nodes.reduce((a, n) => a + n.memoryGB, 0)} GB · {nodes.reduce((a, n) => a + n.gpuCores, 0)} GPU</div>79 <div className="h-1 w-full overflow-hidden rounded-full bg-zinc-200">80 <div className="h-full rounded-full" style={{ width: `${tpct}%`, backgroundColor: color }} />81 </div>82 </div>83 );84 })}85 </div>86 </div>87 );88}8990function StatCard({91 label, value, unit, sub, accent, icon, children,92}: {93 label: string; value: string; unit?: string; sub: string; accent: string; icon: React.ReactNode; children?: React.ReactNode;94}) {95 return (96 <div className="glass glass-hover relative overflow-hidden rounded-2xl p-4">97 <div className="pointer-events-none absolute -right-6 -top-6 h-20 w-20 rounded-full opacity-25 blur-2xl" style={{ background: accent }} />98 <div className="flex items-start justify-between">99 <div className="text-xs font-medium uppercase tracking-wide text-zinc-500">{label}</div>100 <div style={{ color: accent }}>{icon}</div>101 </div>102 <div className="mt-2 text-3xl font-bold tabular-nums text-zinc-900">103 {value}104 {unit && <span className="ml-1 text-base font-medium text-zinc-500">{unit}</span>}105 </div>106 <div className="mt-0.5 text-xs text-zinc-500">{sub}</div>107 {children}108 </div>109 );110}111112/* Minimal inline icons */113const S = { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };114const ServerIcon = () => (<svg {...S}><rect x="3" y="4" width="18" height="6" rx="2" /><rect x="3" y="14" width="18" height="6" rx="2" /><path d="M7 7h.01M7 17h.01" /></svg>);115const CpuIcon = () => (<svg {...S}><rect x="6" y="6" width="12" height="12" rx="2" /><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2" /></svg>);116const MemIcon = () => (<svg {...S}><rect x="3" y="7" width="18" height="10" rx="2" /><path d="M7 7v10M12 7v10M17 7v10" /></svg>);117const GpuIcon = () => (<svg {...S}><rect x="2" y="6" width="20" height="12" rx="2" /><circle cx="9" cy="12" r="2.5" /><path d="M15 10h4M15 14h4" /></svg>);118const DiskIcon = () => (<svg {...S}><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M7 8h10M7 12h10" /><circle cx="8" cy="16" r="1" /></svg>);119