groove-dev 0.27.64 → 0.27.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +1 -1
  2. package/node_modules/@groove-dev/cli/package.json +1 -1
  3. package/node_modules/@groove-dev/daemon/package.json +1 -1
  4. package/node_modules/@groove-dev/daemon/src/api.js +103 -31
  5. package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +0 -1
  6. package/node_modules/@groove-dev/gui/dist/assets/{index-DiiEKVEo.js → index-BvvSZvQz.js} +1735 -1735
  7. package/node_modules/@groove-dev/gui/dist/assets/index-DFp5IOnd.css +1 -0
  8. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  9. package/node_modules/@groove-dev/gui/package.json +1 -1
  10. package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +10 -14
  11. package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +67 -200
  12. package/node_modules/@groove-dev/gui/src/components/network/earnings-card.jsx +30 -0
  13. package/node_modules/@groove-dev/gui/src/components/network/fleet-table.jsx +114 -72
  14. package/node_modules/@groove-dev/gui/src/components/network/identity-bar.jsx +94 -0
  15. package/node_modules/@groove-dev/gui/src/components/network/node-card.jsx +88 -0
  16. package/node_modules/@groove-dev/gui/src/components/network/wallet-view.jsx +77 -0
  17. package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  18. package/node_modules/@groove-dev/gui/src/stores/groove.js +13 -0
  19. package/node_modules/@groove-dev/gui/src/views/network.jsx +59 -18
  20. package/package.json +1 -1
  21. package/packages/cli/package.json +1 -1
  22. package/packages/daemon/package.json +1 -1
  23. package/packages/daemon/src/api.js +103 -31
  24. package/packages/daemon/src/providers/claude-code.js +0 -1
  25. package/packages/gui/dist/assets/{index-DiiEKVEo.js → index-BvvSZvQz.js} +1735 -1735
  26. package/packages/gui/dist/assets/index-DFp5IOnd.css +1 -0
  27. package/packages/gui/dist/index.html +2 -2
  28. package/packages/gui/package.json +1 -1
  29. package/packages/gui/src/components/network/activity-chart.jsx +10 -14
  30. package/packages/gui/src/components/network/compute-header.jsx +67 -200
  31. package/packages/gui/src/components/network/earnings-card.jsx +30 -0
  32. package/packages/gui/src/components/network/fleet-table.jsx +114 -72
  33. package/packages/gui/src/components/network/identity-bar.jsx +94 -0
  34. package/packages/gui/src/components/network/node-card.jsx +88 -0
  35. package/packages/gui/src/components/network/wallet-view.jsx +77 -0
  36. package/packages/gui/src/components/onboarding/setup-wizard.jsx +1 -1
  37. package/packages/gui/src/stores/groove.js +13 -0
  38. package/packages/gui/src/views/network.jsx +59 -18
  39. package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +0 -1
  40. package/packages/gui/dist/assets/index-B3AqeyS4.css +0 -1
@@ -6,59 +6,12 @@ import { HEX } from '../../lib/theme-hex';
6
6
  import { Tooltip } from '../ui/tooltip';
7
7
  import { HelpCircle } from 'lucide-react';
8
8
 
9
- const BAR_WIDTH = 28;
10
-
11
- function gaugeColor(ratio) {
12
- if (ratio > 0.9) return HEX.danger;
13
- if (ratio > 0.7) return HEX.warning;
14
- return HEX.success;
15
- }
16
-
17
9
  function fmtMbToGb(mb) {
18
10
  if (!mb) return '0';
19
11
  return (mb / 1024).toFixed(1);
20
12
  }
21
13
 
