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.
- package/README.md +1 -1
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/api.js +103 -31
- package/node_modules/@groove-dev/daemon/src/providers/claude-code.js +0 -1
- package/node_modules/@groove-dev/gui/dist/assets/{index-DiiEKVEo.js → index-BvvSZvQz.js} +1735 -1735
- package/node_modules/@groove-dev/gui/dist/assets/index-DFp5IOnd.css +1 -0
- package/node_modules/@groove-dev/gui/dist/index.html +2 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/node_modules/@groove-dev/gui/src/components/network/activity-chart.jsx +10 -14
- package/node_modules/@groove-dev/gui/src/components/network/compute-header.jsx +67 -200
- package/node_modules/@groove-dev/gui/src/components/network/earnings-card.jsx +30 -0
- package/node_modules/@groove-dev/gui/src/components/network/fleet-table.jsx +114 -72
- package/node_modules/@groove-dev/gui/src/components/network/identity-bar.jsx +94 -0
- package/node_modules/@groove-dev/gui/src/components/network/node-card.jsx +88 -0
- package/node_modules/@groove-dev/gui/src/components/network/wallet-view.jsx +77 -0
- package/node_modules/@groove-dev/gui/src/components/onboarding/setup-wizard.jsx +1 -1
- package/node_modules/@groove-dev/gui/src/stores/groove.js +13 -0
- package/node_modules/@groove-dev/gui/src/views/network.jsx +59 -18
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/api.js +103 -31
- package/packages/daemon/src/providers/claude-code.js +0 -1
- package/packages/gui/dist/assets/{index-DiiEKVEo.js → index-BvvSZvQz.js} +1735 -1735
- package/packages/gui/dist/assets/index-DFp5IOnd.css +1 -0
- package/packages/gui/dist/index.html +2 -2
- package/packages/gui/package.json +1 -1
- package/packages/gui/src/components/network/activity-chart.jsx +10 -14
- package/packages/gui/src/components/network/compute-header.jsx +67 -200
- package/packages/gui/src/components/network/earnings-card.jsx +30 -0
- package/packages/gui/src/components/network/fleet-table.jsx +114 -72
- package/packages/gui/src/components/network/identity-bar.jsx +94 -0
- package/packages/gui/src/components/network/node-card.jsx +88 -0
- package/packages/gui/src/components/network/wallet-view.jsx +77 -0
- package/packages/gui/src/components/onboarding/setup-wizard.jsx +1 -1
- package/packages/gui/src/stores/groove.js +13 -0
- package/packages/gui/src/views/network.jsx +59 -18
- package/node_modules/@groove-dev/gui/dist/assets/index-B3AqeyS4.css +0 -1
- 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
|
|
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 = `
|
|
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.
|
|
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
|
|
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=
|
|
188
|
-
<div className="
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
{
|
|
192
|
-
<
|
|
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="
|
|
208
|
-
|
|
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
|
|
217
|
-
const
|
|
218
|
-
const
|
|
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
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
{
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
{
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 {
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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 = [...
|
|
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
|
-
}, [
|
|
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="
|
|
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">
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
})}
|