SPB Git

Repository Public

node-table.tsx 6.0 KB 95 lines tsx

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
Files main
Menu

Theme