22
- function AsciiBar({ label, value, max, unit, nodeCount }) {
23
- const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0;
24
- const filled = Math.round(ratio * BAR_WIDTH);
25
- const empty = BAR_WIDTH - filled;
26
- const bar = '\u2502'.repeat(filled) + '\u2500'.repeat(empty);
27
- const color = gaugeColor(ratio);
28
-
29
- let displayVal, displayMax;
30
- if (unit === 'GB') {
31
- displayVal = fmtMbToGb(value);
32
- displayMax = fmtMbToGb(max);
33
- } else if (unit === 'cores' || unit === 'Mbps') {
34
- displayVal = Math.round(value);
35
- displayMax = Math.round(max);
36
- } else {
37
- displayVal = value.toFixed(1);
38
- displayMax = max.toFixed(1);
39
- }
40
-
41
- return (
42
- <div className="flex items-center gap-2 font-mono text-xs leading-tight">
43
- <span className="w-[40px] text-right text-text-3 uppercase text-2xs tracking-wider flex-shrink-0">
44
- {label}
45
- </span>
46
- <span className="text-text-4">[</span>
47
- <span style={{ color: ratio > 0 ? color : undefined }} className={cn('whitespace-pre', !ratio && 'text-text-4')}>
48
- {bar}
49
- </span>
50
- <span className="text-text-4">]</span>
51
- <span className="text-text-1 tabular-nums whitespace-nowrap text-2xs">
52
- {displayVal} / {displayMax} {unit}
53
- </span>
54
- {nodeCount != null && (
55
- <span className="text-text-4 text-2xs whitespace-nowrap">({nodeCount} nodes)</span>
56
- )}
57
- </div>
58
- );
59
- }
60
-
61
- function MiniSparkline({ data, color = HEX.accent, width = 72, height = 22 }) {
14
+ function MiniSparkline({ data, color = HEX.accent, width = 60, height = 16 }) {
62
15
  if (!data || data.length < 2) return <div style={{ width, height }} />;
63
16
  const vals = data.map((d) => (typeof d === 'number' ? d : d.v));
64
17
  const min = Math.min(...vals);
@@ -69,12 +22,12 @@ function MiniSparkline({ data, color = HEX.accent, width = 72, height = 22 }) {
69
22
  const y = height - ((v - min) / range) * (height - 2) - 1;
70
23
  return `${x},${y}`;
71
24
  }).join(' ');
72
- const gradId = `net-${color.replace('#', '')}`;
25
+ const gradId = `ch-${color.replace('#', '')}`;
73
26
  return (
74
- <svg width={width} height={height} className="flex-shrink-0">
27
+ <svg width={width} height={height} className="flex-shrink-0 mt-1">
75
28
  <defs>
76
29
  <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
77
- <stop offset="0%" stopColor={color} stopOpacity="0.2" />
30
+ <stop offset="0%" stopColor={color} stopOpacity="0.25" />
78
31
  <stop offset="100%" stopColor={color} stopOpacity="0" />
79
32
  </linearGradient>
80
33
  </defs>
@@ -84,175 +37,89 @@ function MiniSparkline({ data, color = HEX.accent, width = 72, height = 22 }) {
84
37
  );
85
38
  }
86
39
 
87
- function KpiCard({ label, value, color = HEX.accent, hint, className }) {
88
- return (
89
- <div className={cn('flex items-center gap-2.5 px-3 py-2.5 min-w-0 bg-surface-1', className)}>
90
- <div className="flex-1 min-w-0">
91
- <div className="text-2xs font-mono text-text-3 uppercase tracking-wider mb-0.5 truncate flex items-center gap-1">
92
- {label}
93
- {hint && (
94
- <Tooltip content={<span className="max-w-[220px] block leading-relaxed">{hint}</span>} side="bottom">
95
- <HelpCircle size={10} className="text-text-4 hover:text-text-2 cursor-help flex-shrink-0 transition-colors" />
96
- </Tooltip>
97
- )}
98
- </div>
99
- <div className="text-base font-semibold font-mono text-text-0 tabular-nums leading-none">{value}</div>
100
- </div>
101
- </div>
102
- );
103
- }
104
-
105
- const MAX_RAM_MB = 256 * 1024;
106
- const MAX_VRAM_MB = 128 * 1024;
107
- const MAX_CPU = 128;
108
- const MAX_LOAD = 4.0;
109
-
110
- const SPARKLINE_ROWS = [
111
- { key: 'globalSessions', label: 'Sessions', color: HEX.accent },
112
- { key: 'mySessions', label: 'My Sessions', color: HEX.info },
113
- { key: 'nodeCount', label: 'Nodes', color: HEX.purple },
114
- { key: 'avgLoad', label: 'Load', color: HEX.warning },
115
- { key: 'myLoad', label: 'My Load', color: HEX.success },
116
- ];
117
-
118
- function TrendsColumn({ snapshots }) {
119
- const hasData = snapshots && snapshots.length >= 2;
120
- return (
121
- <div className="flex flex-col gap-1.5 min-w-0">
122
- <div className="text-2xs font-mono text-text-3 uppercase tracking-widest mb-1.5">Trends</div>
123
- {!hasData ? (
124
- <div className="text-2xs font-mono text-text-4">Collecting data…</div>
125
- ) : (
126
- SPARKLINE_ROWS.map((row) => {
127
- const data = snapshots.map((s) => ({ v: s[row.key] ?? 0 }));
128
- const current = data[data.length - 1].v;
129
- const display = Number.isInteger(current) ? current : current.toFixed(2);
130
- return (
131
- <div key={row.key} className="flex items-center gap-2 bg-surface-1 rounded px-2 py-1">
132
- <span className="w-[72px] text-2xs font-mono text-text-3 uppercase truncate flex-shrink-0">{row.label}</span>
133
- <MiniSparkline data={data} color={row.color} width={140} height={24} />
134
- <span className="text-2xs font-mono text-text-1 tabular-nums flex-shrink-0">{display}</span>
135
- </div>
136
- );
137
- })
138
- )}
139
- </div>
140
- );
141
- }
142
-
143
- function YourNodeColumn({ node, compute }) {
144
- if (!node || !node.active) {
145
- return (
146
- <div className="flex flex-col gap-1.5 min-w-0">
147
- <div className="text-2xs font-mono text-text-3 uppercase tracking-widest mb-1.5">Your Node</div>
148
- <div className="text-2xs font-mono text-text-4">Node idle</div>
149
- </div>
150
- );
151
- }
152
-
153
- const layersLabel = node.layers ? `Layers ${node.layers} / 36` : 'Unassigned';
154
- const modelLabel = node.model || 'Qwen/Qwen3-4B';
155
- const bw = compute.totalBandwidthMbps ? `${Math.round(compute.totalBandwidthMbps)} Mbps` : '— Mbps';
156
- const nodeRam = node.hardware?.memory;
157
- const ramPct = nodeRam && compute.totalRamMb > 0
158
- ? `${((nodeRam / compute.totalRamMb) * 100).toFixed(1)}%`
159
- : '—';
160
-
161
- const metrics = [
162
- { label: 'Layers', value: layersLabel },
163
- { label: 'Model', value: modelLabel },
164
- { label: 'Sessions', value: node.sessions ?? 0 },
165
- { label: 'Bandwidth', value: bw },
166
- { label: 'RAM Share', value: ramPct },
167
- ];
168
-
169
- return (
170
- <div className="flex flex-col gap-1.5 min-w-0">
171
- <div className="text-2xs font-mono text-text-3 uppercase tracking-widest mb-1.5">Your Node</div>
172
- {metrics.map((m) => (
173
- <div key={m.label} className="bg-surface-1 rounded px-2.5 py-1.5 min-w-0">
174
- <div className="text-2xs font-mono text-text-4 uppercase tracking-wider leading-none">{m.label}</div>
175
- <div className="text-xs font-mono text-text-1 truncate leading-tight">{m.value}</div>
176
- </div>
177
- ))}
178
- </div>
179
- );
180
- }
181
-
182
- function BarsTrendsNode({ compute, allZero, avgGpuUtil }) {
183
- const snapshots = useGrooveStore((s) => s.networkSnapshots);
184
- const node = useGrooveStore((s) => s.networkNode);
185
-
40
+ function KpiCard({ label, value, color, hint, sparkData, className }) {
186
41
  return (
187
- <div className="bg-surface-1 border-b border-border px-4 py-3" style={{ display: 'grid', gridTemplateColumns: '1fr 1.4fr 1fr', gap: '1.5rem' }}>
188
- <div className="bg-surface-0 rounded border border-border-subtle px-3 py-2.5">
189
- <div className="flex flex-col gap-0.5 min-w-0">
190
- <div className="text-2xs font-mono text-text-3 uppercase tracking-widest mb-1.5">Resources</div>
191
- {allZero ? (
192
- <div className="text-2xs font-mono text-text-4">Waiting for network data...</div>
193
- ) : (
194
- <>
195
- <AsciiBar label="RAM" value={compute.totalRamMb} max={MAX_RAM_MB} unit="GB" nodeCount={compute.totalNodes} />
196
- <AsciiBar label="VRAM" value={compute.totalVramMb} max={MAX_VRAM_MB} unit="GB" nodeCount={compute.totalNodes} />
197
- <AsciiBar label="CPU" value={compute.totalCpuCores} max={MAX_CPU} unit="cores" />
198
- <AsciiBar label="GPU%" value={avgGpuUtil} max={100} unit="%" />
199
- <AsciiBar label="LOAD" value={compute.avgLoad} max={MAX_LOAD} unit="" />
200
- </>
201
- )}
202
- </div>
203
- </div>
204
- <div className="bg-surface-0 rounded border border-border-subtle px-3 py-2.5">
205
- <TrendsColumn snapshots={snapshots} />
42
+ <div className={cn('px-3 py-2 min-w-0', className)}>
43
+ <div className="text-2xs font-mono text-text-4 uppercase tracking-wider truncate flex items-center gap-1">
44
+ {label}
45
+ {hint && (
46
+ <Tooltip content={<span className="max-w-[220px] block leading-relaxed">{hint}</span>} side="bottom">
47
+ <HelpCircle size={9} className="text-text-4 hover:text-text-2 cursor-help flex-shrink-0 transition-colors" />
48
+ </Tooltip>
49
+ )}
206
50
  </div>
207
- <div className="bg-surface-0 rounded border border-border-subtle px-3 py-2.5">
208
- <YourNodeColumn node={node} compute={compute} />
51
+ <div className="text-base font-mono font-semibold tabular-nums leading-none mt-0.5" style={{ color: color || HEX.text0 }}>
52
+ {value}
209
53
  </div>
54
+ {sparkData && <MiniSparkline data={sparkData} color={color || HEX.accent} />}
210
55
  </div>
211
56
  );
212
57
  }
213
58
 
214
59
  export const ComputeHeader = memo(function ComputeHeader() {
215
60
  const compute = useGrooveStore((s) => s.networkCompute);
216
- const nodes = useGrooveStore((s) => s.networkStatus.nodes || []);
217
- const models = useGrooveStore((s) => s.networkStatus.models || []);
218
- const allZero = !compute.totalRamMb && !compute.totalVramMb && !compute.totalCpuCores;
61
+ const status = useGrooveStore((s) => s.networkStatus);
62
+ const snapshots = useGrooveStore((s) => s.networkSnapshots);
63
+ const nodes = status.nodes || [];
219
64
 
220
65
  const activeNodes = nodes.filter((n) => n.status === 'active');
221
66
  const avgGpuUtil = activeNodes.length > 0
222
67
  ? activeNodes.reduce((s, n) => s + (n.gpu_utilization_pct || 0), 0) / activeNodes.length
223
68
  : 0;
69
+
70
+ const totalLayers = status.totalLayers || 36;
71
+ const covered = status.coverage || 0;
72
+ const coverageColor = covered >= totalLayers ? HEX.success : covered >= totalLayers * 0.5 ? HEX.warning : HEX.danger;
224
73
  const gpuColor = avgGpuUtil > 80 ? HEX.danger : avgGpuUtil > 50 ? HEX.warning : HEX.success;
225
- const loadColor = compute.avgLoad > 2.0 ? HEX.danger : compute.avgLoad > 1.0 ? HEX.warning : HEX.success;
226
- const activeModel = models.length > 0
227
- ? (typeof models[0] === 'string' ? models[0] : models[0].name)
228
- : 'Qwen/Qwen3-4B';
74
+
75
+ const nodeSnap = snapshots.map((s) => ({ v: s.nodeCount ?? 0 }));
76
+ const sessionSnap = snapshots.map((s) => ({ v: s.globalSessions ?? 0 }));
77
+ const vramSnap = snapshots.map((s) => ({ v: s.totalVramMb ?? 0 }));
78
+ const ramSnap = snapshots.map((s) => ({ v: s.totalRamMb ?? 0 }));
229
79
 
230
80
  const kpis = [
231
- { label: 'RAM', value: `${fmtMbToGb(compute.totalRamMb)} GB`, color: HEX.accent, hint: 'Total RAM across all network nodes.' },
232
- { label: 'VRAM', value: `${fmtMbToGb(compute.totalVramMb)} GB`, color: HEX.info, hint: 'Total GPU VRAM across all network nodes.' },
233
- { label: 'CPU Cores', value: `${compute.totalCpuCores}`, color: HEX.purple, hint: 'Total CPU cores across all network nodes.' },
234
- { label: 'GPU Util', value: avgGpuUtil > 0 ? `${Math.round(avgGpuUtil)}%` : '--', color: gpuColor, hint: 'Average GPU utilization across active nodes. Green <50%, yellow 50-80%, red >80%.' },
235
- { label: 'Nodes', value: `${compute.activeNodes}/${compute.totalNodes}`, color: HEX.accent, hint: 'Active nodes out of total registered.' },
236
- { label: 'Load', value: compute.avgLoad > 0 ? compute.avgLoad.toFixed(2) : '0.00', color: loadColor, hint: 'Average load across active nodes. Green <1.0, yellow 1.0-2.0, red >2.0.' },
237
- { label: 'Model', value: activeModel, color: HEX.info, hint: 'Active inference model on the network.' },
81
+ {
82
+ label: 'NODES', value: `${compute.activeNodes}/${compute.totalNodes}`,
83
+ color: HEX.accent, hint: 'Active nodes / total registered', sparkData: nodeSnap,
84
+ },
85
+ {
86
+ label: 'SESSIONS', value: `${status.activeSessions || 0}`,
87
+ color: HEX.info, hint: 'Active inference sessions', sparkData: sessionSnap,
88
+ },
89
+ {
90
+ label: 'COVERAGE', value: `${covered}/${totalLayers}`,
91
+ color: coverageColor, hint: 'Layer coverage — green=full, orange=partial, red=<50%',
92
+ },
93
+ {
94
+ label: 'VRAM', value: `${fmtMbToGb(compute.totalVramMb)} GB`,
95
+ color: HEX.purple, hint: 'Total GPU VRAM across nodes', sparkData: vramSnap,
96
+ },
97
+ {
98
+ label: 'RAM', value: `${fmtMbToGb(compute.totalRamMb)} GB`,
99
+ color: HEX.info, hint: 'Total RAM across nodes', sparkData: ramSnap,
100
+ },
101
+ {
102
+ label: 'GPU UTIL', value: avgGpuUtil > 0 ? `${Math.round(avgGpuUtil)}%` : '--',
103
+ color: gpuColor, hint: 'Average GPU utilization — green <50%, yellow 50-80%, red >80%',
104
+ },
238
105
  ];
239
106
 
240
107
  return (
241
- <div className="flex-shrink-0">
242
- <div className="flex flex-wrap border-b border-border" style={{ background: 'var(--color-surface-0)' }}>
243
- {kpis.map((kpi) => (
244
- <KpiCard
245
- key={kpi.label}
246
- label={kpi.label}
247
- value={kpi.value}
248
- color={kpi.color}
249
- hint={kpi.hint}
250
- className={cn('flex-1 basis-[14.2%] min-w-[110px]', 'border-b border-r border-border')}
251
- />
252
- ))}
253
- </div>
254
-
255
- <BarsTrendsNode compute={compute} allZero={allZero} avgGpuUtil={avgGpuUtil} />
108
+ <div className="flex flex-shrink-0 border-b border-border-subtle bg-surface-0">
109
+ {kpis.map((kpi, i) => (
110
+ <KpiCard
111
+ key={kpi.label}
112
+ label={kpi.label}
113
+ value={kpi.value}
114
+ color={kpi.color}
115
+ hint={kpi.hint}
116
+ sparkData={kpi.sparkData}
117
+ className={cn(
118
+ 'basis-[16%] min-w-[120px] flex-shrink-0',
119
+ i < kpis.length - 1 && 'border-r border-border-subtle',
120
+ )}
121
+ />
122
+ ))}
256
123
  </div>
257
124
  );
258
125
  });
@@ -0,0 +1,30 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { memo } from 'react';
3
+ import { Badge } from '../ui/badge';
4
+
5
+ const ROWS = [
6
+ { label: 'Today', value: '—' },
7
+ { label: 'This Week', value: '—' },
8
+ { label: 'All Time', value: '—' },
9
+ ];
10
+
11
+ export const EarningsCard = memo(function EarningsCard() {
12
+ return (
13
+ <div className="flex flex-col h-full">
14
+ <div className="px-3 pt-2.5 pb-1 flex items-center justify-between">
15
+ <span className="text-2xs font-mono text-text-3 uppercase tracking-widest">EARNINGS</span>
16
+ <Badge variant="purple">SOON</Badge>
17
+ </div>
18
+
19
+ <div className="px-3 py-2 space-y-1.5">
20
+ {ROWS.map((row) => (
21
+ <div key={row.label} className="flex items-center justify-between">
22
+ <span className="text-2xs font-sans text-text-4">{row.label}</span>
23
+ <span className="text-xs font-mono text-text-3 tabular-nums">{row.value}</span>
24
+ </div>
25
+ ))}
26
+ <div className="text-2xs text-text-4 mt-2 italic">Connect wallet to track earnings</div>
27
+ </div>
28
+ </div>
29
+ );
30
+ });
@@ -1,12 +1,12 @@
1
1
  // FSL-1.1-Apache-2.0 — see LICENSE
2
- import { memo, useState, useMemo } from 'react';
2
+ import { memo, useState, useMemo, useCallback } from 'react';
3
3
  import { useGrooveStore } from '../../stores/groove';
4
4
  import { cn } from '../../lib/cn';
5
5
  import { HEX } from '../../lib/theme-hex';
6
6
  import { ScrollArea } from '../ui/scroll-area';
7
- import { StatusDot } from '../ui/status-dot';
8
7
  import { Badge } from '../ui/badge';
9
- import { ArrowUp, ArrowDown } from 'lucide-react';
8
+ import { fmtUptime } from '../../lib/format';
9
+ import { ArrowUp, ArrowDown, Search } from 'lucide-react';
10
10
 
11
11
  function shortAddr(addr) {
12
12
  if (!addr || typeof addr !== 'string') return '\u2014';
@@ -20,22 +20,6 @@ function fmtMb(mb) {
20
20
  return `${Math.round(mb)} MB`;
21
21
  }
22
22
 
23
- function statusMap(s) {
24
- if (s === 'active') return 'running';
25
- if (s === 'connecting') return 'starting';
26
- return 'crashed';
27
- }
28
-
29
- function fmtUptime(secs) {
30
- if (!secs && secs !== 0) return '\u2014';
31
- const d = Math.floor(secs / 86400);
32
- const h = Math.floor((secs % 86400) / 3600);
33
- const m = Math.floor((secs % 3600) / 60);
34
- if (d > 0) return `${d}d ${h}h`;
35
- if (h > 0) return `${h}h ${m}m`;
36
- return `${m}m`;
37
- }
38
-
39
23
  const COLUMNS = [
40
24
  { key: 'status', label: 'STATUS', w: 'w-[52px]' },
41
25
  { key: 'node', label: 'NODE', w: 'flex-1 min-w-[90px]' },
@@ -67,19 +51,58 @@ function getSortValue(node, key) {
67
51
  }
68
52
  }
69
53
 
54
+ function ExpandedRow({ node }) {
55
+ const gpuPct = node.gpu_utilization_pct;
56
+ const details = [
57
+ { label: 'GPU Utilization', value: gpuPct != null ? `${Math.round(gpuPct)}%` : '—' },
58
+ { label: 'VRAM Used', value: node.vram_used_mb != null ? fmtMb(node.vram_used_mb) : '—' },
59
+ { label: 'RAM Used', value: node.ram_used_mb != null ? fmtMb(node.ram_used_mb) : '—' },
60
+ { label: 'Uptime', value: node.uptime_seconds ? fmtUptime(node.uptime_seconds) : '—' },
61
+ { label: 'Bandwidth', value: node.bandwidth_mbps ? `${Math.round(node.bandwidth_mbps)} Mbps` : '—' },
62
+ ];
63
+ return (
64
+ <div className="px-3 py-2 bg-surface-0 border-t border-border-subtle">
65
+ <div className="grid grid-cols-5 gap-3">
66
+ {details.map((d) => (
67
+ <div key={d.label}>
68
+ <div className="text-2xs font-mono text-text-4 uppercase tracking-wider">{d.label}</div>
69
+ <div className="text-xs font-mono text-text-1 tabular-nums">{d.value}</div>
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
70
77
  export const FleetTable = memo(function FleetTable() {
71
78
  const nodes = useGrooveStore((s) => s.networkStatus.nodes || []);
72
79
  const ownNodeId = useGrooveStore((s) => s.networkNode.nodeId);
73
80
  const [sortKey, setSortKey] = useState('status');
74
81
  const [sortAsc, setSortAsc] = useState(true);
82
+ const [search, setSearch] = useState('');
83
+ const [expandedId, setExpandedId] = useState(null);
75
84
 
76
- function handleSort(key) {
77
- if (sortKey === key) setSortAsc((v) => !v);
78
- else { setSortKey(key); setSortAsc(true); }
79
- }
85
+ const handleSort = useCallback((key) => {
86
+ setSortKey((prev) => {
87
+ if (prev === key) { setSortAsc((v) => !v); return prev; }
88
+ setSortAsc(true);
89
+ return key;
90
+ });
91
+ }, []);
92
+
93
+ const filtered = useMemo(() => {
94
+ if (!search.trim()) return nodes;
95
+ const q = search.toLowerCase();
96
+ return nodes.filter((n) => {
97
+ const id = (n.node_id || n.nodeId || '').toLowerCase();
98
+ const gpu = (n.gpu_model || '').toLowerCase();
99
+ const device = (n.device || '').toLowerCase();
100
+ return id.includes(q) || gpu.includes(q) || device.includes(q);
101
+ });
102
+ }, [nodes, search]);
80
103
 
81
104
  const sorted = useMemo(() => {
82
- const list = [...nodes];
105
+ const list = [...filtered];
83
106
  list.sort((a, b) => {
84
107
  const va = getSortValue(a, sortKey);
85
108
  const vb = getSortValue(b, sortKey);
@@ -88,7 +111,7 @@ export const FleetTable = memo(function FleetTable() {
88
111
  return 0;
89
112
  });
90
113
  return list;
91
- }, [nodes, sortKey, sortAsc]);
114
+ }, [filtered, sortKey, sortAsc]);
92
115
 
93
116
  return (
94
117
  <div className="flex flex-col h-full">
@@ -97,7 +120,20 @@ export const FleetTable = memo(function FleetTable() {
97
120
  <span className="text-2xs font-mono text-text-3 tabular-nums">{nodes.length} nodes</span>
98
121
  </div>
99
122
 
100
- <div className="flex items-center gap-0 px-3 py-1 flex-shrink-0" style={{ background: 'rgba(51,175,188,0.04)' }}>
123
+ <div className="px-3 pb-1 flex-shrink-0">
124
+ <div className="relative">
125
+ <Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-4" />
126
+ <input
127
+ type="text"
128
+ placeholder="Search nodes..."
129
+ value={search}
130
+ onChange={(e) => setSearch(e.target.value)}
131
+ className="h-7 w-full rounded-md pl-7 pr-2 text-xs bg-surface-1 border border-border-subtle text-text-0 placeholder:text-text-4 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent font-mono transition-colors"
132
+ />
133
+ </div>
134
+ </div>
135
+
136
+ <div className="flex items-center gap-0 px-3 py-1 flex-shrink-0 bg-accent/[0.04]">
101
137
  {COLUMNS.map((col) => (
102
138
  <button
103
139
  key={col.key}
@@ -118,7 +154,9 @@ export const FleetTable = memo(function FleetTable() {
118
154
 
119
155
  <ScrollArea className="flex-1 min-h-0">
120
156
  {sorted.length === 0 ? (
121
- <div className="px-3 py-6 text-2xs font-mono text-text-4 text-center">No nodes online</div>
157
+ <div className="px-3 py-6 text-2xs font-mono text-text-4 text-center">
158
+ {search ? 'No matching nodes' : 'No nodes online'}
159
+ </div>
122
160
  ) : (
123
161
  <div>
124
162
  {sorted.map((n, i) => {
@@ -130,55 +168,59 @@ export const FleetTable = memo(function FleetTable() {
130
168
  const gpuClr = gpuPct == null ? undefined : gpuPct > 80 ? HEX.danger : gpuPct > 50 ? HEX.warning : HEX.success;
131
169
  const ramPct = n.ram_pct || (n.ram_mb > 0 && n.ram_used_mb != null ? (n.ram_used_mb / n.ram_mb) * 100 : null);
132
170
  const vramPct = n.vram_mb > 0 && n.vram_used_mb != null ? (n.vram_used_mb / n.vram_mb) * 100 : null;
171
+ const isExpanded = expandedId === id;
133
172
 
134
173
  return (
135
- <div
136
- key={id || i}
137
- className={cn(
138
- 'flex items-center gap-0 px-3 py-2 text-xs font-mono transition-colors',
139
- isSelf ? 'bg-[rgba(51,175,188,0.06)]' : 'hover:bg-[rgba(51,175,188,0.06)]',
140
- )}
141
- >
142
- <div className={cn('flex items-center gap-1.5', COLUMNS[0].w)}>
143
- <span className="relative flex-shrink-0 w-[6px] h-[6px]">
144
- <span className="absolute inset-0 rounded-sm" style={{ background: isActive ? HEX.success : HEX.text4 }} />
145
- {isActive && (
146
- <span
147
- className="absolute inset-[-2px] rounded-sm"
148
- style={{ background: HEX.success, opacity: 0.15, animation: 'node-pulse-bar 2s ease-in-out infinite' }}
149
- />
150
- )}
151
- </span>
152
- </div>
153
- <div className={cn('truncate text-text-1', COLUMNS[1].w)}>
154
- {shortAddr(id)}
155
- {isSelf && <Badge variant="accent" className="ml-1 text-2xs">You</Badge>}
156
- </div>
157
- <div className={cn('truncate text-text-2', COLUMNS[2].w)}>{n.device || '\u2014'}</div>
158
- <div className={cn('truncate text-text-2', COLUMNS[3].w)}>{n.gpu_model || '\u2014'}</div>
159
- <div className={cn('text-right tabular-nums', COLUMNS[4].w)}>
160
- <div className="text-text-2">{n.ram_used_mb != null ? `${fmtMb(n.ram_used_mb)}/${fmtMb(n.ram_mb)}` : fmtMb(n.ram_mb)}</div>
161
- {ramPct != null && (
162
- <div className="h-0.5 rounded-sm mt-0.5 overflow-hidden" style={{ background: 'rgba(51,175,188,0.08)' }}>
163
- <div className="h-full rounded-sm" style={{ width: `${Math.min(100, ramPct)}%`, background: ramPct > 90 ? HEX.danger : ramPct > 70 ? HEX.warning : HEX.accent }} />
164
- </div>
165
- )}
166
- </div>
167
- <div className={cn('text-right tabular-nums', COLUMNS[5].w)}>
168
- <div className="text-text-2">{n.vram_used_mb != null ? `${fmtMb(n.vram_used_mb)}/${fmtMb(n.vram_mb)}` : fmtMb(n.vram_mb)}</div>
169
- {vramPct != null && (
170
- <div className="h-0.5 rounded-sm mt-0.5 overflow-hidden" style={{ background: 'rgba(51,175,188,0.08)' }}>
171
- <div className="h-full rounded-sm" style={{ width: `${Math.min(100, vramPct)}%`, background: vramPct > 90 ? HEX.danger : vramPct > 70 ? HEX.warning : HEX.info }} />
172
- </div>
174
+ <div key={id || i}>
175
+ <div
176
+ onClick={() => setExpandedId(isExpanded ? null : id)}
177
+ className={cn(
178
+ 'flex items-center gap-0 px-3 py-2 text-xs font-mono transition-colors cursor-pointer',
179
+ isSelf ? 'border-l-2 border-accent bg-accent/[0.04]' : 'hover:bg-surface-2',
173
180
  )}
181
+ >
182
+ <div className={cn('flex items-center gap-1.5', COLUMNS[0].w)}>
183
+ <span className="relative flex-shrink-0 w-[6px] h-[6px]">
184
+ <span className="absolute inset-0 rounded-sm" style={{ background: isActive ? HEX.success : HEX.text4 }} />
185
+ {isActive && (
186
+ <span
187
+ className="absolute inset-[-2px] rounded-sm"
188
+ style={{ background: HEX.success, opacity: 0.15, animation: 'node-pulse-bar 2s ease-in-out infinite' }}
189
+ />
190
+ )}
191
+ </span>
192
+ </div>
193
+ <div className={cn('truncate text-text-1', COLUMNS[1].w)}>
194
+ {shortAddr(id)}
195
+ {isSelf && <Badge variant="accent" className="ml-1 text-2xs">You</Badge>}
196
+ </div>
197
+ <div className={cn('truncate text-text-2', COLUMNS[2].w)}>{n.device || '\u2014'}</div>
198
+ <div className={cn('truncate text-text-2', COLUMNS[3].w)}>{n.gpu_model || '\u2014'}</div>
199
+ <div className={cn('text-right tabular-nums', COLUMNS[4].w)}>
200
+ <div className="text-text-2">{n.ram_used_mb != null ? `${fmtMb(n.ram_used_mb)}/${fmtMb(n.ram_mb)}` : fmtMb(n.ram_mb)}</div>
201
+ {ramPct != null && (
202
+ <div className="h-0.5 rounded-sm mt-0.5 overflow-hidden bg-accent/[0.08]">
203
+ <div className="h-full rounded-sm" style={{ width: `${Math.min(100, ramPct)}%`, background: ramPct > 90 ? HEX.danger : ramPct > 70 ? HEX.warning : HEX.accent }} />
204
+ </div>
205
+ )}
206
+ </div>
207
+ <div className={cn('text-right tabular-nums', COLUMNS[5].w)}>
208
+ <div className="text-text-2">{n.vram_used_mb != null ? `${fmtMb(n.vram_used_mb)}/${fmtMb(n.vram_mb)}` : fmtMb(n.vram_mb)}</div>
209
+ {vramPct != null && (
210
+ <div className="h-0.5 rounded-sm mt-0.5 overflow-hidden bg-accent/[0.08]">
211
+ <div className="h-full rounded-sm" style={{ width: `${Math.min(100, vramPct)}%`, background: vramPct > 90 ? HEX.danger : vramPct > 70 ? HEX.warning : HEX.info }} />
212
+ </div>
213
+ )}
214
+ </div>
215
+ <div className={cn('text-right text-text-2 tabular-nums', COLUMNS[6].w)}>{n.cpu_cores || '\u2014'}</div>
216
+ <div className={cn('text-right tabular-nums', COLUMNS[7].w)} style={{ color: gpuClr }}>
217
+ {gpuPct != null ? `${Math.round(gpuPct)}%` : '\u2014'}
218
+ </div>
219
+ <div className={cn('text-text-2', COLUMNS[8].w)}>{layersLabel}</div>
220
+ <div className={cn('text-right text-text-2 tabular-nums', COLUMNS[9].w)}>{n.sessions ?? n.active_sessions ?? 0}</div>
221
+ <div className={cn('text-text-2', COLUMNS[10].w)}>{n.uptime_seconds ? fmtUptime(n.uptime_seconds) : '\u2014'}</div>
174
222
  </div>
175
- <div className={cn('text-right text-text-2 tabular-nums', COLUMNS[6].w)}>{n.cpu_cores || '\u2014'}</div>
176
- <div className={cn('text-right tabular-nums', COLUMNS[7].w)} style={{ color: gpuClr }}>
177
- {gpuPct != null ? `${Math.round(gpuPct)}%` : '\u2014'}
178
- </div>
179
- <div className={cn('text-text-2', COLUMNS[8].w)}>{layersLabel}</div>
180
- <div className={cn('text-right text-text-2 tabular-nums', COLUMNS[9].w)}>{n.sessions ?? n.active_sessions ?? 0}</div>
181
- <div className={cn('text-text-2', COLUMNS[10].w)}>{fmtUptime(n.uptime_seconds)}</div>
223
+ {isExpanded && <ExpandedRow node={n} />}
182
224
  </div>
183
225
  );
184
226
  })}