spb/maclustr-console
Public
TypeScript 96.1%
CSS 3.1%
JavaScript 0.8%
1"use client";23import { ClusterNode, TIER_COLORS } from "@/lib/cluster";4import type { NodeMetrics } from "@/lib/ssh";56interface TailscaleStatus {7 online: boolean;8 lastSeen: string;9 ip: string;10}1112export default function NodeGrid({13 nodes,14 statusMap,15 metricsMap,16 selectedNode,17 onSelect,18}: {19 nodes: ClusterNode[];20 statusMap: Record<string, TailscaleStatus>;21 metricsMap: Record<string, NodeMetrics>;22 selectedNode: string | null;23 onSelect: (id: string) => void;24}) {25 return (26 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">27 {nodes.map((node) => {28 const ts = statusMap[node.id];29 const m = metricsMap[node.id];30 const online = ts?.online ?? m?.online ?? false;31 const selected = selectedNode === node.id;32 const color = TIER_COLORS[node.tier];33 const cpu = m ? Math.round(m.cpuUsage) : null;34 const mem = m && m.memoryTotal > 0 ? Math.round((m.memoryUsed / m.memoryTotal) * 100) : null;35 const dsk = m && m.diskTotal > 0 ? Math.round((m.diskUsed / m.diskTotal) * 100) : null;36 const ext = m && (m.extDiskTotal ?? 0) > 0 ? Math.round((m.extDiskUsed / m.extDiskTotal) * 100) : null;3738 return (39 <button40 key={node.id}41 onClick={() => onSelect(node.id)}42 className={`glass glass-hover text-left rounded-2xl p-4 ${43 selected ? "ring-2 ring-violet-500/50 border-violet-300" : ""44 } ${online ? "" : "opacity-60"}`}45 >46 <div className="mb-3 flex items-center justify-between">47 <div className="flex items-center gap-2">48 <span className="text-base font-semibold text-zinc-900">{node.name}</span>49 <span50 className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase"51 style={{ color, borderColor: color + "55", backgroundColor: color + "18" }}52 >53 {node.tier}54 </span>55 </div>56 <span className={`h-2.5 w-2.5 rounded-full ${online ? "dot-online" : "dot-offline"}`} />57 </div>5859 <div className="mb-3 text-xs text-zinc-500">60 {node.chip} · {node.modelName}61 </div>6263 {/* Usage bars */}64 <div className="space-y-2">65 <UsageBar label="CPU" value={cpu} color="#2563eb" />66 <UsageBar label="MEM" value={mem} color="#7c3aed" />67 <UsageBar label="DSK" value={dsk} color="#d97706" />68 {ext !== null && <UsageBar label="EXT" value={ext} color="#ca8a04" />}69 </div>7071 <div className="mt-3 flex items-center justify-between text-[10px] text-zinc-500">72 <span>73 {node.cpuCores} cores · {node.memoryGB} GB · {node.gpuCores} GPU74 {ext !== null && (75 <span className="text-yellow-600 font-medium"> · +{fmtStorage(m!.extDiskTotal)} ext</span>76 )}77 </span>78 {m && <span className="font-mono">load {m.loadAverage[0].toFixed(1)}</span>}79 </div>80 </button>81 );82 })}83 </div>84 );85}8687function fmtStorage(bytes: number): string {88 const gb = bytes / (1024 * 1024 * 1024);89 if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`;90 return `${Math.round(gb)} GB`;91}9293function UsageBar({ label, value, color }: { label: string; value: number | null; color: string }) {94 const v = value ?? 0;95 const hot = v >= 85;96 return (97 <div>98 <div className="mb-1 flex items-center justify-between text-[11px]">99 <span className="font-medium text-zinc-500">{label}</span>100 <span className="font-mono tabular-nums text-zinc-700">{value === null ? "—" : `${v}%`}</span>101 </div>102 <div className="h-1.5 w-full overflow-hidden rounded-full bg-zinc-200">103 <div104 className="h-full rounded-full transition-all"105 style={{ width: `${v}%`, backgroundColor: hot ? "#dc2626" : color }}106 />107 </div>108 </div>109 );110}111