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:
1 parent
cde7bbe540
commit
7e1c8f583c
84 files changed
+7729
-1507
No files matched your search
@@ -1,28 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import {
|
||||
Rocket,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Tag,
|
||||
Server,
|
||||
Network,
|
||||
Globe,
|
||||
Container,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ChevronRight as ChevronRightSmall,
|
||||
ChevronDown,
|
||||
Settings2,
|
||||
} from 'lucide-react';
|
||||
import { credentialApi, deployApi, pipelineApi } from '@/api';
|
||||
import { buildNodeApi, credentialApi, deployApi, pipelineApi, type BuildNode } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nameToCode } from '../pipeline-helpers';
|
||||
import { STAGE_DEFS } from './constants';
|
||||
import { DeployTargetFields, type DeployTargetValue } from './DeployTargetFields';
|
||||
|
||||
interface PipelineCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -30,45 +38,79 @@ interface PipelineCreateDialogProps {
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
// 新建流水线对话框(含表单状态) // Logan.Li
|
||||
const PRESETS = [
|
||||
{
|
||||
key: 'ssh',
|
||||
label: '主机发布',
|
||||
desc: 'SCP 上传构建产物到关联主机',
|
||||
stages: ['checkout', 'build', 'deploy'],
|
||||
deployTarget: 'ssh' as const,
|
||||
},
|
||||
{
|
||||
key: 'k8s',
|
||||
label: 'Kubernetes 发布',
|
||||
desc: '镜像打包后 CI 更新 Deployment',
|
||||
stages: ['checkout', 'build', 'image', 'deploy'],
|
||||
deployTarget: 'k8s' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_TARGET: DeployTargetValue = {
|
||||
deployTarget: 'ssh',
|
||||
serverIds: [],
|
||||
deployRemotePath: '',
|
||||
deployScript: '',
|
||||
k8sClusterId: '',
|
||||
k8sNamespace: '',
|
||||
k8sDeployment: '',
|
||||
k8sContainerName: '',
|
||||
};
|
||||
|
||||
// 新建流水线对话框 — 精简分步 + 双发布目标 // Logan.Li
|
||||
export function PipelineCreateDialog({ open, onOpenChange, onCreated }: PipelineCreateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [preset, setPreset] = useState('ssh');
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newJobName, setNewJobName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newSource, setNewSource] = useState('');
|
||||
const [newBranch, setNewBranch] = useState('main');
|
||||
const [newDockerfile, setNewDockerfile] = useState('');
|
||||
const [selectedStages, setSelectedStages] = useState<string[]>(['checkout', 'build', 'image', 'deploy']);
|
||||
const [newDeployMode, setNewDeployMode] = useState('branch');
|
||||
const [newDeployTarget, setNewDeployTarget] = useState('ssh');
|
||||
const [selectedStages, setSelectedStages] = useState<string[]>(['checkout', 'build', 'deploy']);
|
||||
const [deployTarget, setDeployTarget] = useState<DeployTargetValue>(DEFAULT_TARGET);
|
||||
const [newEnvironmentId, setNewEnvironmentId] = useState<number | string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
|
||||
const [gitCredentials, setGitCredentials] = useState<Record<string, unknown>[]>([]);
|
||||
const [dockerCredentials, setDockerCredentials] = useState<Record<string, unknown>[]>([]);
|
||||
const [newGitCredentialId, setNewGitCredentialId] = useState<number | string>('');
|
||||
const [newRegistryCredentialId, setNewRegistryCredentialId] = useState<number | string>('');
|
||||
|
||||
const [newJobName, setNewJobName] = useState('');
|
||||
const [newTriggerMode, setNewTriggerMode] = useState('manual');
|
||||
const [newWatchBranches, setNewWatchBranches] = useState('');
|
||||
const [newExcludeBranch, setNewExcludeBranch] = useState('');
|
||||
const [newCronExpr, setNewCronExpr] = useState('');
|
||||
const [newWebhookToken, setNewWebhookToken] = useState('');
|
||||
const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string; desc: string }[]>([]);
|
||||
const [newCachePaths, setNewCachePaths] = useState('');
|
||||
const [newMaxExecutors, setNewMaxExecutors] = useState(2);
|
||||
const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string }[]>([]);
|
||||
const [environments, setEnvironments] = useState<Record<string, unknown>[]>([]);
|
||||
const [buildNodes, setBuildNodes] = useState<BuildNode[]>([]);
|
||||
const [buildNodeId, setBuildNodeId] = useState('');
|
||||
|
||||
const loadRefs = useCallback(async () => {
|
||||
try {
|
||||
const [rEnv, rCred] = await Promise.all([
|
||||
const [rEnv, rCred, rNodes] = await Promise.all([
|
||||
deployApi.getEnvironments().catch(() => ({ list: [] })),
|
||||
credentialApi.list({ page_size: 100 }).catch(() => ({ list: [] })),
|
||||
buildNodeApi.list().catch(() => ({ list: [] })),
|
||||
] as const);
|
||||
setEnvironments(Array.isArray((rEnv as Record<string, unknown>)?.list) ? (rEnv as Record<string, unknown>).list as Record<string, unknown>[] : []);
|
||||
setEnvironments(Array.isArray((rEnv as Record<string, unknown>)?.list)
|
||||
? (rEnv as Record<string, unknown>).list as Record<string, unknown>[]
|
||||
: []);
|
||||
const allCreds: Record<string, unknown>[] = (rCred as Record<string, unknown>)?.list as Record<string, unknown>[] || [];
|
||||
setGitCredentials(allCreds.filter((c) => c.secret_type === 'api'));
|
||||
setDockerCredentials(allCreds.filter((c) => c.secret_type === 'docker'));
|
||||
const nodeData = rNodes as { list?: BuildNode[] };
|
||||
setBuildNodes(Array.isArray(nodeData?.list) ? nodeData.list.filter((n) => n.enabled) : []);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
@@ -76,33 +118,71 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
|
||||
if (open) loadRefs();
|
||||
}, [open, loadRefs]);
|
||||
|
||||
const applyPreset = (key: string) => {
|
||||
const p = PRESETS.find((x) => x.key === key);
|
||||
if (!p) return;
|
||||
setPreset(key);
|
||||
setSelectedStages(p.stages);
|
||||
setDeployTarget({
|
||||
...DEFAULT_TARGET,
|
||||
deployTarget: p.deployTarget,
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setStep(1);
|
||||
setPreset('ssh');
|
||||
setNewName('');
|
||||
setNewJobName('');
|
||||
setNewDesc('');
|
||||
setNewSource('');
|
||||
setNewBranch('main');
|
||||
setNewDockerfile('');
|
||||
setSelectedStages(['checkout', 'build', 'image', 'deploy']);
|
||||
setNewDeployMode('branch');
|
||||
setNewDeployTarget('ssh');
|
||||
setSelectedStages(['checkout', 'build', 'deploy']);
|
||||
setDeployTarget(DEFAULT_TARGET);
|
||||
setNewEnvironmentId('');
|
||||
setNewGitCredentialId('');
|
||||
setNewRegistryCredentialId('');
|
||||
setNewJobName('');
|
||||
setNewTriggerMode('manual');
|
||||
setNewWatchBranches('');
|
||||
setNewExcludeBranch('');
|
||||
setNewCronExpr('');
|
||||
setNewWebhookToken('');
|
||||
setNewEnvVars([]);
|
||||
setNewCachePaths('');
|
||||
setNewMaxExecutors(2);
|
||||
setAdvancedOpen(false);
|
||||
setBuildNodeId('');
|
||||
};
|
||||
|
||||
const validateStep1 = () => {
|
||||
if (!newName.trim()) {
|
||||
toast.error('请输入流水线名称');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateStep2 = () => {
|
||||
if (selectedStages.includes('checkout') && !newSource.trim()) {
|
||||
toast.error(t('deploy.stage.checkoutNeedGit'));
|
||||
return false;
|
||||
}
|
||||
if (selectedStages.includes('image') && !newRegistryCredentialId) {
|
||||
toast.error(t('deploy.stage.imageNeedDocker'));
|
||||
return false;
|
||||
}
|
||||
if (selectedStages.includes('deploy')) {
|
||||
if (deployTarget.deployTarget === 'ssh' && deployTarget.serverIds.length === 0) {
|
||||
toast.error('主机发布需至少关联一台主机');
|
||||
return false;
|
||||
}
|
||||
if (deployTarget.deployTarget === 'k8s' && (!deployTarget.k8sClusterId || !deployTarget.k8sDeployment)) {
|
||||
toast.error('Kubernetes 发布需选择集群与 Deployment');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) { toast.error('请输入流水线名称'); return; }
|
||||
if (selectedStages.includes('checkout') && !newSource.trim()) { toast.error('代码检出阶段需要填写 Git URL'); return; }
|
||||
if (selectedStages.includes('image') && !newRegistryCredentialId) { toast.error('镜像打包阶段需要关联 Docker 凭证'); return; }
|
||||
if (!validateStep1() || !validateStep2()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const stageNames = selectedStages.map((k) => STAGE_DEFS.find((s) => s.key === k)?.label || k);
|
||||
@@ -122,381 +202,358 @@ export function PipelineCreateDialog({ open, onOpenChange, onCreated }: Pipeline
|
||||
dockerfile: newDockerfile,
|
||||
type: 'deploy',
|
||||
stages,
|
||||
deploy_mode: newDeployMode,
|
||||
deploy_target: newDeployTarget,
|
||||
deploy_mode: 'branch',
|
||||
deploy_target: deployTarget.deployTarget,
|
||||
server_ids: deployTarget.serverIds.length > 0 ? JSON.stringify(deployTarget.serverIds) : undefined,
|
||||
deploy_remote_path: deployTarget.deployRemotePath || undefined,
|
||||
deploy_script: deployTarget.deployScript || undefined,
|
||||
k8s_cluster_id: deployTarget.k8sClusterId ? Number(deployTarget.k8sClusterId) : null,
|
||||
k8s_namespace: deployTarget.k8sNamespace || undefined,
|
||||
k8s_deployment: deployTarget.k8sDeployment || undefined,
|
||||
k8s_container_name: deployTarget.k8sContainerName || undefined,
|
||||
environment_id: newEnvironmentId ? Number(newEnvironmentId) : null,
|
||||
repo_id: null,
|
||||
registry_id: null,
|
||||
git_credential_id: newGitCredentialId ? Number(newGitCredentialId) : null,
|
||||
registry_credential_id: newRegistryCredentialId ? Number(newRegistryCredentialId) : null,
|
||||
trigger_mode: newTriggerMode,
|
||||
watch_branches: newWatchBranches || undefined,
|
||||
exclude_branch: newExcludeBranch || undefined,
|
||||
cron_expr: newCronExpr || undefined,
|
||||
webhook_token: newWebhookToken || undefined,
|
||||
variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined,
|
||||
cache_paths: newCachePaths || undefined,
|
||||
max_executors: newMaxExecutors,
|
||||
build_node_id: buildNodeId ? Number(buildNodeId) : null,
|
||||
});
|
||||
toast.success('流水线已创建');
|
||||
toast.success(t('deploy.pipeline.created'));
|
||||
onOpenChange(false);
|
||||
resetForm();
|
||||
onCreated();
|
||||
} catch {
|
||||
toast.error('创建失败');
|
||||
toast.error(t('deploy.pipeline.createFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2"><Rocket className="h-5 w-5" />新建流水线</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>名称 <span className="dg-text-red">*</span></Label>
|
||||
<Input value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="例如: demo-jar 后端部署" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Job 名称 <span className="text-xs text-[var(--dg-text-secondary)]">(Webhook URL)</span></Label>
|
||||
<Input value={newJobName} onChange={(e) => setNewJobName(e.target.value)} placeholder="cex-admin-server" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-sm" />
|
||||
{newJobName && <p className="text-[10px] text-[var(--dg-text-secondary)] opacity-60">Webhook: POST .../webhook/job/<b>{newJobName}</b></p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>描述</Label>
|
||||
<Textarea value={newDesc} onChange={(e) => setNewDesc(e.target.value)} placeholder="流水线描述..." rows={2} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]" />
|
||||
</div>
|
||||
const toggleStage = (key: string) => {
|
||||
setSelectedStages((prev) => (
|
||||
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
|
||||
));
|
||||
};
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>部署模式</Label>
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'branch', label: '分支', icon: GitBranch },
|
||||
{ key: 'tag', label: 'Tag 发版', icon: Tag },
|
||||
].map((m) => {
|
||||
const active = newDeployMode === m.key;
|
||||
return (
|
||||
<button
|
||||
key={m.key}
|
||||
onClick={() => setNewDeployMode(m.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
active ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<m.icon className="h-3.5 w-3.5" />{m.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) resetForm(); onOpenChange(v); }}>
|
||||
<DialogContent className="max-w-lg max-h-[88vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)]">新建流水线</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
选择发布方式,关联主机或 Kubernetes Deployment,创建后可继续编辑阶段脚本
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 步骤指示 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep(1)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md transition-colors',
|
||||
step === 1 ? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]',
|
||||
)}
|
||||
>
|
||||
1. 基础
|
||||
</button>
|
||||
<span className="text-[var(--dg-border)]">/</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (validateStep1()) setStep(2); }}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md transition-colors',
|
||||
step === 2 ? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]',
|
||||
)}
|
||||
>
|
||||
2. 代码与发布
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => applyPreset(p.key)}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border text-left transition-colors',
|
||||
preset === p.key
|
||||
? 'border-[var(--dg-blue)] bg-[color-mix(in_srgb,var(--dg-blue)_8%,transparent)]'
|
||||
: 'border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]',
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{p.label}</div>
|
||||
<div className="text-[11px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>部署目标</Label>
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'ssh', label: 'SSH 主机', icon: Server },
|
||||
{ key: 'k8s', label: 'Kubernetes 集群', icon: Network },
|
||||
].map((t) => {
|
||||
const active = newDeployTarget === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setNewDeployTarget(t.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||
active ? 'bg-green-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" />{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">名称 <span className="text-[var(--dg-red)]">*</span></Label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="例如 demo-api"
|
||||
className="h-9 dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>环境分组</Label>
|
||||
<Select value={String(newEnvironmentId)} onValueChange={(v) => setNewEnvironmentId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择环境" /></SelectTrigger>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">描述</Label>
|
||||
<Textarea
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
placeholder="可选"
|
||||
rows={2}
|
||||
className="dg-input text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">环境分组</Label>
|
||||
<Select
|
||||
value={String(newEnvironmentId)}
|
||||
onValueChange={(v) => setNewEnvironmentId(v === '__none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-9 dg-input"><SelectValue placeholder="选择环境" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不指定</SelectItem>
|
||||
{environments.map((env) => (
|
||||
<SelectItem key={env.id as number} value={String(env.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color as string }} />
|
||||
{String(env.display_name)}
|
||||
</span>
|
||||
{String(env.display_name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Git URL {selectedStages.includes('checkout') && <span className="dg-text-red">*</span>}</Label>
|
||||
<Input value={newSource} onChange={(e) => setNewSource(e.target.value)} placeholder="https://gitee.com/user/repo.git" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>分支 / Tag</Label>
|
||||
<Input value={newBranch} onChange={(e) => setNewBranch(e.target.value)} placeholder={newDeployMode === 'tag' ? 'v1.0.0' : 'main'} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Git 凭证 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(API Token)</span></Label>
|
||||
<Select value={String(newGitCredentialId)} onValueChange={(v) => setNewGitCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择凭证" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联</SelectItem>
|
||||
{gitCredentials.map((c) => (
|
||||
<SelectItem key={c.id as number} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Globe className="h-3 w-3 opacity-60" />{String(c.name)} {c.summary && <span className="text-[10px] opacity-50">({String(c.summary)})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!newGitCredentialId && selectedStages.includes('checkout') && <p className="text-xs dg-text-yellow">建议关联凭证</p>}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>镜像仓库凭证 {selectedStages.includes('image') && <span className="dg-text-red">*</span>}</Label>
|
||||
<Select value={String(newRegistryCredentialId)} onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9"><SelectValue placeholder="选择 Docker 凭证" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联</SelectItem>
|
||||
{dockerCredentials.map((c) => (
|
||||
<SelectItem key={c.id as number} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Container className="h-3 w-3 opacity-60" />{String(c.name)} {c.summary && <span className="text-[10px] opacity-50">({String(c.summary)})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!newRegistryCredentialId && selectedStages.includes('image') && <p className="text-xs dg-text-yellow">需要关联 Docker 凭证</p>}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={() => { if (validateStep1()) setStep(2); }}>下一步</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<DeployTargetFields
|
||||
value={deployTarget}
|
||||
onChange={(patch) => setDeployTarget((prev) => ({ ...prev, ...patch }))}
|
||||
requireTarget={selectedStages.includes('deploy')}
|
||||
/>
|
||||
|
||||
{newRegistryCredentialId && newJobName && (
|
||||
<div className="p-2.5 rounded-md bg-blue-500/5 border border-blue-500/20 text-xs text-[var(--dg-text-secondary)]">
|
||||
<span className="dg-text-blue">镜像命名:</span>
|
||||
<code className="text-[var(--dg-text-primary)]">{'{registry}'}/{'{namespace}'}/{'{'}{'{JOB_BASE_NAME}'}{'}'}:{'{'}{'{tag}'}{'}'}</code>
|
||||
<span className="ml-2 opacity-60">例:</span>
|
||||
<code className="opacity-60">harbor.ast1001.com/test/{newJobName}:latest</code>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">
|
||||
Git 仓库 {selectedStages.includes('checkout') && <span className="text-[var(--dg-red)]">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
value={newSource}
|
||||
onChange={(e) => setNewSource(e.target.value)}
|
||||
placeholder="https://gitee.com/user/repo.git"
|
||||
className="h-8 text-xs font-mono dg-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">分支</Label>
|
||||
<Input value={newBranch} onChange={(e) => setNewBranch(e.target.value)} className="h-8 text-xs dg-input" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>Dockerfile 路径</Label>
|
||||
<Input value={newDockerfile} onChange={(e) => setNewDockerfile(e.target.value)} placeholder="jar/Dockerfile" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-9 font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>发布流程 <span className="text-xs text-[var(--dg-text-secondary)] opacity-70 ml-1">按顺序选择阶段</span></Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STAGE_DEFS.map((def) => {
|
||||
const active = selectedStages.includes(def.key);
|
||||
return (
|
||||
<Badge
|
||||
key={def.key}
|
||||
className={`cursor-pointer transition-all text-xs px-3 py-1.5 ${active ? 'border-current' : 'border-[var(--dg-border)] opacity-50 hover:opacity-80'}`}
|
||||
style={active ? { backgroundColor: `${def.color}20`, color: def.color, borderColor: def.color } : {}}
|
||||
onClick={() => setSelectedStages(active ? selectedStages.filter((t) => t !== def.key) : [...selectedStages, def.key])}
|
||||
title={def.desc}
|
||||
>
|
||||
<def.icon className="h-3 w-3 mr-1" />{def.label} {def.needs.includes('repo') ? '🔗' : ''}{def.needs.includes('registry') ? '📦' : ''}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Git 凭证</Label>
|
||||
<Select value={String(newGitCredentialId)} onValueChange={(v) => setNewGitCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="选择凭证" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联</SelectItem>
|
||||
{gitCredentials.map((c) => (
|
||||
<SelectItem key={c.id as number} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(c.name)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedStages.includes('image') && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">镜像凭证 <span className="text-[var(--dg-red)]">*</span></Label>
|
||||
<Select value={String(newRegistryCredentialId)} onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="Docker 凭证" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联</SelectItem>
|
||||
{dockerCredentials.map((c) => (
|
||||
<SelectItem key={c.id as number} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(c.name)}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedStages.length > 0 && (
|
||||
<div className="flex items-center mt-2">
|
||||
{selectedStages.map((key, i) => {
|
||||
const def = STAGE_DEFS.find((t) => t.key === key)!;
|
||||
|
||||
{deployTarget.deployTarget === 'k8s' && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Dockerfile 路径</Label>
|
||||
<Input
|
||||
value={newDockerfile}
|
||||
onChange={(e) => setNewDockerfile(e.target.value)}
|
||||
placeholder="Dockerfile"
|
||||
className="h-8 text-xs font-mono dg-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">CI 阶段</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{STAGE_DEFS.map((def) => {
|
||||
const active = selectedStages.includes(def.key);
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium border"
|
||||
style={{
|
||||
backgroundColor: `${def.color}20`,
|
||||
borderColor: `${def.color}40`,
|
||||
color: def.color,
|
||||
borderRadius: i === 0 ? '6px 0 0 6px' : i === selectedStages.length - 1 ? '0 6px 6px 0' : '0',
|
||||
}}
|
||||
>
|
||||
<def.icon className="h-3 w-3" />{def.label}
|
||||
</div>
|
||||
{i < selectedStages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-4 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3 w-3" style={{ color: def.color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Badge
|
||||
key={def.key}
|
||||
variant={active ? 'default' : 'outline'}
|
||||
className={cn('cursor-pointer text-xs', !active && 'opacity-50')}
|
||||
onClick={() => toggleStage(def.key)}
|
||||
>
|
||||
<def.icon className="h-3 w-3 mr-1" />
|
||||
{def.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<Label className="mb-2 block">触发方式</Label>
|
||||
<div className="flex gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'manual', label: '手动', desc: '手动触发执行' },
|
||||
{ key: 'git_push', label: '代码推送', desc: 'Git Push 自动触发' },
|
||||
{ key: 'cron', label: '定时', desc: 'Cron 定时执行' },
|
||||
{ key: 'webhook', label: 'Webhook', desc: '外部 Webhook 调用' },
|
||||
].map((t) => {
|
||||
const active = newTriggerMode === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setNewTriggerMode(t.key)}
|
||||
className={`flex-1 flex flex-col items-center gap-0.5 px-2 py-2 rounded-md text-xs font-medium transition-all ${
|
||||
active ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
title={t.desc}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{newTriggerMode === 'git_push' && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">监听分支 <span className="text-[var(--dg-text-secondary)] opacity-60">(逗号分隔,支持 * 通配)</span></Label>
|
||||
<Input value={newWatchBranches} onChange={(e) => setNewWatchBranches(e.target.value)} placeholder="release/*, main" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">排除分支 <span className="text-[var(--dg-text-secondary)] opacity-60">(不触发构建)</span></Label>
|
||||
<Input value={newExcludeBranch} onChange={(e) => setNewExcludeBranch(e.target.value)} placeholder="wip/*, draft/*" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newTriggerMode === 'cron' && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Cron 表达式 <span className="text-[var(--dg-text-secondary)] opacity-60">(标准 5 位)</span></Label>
|
||||
<Input value={newCronExpr} onChange={(e) => setNewCronExpr(e.target.value)} placeholder="0 2 * * *" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{[
|
||||
{ expr: '*/5 * * * *', label: '每5分钟' },
|
||||
{ expr: '0 * * * *', label: '每小时' },
|
||||
{ expr: '0 2 * * *', label: '每天2点' },
|
||||
{ expr: '0 0 * * 0', label: '每周日' },
|
||||
].map((c) => (
|
||||
<Badge key={c.expr} variant="outline" className="cursor-pointer text-[10px] hover:bg-blue-500/10" onClick={() => setNewCronExpr(c.expr)}>
|
||||
{c.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{newTriggerMode === 'webhook' && (
|
||||
<div className="mt-3 space-y-2 p-3 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Webhook Token <span className="text-[var(--dg-text-secondary)] opacity-60">(签名验证,可选)</span></Label>
|
||||
<Input value={newWebhookToken} onChange={(e) => setNewWebhookToken(e.target.value)} placeholder="随机字符串" className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] h-8 font-mono text-xs" />
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-60">
|
||||
Webhook URL: <code className="text-[var(--dg-blue)]">POST /api/v1/webhook/pipeline/{'{code}'}</code>
|
||||
{selectedStages.length > 0 && (
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1 flex-wrap">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{selectedStages.map((k) => STAGE_DEFS.find((d) => d.key === k)?.label || k).join(' → ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>环境变量 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(${'{VAR_NAME}'} 引用)</span></Label>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => setNewEnvVars([...newEnvVars, { key: '', value: '', desc: '' }])}>
|
||||
<Plus className="h-3 w-3 mr-1" />添加变量
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{newEnvVars.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">暂无变量,点击"添加变量"新增</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{newEnvVars.map((v, i) => (
|
||||
<div key={i} className="grid grid-cols-12 gap-2 items-start p-2 rounded-md bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<Input
|
||||
value={v.key}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], key: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="变量名"
|
||||
className="col-span-3 h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Input
|
||||
value={v.value}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], value: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="变量值"
|
||||
className="col-span-4 h-8 text-xs font-mono bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Input
|
||||
value={v.desc}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], desc: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="说明"
|
||||
className="col-span-4 h-8 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
<Button variant="ghost" size="icon" className="col-span-1 h-8 w-8 dg-text-red" onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
高级设置
|
||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', advancedOpen && 'rotate-180')} />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-3 space-y-3 pt-3 border-t border-[var(--dg-border)]">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">构建节点</Label>
|
||||
<Select value={buildNodeId || '__auto'} onValueChange={(v) => setBuildNodeId(v === '__auto' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__auto">自动调度(Master 优先)</SelectItem>
|
||||
{buildNodes.map((n) => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>
|
||||
{n.name} ({n.running_count || 0}/{n.max_executors})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Job 名称 <span className="text-[var(--dg-text-muted)]">(Webhook)</span></Label>
|
||||
<Input value={newJobName} onChange={(e) => setNewJobName(e.target.value)} className="h-8 text-xs font-mono dg-input" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">触发方式</Label>
|
||||
<Select value={newTriggerMode} onValueChange={setNewTriggerMode}>
|
||||
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">手动</SelectItem>
|
||||
<SelectItem value="git_push">代码推送</SelectItem>
|
||||
<SelectItem value="cron">定时</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{newTriggerMode === 'git_push' && (
|
||||
<Input
|
||||
value={newWatchBranches}
|
||||
onChange={(e) => setNewWatchBranches(e.target.value)}
|
||||
placeholder="监听分支,如 main, release/*"
|
||||
className="h-8 text-xs font-mono dg-input"
|
||||
/>
|
||||
)}
|
||||
{newTriggerMode === 'cron' && (
|
||||
<Input
|
||||
value={newCronExpr}
|
||||
onChange={(e) => setNewCronExpr(e.target.value)}
|
||||
placeholder="0 2 * * *"
|
||||
className="h-8 text-xs font-mono dg-input"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">环境变量</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => setNewEnvVars([...newEnvVars, { key: '', value: '' }])}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-0.5" />添加
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{newEnvVars.map((v, i) => (
|
||||
<div key={i} className="flex gap-1.5">
|
||||
<Input
|
||||
value={v.key}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], key: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="KEY"
|
||||
className="h-7 text-xs font-mono dg-input flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={v.value}
|
||||
onChange={(e) => {
|
||||
const nv = [...newEnvVars];
|
||||
nv[i] = { ...nv[i], value: e.target.value };
|
||||
setNewEnvVars(nv);
|
||||
}}
|
||||
placeholder="value"
|
||||
className="h-7 text-xs font-mono dg-input flex-[2]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-[var(--dg-red)]" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<div className="flex justify-between gap-2 pt-2 border-t border-[var(--dg-border)]">
|
||||
<Button variant="ghost" onClick={() => setStep(1)}>上一步</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||
{saving ? '创建中' : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<Label className="mb-2 block">构建缓存 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(加速后续构建)</span></Label>
|
||||
<Input value={newCachePaths} onChange={(e) => setNewCachePaths(e.target.value)} placeholder="node_modules, .m2, target" className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] h-8 text-xs font-mono" />
|
||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">多个路径用逗号分隔,相同的 Key 会命中已有缓存</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[var(--dg-border)] pt-4">
|
||||
<Label className="mb-2 block">并发执行数 <span className="text-xs text-[var(--dg-text-secondary)] opacity-60">(Number of executors)</span></Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={String(newMaxExecutors)} onValueChange={(v) => setNewMaxExecutors(Number(v))}>
|
||||
<SelectTrigger className="w-32 h-8 text-xs bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 — 串行</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="4">4</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-[10px] text-[var(--dg-text-secondary)] opacity-50 mt-1">设为 1 则按顺序串行执行,默认 2 可并发构建</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t border-[var(--dg-border)]">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Rocket className="h-4 w-4 mr-1" />}
|
||||
{saving ? '创建中...' : '创建流水线'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user