feat: 构建节点磁盘性能测试(dd 实测读写)

- 新增 internal/sshexec(x/crypto/ssh):远程命令通道,凭证来自
  Credential(type=ssh) 解密(私钥/密码/端口/用户)
- POST /api/build-nodes/:id/disk-test:写 dd(256MB, oflag=direct,
  不支持自动降级含缓存并标注) + 读 dd + 清理;IP 为本机时直接本地
  exec;输出解析兼容 Linux/Mac 两种 dd 格式;60s 超时保护
- BuildNode 新增 credentialId 关联 SSH 凭证 + 磁盘测速结果持久字段
  (显式 column 标签修正 GORM MBps 命名)
- 节点卡片新增仪表盘按钮触发测试;磁盘行显示 ↑写 ↓读 MB/s
  (>200 绿 SSD 级 / 50-200 黄 / <50 红),hover 看测试模式与时间
- 编辑弹窗新增 SSH 凭证下拉
- 本机实测:local-mac 节点 写 2336 MB/s · 读 1793 MB/s(direct)
This commit is contained in:
Logan.Li committed 2026-10-27 12:21:39 +08:00
1 parent 6841c1104a
commit 555934cc64
8 files changed
+314 -2

No files matched your search

+1
View File
@@ -125,6 +125,7 @@ export const apiClient = {
updateBuildNode: (id: any, b: any) => api(`/api/build-nodes/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
deleteBuildNode: (id: any) => api(`/api/build-nodes/${id}`, { method: 'DELETE' }),
testBuildNode: (id: any) => api<any>(`/api/build-nodes/${id}/test`, { method: 'POST' }),
testBuildNodeDisk: (id: any) => api<any>(`/api/build-nodes/${id}/disk-test`, { method: 'POST' }),
// 工具
listTools: () => api<any[]>('/api/tools'),
updateTool: (id: any, b: any) => api(`/api/tools/${id}`, { method: 'PUT', body: JSON.stringify(b) }),
+52 -1
View File
@@ -11,6 +11,7 @@ import {
Tag,
Zap,
HardDrive,
Gauge,
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -77,6 +78,9 @@ export default function BuildNodeManager() {
const [searchQuery, setSearchQuery] = useState('');
const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() });
// SSH 凭证(磁盘性能测试等远程操作用)
const credentialsQuery = useQuery({ queryKey: ['credentials', 'ssh'], queryFn: () => apiClient.listCredentials('ssh') });
const sshCredentials: any[] = credentialsQuery.data ?? [];
const nodes: any[] = nodesQuery.data ?? [];
// ---- 弹窗 ----
@@ -133,6 +137,17 @@ export default function BuildNodeManager() {
onError: (err: Error) => toast.error(err.message),
});
// 磁盘性能测试(dd 实测)
const diskMutation = useMutation({
mutationFn: (id: any) => apiClient.testBuildNodeDisk(id),
onSuccess: (result: any) => {
if (result?.success) toast.success('磁盘性能:' + result.message, { duration: 6000 });
else toast.error(result?.message || '磁盘测试失败');
invalidate();
},
onError: (err: Error) => toast.error('磁盘测试失败', { description: err.message }),
});
const toggleMutation = useMutation({
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
onSuccess: (_data, vars) => {
@@ -152,6 +167,7 @@ export default function BuildNodeManager() {
body: {
name: form.name.trim(),
ip: form.ip.trim(),
credentialId: editing?.credentialId ?? null,
labels: JSON.stringify(splitComma(form.labels)),
maxConcurrency: Number(form.maxConcurrency) || 1,
workspacePath: form.workspacePath.trim(),
@@ -252,6 +268,18 @@ export default function BuildNodeManager() {
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
/>
</div>
<button
className="p-1 rounded hover:bg-black/5"
title="磁盘性能测试(dd 读写 256MB)"
disabled={diskMutation.isPending && diskMutation.variables === node.id}
onClick={() => diskMutation.mutate(node.id)}
>
{diskMutation.isPending && diskMutation.variables === node.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--accent-blue)' }} />
) : (
<Gauge className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
)}
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="测试连接"
@@ -296,13 +324,22 @@ export default function BuildNodeManager() {
</div>
</div>
{/* Info: 磁盘 / 工作空间 / 心跳 */}
{/* Info: 磁盘(含性能) / 工作空间 / 心跳 */}
<div className="grid grid-cols-3 gap-3 text-[11px]">
<div className="flex items-center gap-1.5 min-w-0">
<HardDrive className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.diskInfo ? `磁盘 ${node.diskInfo}` : '磁盘未上报'}>
{node.diskInfo || '—'}
</span>
{node.diskWriteMBps > 0 && (
<span
className="font-mono"
style={{ color: node.diskWriteMBps > 200 ? '#1a7f37' : node.diskWriteMBps > 50 ? '#9a6700' : '#cf222e' }}
title={`磁盘性能(${node.diskTestMode || ''} ${node.diskTestedAt ? new Date(node.diskTestedAt).toLocaleString('zh-CN', { hour12: false }) : ''})`}
>
↑{Math.round(node.diskWriteMBps)}M ↓{Math.round(node.diskReadMBps)}M
</span>
)}
</div>
<div className="flex items-center gap-1.5 min-w-0">
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>空间</span>
@@ -380,6 +417,20 @@ export default function BuildNodeManager() {
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>磁盘用量</Label>
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.diskInfo} onChange={(e) => setForm({ ...form, diskInfo: e.target.value })} placeholder="如 45G/200G(已用/总量)" />
</div>
<div className="space-y-1.5">
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>SSH 凭证(磁盘性能测试等远程操作)</Label>
<select
className="w-full h-8 px-2 text-[12px] rounded-md border outline-none"
style={inputStyle}
value={editing?.credentialId ? String(editing.credentialId) : ''}
onChange={(e) => setEditing((prev: any) => ({ ...(prev || {}), credentialId: e.target.value ? Number(e.target.value) : null }))}
>
<option value="">不关联(本机节点无需凭证)</option>
{sshCredentials.map((cred: any) => (
<option key={cred.id} value={cred.id}>{cred.name}{cred.summary ? ` · ${cred.summary}` : ''}</option>
))}
</select>
</div>
</div>
<DialogFooter>
<button