SPB Git

Repository Public

node-cards.tsx 3.4 KB 58 lines tsx

1'use client';2import { motion } from 'motion/react';3import type { ClusterNode } from '@cr/shared';4import { Badge, StatusDot } from '@/components/ui/badge';5import { RingGauge } from '@/components/ui/ring-gauge';6import { DASH, fmtMbAsGb, fmtNum, fmtTime } from '@/lib/format';7import { stagger } from '@/components/home/sections';8import { effectiveHealth, memPct, tierOf, TIER_LABEL, TIER_ORDER } from './util';910/** Phone view of the cluster: one card per node with CPU / memory rings. Stale or never-collected = dashed ring. */11export function NodeCards({ nodes, onSelect }: { nodes: ClusterNode[]; onSelect: (id: string) => void }) {12  let index = 0;13  return (14    <div className="flex flex-col gap-5" data-testid="node-cards">15      {TIER_ORDER.map((tier) => {16        const list = nodes.filter((n) => tierOf(n) === tier);17        if (list.length === 0) return null;18        return (19          <section key={tier} aria-label={TIER_LABEL[tier]}>20            <h2 className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-muted">{TIER_LABEL[tier]} · {list.length}</h2>21            <ul className="flex flex-col gap-2.5">22              {list.map((n) => {23                const h = effectiveHealth(n);24                const known = n.metricsAt != null && !n.stale;25                const mem = memPct(n);26                return (27                  <motion.li key={n.id} {...stagger(index++)}>28                    <button onClick={() => onSelect(n.id)} data-testid="node-row" data-node={n.id} data-health={h} className="card pressable flex w-full items-center gap-4 p-4 text-left">29                      <span className="min-w-0 flex-1">30                        <span className="flex items-center gap-2">31                          <StatusDot health={h} />32                          <span className="truncate text-[15px] font-semibold text-text">{n.id}</span>33                          {n.isHost && <Badge tone="accent">host</Badge>}34                        </span>35                        <span className="mt-0.5 block truncate text-[13px] text-text-2">{[n.model, n.chip].filter(Boolean).join(' · ') || DASH}</span>36                        <span className="tnum mt-1 block truncate text-xs text-muted">37                          {[n.cpuCores != null ? `${n.cpuCores} cores` : null, n.memoryMb != null ? fmtMbAsGb(n.memoryMb) : null, known && n.diskFreeGb != null ? `${fmtNum(n.diskFreeGb, 0)} GB free` : null].filter(Boolean).join(' · ') || 'No hardware details'}38                        </span>39                        <span className="tnum mt-1.5 block text-xs font-medium" data-stale={n.stale || undefined}>40                          {h === 'UNKNOWN' ? <span className="text-muted">UNKNOWN · {n.metricsAt ? `last seen ${fmtTime(n.metricsAt)}` : 'never collected'}</span> : <span className="text-muted">{h} · as of {fmtTime(n.metricsAt)}</span>}41                        </span>42                      </span>43                      <span className="flex shrink-0 gap-3">44                        <RingGauge label="CPU" size={54} fraction={known && n.cpuPct != null ? n.cpuPct / 100 : null} />45                        <RingGauge label="Memory" caption="Mem" size={54} fraction={known && mem != null ? mem / 100 : null} />46                      </span>47                    </button>48                  </motion.li>49                );50              })}51            </ul>52          </section>53        );54      })}55    </div>56  );57}58
Files main
Menu

Theme