- 151 个 hash-style flat key (s+hex 后缀) 全部重命名为语义化 nested key
- monitor 58 个: alertChannelDialog/alertManager/alertRulesTab/chartRangeHint/
esIndexPicker/helpers/hostMonitor/logAlertSpanel/logClusterPanel/logDingAssistant/
logQueryToolbar/logRbac/lokiLabelBrowse/metricsExplorer/middlewareMonit/
mwOverviewChart/portPolicyDialog/prometheusDashb/queryHistoryPan/savedQueriesPan/
siteMonitor/snmpMonitor/snmpOverviewChart/zabbixDashboard/zabbixDatasource
- kubernetes 42 个: deploymentBundle/deploymentEditAdvanced/deploymentEditService/
deploymentView/deploymentWorkload/eventsPage/kubernetesCluster/namespaceManage/
namespaceResource/podExecTerminal/podFileBrowser/podLogsViewer/podMetricsPage/
resourceVisualEditor/workloadViewPage
- aiops 17 个: agentCliMessage/aiAnalysisResult/analyzerHelpers/assistant/predictUtils
- agent 12 个: agentAlerts/agentMonitor/hostDetailDialog
- cloud 13 个: billingRecordDialog/billingRuleDialog/platformManager
- pipeline 6 个: buildNodesManage/deployRbac/deployTargetField
- system 3 个: auditSetting
- 58 个源文件中的 147 处 t()/i18n.t() 调用更新为语义化键路径
- 3 个命名空间冲突修复 (monitor.hostMonitor/agent.agentMonitor/aiops.aiAnalysisResult
原 string 值提升为 .title 子键)
- React Hooks 警告修复 (53→43, -10):
- exhaustive-deps 4→0: useMemo/useCallback 添加 t 到 deps 数组
- preserve-manual-memoization 5→0: 变量遮蔽修复 (filter(t)→filter(tg))
- 1 个冗余 eslint-disable 指令移除 (CronJobManager)
- zh/en 7480=7480 完全对齐, 0 个 hash key 残留
- tsc -b=0, npm build✓ (17.59s), ESLint errors=0/warnings=43/any=0
- go build=0, 17/17 集成测试通过, Docker healthy
351 lines
15 KiB
TypeScript
351 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { Plus, Pencil, Trash2, RefreshCw, 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';
|
|
import i18n from '@/i18n';
|
|
|
|
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(i18n.t('deploy.buildNodes.loadFailed'));
|
|
} 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(i18n.t('deploy.buildNodes.loadFailed'));
|
|
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(i18n.t('deploy.toast.updated'));
|
|
} else {
|
|
await buildNodeApi.create(payload);
|
|
toast.success(i18n.t('kubernetes.event.created'));
|
|
}
|
|
setDialogOpen(false);
|
|
load();
|
|
} catch {
|
|
toast.error(i18n.t('cloud.operationFailed'));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (node: BuildNode) => {
|
|
const ok = await confirm({ title: i18n.t('kubernetes.topology.deleteNode'), description: i18n.t('deploy.buildNodes.deleteConfirm', { name: node.name }) });
|
|
if (!ok) return;
|
|
try {
|
|
await buildNodeApi.delete(node.id);
|
|
toast.success(i18n.t('monitor.silenceRules.deleted'));
|
|
load();
|
|
} catch {
|
|
toast.error(i18n.t('deploy.buildNodes.loadFailed'));
|
|
}
|
|
};
|
|
|
|
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(i18n.t('deploy.buildNodes.testSuccess') + (res.message ? `: ${res.message}` : ''));
|
|
} else {
|
|
toast.error(res?.message || i18n.t('aiops.llmTestFailed'));
|
|
}
|
|
} catch {
|
|
toast.error(i18n.t('monitor.alertChannels.testFailed'));
|
|
} finally {
|
|
setTestingId(null);
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (node: BuildNode) => {
|
|
try {
|
|
await buildNodeApi.update(node.id, { enabled: !node.enabled });
|
|
load();
|
|
} catch {
|
|
toast.error(i18n.t('kubernetes.deployment.imageUpdateFailed'));
|
|
}
|
|
};
|
|
|
|
const handleSaveMaster = async () => {
|
|
const n = Number(masterMax);
|
|
if (!n || n < 1) {
|
|
toast.error(i18n.t('deploy.buildNodes.loadFailed'));
|
|
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(i18n.t('deploy.buildNodes.deleted'));
|
|
} catch {
|
|
toast.error(i18n.t('kubernetes.deployment.imageUpdateFailed'));
|
|
} 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)]"> {i18n.t('pipeline.buildNodes')} </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)]">{i18n.t('deploy.buildNodes.masterBuiltin')}</span>
|
|
<Badge variant="outline" className="text-[10px]"> {i18n.t('pipeline.buildNodesManage.defaultSchedule')} </Badge>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">
|
|
{i18n.t('deploy.buildNodes.executorUsage', { running: master.running_count, max: master.max_executors })}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0"> {i18n.t('pipeline.buildNodesManage.maxConcurrency')} </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 ? i18n.t('deploy.buildNodes.saving') : i18n.t('task.script.save')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="text-[11px] text-[var(--dg-text-muted)] mt-2">
|
|
{i18n.t('deploy.buildNodes.schedulingHint')}
|
|
</p>
|
|
</div>
|
|
|
|
{nodes.length === 0 ? (
|
|
<PageEmpty
|
|
title={i18n.t('deploy.buildNodes.noRemoteNodes')}
|
|
description={i18n.t('deploy.buildNodes.noRemoteDesc')}
|
|
action={<Button size="sm" onClick={openCreate}><Plus className="h-3.5 w-3.5 mr-1" /> {i18n.t('kubernetes.topology.addNode')} </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 ? i18n.t('system.userActive') : i18n.t('system.userInactive')}
|
|
</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>{i18n.t('deploy.buildNodes.executorLabel', { running: node.running_count || 0, max: node.max_executors })}</span>
|
|
{node.labels && <span>{i18n.t('deploy.buildNodes.labelsLabel', { labels: 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 ? i18n.t('deploy.buildNodes.testing') : i18n.t('pipeline.testTool')}
|
|
</Button>
|
|
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggle(node)}>
|
|
{node.enabled ? i18n.t('system.userInactive') : i18n.t('system.userActive')}
|
|
</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 ? i18n.t('kubernetes.topology.editNode') : i18n.t('kubernetes.topology.addNode')}</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"> {i18n.t('kubernetes.node.nodeName')} </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"> {i18n.t('common.tag')} </Label>
|
|
<Select value={serverId || '__none'} onValueChange={(v) => setServerId(v === '__none' ? '' : v)}>
|
|
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder={i18n.t('deploy.buildNodes.selectServer')} /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="__none"> {i18n.t('k8s.storage.pleaseSelect')} </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"> {i18n.t('pipeline.buildNodesManage.maxConcurrency')} </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"> {i18n.t('pipeline.buildNodesManage.workspacePath')} </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"> {i18n.t('kubernetes.namespace.labels')} <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"> {i18n.t('aiops.llmModelDesc')} </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)}> {i18n.t('rbac.jobRole.cancel')} </Button>
|
|
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? i18n.t('deploy.buildNodes.saving') : i18n.t('task.script.save')}</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
|
|
if (embedded) {
|
|
return (
|
|
<>
|
|
{panel}
|
|
{dialog}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageShell title={i18n.t('pipeline.buildNodes')} subtitle={i18n.t('deploy.pipeline.buildNodes')}>
|
|
{panel}
|
|
{dialog}
|
|
</PageShell>
|
|
);
|
|
} |