SPB Git

Repository Public

page.tsx 8.6 KB 142 lines tsx

1'use client';2import { useState } from 'react';3import dynamic from 'next/dynamic';4import type { ClusterNode } from '@cr/shared';5import { RefreshCw } from 'lucide-react';6import { useCluster } from '@/lib/queries';7import { DASH, fmtDateTime, fmtMbAsGb, fmtNum, fmtPct, fmtTime } from '@/lib/format';8import { Button } from '@/components/ui/button';9import { Badge, StatusDot } from '@/components/ui/badge';10import { Sheet } from '@/components/ui/sheet';11import { EmptyState, ErrorState, PageHeader, Skeleton, Spinner } from '@/components/ui/misc';12import { NodeTable } from '@/components/cluster/node-table';13import { NodeCards } from '@/components/cluster/node-cards';14import { PullToRefresh } from '@/components/ui/pull-to-refresh';15import { IllusNodes } from '@/components/ui/illustrations';16import { useBreakpoint } from '@/hooks/use-viewport';17import { LocalModels } from '@/components/cluster/models';18import { effectiveHealth, memPct } from '@/components/cluster/util';19import { cn } from '@/lib/cn';20import { useBrand } from '@/stores/branding';2122const Topology = dynamic(() => import('@/components/cluster/topology'), { ssr: false, loading: () => <div className="flex h-full items-center justify-center"><Spinner /></div> });2324export default function ClusterPage() {25  const brand = useBrand();26  const q = useCluster();27  const bp = useBreakpoint();28  const mobile = bp === 'mobile';29  const [view, setView] = useState<'table' | 'topology'>('table');30  const [selected, setSelected] = useState<string | null>(null);31  const c = q.data;32  const node = c?.nodes.find((n) => n.id === selected) ?? null;33  return (34    <div className="flex h-full flex-col overflow-hidden">35      <PageHeader36        title="Cluster"37        description="MacLustr nodes as reported by the telemetry agent. Stale values are shown as UNKNOWN."38        meta={c ? <span className="tnum" data-testid="cluster-collected">Collected {c.collectedAt ? fmtDateTime(c.collectedAt) : 'never'} · refreshes every 20 s while this tab is visible</span> : undefined}39        actions={<Button onClick={() => q.refetch()} loading={q.isFetching}><RefreshCw className="size-3.5" /> Refresh</Button>}40      />41      <PullToRefresh onRefresh={() => q.refetch()} className="min-h-0 flex-1">42        {q.isPending ? <div className="grid gap-3 p-4 md:p-6">{[0, 1].map((i) => <Skeleton key={i} className="h-24" />)}</div>43        : q.isError ? <ErrorState error={q.error} onRetry={() => q.refetch()} />44        : !c ? null45        : (46          <div className="flex flex-col gap-4 p-4 md:p-6">47            {c.source === 'none' && (48              <div className="card" data-testid="cluster-none">49                <EmptyState50                  illustration={<IllusNodes />}51                  title="Cluster telemetry is not connected"52                  description={`No telemetry source (agentd) is configured for this server${c.nodes.length ? `, so only the ${brand.product} host itself is listed below` : ', so no node data exists'}. Other nodes are not shown rather than estimated. Set CR_AGENTD_URL and CR_AGENTD_TOKEN on the server to enable the full view.`}53                  className="py-6"54                />55              </div>56            )}57            {(c.source !== 'none' || c.nodes.length > 0) && (58              <>59                <dl className="grid grid-cols-2 gap-px overflow-hidden rounded-md border border-card-line bg-line shadow-card sm:grid-cols-5" data-testid="cluster-totals">60                  {([61                    ['Overall', <StatusDot key="h" health={c.health} withLabel />],62                    ['Nodes', fmtNum(c.totals.nodes)],63                    ['Online', `${fmtNum(c.totals.online)} of ${fmtNum(c.totals.nodes)}`],64                    // a total of 0 while some node has no core count means "unknown", not zero65                    ['CPU cores', c.totals.cores === 0 && c.nodes.some((n) => n.cpuCores == null) ? DASH : fmtNum(c.totals.cores)],66                    ['Memory', `${fmtNum(c.totals.memoryGb)} GB`],67                  ] as const).map(([k, v]) => (68                    <div key={k} className="bg-surface px-4 py-3.5 last:col-span-2 sm:last:col-span-1">69                      <dt className="text-[11px] font-medium uppercase tracking-wide text-muted">{k}</dt>70                      <dd className="tnum mt-1 text-base font-semibold text-text">{v}</dd>71                    </div>72                  ))}73                </dl>74                {mobile && c.nodes.length > 0 && <NodeCards nodes={c.nodes} onSelect={setSelected} />}75                <section className={cn('flex flex-col card', mobile && c.nodes.length > 0 && 'hidden')}>76                  <header className="flex items-center gap-3 border-b border-line px-4 py-2">77                    <h2 className="flex-1 text-xs font-semibold uppercase tracking-wide text-text-2">Nodes</h2>78                    <div role="tablist" aria-label="Node view" className="flex overflow-hidden rounded-sm border border-line-2">79                      {(['table', 'topology'] as const).map((v) => (80                        <button key={v} role="tab" aria-selected={view === v} data-testid={`view-${v}`} onClick={() => setView(v)} className={cn('h-7 px-3 text-xs capitalize pointer-coarse:h-11', view === v ? 'bg-surface-2 text-text' : 'text-muted hover:text-text-2')}>{v}</button>81                      ))}82                    </div>83                  </header>84                  {c.nodes.length === 0 ? <EmptyState title="No nodes reported" description="The telemetry source is connected but returned no nodes." />85                  : mobile ? null86                  : view === 'table' ? <div className="max-h-[60vh] min-h-48"><NodeTable nodes={c.nodes} onSelect={setSelected} /></div>87                  : <div className="h-[min(640px,62vh)]"><Topology overview={c} onSelect={setSelected} /></div>}88                </section>89              </>90            )}91            <LocalModels />92          </div>93        )}94      </PullToRefresh>95      <Sheet open={!!node} onOpenChange={(o) => !o && setSelected(null)} title={node?.id ?? 'Node'}>96        {node && <NodeDetail node={node} />}97      </Sheet>98    </div>99  );100}101102function NodeDetail({ node: n }: { node: ClusterNode }) {103  const brand = useBrand();104  const h = effectiveHealth(n);105  const metric = (v: string) => (n.metricsAt == null ? DASH : v);106  const rows: [string, React.ReactNode][] = [107    ['Model', n.model ?? DASH], ['Chip', n.chip ?? DASH], ['Kind', n.kind], ['Role', n.role ?? DASH], ['OS', n.os ?? DASH],108    ['CPU cores', n.cpuCores ?? DASH], ['GPU cores', n.gpuCores ?? DASH], ['Memory', fmtMbAsGb(n.memoryMb)],109  ];110  const metrics: [string, string][] = [111    ['CPU', metric(fmtPct(n.cpuPct, 1))],112    ['Memory used', metric(n.memUsedMb == null ? DASH : `${fmtMbAsGb(n.memUsedMb, 1)} (${fmtPct(memPct(n))})`)],113    ['Disk free', metric(n.diskFreeGb == null ? DASH : `${fmtNum(n.diskFreeGb, 1)} GB of ${fmtNum(n.diskTotalGb, 0)} GB`)],114    ['Load (1 min)', metric(fmtNum(n.load1, 2))],115  ];116  return (117    <div className="flex flex-col gap-5 p-4 text-xs" data-testid="node-detail">118      <div className="flex flex-wrap items-center gap-2">119        <StatusDot health={h} withLabel />120        {n.isHost && <Badge tone="accent">{brand.product} host</Badge>}121        <Badge tone={n.execAllowed ? 'neutral' : 'muted'}>{n.execAllowed ? 'remote exec allowed' : 'remote exec not allowed'}</Badge>122      </div>123      {n.stale && <p className="rounded-sm border border-line-2 bg-bg px-3 py-2 text-text-2">Telemetry for this node is stale, so its health is shown as UNKNOWN. The values below are the last ones received.</p>}124      <dl className="tnum grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5">125        {rows.map(([k, v]) => (<div key={k} className="contents"><dt className="text-muted">{k}</dt><dd className="text-text-2">{v}</dd></div>))}126      </dl>127      <div>128        <h3 className="mb-2 flex items-baseline justify-between text-[11px] font-medium uppercase tracking-wide text-muted">129          Telemetry <span className="tnum normal-case tracking-normal">{n.metricsAt ? `as of ${fmtTime(n.metricsAt)}${n.stale ? ' (stale)' : ''}` : 'never collected'}</span>130        </h3>131        <dl className={cn('tnum grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5', n.stale && 'opacity-60')}>132          {metrics.map(([k, v]) => (<div key={k} className="contents"><dt className="text-muted">{k}</dt><dd className="text-text-2">{v}</dd></div>))}133        </dl>134      </div>135      <div>136        <h3 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted">Apps</h3>137        {n.apps.length === 0 ? <p className="text-muted">No apps reported</p> : <ul className="flex flex-wrap gap-1.5">{n.apps.map((a) => <li key={a}><Badge>{a}</Badge></li>)}</ul>}138      </div>139    </div>140  );141}142
Files main
Menu

Theme