SPB Git forge
2commits 1branches 0releases
492.0 KBsize
maindefault branch
2 h agolast push
TypeScript 96.1% CSS 3.1% JavaScript 0.8%
6.7 KB · 180 lines tsx
Raw Blame History
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} &middot; {node.modelName} &middot; {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