Repository Public
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