feat: 工具配置增强、站点拨测与日志中心多项改进

- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖
- 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败
- Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS
- 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率
- 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示
- 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
Logan committed 2026-07-02 07:47:33 +08:00
1 parent cde7bbe540
commit 7e1c8f583c
84 files changed
+7729 -1507

No files matched your search

@@ -0,0 +1,350 @@
import { useState, useEffect, useCallback } from 'react';
import { Plus, Pencil, Trash2, RefreshCw, Server, Cpu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { PageShell } from '@/components/layout/PageShell';
import { ContentPanel } from '@/components/layout/ContentPanel';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { buildNodeApi, serverApi, type BuildNode } from '@/api';
interface ServerItem {
id: number;
hostname: string;
ip: string;
}
// 构建节点管理(Jenkins Agent 风格 SSH 节点) // Logan.Li
export default function BuildNodesManager({ embedded = false }: { embedded?: boolean }) {
const confirm = useConfirm();
const [nodes, setNodes] = useState<BuildNode[]>([]);
const [master, setMaster] = useState({ name: 'Master', max_executors: 2, running_count: 0 });
const [servers, setServers] = useState<ServerItem[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editNode, setEditNode] = useState<BuildNode | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
const [masterMax, setMasterMax] = useState('2');
const [savingMaster, setSavingMaster] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [serverId, setServerId] = useState('');
const [labels, setLabels] = useState('');
const [maxExecutors, setMaxExecutors] = useState('2');
const [workspacePath, setWorkspacePath] = useState('/data/aiops-workspace');
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const [nodeRes, srvRes] = await Promise.all([
buildNodeApi.list(),
serverApi.getServers({ page_size: 200 }).catch(() => ({ list: [] })),
]);
const data = nodeRes as { list?: BuildNode[]; master?: typeof master };
setNodes(Array.isArray(data?.list) ? data.list : []);
if (data?.master) {
setMaster(data.master);
setMasterMax(String(data.master.max_executors || 2));
}
const srvList = (srvRes as { list?: ServerItem[] })?.list || [];
setServers(srvList);
} catch {
toast.error('加载构建节点失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openCreate = () => {
setEditNode(null);
setName('');
setDescription('');
setServerId('');
setLabels('');
setMaxExecutors('2');
setWorkspacePath('/data/aiops-workspace');
setDialogOpen(true);
};
const openEdit = (node: BuildNode) => {
setEditNode(node);
setName(node.name);
setDescription(node.description || '');
setServerId(String(node.server_id));
setLabels(node.labels || '');
setMaxExecutors(String(node.max_executors || 2));
setWorkspacePath(node.workspace_path || '/data/aiops-workspace');
setDialogOpen(true);
};
const handleSubmit = async () => {
if (!name.trim() || !serverId) {
toast.error('请填写名称并选择服务器');
return;
}
setSubmitting(true);
try {
const payload = {
name: name.trim(),
description,
server_id: Number(serverId),
labels,
max_executors: Number(maxExecutors) || 2,
workspace_path: workspacePath || '/data/aiops-workspace',
};
if (editNode) {
await buildNodeApi.update(editNode.id, payload);
toast.success('已更新');
} else {
await buildNodeApi.create(payload);
toast.success('已创建');
}
setDialogOpen(false);
load();
} catch {
toast.error('操作失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (node: BuildNode) => {
const ok = await confirm({ title: '删除节点', description: `确定删除构建节点「${node.name}」?` });
if (!ok) return;
try {
await buildNodeApi.delete(node.id);
toast.success('已删除');
load();
} catch {
toast.error('删除失败,请确认无流水线绑定');
}
};
const handleTest = async (node: BuildNode) => {
setTestingId(node.id);
try {
const res = await buildNodeApi.test(node.id) as { success?: boolean; message?: string };
if (res?.success) {
toast.success(`连通成功: ${res.message || ''}`);
} else {
toast.error(res?.message || '连接失败');
}
} catch {
toast.error('测试失败');
} finally {
setTestingId(null);
}
};
const handleToggle = async (node: BuildNode) => {
try {
await buildNodeApi.update(node.id, { enabled: !node.enabled });
load();
} catch {
toast.error('更新失败');
}
};
const handleSaveMaster = async () => {
const n = Number(masterMax);
if (!n || n < 1) {
toast.error('并发数至少为 1');
return;
}
setSavingMaster(true);
try {
const res = await buildNodeApi.updateMaster({ max_executors: n });
const m = res as { max_executors?: number; running_count?: number };
setMaster((prev) => ({
...prev,
max_executors: m?.max_executors ?? n,
running_count: m?.running_count ?? prev.running_count,
}));
toast.success('Master 并发已更新');
} catch {
toast.error('更新失败');
} finally {
setSavingMaster(false);
}
};
const panel = (
<div className={embedded ? 'space-y-4' : 'mt-4 space-y-4'}>
<ContentPanel
toolbar={(
<div className="flex items-center justify-between w-full">
<span className="text-sm font-medium text-[var(--dg-text-primary)]">构建节点</span>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`h-3.5 w-3.5 mr-1${loading ? ' animate-spin' : ''}`} />刷新
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="h-3.5 w-3.5 mr-1" />添加
</Button>
</div>
</div>
)}
>
<div className="mb-3 p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4 text-[var(--dg-text-muted)]" />
<span className="text-sm font-medium text-[var(--dg-text-primary)]">Master(内置)</span>
<Badge variant="outline" className="text-[10px]">默认调度</Badge>
<span className="text-xs text-[var(--dg-text-muted)]">
占用 {master.running_count}/{master.max_executors}
</span>
</div>
<div className="flex items-center gap-2">
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0">最大并发</Label>
<Input
type="number"
min={1}
max={32}
value={masterMax}
onChange={(e) => setMasterMax(e.target.value)}
className="h-7 w-16 text-xs dg-input"
/>
<Button size="sm" className="h-7 text-xs" onClick={handleSaveMaster} disabled={savingMaster}>
{savingMaster ? '保存中' : '保存'}
</Button>
</div>
</div>
<p className="text-[11px] text-[var(--dg-text-muted)] mt-2">
未指定节点时优先在 Master 执行;并发设为 1 时按触发顺序串行排队;满载后自动调度到空闲远程节点
</p>
</div>
{nodes.length === 0 ? (
<PageEmpty
title="暂无远程构建节点"
description="关联服务器资产添加 SSH 节点,将重型构建任务调度到高性能机器"
action={<Button size="sm" onClick={openCreate}><Plus className="h-3.5 w-3.5 mr-1" />添加节点</Button>}
/>
) : (
<div className="space-y-2">
{nodes.map((node) => (
<div
key={node.id}
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-[var(--dg-border)] hover:bg-[var(--dg-bg-secondary)]/50"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm text-[var(--dg-text-primary)]">{node.name}</span>
<Badge variant={node.enabled ? 'default' : 'secondary'} className="text-[10px]">
{node.enabled ? '启用' : '禁用'}
</Badge>
<span className="text-[10px] text-[var(--dg-text-muted)] font-mono">
{node.hostname || node.ip || `server#${node.server_id}`}
</span>
</div>
<div className="flex items-center gap-3 mt-1 text-[11px] text-[var(--dg-text-muted)]">
<span>执行器 {node.running_count || 0}/{node.max_executors}</span>
{node.labels && <span>标签 {node.labels}</span>}
<span className="truncate">{node.workspace_path}</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="sm" className="h-7 text-xs" disabled={testingId === node.id} onClick={() => handleTest(node)}>
{testingId === node.id ? '测试中' : '测试'}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggle(node)}>
{node.enabled ? '禁用' : '启用'}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(node)}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleDelete(node)}>
<Trash2 className="h-3.5 w-3.5 text-[var(--dg-red)]" />
</Button>
</div>
</div>
))}
</div>
)}
</ContentPanel>
</div>
);
const dialog = (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle>{editNode ? '编辑节点' : '添加节点'}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
基于服务器 SSH 凭据注册构建 Agent,流水线可绑定或自动调度
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs">节点名称</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} className="h-8 text-xs dg-input" placeholder="build-node-01" />
</div>
<div className="space-y-1">
<Label className="text-xs">关联服务器</Label>
<Select value={serverId || '__none'} onValueChange={(v) => setServerId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="选择服务器" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">请选择</SelectItem>
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.hostname} ({s.ip})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">最大并发</Label>
<Input value={maxExecutors} onChange={(e) => setMaxExecutors(e.target.value)} type="number" min={1} className="h-8 text-xs dg-input" />
</div>
<div className="space-y-1">
<Label className="text-xs">工作区路径</Label>
<Input value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} className="h-8 text-xs font-mono dg-input" />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">标签 <span className="text-[var(--dg-text-muted)]">(可选)</span></Label>
<Input value={labels} onChange={(e) => setLabels(e.target.value)} className="h-8 text-xs dg-input" placeholder="linux,docker,high-mem" />
</div>
<div className="space-y-1">
<Label className="text-xs">描述</Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-8 text-xs dg-input" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
if (embedded) {
return (
<>
{panel}
{dialog}
</>
);
}
return (
<PageShell title="构建节点" subtitle="Master 与远程 SSH 构建节点">
{panel}
{dialog}
</PageShell>
);
}