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