Repository Public
1'use client';2import { useMemo, useState } from 'react';3import type { ClusterNode } from '@cr/shared';4import { ArrowDown, ArrowUp, Search } from 'lucide-react';5import { Badge, StatusDot } from '@/components/ui/badge';6import { Input } from '@/components/ui/input';7import { Tooltip } from '@/components/ui/tooltip';8import { EmptyState } from '@/components/ui/misc';9import { DASH, fmtMbAsGb, fmtNum, fmtPct, fmtTime } from '@/lib/format';10import { cn } from '@/lib/cn';11import { effectiveHealth, memPct } from './util';1213type Col = { key: string; label: string; num?: boolean; get: (n: ClusterNode) => string | number | null; metric?: boolean; render?: (n: ClusterNode) => React.ReactNode };14const HEALTH_RANK = { OFFLINE: 0, DEGRADED: 1, UNKNOWN: 2, ONLINE: 3 } as const;1516/** A telemetry value with its age: dimmed when stale, tooltip always gives the collection time. */17function Metric({ node, children }: { node: ClusterNode; children: React.ReactNode }) {18 if (node.metricsAt == null) return <Tooltip content="Never collected"><span className="text-muted">{DASH}</span></Tooltip>;19 return (20 <Tooltip content={`as of ${fmtTime(node.metricsAt)}${node.stale ? ' — stale' : ''}`}>21 <span className={cn(node.stale && 'text-muted/70 italic')} data-stale={node.stale || undefined}>{children}</span>22 </Tooltip>23 );24}25const COLS: Col[] = [26 { key: 'health', label: 'Health', get: (n) => HEALTH_RANK[effectiveHealth(n)], render: (n) => <StatusDot health={effectiveHealth(n)} withLabel /> },27 { key: 'id', label: 'Node', get: (n) => n.id, render: (n) => (28 <span className="flex items-center gap-1.5"><span className="font-medium text-text">{n.id}</span>{n.isHost && <Badge tone="accent">host</Badge>}{n.execAllowed && <Badge tone="neutral" title="On the remote-execution allowlist">exec</Badge>}</span>29 ) },30 { key: 'model', label: 'Model', get: (n) => n.model },31 { key: 'chip', label: 'Chip', get: (n) => n.chip },32 { key: 'cores', label: 'CPU / GPU', num: true, get: (n) => n.cpuCores, render: (n) => `${n.cpuCores ?? DASH} / ${n.gpuCores ?? DASH}` },33 { key: 'memory', label: 'Memory', num: true, get: (n) => n.memoryMb, render: (n) => fmtMbAsGb(n.memoryMb) },34 { key: 'os', label: 'OS', get: (n) => n.os },35 { key: 'cpu', label: 'CPU %', num: true, metric: true, get: (n) => n.cpuPct, render: (n) => fmtPct(n.cpuPct) },36 { key: 'mem', label: 'Mem %', num: true, metric: true, get: memPct, render: (n) => fmtPct(memPct(n)) },37 { key: 'disk', label: 'Free disk', num: true, metric: true, get: (n) => n.diskFreeGb, render: (n) => fmtNum(n.diskFreeGb, 0, ' GB') },38 { key: 'load', label: 'Load', num: true, metric: true, get: (n) => n.load1, render: (n) => fmtNum(n.load1, 2) },39 { key: 'apps', label: 'Apps', num: true, get: (n) => n.apps.length, render: (n) => (n.apps.length ? <Tooltip content={n.apps.join(', ')}><span>{n.apps.length}</span></Tooltip> : '0') },40];4142export function NodeTable({ nodes, onSelect }: { nodes: ClusterNode[]; onSelect: (id: string) => void }) {43 const [sort, setSort] = useState<{ key: string; dir: 1 | -1 }>({ key: 'id', dir: 1 });44 const [filter, setFilter] = useState('');45 const rows = useMemo(() => {46 const f = filter.trim().toLowerCase();47 const col = COLS.find((c) => c.key === sort.key) ?? COLS[1]!;48 return nodes49 .filter((n) => !f || [n.id, n.model, n.chip, n.os, n.role, effectiveHealth(n), ...n.apps].some((v) => v?.toLowerCase().includes(f)))50 .sort((a, b) => {51 const x = col.get(a), y = col.get(b);52 if (x == null && y == null) return a.id.localeCompare(b.id);53 if (x == null) return 1; // unknown values always last54 if (y == null) return -1;55 return (typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y))) * sort.dir || a.id.localeCompare(b.id);56 });57 }, [nodes, filter, sort]);58 return (59 <div className="flex h-full min-h-0 flex-col">60 <div className="relative m-3 max-w-xs shrink-0">61 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted" aria-hidden />62 <Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter nodes" aria-label="Filter nodes" className="pl-8" />63 </div>64 <div className="min-h-0 flex-1 overflow-auto">65 {rows.length === 0 ? <EmptyState title="No nodes match the filter" /> : (66 <table className="w-full min-w-[980px] border-collapse text-xs" data-testid="node-table">67 <thead className="sticky top-0 z-10 bg-surface">68 <tr>69 {COLS.map((c) => (70 <th key={c.key} scope="col" aria-sort={sort.key === c.key ? (sort.dir === 1 ? 'ascending' : 'descending') : 'none'} className={cn('border-b border-line-2 px-3 py-0 font-medium text-muted', c.num ? 'text-right' : 'text-left')}>71 <button onClick={() => setSort((s) => ({ key: c.key, dir: s.key === c.key && s.dir === 1 ? -1 : 1 }))} className={cn('inline-flex h-8 items-center gap-1 hover:text-text pointer-coarse:h-11', sort.key === c.key && 'text-text')}>72 {c.label}73 {sort.key === c.key && (sort.dir === 1 ? <ArrowUp className="size-3" /> : <ArrowDown className="size-3" />)}74 </button>75 </th>76 ))}77 </tr>78 </thead>79 <tbody>80 {rows.map((n) => (81 <tr key={n.id} tabIndex={0} onClick={() => onSelect(n.id)} onKeyDown={(e) => e.key === 'Enter' && onSelect(n.id)} data-testid="node-row" data-node={n.id} data-health={effectiveHealth(n)} className="cursor-pointer border-b border-line hover:bg-surface-2 focus-visible:bg-surface-2">82 {COLS.map((c) => {83 const v = c.render ? c.render(n) : (c.get(n) ?? DASH);84 return <td key={c.key} className={cn('tnum h-9 whitespace-nowrap px-3 text-text-2', c.num && 'text-right')}>{c.metric ? <Metric node={n}>{v}</Metric> : v}</td>;85 })}86 </tr>87 ))}88 </tbody>89 </table>90 )}91 </div>92 </div>93 );94}95