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 NodeDetail({13 node,14 status,15 metrics,16}: {17 node: ClusterNode;18 status?: TailscaleStatus;19 metrics?: NodeMetrics;20}) {21 const online = status?.online ?? metrics?.online ?? false;22 const m = metrics;2324 const formatUptime = (seconds: number) => {25 const d = Math.floor(seconds / 86400);26 const h = Math.floor((seconds % 86400) / 3600);27 const min = Math.floor((seconds % 3600) / 60);28 if (d > 0) return `${d}d ${h}h`;29 if (h > 0) return `${h}h ${min}m`;30 return `${min}m`;31 };3233 const formatBytes = (bytes: number) => {34 if (bytes === 0) return "0 B";35 const gb = bytes / (1024 * 1024 * 1024);36 if (gb >= 1) return `${gb.toFixed(1)} GB`;37 const mb = bytes / (1024 * 1024);38 return `${mb.toFixed(0)} MB`;39 };4041 return (42 <div className="glass fade-in rounded-2xl p-6 space-y-6 sticky top-20">43 {/* Header */}44 <div className="flex items-center justify-between">45 <div>46 <div className="flex items-center gap-3">47 <h2 className="text-xl font-bold text-zinc-900">{node.name}</h2>48 <span49 className="text-xs font-semibold px-2 py-0.5 rounded-full border uppercase"50 style={{51 color: TIER_COLORS[node.tier],52 borderColor: TIER_COLORS[node.tier] + "55",53 backgroundColor: TIER_COLORS[node.tier] + "18",54 }}55 >56 {node.tier}57 </span>58 <span className={`text-xs font-medium px-2 py-0.5 rounded-full ${online ? "bg-green-100 text-green-700" : "bg-red-100 text-red-600"}`}>59 {online ? "Online" : "Offline"}60 </span>61 </div>62 <div className="text-sm text-zinc-500 mt-1">63 {node.chip} · {node.modelName} · {node.generation.toUpperCase()}64 </div>65 </div>66 </div>6768 {/* Hardware specs */}69 <div className="grid grid-cols-3 gap-4">70 <InfoBlock label="CPU Cores" value={node.cpuCores.toString()} />71 <InfoBlock label="Memory" value={`${node.memoryGB} GB`} />72 <InfoBlock label="GPU Cores" value={node.gpuCores.toString()} />73 </div>7475 {/* Live metrics */}76 {m && m.online && (77 <>78 <div className="border-t border-zinc-200 pt-4">79 <h3 className="text-sm font-semibold text-zinc-600 mb-3">Live Metrics</h3>80 <div className="grid grid-cols-2 gap-4">81 <MetricBar label="CPU Usage" value={m.cpuUsage} color="#2563eb" />82 <MetricBar83 label="Memory"84 value={m.memoryTotal > 0 ? (m.memoryUsed / m.memoryTotal) * 100 : 0}85 color="#7c3aed"86 detail={`${formatBytes(m.memoryUsed)} / ${formatBytes(m.memoryTotal)}`}87 />88 <MetricBar89 label="Internal SSD"90 value={m.diskTotal > 0 ? (m.diskUsed / m.diskTotal) * 100 : 0}91 color="#d97706"92 detail={`${formatBytes(m.diskUsed)} / ${formatBytes(m.diskTotal)}`}93 />94 {m.extDiskTotal > 0 && (95 <MetricBar96 label="External"97 value={(m.extDiskUsed / m.extDiskTotal) * 100}98 color="#ca8a04"99 detail={`${formatBytes(m.extDiskUsed)} / ${formatBytes(m.extDiskTotal)}`}100 />101 )}102 {m.extDiskTotal > 0 && (103 <div className="bg-zinc-100 rounded-lg p-3">104 <div className="text-xs text-zinc-500 mb-1">Storage Total</div>105 <div className="text-lg font-mono text-zinc-900">106 {formatBytes(m.diskUsed + m.extDiskUsed)} / {formatBytes(m.diskTotal + m.extDiskTotal)}107 </div>108 </div>109 )}110 <div className="bg-zinc-100 rounded-lg p-3">111 <div className="text-xs text-zinc-500 mb-1">Load Average</div>112 <div className="text-lg font-mono text-zinc-900">113 {m.loadAverage.map((l) => l.toFixed(2)).join(" ")}114 </div>115 </div>116 </div>117 </div>118119 <div className="grid grid-cols-3 gap-4">120 <InfoBlock label="Uptime" value={formatUptime(m.uptime)} />121 <InfoBlock label="Processes" value={m.processes.toString()} />122 <InfoBlock label="Thermal" value={m.thermalState} />123 </div>124 </>125 )}126127 {/* Network info */}128 <div className="border-t border-zinc-200 pt-4">129 <h3 className="text-sm font-semibold text-zinc-600 mb-3">Network</h3>130 <div className="grid grid-cols-2 gap-4 text-sm">131 <div>132 <span className="text-zinc-500">Hostname:</span>{" "}133 <span className="text-zinc-800 font-mono text-xs">{node.hostname}</span>134 </div>135 <div>136 <span className="text-zinc-500">Tailscale IP:</span>{" "}137 <span className="text-zinc-800 font-mono text-xs">{status?.ip || node.tailscaleIp}</span>138 </div>139 <div>140 <span className="text-zinc-500">Tailscale Name:</span>{" "}141 <span className="text-zinc-800 font-mono text-xs">{node.tailscaleName}</span>142 </div>143 {status?.lastSeen && (144 <div>145 <span className="text-zinc-500">Last Seen:</span>{" "}146 <span className="text-zinc-800">{new Date(status.lastSeen).toLocaleString()}</span>147 </div>148 )}149 </div>150 </div>151 </div>152 );153}154155function InfoBlock({ label, value }: { label: string; value: string }) {156 return (157 <div className="bg-zinc-100 rounded-lg p-3">158 <div className="text-[10px] text-zinc-500 uppercase tracking-wide">{label}</div>159 <div className="text-lg font-semibold text-zinc-900 mt-0.5">{value}</div>160 </div>161 );162}163164function MetricBar({ label, value, color, detail }: { label: string; value: number; color: string; detail?: string }) {165 const pct = Math.min(100, Math.max(0, value));166 const hot = pct >= 85;167 return (168 <div className="bg-zinc-100 rounded-lg p-3">169 <div className="flex justify-between text-xs mb-1.5">170 <span className="text-zinc-500">{label}</span>171 <span className="text-zinc-800 font-mono">{pct.toFixed(1)}%</span>172 </div>173 <div className="h-2 bg-zinc-200 rounded-full overflow-hidden">174 <div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: hot ? "#dc2626" : color }} />175 </div>176 {detail && <div className="text-[10px] text-zinc-500 mt-1">{detail}</div>}177 </div>178 );179}180