feat: 去 mock 化 —— 全模块接入真实数据 (参考 jianmu/opsx-jenkins/aiops)
后端(35+ 新路由): - AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key - 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/ JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列, PipelineRun 扩展 buildNumber/externalUrl - 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、 工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、 入站流水线触发 /api/hooks/pipeline/:id - Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/ buildWithParameters/Location 队列/progressiveText 增量控制台, 服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、 jenkins 类型流水线真实执行回传日志 - 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表 - 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进 前端(14 页全部去 mock): - 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/ 系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API - 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史) - TopBar 真实流水线切换 + 仅保存/保存并运行 - Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
This commit is contained in:
1 parent
04d1342b27
commit
26341c3e8a
37 files changed
+5702
-4683
No files matched your search
@@ -1,23 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ChevronRight, CheckCircle2, Circle, GitBranch, Server,
|
||||
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
interface PipelineItem {
|
||||
id: string; name: string; branch: string; type: 'native' | 'jenkins';
|
||||
deployType: 'host' | 'k8s'; env: string; lastStatus: string;
|
||||
// ---- 类型(与后端 GET /api/pipelines、GET /api/runs 对齐)----
|
||||
interface PipelineDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
sourceBranch?: string;
|
||||
deployType: string;
|
||||
env?: string;
|
||||
lastRun: { status: string } | null;
|
||||
}
|
||||
|
||||
const mockPipelines: PipelineItem[] = [
|
||||
{ id: '1', name: 'aiops-backend', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
||||
{ id: '2', name: 'aiops-web', branch: 'master', type: 'native', deployType: 'host', env: 'production', lastStatus: 'success' },
|
||||
{ id: '3', name: 'gateway-api', branch: 'release/v2.1', type: 'jenkins', deployType: 'k8s', env: 'staging', lastStatus: 'pending' },
|
||||
{ id: '4', name: 'admin-dashboard', branch: 'master', type: 'native', deployType: 'host', env: 'staging', lastStatus: 'success' },
|
||||
{ id: '5', name: 'data-processor', branch: 'develop', type: 'jenkins', deployType: 'k8s', env: 'test', lastStatus: 'failed' },
|
||||
{ id: '6', name: 'notification-service', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
||||
];
|
||||
interface BatchRun {
|
||||
runId: string;
|
||||
pipelineName: string;
|
||||
status: string;
|
||||
triggerType?: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
const statusMeta: Record<string, { label: string; color: string }> = {
|
||||
success: { label: '成功', color: '#1a7f37' },
|
||||
failed: { label: '失败', color: '#cf222e' },
|
||||
running: { label: '运行中', color: '#0969da' },
|
||||
pending: { label: '排队中', color: '#9a6700' },
|
||||
queued: { label: '排队中', color: '#9a6700' },
|
||||
cancelled: { label: '已取消', color: '#6e7781' },
|
||||
none: { label: '未执行', color: '#6e7781' },
|
||||
};
|
||||
|
||||
const typeMeta: Record<string, { label: string; color: string; bg: string }> = {
|
||||
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
|
||||
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
|
||||
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||
};
|
||||
const fallbackType = typeMeta.native;
|
||||
|
||||
const failPolicyLabels: Record<string, string> = {
|
||||
stop: '立即中止后续发布',
|
||||
continue: '继续发布其他流水线',
|
||||
skip: '标记失败并跳过',
|
||||
};
|
||||
|
||||
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
|
||||
const steps: { id: StepType; label: string }[] = [
|
||||
@@ -28,44 +58,161 @@ const steps: { id: StepType; label: string }[] = [
|
||||
{ id: 'done', label: '完成' },
|
||||
];
|
||||
|
||||
export default function BatchRelease() {
|
||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const stamp = () => new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||
|
||||
export default function BatchRelease({ onViewRecords }: { onViewRecords?: () => void } = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
// 发布配置(本地状态)
|
||||
const [strategy, setStrategy] = useState<'sequential' | 'parallel'>('sequential');
|
||||
const [failPolicy, setFailPolicy] = useState<string>('stop');
|
||||
const [checkDeployment, setCheckDeployment] = useState(true);
|
||||
const [checkService, setCheckService] = useState(true);
|
||||
const [checkPod, setCheckPod] = useState(false);
|
||||
const [notifyFeishu, setNotifyFeishu] = useState(true);
|
||||
const [notifyDingtalk, setNotifyDingtalk] = useState(false);
|
||||
const [notifyWebhook, setNotifyWebhook] = useState(false);
|
||||
// 执行状态
|
||||
const [execLogs, setExecLogs] = useState<string[]>([]);
|
||||
const [completedCount, setCompletedCount] = useState(0);
|
||||
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
|
||||
const [failedTriggerIds, setFailedTriggerIds] = useState<Set<string>>(new Set());
|
||||
const [triggeredRuns, setTriggeredRuns] = useState<Record<string, string>>({});
|
||||
const [triggerDone, setTriggerDone] = useState(false);
|
||||
const [execStartedAt, setExecStartedAt] = useState<number | null>(null);
|
||||
|
||||
// 流水线列表(真实数据)
|
||||
const { data: pipelines = [], isLoading: pipelinesLoading } = useQuery({
|
||||
queryKey: ['pipelines'],
|
||||
queryFn: () => apiClient.listPipelines(),
|
||||
});
|
||||
|
||||
// 执行期间轮询运行列表,展示每项进度
|
||||
const { data: progressRuns = [] } = useQuery({
|
||||
queryKey: ['runs'],
|
||||
queryFn: () => apiClient.listRuns(),
|
||||
refetchInterval: currentStep === 'executing' ? 2000 : false,
|
||||
});
|
||||
|
||||
const items = (pipelines as PipelineDTO[]).map((p) => ({
|
||||
id: String(p.id),
|
||||
raw: p,
|
||||
name: p.name,
|
||||
branch: p.sourceBranch || '-',
|
||||
type: p.type,
|
||||
deployType: p.deployType,
|
||||
env: p.env || '-',
|
||||
lastStatus: p.lastRun?.status ?? 'none',
|
||||
}));
|
||||
|
||||
const selectedPipelines = items.filter((p) => selectedIds.includes(p.id));
|
||||
const stepIndex = steps.findIndex((s) => s.id === currentStep);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const selectedPipelines = mockPipelines.filter(p => selectedIds.includes(p.id));
|
||||
const stepIndex = steps.findIndex(s => s.id === currentStep);
|
||||
// 按 runId 精确匹配,回退到 pipelineName 匹配本次批量发布时间窗内最新的 run
|
||||
const getItemRun = (p: (typeof items)[number]): BatchRun | null => {
|
||||
const rid = triggeredRuns[p.id];
|
||||
if (rid) {
|
||||
const hit = (progressRuns as BatchRun[]).find((r) => r.runId === rid);
|
||||
if (hit) return hit;
|
||||
}
|
||||
const since = execStartedAt ? execStartedAt - 60_000 : 0;
|
||||
const candidates = (progressRuns as BatchRun[]).filter(
|
||||
(r) => r.pipelineName === p.name && r.triggerType === 'batch' && new Date(r.startedAt).getTime() >= since
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates.reduce((latest, r) =>
|
||||
new Date(r.startedAt).getTime() > new Date(latest.startedAt).getTime() ? r : latest
|
||||
);
|
||||
};
|
||||
|
||||
const startExecution = () => {
|
||||
const itemStatus = (p: (typeof items)[number]): { label: string; color: string; pulse: boolean; terminal: boolean } => {
|
||||
if (executingIds.has(p.id)) return { label: '发布中…', color: '#0969da', pulse: true, terminal: false };
|
||||
if (failedTriggerIds.has(p.id)) return { label: '触发失败', color: '#cf222e', pulse: false, terminal: true };
|
||||
const run = getItemRun(p);
|
||||
if (!run) return { label: triggerDone ? '等待中' : '排队中', color: '#9a6700', pulse: false, terminal: false };
|
||||
const meta = statusMeta[run.status] || statusMeta.pending;
|
||||
const terminal = run.status === 'success' || run.status === 'failed' || run.status === 'cancelled';
|
||||
return { label: meta.label, color: meta.color, pulse: run.status === 'running', terminal };
|
||||
};
|
||||
|
||||
const completedCount = selectedPipelines.filter((p) => itemStatus(p).terminal).length;
|
||||
const successCount = selectedPipelines.filter((p) => getItemRun(p)?.status === 'success').length;
|
||||
const failedCount = selectedPipelines.length - successCount;
|
||||
|
||||
// 全部触发完成且每个 run 都到终态后,进入完成步骤
|
||||
useEffect(() => {
|
||||
if (currentStep !== 'executing' || !triggerDone) return;
|
||||
const allDone = selectedPipelines.length > 0 && selectedPipelines.every((p) => itemStatus(p).terminal);
|
||||
if (!allDone) return;
|
||||
const ok = selectedPipelines.filter((p) => getItemRun(p)?.status === 'success').length;
|
||||
setExecLogs((prev) => [...prev, `[${stamp()}] 批量发布结束:成功 ${ok} 个,失败 ${selectedPipelines.length - ok} 个`]);
|
||||
setCurrentStep('done');
|
||||
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [progressRuns, triggerDone, currentStep, executingIds, failedTriggerIds]);
|
||||
|
||||
const startExecution = async () => {
|
||||
if (currentStep === 'executing' || selectedPipelines.length === 0) return;
|
||||
setCurrentStep('executing');
|
||||
setExecLogs([`[${new Date().toLocaleTimeString()}] 批量发布开始,共 ${selectedIds.length} 个流水线`]);
|
||||
|
||||
let count = 0;
|
||||
selectedPipelines.forEach((p, idx) => {
|
||||
setTimeout(() => {
|
||||
setExecLogs(prev => [
|
||||
...prev,
|
||||
`[${new Date().toLocaleTimeString()}] [${idx + 1}/${selectedPipelines.length}] 开始发布: ${p.name} (${p.branch})`,
|
||||
`[${new Date().toLocaleTimeString()}] -> 拉取代码...`,
|
||||
`[${new Date().toLocaleTimeString()}] -> 构建中...`,
|
||||
`[${new Date().toLocaleTimeString()}] -> ${p.deployType === 'k8s' ? '更新 K8s Deployment...' : 'SCP 上传 + SSH 部署...'}`,
|
||||
`[${new Date().toLocaleTimeString()}] ✓ ${p.name} 发布成功`,
|
||||
]);
|
||||
count++;
|
||||
setCompletedCount(count);
|
||||
if (count === selectedPipelines.length) {
|
||||
setTimeout(() => {
|
||||
setExecLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] 批量发布全部完成`]);
|
||||
setCurrentStep('done');
|
||||
}, 500);
|
||||
}
|
||||
}, idx * 2500);
|
||||
});
|
||||
setTriggeredRuns({});
|
||||
setFailedTriggerIds(new Set());
|
||||
setTriggerDone(false);
|
||||
setExecStartedAt(Date.now());
|
||||
setExecLogs([`[${stamp()}] 批量发布开始,共 ${selectedPipelines.length} 个流水线(${strategy === 'sequential' ? '顺序' : '并行'}执行)`]);
|
||||
|
||||
const triggerOne = async (p: (typeof items)[number]) => {
|
||||
setExecutingIds((prev) => new Set(prev).add(p.id));
|
||||
try {
|
||||
const run = await apiClient.createRun(p.raw.id, { triggerType: 'batch' });
|
||||
setTriggeredRuns((prev) => ({ ...prev, [p.id]: run.runId }));
|
||||
setExecLogs((prev) => [...prev, `[${stamp()}] ✓ 已触发 ${p.name} (${p.branch}) → ${run.runId}`]);
|
||||
} catch (e) {
|
||||
setFailedTriggerIds((prev) => new Set(prev).add(p.id));
|
||||
setExecLogs((prev) => [...prev, `[${stamp()}] ✗ 触发失败 ${p.name}: ${(e as Error).message}`]);
|
||||
toast.error(`触发失败: ${p.name}`, { description: (e as Error).message });
|
||||
} finally {
|
||||
setExecutingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(p.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (strategy === 'sequential') {
|
||||
for (let i = 0; i < selectedPipelines.length; i++) {
|
||||
await triggerOne(selectedPipelines[i]);
|
||||
if (i < selectedPipelines.length - 1) await sleep(300);
|
||||
}
|
||||
} else {
|
||||
await Promise.all(selectedPipelines.map(triggerOne));
|
||||
}
|
||||
|
||||
setTriggerDone(true);
|
||||
setExecLogs((prev) => [...prev, `[${stamp()}] 全部触发完成,等待运行结束...`]);
|
||||
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||
};
|
||||
|
||||
const resetWizard = () => {
|
||||
setCurrentStep('select');
|
||||
setSelectedIds([]);
|
||||
setExecLogs([]);
|
||||
setExecutingIds(new Set());
|
||||
setFailedTriggerIds(new Set());
|
||||
setTriggeredRuns({});
|
||||
setTriggerDone(false);
|
||||
setExecStartedAt(null);
|
||||
};
|
||||
|
||||
const handleViewRecords = () => {
|
||||
if (onViewRecords) onViewRecords();
|
||||
else toast.info('请点击左侧【发布记录】查看详细记录');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -100,26 +247,47 @@ export default function BatchRelease() {
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setSelectedIds(items.map((p) => p.id))}
|
||||
className="text-[12px] px-2 py-[3px] rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>全选</button>
|
||||
<button onClick={() => setSelectedIds(items.filter((p) => !selectedIds.includes(p.id)).map((p) => p.id))}
|
||||
className="text-[12px] px-2 py-[3px] rounded border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>反选</button>
|
||||
</div>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div>
|
||||
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px_100px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div><div>最近状态</div>
|
||||
</div>
|
||||
{mockPipelines.map((p) => {
|
||||
{pipelinesLoading ? (
|
||||
<div className="flex items-center justify-center py-10 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-10 text-center border-t text-[12px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>暂无可发布的流水线</div>
|
||||
) : items.map((p) => {
|
||||
const isSelected = selectedIds.includes(p.id);
|
||||
const tp = typeMeta[p.type] || fallbackType;
|
||||
const last = statusMeta[p.lastStatus] || statusMeta.none;
|
||||
return (
|
||||
<div key={p.id} onClick={() => toggleSelect(p.id)}
|
||||
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px_100px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
|
||||
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
|
||||
</div></div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
||||
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
||||
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span></div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
||||
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : p.deployType === 'gitops' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> GitOps</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${p.lastStatus === 'running' ? 'animate-pulse' : ''}`} style={{ background: last.color }} />
|
||||
<span className="text-[11px]" style={{ color: last.color }}>{last.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -139,34 +307,42 @@ export default function BatchRelease() {
|
||||
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
|
||||
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option>串行发布(逐个执行,失败即停)</option><option>并行发布(同时执行,互不影响)</option><option>分组发布(按环境分组串行)</option>
|
||||
<select value={strategy} onChange={(e) => setStrategy(e.target.value as 'sequential' | 'parallel')}
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option value="sequential">顺序发布(逐个执行,间隔 300ms)</option>
|
||||
<option value="parallel">并行发布(同时执行,互不影响)</option>
|
||||
</select></div>
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
|
||||
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option>立即中止后续发布</option><option>继续发布其他流水线</option><option>标记失败并跳过</option>
|
||||
<select value={failPolicy} onChange={(e) => setFailPolicy(e.target.value)}
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option value="stop">立即中止后续发布</option>
|
||||
<option value="continue">继续发布其他流水线</option>
|
||||
<option value="skip">标记失败并跳过</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkDeployment} onChange={(e) => setCheckDeployment(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkService} onChange={(e) => setCheckService(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={checkPod} onChange={(e) => setCheckPod(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
|
||||
</div></div>
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyFeishu} onChange={(e) => setNotifyFeishu(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyDingtalk} onChange={(e) => setNotifyDingtalk(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" checked={notifyWebhook} onChange={(e) => setNotifyWebhook(e.target.checked)} className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedPipelines.map((p) => (
|
||||
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
<Package className="w-3 h-3" />{p.name}</span>
|
||||
))}
|
||||
{selectedPipelines.map((p) => {
|
||||
const tp = typeMeta[p.type] || fallbackType;
|
||||
return (
|
||||
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
<Package className="w-3 h-3" style={{ color: tp.color }} />{p.name}</span>
|
||||
);
|
||||
})}
|
||||
</div></div>
|
||||
<div className="flex justify-between">
|
||||
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
||||
@@ -181,17 +357,20 @@ export default function BatchRelease() {
|
||||
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
|
||||
</div>
|
||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,串行执行,失败即停</p>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,{strategy === 'sequential' ? '顺序' : '并行'}执行,{failPolicyLabels[failPolicy] || failPolicy}</p>
|
||||
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{selectedPipelines.map((p, idx) => (
|
||||
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
|
||||
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
|
||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span>
|
||||
</div>
|
||||
))}
|
||||
{selectedPipelines.map((p, idx) => {
|
||||
const tp = typeMeta[p.type] || fallbackType;
|
||||
return (
|
||||
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
|
||||
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
|
||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
|
||||
@@ -212,7 +391,24 @@ export default function BatchRelease() {
|
||||
</div>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
{/* 每个流水线的实时状态 */}
|
||||
<div className="divide-y" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
{selectedPipelines.map((p) => {
|
||||
const st = itemStatus(p);
|
||||
return (
|
||||
<div key={p.id} className="px-4 py-2 flex items-center gap-3">
|
||||
<GitBranch className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[13px] font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
||||
<span className={`flex items-center gap-1.5 text-[12px] font-medium w-[90px] justify-end ${st.pulse ? 'animate-pulse' : ''}`} style={{ color: st.color }}>
|
||||
<span className="w-2 h-2 rounded-full" style={{ background: st.color }} />
|
||||
{st.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="p-0 border-t" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
||||
@@ -221,7 +417,7 @@ export default function BatchRelease() {
|
||||
</div>
|
||||
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
|
||||
{execLogs.map((log, idx) => (
|
||||
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('->') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
|
||||
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('✗') ? <span style={{ color: '#f85149' }}>{log}</span> : log.includes('→') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
|
||||
))}
|
||||
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
|
||||
</div>
|
||||
@@ -232,15 +428,20 @@ export default function BatchRelease() {
|
||||
|
||||
{currentStep === 'done' && (
|
||||
<div className="max-w-2xl mx-auto text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}>
|
||||
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: failedCount > 0 ? '#fff8c5' : '#dafbe1' }}>
|
||||
{failedCount > 0
|
||||
? <AlertCircle className="w-8 h-8" style={{ color: '#9a6700' }} />
|
||||
: <CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />}
|
||||
</div>
|
||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>共成功发布 <span className="font-semibold" style={{ color: 'var(--accent-green)' }}>{selectedIds.length}</span> 个流水线</p>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
共发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线:
|
||||
成功 <span className="font-semibold" style={{ color: '#1a7f37' }}>{successCount}</span> 个,失败 <span className="font-semibold" style={{ color: '#cf222e' }}>{failedCount}</span> 个
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => { setCurrentStep('select'); setSelectedIds([]); setExecLogs([]); setCompletedCount(0); }}
|
||||
<button onClick={resetWizard}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
|
||||
<button onClick={() => toast.success('已跳转至发布记录')} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看记录</button>
|
||||
<button onClick={handleViewRecords} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看发布记录</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
import {
|
||||
Cpu,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Edit2,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Tag,
|
||||
Activity
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
interface BuildNode {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
labels: string[];
|
||||
maxConcurrency: number;
|
||||
currentJobs: number;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
cpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
lastHeartbeat: string;
|
||||
// 后端 labels 为 JSON 字符串,安全解析为数组
|
||||
function parseJsonArray(v: unknown): string[] {
|
||||
try {
|
||||
const arr = JSON.parse(String(v ?? '[]'));
|
||||
return Array.isArray(arr) ? arr.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const mockNodes: BuildNode[] = [
|
||||
{ id: '1', name: 'build-host-01', ip: '192.168.10.101', labels: ['docker', 'go', 'java'], maxConcurrency: 4, currentJobs: 2, status: 'busy', cpu: '45%', memory: '6.2GB/16GB', disk: '120GB/500GB', lastHeartbeat: '10秒前' },
|
||||
{ id: '2', name: 'build-host-02', ip: '192.168.10.102', labels: ['docker', 'node', 'python'], maxConcurrency: 3, currentJobs: 0, status: 'online', cpu: '12%', memory: '2.1GB/8GB', disk: '80GB/300GB', lastHeartbeat: '5秒前' },
|
||||
{ id: '3', name: 'build-host-03', ip: '192.168.10.103', labels: ['k8s', 'helm'], maxConcurrency: 2, currentJobs: 0, status: 'offline', cpu: '-', memory: '-', disk: '-', lastHeartbeat: '2小时前' },
|
||||
];
|
||||
function splitComma(v: string): string[] {
|
||||
return v.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// 相对时间:如 "10秒前"
|
||||
function timeAgo(v?: string): string {
|
||||
if (!v) return '—';
|
||||
const t = new Date(v).getTime();
|
||||
if (Number.isNaN(t)) return v;
|
||||
const s = Math.floor((Date.now() - t) / 1000);
|
||||
if (s < 10) return '刚刚';
|
||||
if (s < 60) return `${s}秒前`;
|
||||
if (s < 3600) return `${Math.floor(s / 60)}分钟前`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}小时前`;
|
||||
return `${Math.floor(s / 86400)}天前`;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
|
||||
@@ -39,15 +52,122 @@ const statusConfig = {
|
||||
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
|
||||
};
|
||||
|
||||
const unknownStatus = { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '未知' };
|
||||
|
||||
interface NodeForm {
|
||||
name: string;
|
||||
ip: string;
|
||||
labels: string;
|
||||
maxConcurrency: string;
|
||||
workspacePath: string;
|
||||
}
|
||||
|
||||
const emptyForm: NodeForm = { name: '', ip: '', labels: '', maxConcurrency: '2', workspacePath: '/var/lib/flowstack/workspace' };
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
|
||||
export default function BuildNodeManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const filtered = mockNodes.filter(n =>
|
||||
n.name.includes(searchQuery) || n.ip.includes(searchQuery)
|
||||
);
|
||||
|
||||
const nodesQuery = useQuery({ queryKey: ['build-nodes'], queryFn: () => apiClient.listBuildNodes() });
|
||||
const nodes: any[] = nodesQuery.data ?? [];
|
||||
|
||||
// ---- 弹窗 ----
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [form, setForm] = useState<NodeForm>(emptyForm);
|
||||
|
||||
const openDialog = (node?: any) => {
|
||||
setEditing(node ?? null);
|
||||
setForm(
|
||||
node
|
||||
? {
|
||||
name: node.name ?? '',
|
||||
ip: node.ip ?? '',
|
||||
labels: parseJsonArray(node.labels).join(','),
|
||||
maxConcurrency: String(node.maxConcurrency ?? 2),
|
||||
workspacePath: node.workspacePath ?? '',
|
||||
}
|
||||
: emptyForm,
|
||||
);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['build-nodes'] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (vars: { id?: any; body: any }) =>
|
||||
vars.id ? apiClient.updateBuildNode(vars.id, vars.body) : apiClient.createBuildNode(vars.body),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success(vars.id ? '节点已更新' : '节点已创建');
|
||||
invalidate();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.deleteBuildNode(id),
|
||||
onSuccess: () => {
|
||||
toast.success('节点已删除');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.testBuildNode(id),
|
||||
onSuccess: (result: any) => {
|
||||
if (result?.success) toast.success(result?.message || '连接成功');
|
||||
else toast.error(result?.message || '连接失败');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (vars: { id: any; enabled: boolean }) => apiClient.updateBuildNode(vars.id, { enabled: vars.enabled }),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success(vars.enabled ? '节点已启用' : '节点已停用');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (!form.name.trim() || !form.ip.trim()) {
|
||||
toast.error('请填写节点名称和 IP 地址');
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate({
|
||||
id: editing?.id,
|
||||
body: {
|
||||
name: form.name.trim(),
|
||||
ip: form.ip.trim(),
|
||||
labels: JSON.stringify(splitComma(form.labels)),
|
||||
maxConcurrency: Number(form.maxConcurrency) || 1,
|
||||
workspacePath: form.workspacePath.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 统计(真实数据计算)
|
||||
const stats = [
|
||||
{ label: '在线节点', count: nodes.filter((n) => n.status === 'online').length },
|
||||
{ label: '忙碌节点', count: nodes.filter((n) => n.status === 'busy').length },
|
||||
{ label: '离线节点', count: nodes.filter((n) => n.status !== 'online' && n.status !== 'busy').length },
|
||||
];
|
||||
|
||||
const filtered = nodes.filter((n) => n.name?.includes(searchQuery) || n.ip?.includes(searchQuery));
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -62,7 +182,7 @@ export default function BuildNodeManager() {
|
||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => toast.info('添加节点', { description: '功能开发中...' })}>
|
||||
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => openDialog()}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
添加节点
|
||||
</button>
|
||||
@@ -70,56 +190,101 @@ export default function BuildNodeManager() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '在线节点', count: 2, color: '#1a7f37' },
|
||||
{ label: '忙碌节点', count: 1, color: '#9a6700' },
|
||||
{ label: '离线节点', count: 1, color: '#cf222e' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map((node) => {
|
||||
const status = statusConfig[node.status];
|
||||
const StatusIcon = status.icon;
|
||||
const utilization = node.status === 'offline' ? 0 : (node.currentJobs / node.maxConcurrency) * 100;
|
||||
return (
|
||||
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
|
||||
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
|
||||
{status.label}
|
||||
</span>
|
||||
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
</div>
|
||||
{nodesQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : nodesQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16">
|
||||
<XCircle className="w-6 h-6" style={{ color: 'var(--accent-red)' }} />
|
||||
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(nodesQuery.error as Error).message}</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
onClick={() => nodesQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map((node) => {
|
||||
const status = statusConfig[node.status as keyof typeof statusConfig] ?? { ...unknownStatus, label: String(node.status) };
|
||||
const StatusIcon = status.icon;
|
||||
const maxConcurrency = Number(node.maxConcurrency) || 1;
|
||||
const currentJobs = Number(node.currentJobs) || 0;
|
||||
const utilization = node.status === 'offline' ? 0 : (currentJobs / maxConcurrency) * 100;
|
||||
const labels = parseJsonArray(node.labels);
|
||||
return (
|
||||
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', opacity: node.enabled === false ? 0.7 : 1 }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
|
||||
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
|
||||
{status.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-1" title={node.enabled === false ? '已停用' : '已启用'}>
|
||||
<Switch
|
||||
checked={node.enabled !== false}
|
||||
disabled={toggleMutation.isPending && toggleMutation.variables?.id === node.id}
|
||||
onCheckedChange={(checked) => toggleMutation.mutate({ id: node.id, enabled: checked })}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="测试连接"
|
||||
disabled={testMutation.isPending && testMutation.variables === node.id}
|
||||
onClick={() => testMutation.mutate(node.id)}
|
||||
>
|
||||
{testMutation.isPending && testMutation.variables === node.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openDialog(node)}>
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除节点 ${node.name} 吗?`)) deleteMutation.mutate(node.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{node.status !== 'offline' && (
|
||||
<>
|
||||
{/* Concurrency bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.currentJobs} / {node.maxConcurrency}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{currentJobs} / {maxConcurrency}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<div
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
style={{
|
||||
width: `${utilization}%`,
|
||||
background: utilization > 80 ? 'var(--accent-red)' : utilization > 50 ? 'var(--accent-amber)' : 'var(--accent-green)',
|
||||
}}
|
||||
@@ -127,42 +292,101 @@ export default function BuildNodeManager() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource metrics */}
|
||||
<div className="grid grid-cols-4 gap-3 text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Activity className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span style={{ color: 'var(--text-secondary)' }}>CPU {node.cpu}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>内存</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.memory}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>磁盘</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.disk}</span>
|
||||
{/* Info: 工作空间 / 心跳(后端暂无 CPU/内存/磁盘数据) */}
|
||||
<div className="grid grid-cols-2 gap-3 text-[11px]">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="flex-shrink-0" style={{ color: 'var(--text-tertiary)' }}>工作空间</span>
|
||||
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }} title={node.workspacePath}>
|
||||
{node.workspacePath || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.lastHeartbeat}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{timeAgo(node.lastHeartbeat)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
||||
{node.labels.map((label) => (
|
||||
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
||||
{labels.map((label) => (
|
||||
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<div
|
||||
className="rounded-lg border p-10 text-center text-[12px]"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
暂无构建节点,点击右上角「添加节点」创建
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑节点弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||
{editing ? '编辑节点' : '添加节点'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="build-host-01" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>IP 地址 *</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.ip} onChange={(e) => setForm({ ...form, ip: e.target.value })} placeholder="192.168.10.101" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>标签(逗号分隔)</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.labels} onChange={(e) => setForm({ ...form, labels: e.target.value })} placeholder="docker,go,java" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>最大并发</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={form.maxConcurrency} onChange={(e) => setForm({ ...form, maxConcurrency: e.target.value })} placeholder="2" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>工作空间路径</Label>
|
||||
<Input className="h-8 text-[12px] font-mono" style={inputStyle} value={form.workspacePath} onChange={(e) => setForm({ ...form, workspacePath: e.target.value })} placeholder="/var/lib/flowstack/workspace" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Search,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Trash2,
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Search,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Shield,
|
||||
GitBranch,
|
||||
Container
|
||||
Container,
|
||||
Cloud,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Credential {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'ssh' | 'password' | 'docker' | 'git' | 'kubeconfig';
|
||||
scope: string;
|
||||
createdAt: string;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
const mockCredentials: Credential[] = [
|
||||
{ id: '1', name: 'production-ssh-key', type: 'ssh', scope: 'production-group', createdAt: '2024-06-01', usageCount: 12 },
|
||||
{ id: '2', name: 'docker-registry-auth', type: 'docker', scope: '全局', createdAt: '2024-06-05', usageCount: 8 },
|
||||
{ id: '3', name: 'gitee-token', type: 'git', scope: '全局', createdAt: '2024-06-10', usageCount: 5 },
|
||||
{ id: '4', name: 'staging-ssh-key', type: 'ssh', scope: 'staging-group', createdAt: '2024-06-12', usageCount: 3 },
|
||||
{ id: '5', name: 'ack-kubeconfig', type: 'kubeconfig', scope: 'ack-production', createdAt: '2024-06-15', usageCount: 6 },
|
||||
{ id: '6', name: 'db-password', type: 'password', scope: 'production-group', createdAt: '2024-06-18', usageCount: 2 },
|
||||
];
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
||||
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
|
||||
@@ -38,41 +27,137 @@ const typeConfig: Record<string, { icon: typeof Key; label: string; color: strin
|
||||
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
|
||||
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
|
||||
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
|
||||
aksk: { icon: Cloud, label: 'AK/SK', color: '#0f766e', bg: '#ccfbf1' },
|
||||
};
|
||||
|
||||
const fallbackConfig = { icon: Key, label: '其他', color: '#6e7781', bg: '#f6f8fa' };
|
||||
|
||||
function getConfig(type: string) {
|
||||
return typeConfig[type] ?? { ...fallbackConfig, label: type };
|
||||
}
|
||||
|
||||
function fmtTime(v?: string): string {
|
||||
if (!v || v.startsWith('0001-')) return '—';
|
||||
return v.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
interface CredentialForm {
|
||||
name: string;
|
||||
type: string;
|
||||
scope: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const emptyForm: CredentialForm = { name: '', type: 'ssh', scope: '全局', content: '' };
|
||||
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
|
||||
export default function CredentialManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
|
||||
const filtered = mockCredentials.filter(c =>
|
||||
c.name.includes(searchQuery) || c.type.includes(searchQuery)
|
||||
// 列表(跟随类型筛选)
|
||||
const credentialsQuery = useQuery({
|
||||
queryKey: ['credentials', typeFilter],
|
||||
queryFn: () => apiClient.listCredentials(typeFilter),
|
||||
});
|
||||
// 全量(用于类型统计卡数量)
|
||||
const allQuery = useQuery({ queryKey: ['credentials', 'all'], queryFn: () => apiClient.listCredentials() });
|
||||
|
||||
const credentials: any[] = credentialsQuery.data ?? [];
|
||||
const allCredentials: any[] = allQuery.data ?? [];
|
||||
|
||||
// ---- 弹窗 ----
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any | null>(null);
|
||||
const [form, setForm] = useState<CredentialForm>(emptyForm);
|
||||
|
||||
const openDialog = (cred?: any) => {
|
||||
setEditing(cred ?? null);
|
||||
setForm(
|
||||
cred
|
||||
? { name: cred.name ?? '', type: cred.type ?? 'ssh', scope: cred.scope ?? '', content: '' }
|
||||
: emptyForm,
|
||||
);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['credentials'] });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (vars: { id?: any; body: any }) =>
|
||||
vars.id ? apiClient.updateCredential(vars.id, vars.body) : apiClient.createCredential(vars.body),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success(vars.id ? '凭证已更新' : '凭证已创建');
|
||||
invalidate();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.deleteCredential(id),
|
||||
onSuccess: () => {
|
||||
toast.success('凭证已删除');
|
||||
invalidate();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('请填写凭证名称');
|
||||
return;
|
||||
}
|
||||
// 内容加密存储(write-only):编辑时留空表示不修改
|
||||
const body: any = { name: form.name.trim(), type: form.type, scope: form.scope.trim() };
|
||||
if (form.content.trim()) body.content = form.content.trim();
|
||||
saveMutation.mutate({ id: editing?.id, body });
|
||||
};
|
||||
|
||||
const copySummary = (cred: any) => {
|
||||
const text = cred.summary || getConfig(cred.type).label;
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => toast.success('已复制', { description: text }))
|
||||
.catch(() => toast.error('复制失败'));
|
||||
};
|
||||
|
||||
const filtered = credentials.filter(
|
||||
(c) => c.name?.includes(searchQuery) || c.scope?.includes(searchQuery) || c.type?.includes(searchQuery),
|
||||
);
|
||||
|
||||
const toggleSecret = (id: string) => {
|
||||
setShowSecrets(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const copyValue = (name: string) => {
|
||||
toast.success('已复制', { description: `${name} 的值已复制到剪贴板` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>凭证管理</h1>
|
||||
<span
|
||||
<span
|
||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{filtered.length}
|
||||
</span>
|
||||
{typeFilter !== 'all' && (
|
||||
<button
|
||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||
style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}
|
||||
onClick={() => setTypeFilter('all')}
|
||||
>
|
||||
{getConfig(typeFilter).label} ×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
@@ -88,7 +173,7 @@ export default function CredentialManager() {
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => toast.info('新建凭证', { description: '功能开发中...' })}
|
||||
onClick={() => openDialog()}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建凭证
|
||||
@@ -97,86 +182,220 @@ export default function CredentialManager() {
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{/* Type summary */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-4">
|
||||
{Object.entries(typeConfig).map(([type, config]) => {
|
||||
const count = mockCredentials.filter(c => c.type === type).length;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={type} className="rounded-lg border p-3 flex items-center gap-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center" style={{ background: config.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: config.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
|
||||
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Credential list */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
{['凭证名称', '类型', '使用范围', '使用次数', '创建时间', '值', '操作'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((cred) => {
|
||||
const config = typeConfig[cred.type];
|
||||
{credentialsQuery.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : credentialsQuery.isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16">
|
||||
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(credentialsQuery.error as Error).message}</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
onClick={() => credentialsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Type summary(点击可按类型筛选) */}
|
||||
<div className="grid grid-cols-6 gap-3 mb-4">
|
||||
{Object.entries(typeConfig).map(([type, config]) => {
|
||||
const count = allCredentials.filter((c) => c.type === type).length;
|
||||
const Icon = config.icon;
|
||||
const isRevealed = showSecrets[cred.id];
|
||||
const active = typeFilter === type;
|
||||
return (
|
||||
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
|
||||
<Icon className="w-3 h-3" style={{ color: config.color }} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
|
||||
{config.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{cred.createdAt}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-[11px] px-2 py-[3px] rounded font-mono" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
{isRevealed ? 'AKIAIOSFODNN7EXAMPLE' : '••••••••••••••••'}
|
||||
</code>
|
||||
<button onClick={() => toggleSecret(cred.id)} className="p-1 rounded hover:bg-black/5">
|
||||
{isRevealed ? <EyeOff className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> : <Eye className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />}
|
||||
</button>
|
||||
<button onClick={() => copyValue(cred.name)} className="p-1 rounded hover:bg-black/5">
|
||||
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setTypeFilter(active ? 'all' : type)}
|
||||
className="rounded-lg border p-3 flex items-center gap-3 text-left transition-colors hover:bg-black/[0.02]"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: active ? 'var(--accent-blue)' : 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0" style={{ background: config.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: config.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
|
||||
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credential list */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
{['凭证名称', '类型', '使用范围', '使用次数', '最近使用', '创建时间', '摘要', '操作'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((cred) => {
|
||||
const config = getConfig(cred.type);
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
|
||||
<Icon className="w-3 h-3" style={{ color: config.color }} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
|
||||
{config.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope || '—'}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount ?? 0}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(cred.lastUsedAt)}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{fmtTime(cred.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{cred.contentSet === false ? (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>未设置内容</span>
|
||||
) : (
|
||||
<>
|
||||
<code
|
||||
className="text-[11px] px-2 py-[3px] rounded font-mono max-w-[220px] truncate inline-block"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}
|
||||
title={cred.summary || config.label}
|
||||
>
|
||||
{cred.summary || config.label}
|
||||
</code>
|
||||
<button onClick={() => copySummary(cred)} className="p-1 rounded hover:bg-black/5" title="复制摘要">
|
||||
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openDialog(cred)}>
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除凭证 ${cred.name} 吗?`)) deleteMutation.mutate(cred.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
暂无凭证,点击右上角「新建凭证」创建
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建/编辑凭证弹窗 */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||
{editing ? '编辑凭证' : '新建凭证'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||
<Input
|
||||
className="h-8 text-[12px]"
|
||||
style={inputStyle}
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="production-ssh-key"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>类型</Label>
|
||||
<Select value={form.type} onValueChange={(v) => setForm({ ...form, type: v })}>
|
||||
<SelectTrigger className="h-8 text-[12px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(typeConfig).map(([type, config]) => (
|
||||
<SelectItem key={type} value={type} className="text-[12px]">
|
||||
{config.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>使用范围</Label>
|
||||
<Input
|
||||
className="h-8 text-[12px]"
|
||||
style={inputStyle}
|
||||
value={form.scope}
|
||||
onChange={(e) => setForm({ ...form, scope: e.target.value })}
|
||||
placeholder="全局 / production-group"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>内容</Label>
|
||||
<Textarea
|
||||
className="text-[12px] min-h-[88px] font-mono"
|
||||
style={inputStyle}
|
||||
value={form.content}
|
||||
onChange={(e) => setForm({ ...form, content: e.target.value })}
|
||||
placeholder={editing ? '留空表示不修改' : '私钥 / 密码 / Token / kubeconfig / AK·SK 等'}
|
||||
/>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
内容加密存储,保存后不可查看。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={submit}
|
||||
>
|
||||
{saveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{editing ? '保存' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,253 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Edit2,
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Terminal,
|
||||
CloudCog,
|
||||
|
||||
Zap,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
interface Host {
|
||||
id: string;
|
||||
// 后端 tags/namespaces 为 JSON 字符串,安全解析为数组
|
||||
function parseJsonArray(v: unknown): string[] {
|
||||
try {
|
||||
const arr = JSON.parse(String(v ?? '[]'));
|
||||
return Array.isArray(arr) ? arr.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function splitComma(v: string): string[] {
|
||||
return v.split(/[,,]/).map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtTime(v?: string): string {
|
||||
if (!v || v.startsWith('0001-')) return '—';
|
||||
return v.replace('T', ' ').slice(0, 19);
|
||||
}
|
||||
|
||||
interface HostForm {
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
port: string;
|
||||
username: string;
|
||||
tags: string[];
|
||||
status: 'online' | 'offline';
|
||||
tags: string;
|
||||
os: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
interface K8sCluster {
|
||||
id: string;
|
||||
const emptyHostForm: HostForm = { name: '', ip: '', port: '22', username: 'root', tags: '', os: '', remark: '' };
|
||||
|
||||
interface ClusterForm {
|
||||
name: string;
|
||||
context: string;
|
||||
apiServer: string;
|
||||
version: string;
|
||||
nodes: number;
|
||||
namespaces: string[];
|
||||
status: 'connected' | 'disconnected';
|
||||
kubeconfig: string;
|
||||
}
|
||||
|
||||
const mockHosts: Host[] = [
|
||||
{ id: '1', name: 'prod-web-01', ip: '192.168.1.101', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
|
||||
{ id: '2', name: 'prod-web-02', ip: '192.168.1.102', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
|
||||
{ id: '3', name: 'prod-api-01', ip: '192.168.1.111', port: 22, username: 'root', tags: ['production', 'api'], status: 'online', os: 'Ubuntu 22.04' },
|
||||
{ id: '4', name: 'staging-web-01', ip: '192.168.2.101', port: 22, username: 'admin', tags: ['staging', 'web'], status: 'online', os: 'Ubuntu 22.04' },
|
||||
{ id: '5', name: 'staging-api-01', ip: '192.168.2.111', port: 22, username: 'admin', tags: ['staging', 'api'], status: 'offline', os: 'CentOS 7' },
|
||||
];
|
||||
const emptyClusterForm: ClusterForm = { name: '', context: '', apiServer: '', version: '', kubeconfig: '' };
|
||||
|
||||
const mockClusters: K8sCluster[] = [
|
||||
{ id: '1', name: 'ack-production', context: 'ack-prod', version: 'v1.28.3', nodes: 6, namespaces: ['aiops', 'gateway', 'monitoring'], status: 'connected' },
|
||||
{ id: '2', name: 'ack-staging', context: 'ack-staging', version: 'v1.27.8', nodes: 3, namespaces: ['aiops', 'default'], status: 'connected' },
|
||||
{ id: '3', name: 'tke-test', context: 'tke-test', version: 'v1.26.5', nodes: 2, namespaces: ['default'], status: 'disconnected' },
|
||||
];
|
||||
const inputStyle = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
|
||||
export default function EnvironmentManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredHosts = mockHosts.filter(h =>
|
||||
h.name.includes(searchQuery) || h.ip.includes(searchQuery)
|
||||
);
|
||||
// ---- 数据获取 ----
|
||||
const hostsQuery = useQuery({ queryKey: ['hosts'], queryFn: () => apiClient.listHosts() });
|
||||
const clustersQuery = useQuery({ queryKey: ['clusters'], queryFn: () => apiClient.listClusters() });
|
||||
|
||||
const filteredClusters = mockClusters.filter(c =>
|
||||
c.name.includes(searchQuery) || c.context.includes(searchQuery)
|
||||
);
|
||||
const hosts: any[] = hostsQuery.data ?? [];
|
||||
const clusters: any[] = clustersQuery.data ?? [];
|
||||
|
||||
// ---- 主机弹窗 ----
|
||||
const [hostDialogOpen, setHostDialogOpen] = useState(false);
|
||||
const [editingHost, setEditingHost] = useState<any | null>(null);
|
||||
const [hostForm, setHostForm] = useState<HostForm>(emptyHostForm);
|
||||
|
||||
// ---- 集群弹窗 ----
|
||||
const [clusterDialogOpen, setClusterDialogOpen] = useState(false);
|
||||
const [editingCluster, setEditingCluster] = useState<any | null>(null);
|
||||
const [clusterForm, setClusterForm] = useState<ClusterForm>(emptyClusterForm);
|
||||
|
||||
const openHostDialog = (host?: any) => {
|
||||
setEditingHost(host ?? null);
|
||||
setHostForm(
|
||||
host
|
||||
? {
|
||||
name: host.name ?? '',
|
||||
ip: host.ip ?? '',
|
||||
port: String(host.port ?? 22),
|
||||
username: host.username ?? '',
|
||||
tags: parseJsonArray(host.tags).join(','),
|
||||
os: host.os ?? '',
|
||||
remark: host.remark ?? '',
|
||||
}
|
||||
: emptyHostForm,
|
||||
);
|
||||
setHostDialogOpen(true);
|
||||
};
|
||||
|
||||
const openClusterDialog = (cluster?: any) => {
|
||||
setEditingCluster(cluster ?? null);
|
||||
setClusterForm(
|
||||
cluster
|
||||
? {
|
||||
name: cluster.name ?? '',
|
||||
context: cluster.context ?? '',
|
||||
apiServer: cluster.apiServer ?? '',
|
||||
version: cluster.version ?? '',
|
||||
kubeconfig: '',
|
||||
}
|
||||
: emptyClusterForm,
|
||||
);
|
||||
setClusterDialogOpen(true);
|
||||
};
|
||||
|
||||
// ---- 变更 ----
|
||||
const invalidateHosts = () => queryClient.invalidateQueries({ queryKey: ['hosts'] });
|
||||
const invalidateClusters = () => queryClient.invalidateQueries({ queryKey: ['clusters'] });
|
||||
|
||||
const hostSaveMutation = useMutation({
|
||||
mutationFn: (vars: { id?: any; body: any }) =>
|
||||
vars.id ? apiClient.updateHost(vars.id, vars.body) : apiClient.createHost(vars.body),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success(vars.id ? '主机已更新' : '主机已创建');
|
||||
invalidateHosts();
|
||||
setHostDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const hostDeleteMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.deleteHost(id),
|
||||
onSuccess: () => {
|
||||
toast.success('主机已删除');
|
||||
invalidateHosts();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const hostTestMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.testHost(id),
|
||||
onSuccess: (result: any) => {
|
||||
const latency = result?.latencyMs != null && result.latencyMs !== '' ? `${result.latencyMs}ms` : null;
|
||||
if (result?.success) toast.success(result?.message || '连接成功', { description: latency ? `延迟 ${latency}` : undefined });
|
||||
else toast.error(result?.message || '连接失败');
|
||||
invalidateHosts();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const clusterSaveMutation = useMutation({
|
||||
mutationFn: (vars: { id?: any; body: any }) =>
|
||||
vars.id ? apiClient.updateCluster(vars.id, vars.body) : apiClient.createCluster(vars.body),
|
||||
onSuccess: (_data, vars) => {
|
||||
toast.success(vars.id ? '集群已更新' : '集群已创建');
|
||||
invalidateClusters();
|
||||
setClusterDialogOpen(false);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const clusterDeleteMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.deleteCluster(id),
|
||||
onSuccess: () => {
|
||||
toast.success('集群已删除');
|
||||
invalidateClusters();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const clusterTestMutation = useMutation({
|
||||
mutationFn: (id: any) => apiClient.testCluster(id),
|
||||
onSuccess: (result: any) => {
|
||||
if (result?.success) toast.success(result?.message || '连接成功');
|
||||
else toast.error(result?.message || '连接失败');
|
||||
invalidateClusters();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const submitHost = () => {
|
||||
if (!hostForm.name.trim() || !hostForm.ip.trim()) {
|
||||
toast.error('请填写主机名称和 IP 地址');
|
||||
return;
|
||||
}
|
||||
hostSaveMutation.mutate({
|
||||
id: editingHost?.id,
|
||||
body: {
|
||||
name: hostForm.name.trim(),
|
||||
ip: hostForm.ip.trim(),
|
||||
port: Number(hostForm.port) || 22,
|
||||
username: hostForm.username.trim(),
|
||||
tags: JSON.stringify(splitComma(hostForm.tags)),
|
||||
os: hostForm.os.trim(),
|
||||
remark: hostForm.remark.trim(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submitCluster = () => {
|
||||
if (!clusterForm.name.trim()) {
|
||||
toast.error('请填写集群名称');
|
||||
return;
|
||||
}
|
||||
const body: any = {
|
||||
name: clusterForm.name.trim(),
|
||||
context: clusterForm.context.trim(),
|
||||
apiServer: clusterForm.apiServer.trim(),
|
||||
version: clusterForm.version.trim(),
|
||||
};
|
||||
// kubeconfig 只写不读:编辑时留空表示不修改
|
||||
if (clusterForm.kubeconfig.trim()) body.kubeconfig = clusterForm.kubeconfig.trim();
|
||||
clusterSaveMutation.mutate({ id: editingCluster?.id, body });
|
||||
};
|
||||
|
||||
// ---- 统计(真实数据计算) ----
|
||||
const tagsOf = (h: any) => parseJsonArray(h.tags);
|
||||
const hostStats = [
|
||||
{ label: '生产环境', list: hosts.filter((h) => tagsOf(h).some((t) => /prod/i.test(t))) },
|
||||
{ label: '预发环境', list: hosts.filter((h) => tagsOf(h).some((t) => /stag/i.test(t))) },
|
||||
{ label: '测试环境', list: hosts.filter((h) => tagsOf(h).some((t) => /test|qa|dev/i.test(t))) },
|
||||
{ label: '总计', list: hosts },
|
||||
].map((s) => ({ ...s, count: s.list.length, online: s.list.filter((h) => h.status === 'online').length }));
|
||||
|
||||
const clusterStats = [
|
||||
{ label: '已连接', count: clusters.filter((c) => c.status === 'connected').length },
|
||||
{ label: '未连接', count: clusters.filter((c) => c.status !== 'connected').length },
|
||||
{ label: '总节点数', count: clusters.reduce((sum, c) => sum + (Number(c.nodes) || 0), 0) },
|
||||
];
|
||||
|
||||
const filteredHosts = hosts.filter((h) => h.name?.includes(searchQuery) || h.ip?.includes(searchQuery));
|
||||
const filteredClusters = clusters.filter((c) => c.name?.includes(searchQuery) || c.context?.includes(searchQuery));
|
||||
|
||||
const isLoading = activeTab === 'hosts' ? hostsQuery.isLoading : clustersQuery.isLoading;
|
||||
const queryError = activeTab === 'hosts' ? hostsQuery.error : clustersQuery.error;
|
||||
const refetch = activeTab === 'hosts' ? hostsQuery.refetch : clustersQuery.refetch;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -73,7 +257,7 @@ export default function EnvironmentManager() {
|
||||
<button
|
||||
onClick={() => setActiveTab('hosts')}
|
||||
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
background: activeTab === 'hosts' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: activeTab === 'hosts' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
@@ -83,7 +267,7 @@ export default function EnvironmentManager() {
|
||||
<button
|
||||
onClick={() => setActiveTab('k8s')}
|
||||
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
background: activeTab === 'k8s' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
@@ -93,7 +277,7 @@ export default function EnvironmentManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
@@ -109,7 +293,7 @@ export default function EnvironmentManager() {
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => toast.info(`添加${activeTab === 'hosts' ? '主机' : '集群'}`, { description: '功能开发中...' })}
|
||||
onClick={() => (activeTab === 'hosts' ? openHostDialog() : openClusterDialog())}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
添加{activeTab === 'hosts' ? '主机' : '集群'}
|
||||
@@ -119,17 +303,29 @@ export default function EnvironmentManager() {
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{activeTab === 'hosts' ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-16 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
加载中...
|
||||
</div>
|
||||
) : queryError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16">
|
||||
<XCircle className="w-6 h-6" style={{ color: 'var(--accent-red)' }} />
|
||||
<div className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>{(queryError as Error).message}</div>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : activeTab === 'hosts' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Host groups summary */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '生产环境', count: 3, online: 3, color: '#1a7f37' },
|
||||
{ label: '预发环境', count: 2, online: 1, color: '#9a6700' },
|
||||
{ label: '测试环境', count: 0, online: 0, color: '#8250df' },
|
||||
{ label: '总计', count: 5, online: 4, color: '#0969da' },
|
||||
].map((stat) => (
|
||||
<div
|
||||
{hostStats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
@@ -171,8 +367,8 @@ export default function EnvironmentManager() {
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{host.tags.map((tag) => (
|
||||
<span
|
||||
{tagsOf(host).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
@@ -182,7 +378,7 @@ export default function EnvironmentManager() {
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.status === 'online' ? (
|
||||
@@ -197,19 +393,41 @@ export default function EnvironmentManager() {
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button className="p-1 rounded hover:bg-black/5" title="SSH连接" onClick={() => toast.info('SSH连接', { description: `${host.username}@${host.ip}:${host.port}` })}>
|
||||
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="测试连接"
|
||||
disabled={hostTestMutation.isPending && hostTestMutation.variables === host.id}
|
||||
onClick={() => hostTestMutation.mutate(host.id)}
|
||||
>
|
||||
{hostTestMutation.isPending && hostTestMutation.variables === host.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑">
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openHostDialog(host)}>
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5" title="删除">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除主机 ${host.name} 吗?`)) hostDeleteMutation.mutate(host.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredHosts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-12 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
暂无主机,点击右上角「添加主机」创建
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -218,11 +436,7 @@ export default function EnvironmentManager() {
|
||||
/* K8s Clusters */
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '已连接', count: 2, color: '#1a7f37' },
|
||||
{ label: '未连接', count: 1, color: '#cf222e' },
|
||||
{ label: '总节点数', count: 11, color: '#0969da' },
|
||||
].map((stat) => (
|
||||
{clusterStats.map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||
@@ -232,7 +446,7 @@ export default function EnvironmentManager() {
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredClusters.map((cluster) => (
|
||||
<div
|
||||
<div
|
||||
key={cluster.id}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
@@ -242,38 +456,65 @@ export default function EnvironmentManager() {
|
||||
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Context: {cluster.context}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
Context: {cluster.context || '—'} · <span className="font-mono">{cluster.apiServer || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
<span
|
||||
className="text-[11px] px-2 py-[3px] rounded-full font-medium"
|
||||
style={{
|
||||
style={{
|
||||
background: cluster.status === 'connected' ? '#dafbe1' : '#ffebe9',
|
||||
color: cluster.status === 'connected' ? '#1a7f37' : '#cf222e',
|
||||
}}
|
||||
>
|
||||
{cluster.status === 'connected' ? '已连接' : '未连接'}
|
||||
</span>
|
||||
<button className="p-1 rounded hover:bg-black/5">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="测试连接"
|
||||
disabled={clusterTestMutation.isPending && clusterTestMutation.variables === cluster.id}
|
||||
onClick={() => clusterTestMutation.mutate(cluster.id)}
|
||||
>
|
||||
{clusterTestMutation.isPending && clusterTestMutation.variables === cluster.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
) : (
|
||||
<Zap className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
)}
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑" onClick={() => openClusterDialog(cluster)}>
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={() => {
|
||||
if (window.confirm(`确定删除集群 ${cluster.name} 吗?`)) clusterDeleteMutation.mutate(cluster.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-[12px]">
|
||||
<div>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.version}</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.version || '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes}</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes ?? 0}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>最近心跳</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{fmtTime(cluster.lastHeartbeat)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{cluster.namespaces.map((ns) => (
|
||||
<span
|
||||
{parseJsonArray(cluster.namespaces).map((ns) => (
|
||||
<span
|
||||
key={ns}
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
@@ -286,10 +527,151 @@ export default function EnvironmentManager() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredClusters.length === 0 && (
|
||||
<div
|
||||
className="rounded-lg border p-10 text-center text-[12px]"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
暂无集群,点击右上角「添加集群」创建
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑主机弹窗 */}
|
||||
<Dialog open={hostDialogOpen} onOpenChange={setHostDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||
{editingHost ? '编辑主机' : '添加主机'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.name} onChange={(e) => setHostForm({ ...hostForm, name: e.target.value })} placeholder="prod-web-01" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>IP 地址 *</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.ip} onChange={(e) => setHostForm({ ...hostForm, ip: e.target.value })} placeholder="192.168.1.101" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>SSH 端口</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.port} onChange={(e) => setHostForm({ ...hostForm, port: e.target.value })} placeholder="22" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>用户名</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.username} onChange={(e) => setHostForm({ ...hostForm, username: e.target.value })} placeholder="root" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>标签(逗号分隔)</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.tags} onChange={(e) => setHostForm({ ...hostForm, tags: e.target.value })} placeholder="production,web" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>操作系统</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={hostForm.os} onChange={(e) => setHostForm({ ...hostForm, os: e.target.value })} placeholder="Ubuntu 22.04" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>备注</Label>
|
||||
<Textarea
|
||||
className="text-[12px] min-h-[56px]"
|
||||
style={inputStyle}
|
||||
value={hostForm.remark}
|
||||
onChange={(e) => setHostForm({ ...hostForm, remark: e.target.value })}
|
||||
placeholder="可选"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => setHostDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={hostSaveMutation.isPending}
|
||||
onClick={submitHost}
|
||||
>
|
||||
{hostSaveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{editingHost ? '保存' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 添加/编辑集群弹窗 */}
|
||||
<Dialog open={clusterDialogOpen} onOpenChange={setClusterDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[14px]" style={{ color: 'var(--text-primary)' }}>
|
||||
{editingCluster ? '编辑集群' : '添加集群'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>名称 *</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.name} onChange={(e) => setClusterForm({ ...clusterForm, name: e.target.value })} placeholder="ack-production" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Context</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.context} onChange={(e) => setClusterForm({ ...clusterForm, context: e.target.value })} placeholder="ack-prod" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>API Server</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.apiServer} onChange={(e) => setClusterForm({ ...clusterForm, apiServer: e.target.value })} placeholder="https://xxx.xxx.aliyuncs.com:6443" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>版本</Label>
|
||||
<Input className="h-8 text-[12px]" style={inputStyle} value={clusterForm.version} onChange={(e) => setClusterForm({ ...clusterForm, version: e.target.value })} placeholder="v1.28.3" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>Kubeconfig</Label>
|
||||
<Textarea
|
||||
className="text-[12px] min-h-[88px] font-mono"
|
||||
style={inputStyle}
|
||||
value={clusterForm.kubeconfig}
|
||||
onChange={(e) => setClusterForm({ ...clusterForm, kubeconfig: e.target.value })}
|
||||
placeholder={editingCluster ? '留空表示不修改' : '粘贴 kubeconfig YAML(只写不读,保存后不可查看)'}
|
||||
/>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
kubeconfig 加密存储、只写不读,保存后无法再次查看;编辑时留空表示不修改。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
className="px-3 py-1.5 text-[12px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
onClick={() => setClusterDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
disabled={clusterSaveMutation.isPending}
|
||||
onClick={submitCluster}
|
||||
>
|
||||
{clusterSaveMutation.isPending && <Loader2 className="w-3 h-3 animate-spin" />}
|
||||
{editingCluster ? '保存' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Server, AlertTriangle, FolderGit, Package
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface Application {
|
||||
@@ -34,57 +34,6 @@ interface ResourceNode {
|
||||
children?: ResourceNode[];
|
||||
}
|
||||
|
||||
const mockApps: Application[] = [
|
||||
{
|
||||
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'main', path: 'apps/aiops-backend/overlays/production',
|
||||
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
|
||||
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
|
||||
},
|
||||
{
|
||||
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'main', path: 'apps/aiops-web/overlays/production',
|
||||
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
|
||||
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
|
||||
},
|
||||
{
|
||||
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
|
||||
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
|
||||
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
|
||||
},
|
||||
{
|
||||
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'main', path: 'apps/data/overlays/production',
|
||||
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
|
||||
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockResourceTree: ResourceNode[] = [
|
||||
{
|
||||
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
|
||||
children: [
|
||||
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
|
||||
children: [
|
||||
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
|
||||
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
|
||||
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
|
||||
},
|
||||
{
|
||||
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
|
||||
},
|
||||
{
|
||||
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
|
||||
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
|
||||
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
|
||||
@@ -109,38 +58,73 @@ const manifestConfig: Record<string, { label: string; icon: typeof Package }> =
|
||||
export default function GitOpsDeploy() {
|
||||
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
|
||||
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/apps') });
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
|
||||
onSuccess: (data) => toast.success(data.message),
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message);
|
||||
queryClient.invalidateQueries({ queryKey: ['gitops'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const apps: Application[] = dbApps.length > 0
|
||||
? dbApps.map((db: any) => {
|
||||
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name);
|
||||
return {
|
||||
id: String(db.id),
|
||||
name: db.name,
|
||||
repo: db.repo,
|
||||
branch: db.branch,
|
||||
path: db.path,
|
||||
cluster: db.cluster,
|
||||
namespace: db.namespace,
|
||||
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown',
|
||||
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync',
|
||||
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any,
|
||||
lastSync: '刚刚',
|
||||
resources: 0,
|
||||
manifestType: db.manifestType,
|
||||
autoSync: db.syncPolicy === 'automated',
|
||||
};
|
||||
})
|
||||
: mockApps;
|
||||
// dbApps 直接携带 syncStatus/healthStatus(sync 动作会真实推进状态)
|
||||
const apps: Application[] = dbApps.map((db: any) => {
|
||||
const sync = (db.syncStatus || '').toLowerCase();
|
||||
return {
|
||||
id: String(db.id),
|
||||
name: db.name,
|
||||
repo: db.repo,
|
||||
branch: db.branch,
|
||||
path: db.path,
|
||||
cluster: db.cluster,
|
||||
namespace: db.namespace,
|
||||
status: sync === 'synced' ? 'synced' : sync === 'syncing' ? 'syncing' : sync === 'outofsync' ? 'out-of-sync' : 'unknown',
|
||||
syncStatus: sync === 'synced' ? 'synced' : 'out-of-sync',
|
||||
health: (db.healthStatus || 'unknown').toLowerCase(),
|
||||
lastSync: db.lastSyncAt ? new Date(db.lastSyncAt).toLocaleString('zh-CN', { hour12: false }) : '-',
|
||||
resources: 0,
|
||||
manifestType: db.manifestType,
|
||||
autoSync: db.syncPolicy === 'automated',
|
||||
};
|
||||
});
|
||||
|
||||
const selectedApp = apps.find(a => a.id === selectedAppId);
|
||||
const selectedApp = apps.find(a => a.id === selectedAppId) || apps[0];
|
||||
const selectedName = selectedApp?.name;
|
||||
|
||||
// 资源树 / 差异:真实调用后端生成端点
|
||||
const { data: treeData, isLoading: treeLoading } = useQuery({
|
||||
queryKey: ['gitops', 'tree', selectedName],
|
||||
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/tree`),
|
||||
enabled: activeView === 'tree' && !!selectedName,
|
||||
});
|
||||
const { data: diffData, isLoading: diffLoading } = useQuery({
|
||||
queryKey: ['gitops', 'diff', selectedName],
|
||||
queryFn: () => api<any>(`/api/gitops/argo/${selectedName}/diff`),
|
||||
enabled: activeView === 'diff' && !!selectedName,
|
||||
});
|
||||
|
||||
// nodes+edges 扁平结构 → 树
|
||||
const resourceTree: ResourceNode[] = (() => {
|
||||
if (!treeData?.nodes) return [];
|
||||
const byUid = new Map<string, ResourceNode>();
|
||||
for (const n of treeData.nodes) {
|
||||
byUid.set(n.uid, { id: n.uid, kind: n.kind, name: n.name, namespace: n.namespace, status: n.status });
|
||||
}
|
||||
const childSet = new Set<string>();
|
||||
for (const e of treeData.edges || []) {
|
||||
const parent = byUid.get(e.source);
|
||||
const child = byUid.get(e.target);
|
||||
if (parent && child) {
|
||||
parent.children = parent.children || [];
|
||||
parent.children.push(child);
|
||||
childSet.add(e.target);
|
||||
}
|
||||
}
|
||||
return treeData.nodes.filter((n: any) => !childSet.has(n.uid)).map((n: any) => byUid.get(n.uid)!);
|
||||
})();
|
||||
|
||||
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
|
||||
const health = healthConfig[node.status];
|
||||
@@ -184,7 +168,7 @@ export default function GitOpsDeploy() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => toast.success('刷新应用状态')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<button onClick={() => { queryClient.invalidateQueries({ queryKey: ['gitops'] }); toast.success('已刷新'); }} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />刷新
|
||||
</button>
|
||||
</div>
|
||||
@@ -325,29 +309,36 @@ export default function GitOpsDeploy() {
|
||||
) : activeView === 'tree' ? (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span>
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树{selectedName ? ` · ${selectedName}` : ''}</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{resourceTree.length} 个顶级资源</span>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
{mockResourceTree.map(node => renderResourceNode(node))}
|
||||
{treeLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
|
||||
{!treeLoading && resourceTree.map(node => renderResourceNode(node))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异</h3>
|
||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异{selectedName ? ` · ${selectedName}` : ''}</h3>
|
||||
{diffLoading && <div className="py-6 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中…</div>}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}>
|
||||
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
|
||||
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
|
||||
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
|
||||
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
|
||||
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
|
||||
</div>
|
||||
{(diffData?.diffs || []).map((d: any, i: number) => (
|
||||
<div key={i} className="rounded-md border overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-3 px-3 py-2" style={{ background: d.status === 'modified' ? '#fff8c5' : '#f6f8fa' }}>
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: d.status === 'modified' ? '#9a6700' : '#6e7781' }} />
|
||||
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{d.kind}/{d.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{d.namespace}</span>
|
||||
<span className="text-[11px] ml-auto" style={{ color: d.status === 'modified' ? '#9a6700' : 'var(--text-tertiary)' }}>{d.status}</span>
|
||||
</div>
|
||||
<pre className="px-4 py-2 text-[11px] font-mono whitespace-pre-wrap" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>{d.diff}</pre>
|
||||
</div>
|
||||
))}
|
||||
{!diffLoading && (diffData?.diffs || []).length === 0 && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-md" style={{ background: '#dafbe1' }}>
|
||||
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
|
||||
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Git 与集群一致,无差异</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import '../App.css'
|
||||
|
||||
export default function Home() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +1,192 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Link,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
import {
|
||||
Download,
|
||||
Link,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
FileCode,
|
||||
GitBranch,
|
||||
RefreshCw
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Server,
|
||||
Folder,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 与后端 jenkins.JobInfo 对齐:name 即压平全路径(folder/job)
|
||||
interface JenkinsJob {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'freestyle' | 'pipeline' | 'multibranch';
|
||||
lastBuild: string;
|
||||
status: 'success' | 'failed' | 'unstable';
|
||||
selected: boolean;
|
||||
url: string;
|
||||
type: 'folder' | 'freestyle' | 'pipeline' | 'multibranch';
|
||||
status: 'success' | 'failed' | 'running' | 'unknown';
|
||||
}
|
||||
|
||||
const mockJobs: JenkinsJob[] = [
|
||||
{ id: '1', name: 'backend-build', path: 'aiops/backend-build', type: 'pipeline', lastBuild: '2024-06-20 14:30', status: 'success', selected: false },
|
||||
{ id: '2', name: 'frontend-deploy', path: 'aiops/frontend-deploy', type: 'freestyle', lastBuild: '2024-06-20 13:15', status: 'success', selected: false },
|
||||
{ id: '3', name: 'gateway-service', path: 'platform/gateway-service', type: 'pipeline', lastBuild: '2024-06-19 18:45', status: 'failed', selected: false },
|
||||
{ id: '4', name: 'data-sync-job', path: 'data/data-sync-job', type: 'freestyle', lastBuild: '2024-06-18 02:00', status: 'unstable', selected: false },
|
||||
{ id: '5', name: 'mobile-app-build', path: 'mobile/mobile-app-build', type: 'multibranch', lastBuild: '2024-06-17 16:20', status: 'success', selected: false },
|
||||
];
|
||||
// 与后端 model.JenkinsServer 对齐(token 只写不读)
|
||||
interface JenkinsServerItem {
|
||||
id: number;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
tokenSet: boolean;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const typeConfig = {
|
||||
const emptyServerForm = { name: '', baseUrl: '', username: '', token: '' };
|
||||
|
||||
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
folder: { label: '文件夹', color: '#6e7781', bg: '#f6f8fa' },
|
||||
freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' },
|
||||
pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' },
|
||||
multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' },
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
success: { icon: CheckCircle2, color: '#1a7f37' },
|
||||
failed: { icon: AlertCircle, color: '#cf222e' },
|
||||
unstable: { icon: AlertCircle, color: '#9a6700' },
|
||||
const statusConfig: Record<string, { color: string; label: string }> = {
|
||||
success: { color: '#1a7f37', label: '成功' },
|
||||
failed: { color: '#cf222e', label: '失败' },
|
||||
running: { color: '#9a6700', label: '运行中' },
|
||||
unknown: { color: '#6e7781', label: '未知' },
|
||||
};
|
||||
|
||||
export default function JenkinsImport() {
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect');
|
||||
const [jenkinsUrl, setJenkinsUrl] = useState('http://jenkins.company.com:8080');
|
||||
const [jenkinsUser, setJenkinsUser] = useState('');
|
||||
const [jenkinsToken, setJenkinsToken] = useState('');
|
||||
const [jobs, setJobs] = useState<JenkinsJob[]>(mockJobs);
|
||||
const [selectedServerId, setSelectedServerId] = useState<number | null>(null);
|
||||
const [showManage, setShowManage] = useState(false);
|
||||
const [serverFormOpen, setServerFormOpen] = useState(false);
|
||||
const [editingServerId, setEditingServerId] = useState<number | null>(null);
|
||||
const [serverForm, setServerForm] = useState(emptyServerForm);
|
||||
const [selectedJobs, setSelectedJobs] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<{ imported: number; skipped: number } | null>(null);
|
||||
|
||||
// Jenkins 实例列表
|
||||
const { data: servers = [], isLoading: serversLoading } = useQuery({
|
||||
queryKey: ['jenkins-servers'],
|
||||
queryFn: apiClient.listJenkinsServers,
|
||||
});
|
||||
const selectedServer: JenkinsServerItem | null = servers.find((s: JenkinsServerItem) => s.id === selectedServerId) || null;
|
||||
|
||||
// 真实拉取 Job 列表(进入选择/预览步骤后按实例加载)
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: ['jenkins-jobs', selectedServerId],
|
||||
queryFn: () => apiClient.listJenkinsJobs(selectedServerId!),
|
||||
enabled: !!selectedServerId && (step === 'select' || step === 'preview'),
|
||||
});
|
||||
const jobs: JenkinsJob[] = jobsQuery.data || [];
|
||||
|
||||
const invalidateServers = () => queryClient.invalidateQueries({ queryKey: ['jenkins-servers'] });
|
||||
|
||||
// 新增 / 编辑实例(编辑时 Token 留空不改)
|
||||
const saveServerMutation = useMutation({
|
||||
mutationFn: (input: { id: number | null; form: typeof emptyServerForm }) => {
|
||||
const body: Record<string, any> = { name: input.form.name, baseUrl: input.form.baseUrl, username: input.form.username };
|
||||
if (input.form.token) body.token = input.form.token;
|
||||
return input.id != null ? apiClient.updateJenkinsServer(input.id, body) : apiClient.createJenkinsServer(body);
|
||||
},
|
||||
onSuccess: (data: any, input) => {
|
||||
toast.success(input.id != null ? '实例已更新' : '实例已创建');
|
||||
if (input.id == null && data?.id) setSelectedServerId(data.id);
|
||||
invalidateServers();
|
||||
setServerFormOpen(false);
|
||||
setEditingServerId(null);
|
||||
setServerForm(emptyServerForm);
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteServerMutation = useMutation({
|
||||
mutationFn: (id: number) => apiClient.deleteJenkinsServer(id),
|
||||
onSuccess: (_data, id) => {
|
||||
toast.success('实例已删除');
|
||||
if (selectedServerId === id) setSelectedServerId(null);
|
||||
invalidateServers();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// 真实连通测试
|
||||
const testServerMutation = useMutation({
|
||||
mutationFn: (id: number) => apiClient.testJenkinsServer(id),
|
||||
onSuccess: (data: any) => {
|
||||
if (data?.success) {
|
||||
toast.success(data.message || '连接成功', { description: `延迟 ${data.latencyMs}ms` });
|
||||
} else {
|
||||
toast.error(data?.message || '连接失败');
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error('连接失败', { description: err.message }),
|
||||
});
|
||||
|
||||
// 真实导入:创建 jenkins 类型流水线,重名跳过
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => apiClient.importJenkinsJobs(selectedServerId!, selectedJobs),
|
||||
onSuccess: (data: any) => {
|
||||
const imported = data?.imported ?? selectedJobs.length;
|
||||
const skipped = data?.skipped ?? 0;
|
||||
setImportResult({ imported, skipped });
|
||||
toast.success(`成功导入 ${imported} 个(跳过 ${skipped} 个已存在)`);
|
||||
setStep('done');
|
||||
},
|
||||
onError: (err: Error) => toast.error('导入失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const handleConnect = () => {
|
||||
setIsConnecting(true);
|
||||
setTimeout(() => {
|
||||
setIsConnecting(false);
|
||||
setStep('select');
|
||||
toast.success('连接成功', { description: `已连接到 ${jenkinsUrl}` });
|
||||
}, 1500);
|
||||
if (!selectedServerId) {
|
||||
toast.error('请先选择 Jenkins 实例');
|
||||
return;
|
||||
}
|
||||
setSelectedJobs([]);
|
||||
setSearchQuery('');
|
||||
setImportResult(null);
|
||||
setStep('select');
|
||||
};
|
||||
|
||||
const toggleJob = (id: string) => {
|
||||
setJobs(prev => prev.map(j => j.id === id ? { ...j, selected: !j.selected } : j));
|
||||
const startEditServer = (s: JenkinsServerItem) => {
|
||||
setEditingServerId(s.id);
|
||||
setServerForm({ name: s.name, baseUrl: s.baseUrl, username: s.username, token: '' });
|
||||
setServerFormOpen(true);
|
||||
};
|
||||
|
||||
const selectedCount = jobs.filter(j => j.selected).length;
|
||||
|
||||
const filteredJobs = jobs.filter(j =>
|
||||
j.name.includes(searchQuery) || j.path.includes(searchQuery)
|
||||
);
|
||||
|
||||
const handleImport = () => {
|
||||
toast.success('导入完成', { description: `成功导入 ${selectedCount} 个Job` });
|
||||
setStep('done');
|
||||
const handleSaveServer = () => {
|
||||
if (!serverForm.name.trim() || !serverForm.baseUrl.trim()) {
|
||||
toast.error('名称和地址不能为空');
|
||||
return;
|
||||
}
|
||||
saveServerMutation.mutate({ id: editingServerId, form: serverForm });
|
||||
};
|
||||
|
||||
const handleDeleteServer = (s: JenkinsServerItem) => {
|
||||
if (!window.confirm(`确定删除实例「${s.name}」?`)) return;
|
||||
deleteServerMutation.mutate(s.id);
|
||||
};
|
||||
|
||||
// Job 勾选(folder 不可选)
|
||||
const isSelectable = (job: JenkinsJob) => job.type !== 'folder';
|
||||
const filteredJobs = jobs.filter(j => j.name.toLowerCase().includes(searchQuery.trim().toLowerCase()));
|
||||
const selectableFiltered = filteredJobs.filter(isSelectable);
|
||||
const allSelected = selectableFiltered.length > 0 && selectableFiltered.every(j => selectedJobs.includes(j.name));
|
||||
|
||||
const toggleJob = (name: string) => {
|
||||
setSelectedJobs(prev => prev.includes(name) ? prev.filter(n => n !== name) : [...prev, name]);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
setSelectedJobs(prev => allSelected
|
||||
? prev.filter(n => !selectableFiltered.some(j => j.name === n))
|
||||
: Array.from(new Set([...prev, ...selectableFiltered.map(j => j.name)])));
|
||||
};
|
||||
|
||||
const selectedJobEntries = selectedJobs
|
||||
.map(name => jobs.find(j => j.name === name))
|
||||
.filter((j): j is JenkinsJob => !!j);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
@@ -102,54 +214,163 @@ export default function JenkinsImport() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2>
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>输入 Jenkins 地址和认证信息以获取 Job 列表</p>
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>选择已配置的 Jenkins 实例,或先在「管理」中维护实例与认证信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 地址</label>
|
||||
<input
|
||||
value={jenkinsUrl}
|
||||
onChange={(e) => setJenkinsUrl(e.target.value)}
|
||||
placeholder="http://jenkins.company.com:8080"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
||||
<input
|
||||
value={jenkinsUser}
|
||||
onChange={(e) => setJenkinsUser(e.target.value)}
|
||||
placeholder="admin"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 实例</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selectedServerId ?? ''}
|
||||
onChange={(e) => setSelectedServerId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="flex-1 px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>API Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={jenkinsToken}
|
||||
onChange={(e) => setJenkinsToken(e.target.value)}
|
||||
placeholder="11xxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
>
|
||||
<option value="" disabled>{serversLoading ? '加载实例中...' : '请选择 Jenkins 实例'}</option>
|
||||
{servers.map((s: JenkinsServerItem) => (
|
||||
<option key={s.id} value={s.id}>{s.name}({s.baseUrl})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => selectedServerId && testServerMutation.mutate(selectedServerId)}
|
||||
disabled={!selectedServerId || testServerMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[7px] text-[12px] font-medium rounded-md border disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{testServerMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
测试连接
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowManage(v => !v)}
|
||||
className="flex items-center gap-1 px-3 py-[7px] text-[12px] font-medium rounded-md border"
|
||||
style={{
|
||||
borderColor: showManage ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
color: showManage ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Server className="w-3.5 h-3.5" />
|
||||
管理
|
||||
</button>
|
||||
</div>
|
||||
{selectedServer && (
|
||||
<p className="text-[11px] mt-1.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
用户: {selectedServer.username || '匿名'} · Token: {selectedServer.tokenSet ? '已配置' : '未配置'} · {selectedServer.enabled ? '已启用' : '已禁用'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
|
||||
{/* 实例管理:列表 + 内联新增/编辑表单 */}
|
||||
{showManage && (
|
||||
<div className="rounded-md border p-3 space-y-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>实例管理</div>
|
||||
{serversLoading && (
|
||||
<div className="flex items-center gap-1.5 text-[12px] py-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />加载中...
|
||||
</div>
|
||||
)}
|
||||
{!serversLoading && servers.length === 0 && (
|
||||
<div className="text-[12px] py-1" style={{ color: 'var(--text-tertiary)' }}>暂无实例,请在下方新增</div>
|
||||
)}
|
||||
{servers.map((s: JenkinsServerItem) => (
|
||||
<div key={s.id} className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{s.name}</span>
|
||||
{selectedServerId === s.id && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: '#0969da' }}>当前</span>
|
||||
)}
|
||||
{s.tokenSet ? (
|
||||
<span className="text-[10px]" style={{ color: '#1a7f37' }}>Token 已配置</span>
|
||||
) : (
|
||||
<span className="text-[10px]" style={{ color: '#9a6700' }}>Token 未配置</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] font-mono truncate" style={{ color: 'var(--text-tertiary)' }}>{s.baseUrl} · {s.username || '匿名'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button onClick={() => setSelectedServerId(s.id)} disabled={selectedServerId === s.id}
|
||||
className="px-2 py-[4px] text-[11px] font-medium rounded-md border disabled:opacity-40" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
使用
|
||||
</button>
|
||||
<button onClick={() => startEditServer(s)} title="编辑"
|
||||
className="p-1.5 rounded-md hover:bg-black/[0.04] transition-colors" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDeleteServer(s)} disabled={deleteServerMutation.isPending} title="删除"
|
||||
className="p-1.5 rounded-md hover:bg-black/[0.04] transition-colors disabled:opacity-50" style={{ color: '#cf222e' }}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{serverFormOpen ? (
|
||||
<div className="rounded-md border p-3 space-y-2" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{editingServerId != null ? '编辑实例' : '新增实例'}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
|
||||
<input value={serverForm.name} onChange={e => setServerForm(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="如:生产 Jenkins" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>地址</label>
|
||||
<input value={serverForm.baseUrl} onChange={e => setServerForm(p => ({ ...p, baseUrl: e.target.value }))}
|
||||
placeholder="http://jenkins.company.com:8080" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>用户名</label>
|
||||
<input value={serverForm.username} onChange={e => setServerForm(p => ({ ...p, username: e.target.value }))}
|
||||
placeholder="admin" className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>API Token(只写不读)</label>
|
||||
<input type="password" value={serverForm.token} onChange={e => setServerForm(p => ({ ...p, token: e.target.value }))}
|
||||
placeholder={editingServerId != null ? '留空则不修改已保存的 Token' : '11xxxxxxxxxxxxxxxxxxxxxx'}
|
||||
className="w-full px-3 py-[6px] rounded-md border text-[12px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={() => { setServerFormOpen(false); setEditingServerId(null); setServerForm(emptyServerForm); }}
|
||||
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
||||
<button onClick={handleSaveServer} disabled={saveServerMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||
{saveServerMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => { setEditingServerId(null); setServerForm(emptyServerForm); setServerFormOpen(true); }}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<Plus className="w-3.5 h-3.5" />新增实例
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={isConnecting}
|
||||
disabled={!selectedServerId}
|
||||
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
{isConnecting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Link className="w-3.5 h-3.5" />}
|
||||
{isConnecting ? '连接中...' : '连接并获取Job'}
|
||||
<Link className="w-3.5 h-3.5" />
|
||||
连接并获取Job
|
||||
</button>
|
||||
{servers.length === 0 && !serversLoading && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>尚未配置实例,点击「管理」新增</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -163,65 +384,105 @@ export default function JenkinsImport() {
|
||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
{selectedServer && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{selectedServer.name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedCount}</span> 个 Job
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedJobs.length}</span> 个 Job
|
||||
</span>
|
||||
<button onClick={() => setStep('connect')} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
上一步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<th className="w-10 px-3 py-2"></th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>最后构建</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredJobs.map((job) => {
|
||||
const type = typeConfig[job.type];
|
||||
const status = statusConfig[job.status];
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<tr key={job.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={job.selected}
|
||||
onChange={() => toggleJob(job.id)}
|
||||
className="w-4 h-4 rounded cursor-pointer"
|
||||
style={{ accentColor: 'var(--accent-blue)' }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{job.path}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<StatusIcon className="w-3.5 h-3.5" style={{ color: status.color }} />
|
||||
<span className="text-[12px]" style={{ color: status.color }}>
|
||||
{job.status === 'success' ? '成功' : job.status === 'failed' ? '失败' : '不稳定'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{job.lastBuild}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{jobsQuery.isLoading ? (
|
||||
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在从 Jenkins 拉取 Job 列表...
|
||||
</div>
|
||||
) : jobsQuery.isError ? (
|
||||
<div className="py-10 px-4 flex flex-col items-center gap-3 text-center">
|
||||
<AlertCircle className="w-6 h-6" style={{ color: '#cf222e' }} />
|
||||
<div className="text-[13px]" style={{ color: 'var(--text-primary)' }}>拉取 Job 失败: {(jobsQuery.error as Error)?.message}</div>
|
||||
<button onClick={() => jobsQuery.refetch()} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />重试
|
||||
</button>
|
||||
</div>
|
||||
) : filteredJobs.length === 0 ? (
|
||||
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{jobs.length === 0 ? '该实例没有可用的 Job' : '没有匹配搜索的 Job'}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<th className="w-10 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={toggleAll}
|
||||
className="w-4 h-4 rounded cursor-pointer"
|
||||
style={{ accentColor: 'var(--accent-blue)' }}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
|
||||
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredJobs.map((job) => {
|
||||
const type = typeConfig[job.type] || typeConfig.pipeline;
|
||||
const status = statusConfig[job.status] || statusConfig.unknown;
|
||||
const selectable = isSelectable(job);
|
||||
const checked = selectedJobs.includes(job.name);
|
||||
return (
|
||||
<tr key={job.name} className="border-t hover:bg-black/[0.02] transition-colors"
|
||||
style={{ borderColor: 'var(--border-color-light)', opacity: selectable ? 1 : 0.6 }}>
|
||||
<td className="px-3 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!selectable}
|
||||
onChange={() => toggleJob(job.name)}
|
||||
className="w-4 h-4 rounded cursor-pointer disabled:cursor-not-allowed"
|
||||
style={{ accentColor: 'var(--accent-blue)' }}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{job.type === 'folder' && <Folder className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />}
|
||||
<span className="text-[13px] font-medium" style={{ color: selectable ? 'var(--text-primary)' : 'var(--text-tertiary)' }}>{job.name}</span>
|
||||
</div>
|
||||
{!selectable && (
|
||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>文件夹不可导入,其子 Job 已压平列出</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
|
||||
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => selectedCount > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
|
||||
onClick={() => selectedJobs.length > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
|
||||
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
@@ -237,17 +498,17 @@ export default function JenkinsImport() {
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3>
|
||||
<div className="space-y-3">
|
||||
{jobs.filter(j => j.selected).map((job) => (
|
||||
<div key={job.id} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<FileCode className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div className="flex-1">
|
||||
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type].label} → AIOps 流水线</div>
|
||||
{selectedJobEntries.map((job) => (
|
||||
<div key={job.name} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<FileCode className="w-4 h-4 flex-shrink-0" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[12px] font-medium truncate" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type]?.label || job.type} → FlowStack 流水线</div>
|
||||
</div>
|
||||
<ArrowRight className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>aiops-{job.name}</span>
|
||||
<ArrowRight className="w-3 h-3 flex-shrink-0" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<GitBranch className="w-3.5 h-3.5 flex-shrink-0" style={{ color: 'var(--accent-green)' }} />
|
||||
<span className="text-[12px] truncate" style={{ color: 'var(--text-secondary)' }}>{job.name.length > 40 ? job.name.slice(0, 40) + '…' : job.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -255,9 +516,10 @@ export default function JenkinsImport() {
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={() => setStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
||||
<button onClick={handleImport} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
确认导入
|
||||
<button onClick={() => importMutation.mutate()} disabled={importMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||
{importMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
|
||||
{importMutation.isPending ? '导入中...' : '确认导入'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,18 +532,19 @@ export default function JenkinsImport() {
|
||||
</div>
|
||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>导入完成</h2>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
|
||||
成功导入 {selectedCount} 个 Jenkins Job 为 AIOps 流水线
|
||||
成功导入 {importResult?.imported ?? 0} 个 Jenkins Job 为 FlowStack 流水线
|
||||
{importResult && importResult.skipped > 0 && `(跳过 ${importResult.skipped} 个已存在)`}
|
||||
</p>
|
||||
<div className="flex justify-center gap-2">
|
||||
<button
|
||||
onClick={() => { setStep('connect'); setJobs(mockJobs.map(j => ({ ...j, selected: false }))); }}
|
||||
<button
|
||||
onClick={() => { setStep('connect'); setSelectedJobs([]); setSearchQuery(''); setImportResult(null); }}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
继续导入
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast.info('跳转到流水线列表')}
|
||||
<button
|
||||
onClick={() => toast.info('请点击左侧【流水线】查看')}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
|
||||
+396
-147
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Play,
|
||||
Settings,
|
||||
Trash2,
|
||||
import { useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Play,
|
||||
Settings,
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
@@ -13,97 +13,172 @@ import {
|
||||
Filter,
|
||||
LayoutGrid,
|
||||
List,
|
||||
MoreHorizontal
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface Pipeline {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
lastStatus: 'success' | 'failed' | 'running' | 'pending';
|
||||
lastRunAt: string;
|
||||
triggerType: string;
|
||||
duration: string;
|
||||
branch: string;
|
||||
deployType: 'host' | 'k8s';
|
||||
// ---- 工具函数 ----
|
||||
function fmtDuration(ms?: number | null): string {
|
||||
if (ms == null || ms < 0) return '-';
|
||||
const sec = ms / 1000;
|
||||
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
|
||||
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
|
||||
}
|
||||
|
||||
const mockPipelines: Pipeline[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'aiops-backend',
|
||||
description: 'Go后端服务 - Docker构建 + K8s部署',
|
||||
lastStatus: 'success',
|
||||
lastRunAt: '2024-06-20 14:30:15',
|
||||
triggerType: 'Git Push',
|
||||
duration: '3分22秒',
|
||||
branch: 'master',
|
||||
deployType: 'k8s',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'aiops-web',
|
||||
description: 'Vue前端应用 - npm构建 + 主机部署',
|
||||
lastStatus: 'running',
|
||||
lastRunAt: '2024-06-20 15:45:00',
|
||||
triggerType: '手动',
|
||||
duration: '1分12秒',
|
||||
branch: 'master',
|
||||
deployType: 'host',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'gateway-api',
|
||||
description: 'API网关服务 - Java构建 + K8s部署',
|
||||
lastStatus: 'failed',
|
||||
lastRunAt: '2024-06-20 13:20:00',
|
||||
triggerType: '手动',
|
||||
duration: '45秒',
|
||||
branch: 'release/v2.1',
|
||||
deployType: 'k8s',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'admin-dashboard',
|
||||
description: '管理后台 - React构建 + 主机部署',
|
||||
lastStatus: 'success',
|
||||
lastRunAt: '2024-06-20 12:10:00',
|
||||
triggerType: 'Cron',
|
||||
duration: '2分05秒',
|
||||
branch: 'master',
|
||||
deployType: 'host',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'data-processor',
|
||||
description: '数据处理服务 - Python构建 + K8s部署',
|
||||
lastStatus: 'pending',
|
||||
lastRunAt: '-',
|
||||
triggerType: 'Webhook',
|
||||
duration: '-',
|
||||
branch: 'develop',
|
||||
deployType: 'k8s',
|
||||
},
|
||||
];
|
||||
function fmtTime(iso?: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' },
|
||||
function timeAgo(iso?: string | null): string {
|
||||
if (!iso) return '-';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 0) return '刚刚';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}小时前`;
|
||||
return `${Math.floor(h / 24)}天前`;
|
||||
}
|
||||
|
||||
// ---- 类型(与后端 GET /api/pipelines 对齐)----
|
||||
interface LastRun {
|
||||
status: string;
|
||||
startedAt: string;
|
||||
durationMs: number;
|
||||
triggerType: string;
|
||||
}
|
||||
|
||||
interface PipelineDTO {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string; // native | jenkins | tekton
|
||||
sourceBranch?: string;
|
||||
deployType: string; // host | k8s | gitops
|
||||
env?: string;
|
||||
lastRun: LastRun | null;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; label: string }> = {
|
||||
success: { icon: CheckCircle2, color: '#1a7f37', label: '成功' },
|
||||
failed: { icon: XCircle, color: '#cf222e', label: '失败' },
|
||||
running: { icon: Loader2, color: '#0969da', label: '执行中' },
|
||||
pending: { icon: Clock, color: '#9a6700', label: '排队中' },
|
||||
queued: { icon: Clock, color: '#9a6700', label: '排队中' },
|
||||
cancelled: { icon: XCircle, color: '#6e7781', label: '已取消' },
|
||||
none: { icon: Clock, color: '#6e7781', label: '未执行' },
|
||||
};
|
||||
|
||||
const deployTypeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
k8s: { label: 'K8s', color: '#0969da', bg: '#ddf4ff' },
|
||||
host: { label: '主机', color: '#1a7f37', bg: '#dafbe1' },
|
||||
gitops: { label: 'GitOps', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||
};
|
||||
const fallbackDeploy = { label: '未知', color: '#6e7781', bg: '#f6f8fa' };
|
||||
|
||||
const triggerTypeLabels: Record<string, string> = {
|
||||
manual: '手动',
|
||||
webhook: 'Webhook',
|
||||
cron: '定时',
|
||||
batch: '批量',
|
||||
};
|
||||
|
||||
interface CreatePipelineForm {
|
||||
name: string;
|
||||
description: string;
|
||||
sourceRepo: string;
|
||||
sourceBranch: string;
|
||||
deployType: string;
|
||||
env: string;
|
||||
}
|
||||
|
||||
const emptyForm: CreatePipelineForm = {
|
||||
name: '',
|
||||
description: '',
|
||||
sourceRepo: '',
|
||||
sourceBranch: 'main',
|
||||
deployType: 'host',
|
||||
env: 'staging',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-primary)',
|
||||
};
|
||||
|
||||
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [pipelines] = useState<Pipeline[]>(mockPipelines);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form, setForm] = useState<CreatePipelineForm>(emptyForm);
|
||||
|
||||
const filtered = pipelines.filter((p) => {
|
||||
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pipelines = [], isLoading } = useQuery({
|
||||
queryKey: ['pipelines'],
|
||||
queryFn: () => apiClient.listPipelines(),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: CreatePipelineForm) => apiClient.createPipeline(body),
|
||||
onSuccess: (_data, body) => {
|
||||
toast.success('流水线已创建', { description: body.name });
|
||||
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||
setCreateOpen(false);
|
||||
setForm(emptyForm);
|
||||
},
|
||||
onError: (e: Error) => toast.error('创建失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (id: number | string) => apiClient.createRun(id),
|
||||
onSuccess: (run) => toast.success(`已触发运行 ${run?.runId ?? ''}`),
|
||||
onError: (e: Error) => toast.error('触发失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number | string) => apiClient.deletePipeline(id),
|
||||
onSuccess: () => {
|
||||
toast.success('流水线已删除');
|
||||
queryClient.invalidateQueries({ queryKey: ['pipelines'] });
|
||||
},
|
||||
onError: (e: Error) => toast.error('删除失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const items = (pipelines as PipelineDTO[]).map((p) => {
|
||||
const displayStatus = p.lastRun ? p.lastRun.status : 'none';
|
||||
return {
|
||||
raw: p,
|
||||
id: String(p.id),
|
||||
name: p.name,
|
||||
description: p.description || '',
|
||||
branch: p.sourceBranch || '-',
|
||||
type: p.type,
|
||||
deploy: deployTypeConfig[p.deployType] || fallbackDeploy,
|
||||
// 筛选用映射状态:lastRun 为空记为 pending(未执行)
|
||||
mappedStatus: p.lastRun?.status ?? 'pending',
|
||||
status: statusConfig[displayStatus] || statusConfig.none,
|
||||
isRunning: displayStatus === 'running',
|
||||
durationText: p.lastRun ? fmtDuration(p.lastRun.durationMs) : '-',
|
||||
timeText: p.lastRun ? fmtTime(p.lastRun.startedAt) : '-',
|
||||
timeAgoText: p.lastRun ? timeAgo(p.lastRun.startedAt) : '',
|
||||
triggerText: p.lastRun ? (triggerTypeLabels[p.lastRun.triggerType] || p.lastRun.triggerType) : '-',
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = items.filter((p) => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchSearch = p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q);
|
||||
const matchStatus = statusFilter === 'all' || p.mappedStatus === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
});
|
||||
|
||||
@@ -115,10 +190,24 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
{ value: 'pending', label: '未执行' },
|
||||
];
|
||||
|
||||
const handleDelete = (p: (typeof items)[number]) => {
|
||||
if (window.confirm(`确定删除流水线「${p.name}」?此操作不可恢复。`)) {
|
||||
deleteMutation.mutate(p.raw.id);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCreate = () => {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('请输入流水线名称');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({ ...form, name: form.name.trim() });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header toolbar */}
|
||||
<div
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -126,18 +215,18 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
流水线列表
|
||||
</h1>
|
||||
<span
|
||||
<span
|
||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{filtered.length}
|
||||
{items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Search */}
|
||||
<div
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--panel-bg-secondary)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-secondary)',
|
||||
@@ -154,9 +243,9 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
{/* Status filter */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-secondary)',
|
||||
@@ -166,7 +255,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
{statusOptions.find(s => s.value === statusFilter)?.label}
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
|
||||
<div
|
||||
<div
|
||||
className="rounded-md border shadow-lg py-1 min-w-[120px]"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -204,7 +293,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => toast.info('新建流水线', { description: '功能开发中...' })}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建流水线
|
||||
@@ -214,24 +303,29 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{filtered.length === 0 ? (
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件或新建流水线</p>
|
||||
</div>
|
||||
) : viewMode === 'card' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((pipeline) => {
|
||||
const status = statusConfig[pipeline.lastStatus];
|
||||
const status = pipeline.status;
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
key={pipeline.id}
|
||||
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
|
||||
style={{
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
}}
|
||||
@@ -240,28 +334,35 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
{/* Card header */}
|
||||
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
||||
style={{ background: status.color }}
|
||||
/>
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{pipeline.name}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
||||
style={{
|
||||
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
|
||||
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
|
||||
}}
|
||||
>
|
||||
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
|
||||
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
||||
>
|
||||
{pipeline.deploy.label}
|
||||
</span>
|
||||
{pipeline.type === 'jenkins' && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: '#fbefff', color: '#8250df' }}
|
||||
>
|
||||
Jenkins
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Card body */}
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
|
||||
{pipeline.description}
|
||||
{pipeline.description || '暂无描述'}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -270,46 +371,59 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{pipeline.duration}
|
||||
{pipeline.durationText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Card footer */}
|
||||
<div
|
||||
<div
|
||||
className="px-4 py-2 border-t flex items-center justify-between"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon
|
||||
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`}
|
||||
style={{ color: status.color }}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<StatusIcon
|
||||
className={`w-3.5 h-3.5 flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`}
|
||||
style={{ color: status.color }}
|
||||
/>
|
||||
<span className="text-[11px] font-medium" style={{ color: status.color }}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{pipeline.lastRunAt}
|
||||
<span
|
||||
className="text-[11px] truncate"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title={pipeline.timeAgoText || undefined}
|
||||
>
|
||||
{pipeline.timeText}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }}
|
||||
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="配置"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPipeline(pipeline.id);
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
@@ -321,7 +435,7 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
) : (
|
||||
/* List view */
|
||||
<div
|
||||
<div
|
||||
className="rounded-lg border overflow-hidden"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
@@ -337,21 +451,21 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((pipeline) => {
|
||||
const status = statusConfig[pipeline.lastStatus];
|
||||
const status = pipeline.status;
|
||||
const StatusIcon = status.icon;
|
||||
return (
|
||||
<tr
|
||||
key={pipeline.id}
|
||||
<tr
|
||||
key={pipeline.id}
|
||||
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
onClick={() => onSelectPipeline(pipeline.id)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ background: status.color }} />
|
||||
<div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description || '暂无描述'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@@ -362,31 +476,65 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
||||
style={{
|
||||
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
|
||||
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
|
||||
}}
|
||||
>
|
||||
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
|
||||
style={{ background: pipeline.deploy.bg, color: pipeline.deploy.color }}
|
||||
>
|
||||
{pipeline.deploy.label}
|
||||
</span>
|
||||
{pipeline.type === 'jenkins' && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: '#fbefff', color: '#8250df' }}
|
||||
>
|
||||
Jenkins
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} />
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.isRunning ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
||||
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
|
||||
</div>
|
||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={pipeline.timeAgoText || undefined}>
|
||||
{pipeline.timeText}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.duration}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.durationText}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerText}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
runMutation.mutate(pipeline.raw.id);
|
||||
}}
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="执行"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5">
|
||||
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="配置"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectPipeline(pipeline.id);
|
||||
}}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-black/5"
|
||||
title="删除"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(pipeline);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -398,6 +546,107 @@ export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 新建流水线弹窗 */}
|
||||
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setForm(emptyForm); }}>
|
||||
<DialogContent className="sm:max-w-md" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<DialogHeader>
|
||||
<DialogTitle style={{ color: 'var(--text-primary)' }}>新建流水线</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
名称 <span style={{ color: '#cf222e' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
placeholder="例如 aiops-backend"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>描述</label>
|
||||
<input
|
||||
value={form.description}
|
||||
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
|
||||
placeholder="例如 Go后端服务 - Docker构建 + K8s部署"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>代码仓库</label>
|
||||
<input
|
||||
value={form.sourceRepo}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sourceRepo: e.target.value }))}
|
||||
placeholder="https://gitee.com/xxx.git"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>默认分支</label>
|
||||
<input
|
||||
value={form.sourceBranch}
|
||||
onChange={(e) => setForm((f) => ({ ...f, sourceBranch: e.target.value }))}
|
||||
placeholder="main"
|
||||
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>部署类型</label>
|
||||
<Select value={form.deployType} onValueChange={(v) => setForm((f) => ({ ...f, deployType: v }))}>
|
||||
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择部署类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<SelectItem value="host">主机</SelectItem>
|
||||
<SelectItem value="k8s">K8s</SelectItem>
|
||||
<SelectItem value="gitops">GitOps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>环境</label>
|
||||
<Select value={form.env} onValueChange={(v) => setForm((f) => ({ ...f, env: v }))}>
|
||||
<SelectTrigger className="w-full text-[13px]" style={inputStyle}>
|
||||
<SelectValue placeholder="选择环境" />
|
||||
</SelectTrigger>
|
||||
<SelectContent style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<SelectItem value="dev">开发 (dev)</SelectItem>
|
||||
<SelectItem value="test">测试 (test)</SelectItem>
|
||||
<SelectItem value="staging">预发 (staging)</SelectItem>
|
||||
<SelectItem value="production">生产 (production)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={() => setCreateOpen(false)}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={submitCreate}
|
||||
disabled={!form.name.trim() || createMutation.isPending}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-50"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
{createMutation.isPending ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,8 +54,8 @@ export default function PluginManager() {
|
||||
version: p.version,
|
||||
latestVersion: p.latestVersion || p.version,
|
||||
author: p.author || 'Unknown',
|
||||
installs: 0,
|
||||
rating: 4.5,
|
||||
installs: p.installs || 0,
|
||||
rating: p.rating || null,
|
||||
category: p.category || 'general',
|
||||
installed: true,
|
||||
hasUpdate: p.latestVersion && p.latestVersion !== p.version,
|
||||
@@ -71,7 +71,7 @@ export default function PluginManager() {
|
||||
latestVersion: p.version,
|
||||
author: p.author || 'Unknown',
|
||||
installs: p.installs || 0,
|
||||
rating: 4.5,
|
||||
rating: p.rating || null,
|
||||
category: p.category || 'general',
|
||||
installed: false,
|
||||
hasUpdate: false,
|
||||
@@ -228,8 +228,8 @@ export default function PluginManager() {
|
||||
</div>
|
||||
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
|
||||
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>
|
||||
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>
|
||||
{plugin.rating != null && <span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>}
|
||||
{plugin.installs > 0 && <span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>}
|
||||
<span>作者: {plugin.author}</span>
|
||||
<span className="font-mono">v{plugin.version}</span>
|
||||
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
|
||||
|
||||
@@ -1,137 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Filter, Search, GitBranch,
|
||||
import {
|
||||
Search, GitBranch,
|
||||
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
|
||||
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
|
||||
Wifi, WifiOff
|
||||
Wifi, WifiOff, Loader2
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
|
||||
|
||||
interface PipelineRunStage {
|
||||
name: string;
|
||||
status: string;
|
||||
duration: string;
|
||||
podName?: string;
|
||||
logs?: string[];
|
||||
// ---- 工具函数 ----
|
||||
function fmtDuration(ms?: number | null): string {
|
||||
if (ms == null || ms < 0) return '-';
|
||||
const sec = ms / 1000;
|
||||
if (ms < 1000) return `${Math.max(0, Math.round(sec))}s`;
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}分${Math.round(sec % 60)}秒`;
|
||||
return `${Math.floor(sec / 3600)}小时${Math.round((sec % 3600) / 60)}分`;
|
||||
}
|
||||
|
||||
interface ReleaseRecord {
|
||||
id: string;
|
||||
function fmtTime(iso?: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||
}
|
||||
|
||||
function timeAgo(iso?: string | null): string {
|
||||
if (!iso) return '-';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
if (diff < 0) return '刚刚';
|
||||
const s = Math.floor(diff / 1000);
|
||||
if (s < 60) return `${s}秒前`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}分钟前`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}小时前`;
|
||||
return `${Math.floor(h / 24)}天前`;
|
||||
}
|
||||
|
||||
// ---- 类型(与后端 GET /api/runs 对齐)----
|
||||
interface RunRecord {
|
||||
id: number;
|
||||
runId: string;
|
||||
pipelineName: string;
|
||||
branch: string;
|
||||
env: string;
|
||||
deployType: 'host' | 'k8s' | 'gitops';
|
||||
type: 'native' | 'jenkins' | 'tekton';
|
||||
status: 'success' | 'failed' | 'running' | 'cancelled';
|
||||
triggerBy: string;
|
||||
startTime: string;
|
||||
duration: string;
|
||||
stages: PipelineRunStage[];
|
||||
podStatus?: { name: string; status: string; restarts: number; age: string }[];
|
||||
deployType?: string;
|
||||
type?: string;
|
||||
status: string;
|
||||
triggerBy?: string;
|
||||
triggerType?: string;
|
||||
startedAt: string;
|
||||
completedAt?: string | null;
|
||||
durationMs?: number;
|
||||
buildNumber?: number;
|
||||
externalUrl?: string;
|
||||
}
|
||||
|
||||
const mockRecords: ReleaseRecord[] = [
|
||||
{
|
||||
id: 'pr-202410261430',
|
||||
pipelineName: 'aiops-backend',
|
||||
branch: 'master',
|
||||
env: 'production',
|
||||
deployType: 'gitops',
|
||||
type: 'tekton',
|
||||
status: 'success',
|
||||
triggerBy: '张三',
|
||||
startTime: '2024-10-26 14:30:00',
|
||||
duration: '3m 42s',
|
||||
stages: [
|
||||
{ name: 'git-clone', status: 'success', duration: '15s', podName: 'pr-abc-git-clone-xyz', logs: ['Cloning into \'/workspace/source\'...', 'HEAD is now at 7d9f4e2 feat: update api'] },
|
||||
{ name: 'go-build', status: 'success', duration: '1m 30s', podName: 'pr-abc-go-build-xyz', logs: ['go build -o bin/app ./cmd/app', 'Build successful'] },
|
||||
{ name: 'kaniko-build', status: 'success', duration: '1m 45s', podName: 'pr-abc-kaniko-xyz', logs: ['INFO[0000] Resolved base name...', 'Pushing image to registry...', 'Image pushed: registry/aiops:v202410261430'] },
|
||||
{ name: 'update-manifest', status: 'success', duration: '8s', podName: 'pr-abc-update-xyz', logs: ['git clone gitops-repo', 'Updating image tag to v202410261430', 'git push origin main'] },
|
||||
],
|
||||
podStatus: [
|
||||
{ name: 'aiops-backend-7d9f4-abc1', status: 'Running', restarts: 0, age: '5m' },
|
||||
{ name: 'aiops-backend-7d9f4-abc2', status: 'Running', restarts: 0, age: '5m' },
|
||||
{ name: 'aiops-backend-7d9f4-abc3', status: 'Running', restarts: 0, age: '5m' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pr-202410261415',
|
||||
pipelineName: 'aiops-web',
|
||||
branch: 'master',
|
||||
env: 'production',
|
||||
deployType: 'host',
|
||||
type: 'native',
|
||||
status: 'success',
|
||||
triggerBy: '李四',
|
||||
startTime: '2024-10-26 14:15:00',
|
||||
duration: '2m 18s',
|
||||
stages: [
|
||||
{ name: '拉取代码', status: 'success', duration: '12s' },
|
||||
{ name: 'npm-build', status: 'success', duration: '1m 30s' },
|
||||
{ name: 'scp-deploy', status: 'success', duration: '30s' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pr-202410261400',
|
||||
pipelineName: 'gateway-api',
|
||||
branch: 'release/v2.1',
|
||||
env: 'staging',
|
||||
deployType: 'k8s',
|
||||
type: 'jenkins',
|
||||
status: 'failed',
|
||||
triggerBy: '王五',
|
||||
startTime: '2024-10-26 14:00:00',
|
||||
duration: '5m 10s',
|
||||
stages: [
|
||||
{ name: 'git-clone', status: 'success', duration: '18s' },
|
||||
{ name: 'maven-build', status: 'success', duration: '2m 45s' },
|
||||
{ name: 'kubectl-deploy', status: 'failed', duration: '2m 7s', logs: ['kubectl apply -f k8s/', 'Error: deployment gateway-api timeout', 'Pod gateway-api-xxx CrashLoopBackOff'] },
|
||||
],
|
||||
podStatus: [
|
||||
{ name: 'gateway-api-xxx', status: 'CrashLoopBackOff', restarts: 5, age: '10m' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pr-202410261330',
|
||||
pipelineName: 'admin-dashboard',
|
||||
branch: 'master',
|
||||
env: 'staging',
|
||||
deployType: 'host',
|
||||
type: 'native',
|
||||
status: 'running',
|
||||
triggerBy: '赵六',
|
||||
startTime: '2024-10-26 13:30:00',
|
||||
duration: '进行中',
|
||||
stages: [
|
||||
{ name: '拉取代码', status: 'success', duration: '10s' },
|
||||
{ name: '构建', status: 'success', duration: '1m 20s' },
|
||||
{ name: '部署到主机', status: 'running', duration: '30s' },
|
||||
{ name: '后置操作', status: 'pending', duration: '-' },
|
||||
],
|
||||
},
|
||||
];
|
||||
interface RunStage {
|
||||
id: number;
|
||||
name: string;
|
||||
nodeId: string;
|
||||
podName?: string;
|
||||
status: string;
|
||||
startedAt?: string | null;
|
||||
completedAt?: string | null;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
interface RunLog {
|
||||
id?: number;
|
||||
level?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
interface RunDetail {
|
||||
run: RunRecord;
|
||||
stages: RunStage[];
|
||||
logs: RunLog[];
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = {
|
||||
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||
running: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '执行中' },
|
||||
running: { icon: Clock, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||
pending: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '排队中' },
|
||||
queued: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '排队中' },
|
||||
cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
|
||||
};
|
||||
const fallbackStatus = statusConfig.pending;
|
||||
|
||||
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
|
||||
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
|
||||
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
|
||||
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
|
||||
};
|
||||
const fallbackType = { label: '原生', color: '#0969da', bg: '#ddf4ff' };
|
||||
|
||||
type DetailTab = 'stages' | 'logs' | 'pods';
|
||||
|
||||
function stageDuration(stage: RunStage): string {
|
||||
if (!stage.startedAt) return '-';
|
||||
const start = new Date(stage.startedAt).getTime();
|
||||
if (stage.completedAt) return fmtDuration(new Date(stage.completedAt).getTime() - start);
|
||||
if (stage.status === 'running') return fmtDuration(Date.now() - start);
|
||||
return '-';
|
||||
}
|
||||
|
||||
function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||
record: ReleaseRecord;
|
||||
activeTab: 'stages' | 'logs' | 'pods';
|
||||
setActiveTab: (t: 'stages' | 'logs' | 'pods') => void;
|
||||
record: RunRecord;
|
||||
activeTab: DetailTab;
|
||||
setActiveTab: (t: DetailTab) => void;
|
||||
}) {
|
||||
const isRunning = record.status === 'running';
|
||||
const { logs, connected } = usePipelineLogs(record.id, isRunning && activeTab === 'logs');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ['run', record.runId],
|
||||
queryFn: () => apiClient.getRun(record.runId),
|
||||
refetchInterval: (query) => {
|
||||
const st = (query.state.data as RunDetail | undefined)?.run?.status ?? record.status;
|
||||
return st === 'running' || st === 'pending' || st === 'queued' ? 3000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const runStatus = detail?.run?.status ?? record.status;
|
||||
const isRunning = runStatus === 'running';
|
||||
const { logs: liveLogs, connected } = usePipelineLogs(record.runId, isRunning && activeTab === 'logs');
|
||||
|
||||
const stages: RunStage[] = detail?.stages ?? [];
|
||||
const podStages = stages.filter((s) => s.podName);
|
||||
const externalUrl = detail?.run?.externalUrl || record.externalUrl;
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: () => apiClient.retryRun(record.runId),
|
||||
onSuccess: (res) => {
|
||||
toast.success('已重新触发', { description: `新的运行 ${res?.runId ?? record.runId}` });
|
||||
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['run', record.runId] });
|
||||
},
|
||||
onError: (e: Error) => toast.error('重试失败', { description: e.message }),
|
||||
});
|
||||
|
||||
const logsToRender: RunLog[] = isRunning ? liveLogs : (detail?.logs ?? []);
|
||||
|
||||
return (
|
||||
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
@@ -139,7 +150,7 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||
{[
|
||||
{ id: 'stages' as const, label: '阶段', icon: Layers },
|
||||
{ id: 'logs' as const, label: '日志', icon: Terminal },
|
||||
...(record.podStatus ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
|
||||
...(podStages.length > 0 ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||
className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors"
|
||||
@@ -155,111 +166,166 @@ function ExpandedRecord({ record, activeTab, setActiveTab }: {
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{activeTab === 'stages' && (
|
||||
<div className="flex items-center gap-0">
|
||||
{record.stages.map((stage, idx) => {
|
||||
const st = statusConfig[stage.status] || statusConfig.success;
|
||||
const isLast = idx === record.stages.length - 1;
|
||||
return (
|
||||
<div key={idx} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
|
||||
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
||||
stage.status === 'failed' ? <XCircle className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
||||
stage.status === 'running' ? <Clock className="w-3.5 h-3.5 animate-pulse" style={{ color: st.color }} /> :
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
|
||||
detailLoading && stages.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
) : stages.length === 0 ? (
|
||||
<p className="text-[12px] text-center py-6" style={{ color: 'var(--text-tertiary)' }}>暂无阶段数据</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-0">
|
||||
{stages.map((stage, idx) => {
|
||||
const st = statusConfig[stage.status] || fallbackStatus;
|
||||
const isLast = idx === stages.length - 1;
|
||||
return (
|
||||
<div key={stage.id ?? idx} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
|
||||
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
||||
stage.status === 'failed' ? <XCircle className="w-3.5 h-3.5" style={{ color: st.color }} /> :
|
||||
stage.status === 'running' ? <Clock className="w-3.5 h-3.5 animate-pulse" style={{ color: st.color }} /> :
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
|
||||
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stageDuration(stage)}</div>
|
||||
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
|
||||
</div>
|
||||
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
|
||||
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stage.duration}</div>
|
||||
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
|
||||
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
|
||||
</div>
|
||||
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < record.stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'logs' && (
|
||||
<div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}>
|
||||
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}>
|
||||
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
||||
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span>
|
||||
{externalUrl && (
|
||||
<a
|
||||
href={externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1"
|
||||
style={{ background: '#8250df26', color: '#a371f7' }}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5" />外部构建
|
||||
</a>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{isRunning && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium ml-auto" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
|
||||
{connected ? '实时推送中' : '连接中...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}>
|
||||
{record.stages.map((stage, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="py-1" style={{ color: '#58a6ff' }}>=== {stage.name} ===</div>
|
||||
{stage.logs ? stage.logs.map((log, li) => (
|
||||
<div key={li} className="py-[1px]" style={{ color: log.includes('Error') ? '#f85149' : '#e6edf3' }}>{log}</div>
|
||||
)) : <div className="py-[1px]" style={{ color: '#7d8590' }}>No logs available</div>}
|
||||
{logsToRender.length === 0 ? (
|
||||
<div className="py-1" style={{ color: '#7d8590' }}>
|
||||
{isRunning ? '等待日志输出...' : detailLoading ? '日志加载中...' : '暂无日志'}
|
||||
</div>
|
||||
) : logsToRender.map((log, idx) => (
|
||||
<div key={log.id ?? idx} className="py-[1px] flex items-start gap-1.5">
|
||||
<span className="flex-shrink-0" style={{ color: '#7d8590' }}>
|
||||
[{log.timestamp ? new Date(log.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}]
|
||||
</span>
|
||||
{log.source === 'jenkins' && (
|
||||
<span className="text-[9px] px-1 py-[1px] rounded font-medium flex-shrink-0 self-center" style={{ background: '#8250df33', color: '#a371f7' }}>Jenkins</span>
|
||||
)}
|
||||
<span style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{logs.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t" style={{ borderColor: '#21262d' }}>
|
||||
<div className="py-1" style={{ color: '#58a6ff' }}>=== 实时日志 ===</div>
|
||||
{logs.map((log, idx) => (
|
||||
<div key={idx} className="py-[1px]" style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
|
||||
<span style={{ color: '#7d8590' }}>[{new Date(log.timestamp || '').toLocaleTimeString()}]</span> {log.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'pods' && record.podStatus && (
|
||||
{activeTab === 'pods' && podStages.length > 0 && (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<div className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<div>Pod 名称</div><div>状态</div><div>重启次数</div><div>运行时间</div>
|
||||
<div className="grid grid-cols-[1fr_120px_100px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<div>Pod 名称</div><div>状态</div><div>运行时间</div>
|
||||
</div>
|
||||
{record.podStatus.map((pod, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="font-mono" style={{ color: 'var(--text-primary)' }}>{pod.name}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Container className="w-3 h-3" style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }} />
|
||||
<span style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }}>{pod.status}</span>
|
||||
{podStages.map((stage, idx) => {
|
||||
const st = statusConfig[stage.status] || fallbackStatus;
|
||||
return (
|
||||
<div key={stage.id ?? idx} className="grid grid-cols-[1fr_120px_100px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="font-mono truncate" style={{ color: 'var(--text-primary)' }}>{stage.podName}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`w-2 h-2 rounded-full ${stage.status === 'running' ? 'animate-pulse' : ''}`} style={{ background: st.color }} />
|
||||
<span style={{ color: st.color }}>{st.label}</span>
|
||||
</div>
|
||||
<div title={stage.startedAt ? fmtTime(stage.startedAt) : undefined} style={{ color: 'var(--text-tertiary)' }}>
|
||||
{timeAgo(stage.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ color: pod.restarts > 0 ? '#cf222e' : 'var(--text-secondary)' }}>{pod.restarts}</div>
|
||||
<div style={{ color: 'var(--text-tertiary)' }}>{pod.age}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<button onClick={() => toast.info('查看日志', { description: `${record.pipelineName} 完整日志` })}
|
||||
<button onClick={() => setActiveTab('logs')}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<Terminal className="w-3 h-3" />查看日志
|
||||
</button>
|
||||
{record.status === 'failed' && (
|
||||
<button onClick={() => toast.success('重试发布', { description: `${record.pipelineName} 重新开始发布` })}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RotateCcw className="w-3 h-3" />重试
|
||||
{(record.status === 'failed' || record.status === 'cancelled') && (
|
||||
<button
|
||||
onClick={() => retryMutation.mutate()}
|
||||
disabled={retryMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RotateCcw className={`w-3 h-3 ${retryMutation.isPending ? 'animate-spin' : ''}`} />{retryMutation.isPending ? '重试中...' : '重试'}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => toast.info('查看流水线', { description: `跳转至 ${record.pipelineName}` })}
|
||||
<button onClick={() => toast.info('请点击左侧【流水线】进入对应编辑器')}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<ExternalLink className="w-3 h-3" />查看流水线
|
||||
</button>
|
||||
{record.buildNumber != null && record.buildNumber > 0 && (
|
||||
<span className="text-[11px] ml-auto" style={{ color: 'var(--text-tertiary)' }}>构建 #{record.buildNumber}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabOptions = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'success', label: '成功' },
|
||||
{ id: 'failed', label: '失败' },
|
||||
{ id: 'running', label: '执行中' },
|
||||
];
|
||||
|
||||
export default function ReleaseRecords() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [statusTab, setStatusTab] = useState<string>('all');
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'stages' | 'logs' | 'pods'>('stages');
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('stages');
|
||||
|
||||
const filtered = mockRecords.filter(r => {
|
||||
const matchSearch = r.pipelineName.includes(search) || r.id.includes(search);
|
||||
const matchStatus = statusFilter === 'all' || r.status === statusFilter;
|
||||
return matchSearch && matchStatus;
|
||||
// 统计始终基于全量列表
|
||||
const { data: allRuns = [] } = useQuery({
|
||||
queryKey: ['runs', 'all'],
|
||||
queryFn: () => apiClient.listRuns(),
|
||||
});
|
||||
|
||||
// 列表按 Tab 使用服务端过滤(all 时与统计共用同一查询)
|
||||
const { data: runs = [], isLoading } = useQuery({
|
||||
queryKey: ['runs', statusTab],
|
||||
queryFn: () => apiClient.listRuns({ status: statusTab }),
|
||||
});
|
||||
|
||||
const runList = allRuns as RunRecord[];
|
||||
const total = runList.length;
|
||||
const successCount = runList.filter(r => r.status === 'success').length;
|
||||
const failedCount = runList.filter(r => r.status === 'failed').length;
|
||||
const runningCount = runList.filter(r => r.status === 'running' || r.status === 'pending' || r.status === 'queued').length;
|
||||
const finished = total - runningCount;
|
||||
const successRate = finished > 0 ? `${Math.round((successCount / finished) * 100)}%` : '-';
|
||||
|
||||
const filtered = (runs as RunRecord[]).filter(r => {
|
||||
const q = search.toLowerCase();
|
||||
const matchSearch = r.pipelineName.toLowerCase().includes(q) || r.runId.toLowerCase().includes(q);
|
||||
return matchSearch;
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -270,65 +336,103 @@ export default function ReleaseRecords() {
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布记录</h1>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
|
||||
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-[5px] text-[12px] border rounded-md outline-none appearance-none pr-7"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<option value="all">全部状态</option><option value="success">成功</option><option value="failed">失败</option><option value="running">执行中</option><option value="cancelled">已取消</option>
|
||||
</select>
|
||||
<Filter className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
|
||||
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats + Tabs */}
|
||||
<div className="px-5 py-2.5 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-5">
|
||||
{[
|
||||
{ label: '总次数', value: total, color: 'var(--text-primary)' },
|
||||
{ label: '成功率', value: successRate, color: '#1a7f37' },
|
||||
{ label: '成功', value: successCount, color: '#1a7f37' },
|
||||
{ label: '失败', value: failedCount, color: '#cf222e' },
|
||||
{ label: '执行中', value: runningCount, color: '#0969da' },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="flex items-baseline gap-1.5">
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</span>
|
||||
<span className="text-[13px] font-semibold" style={{ color: stat.color }}>{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{tabOptions.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusTab(tab.id)}
|
||||
className="px-3 py-[5px] text-[12px] font-medium rounded-md transition-colors"
|
||||
style={{
|
||||
background: statusTab === tab.id ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)',
|
||||
color: statusTab === tab.id ? '#fff' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
<div className="space-y-2">
|
||||
{filtered.map((record) => {
|
||||
const status = statusConfig[record.status];
|
||||
const StatusIcon = status.icon;
|
||||
const tp = typeConfig[record.type];
|
||||
const isExpanded = expandedId === record.id;
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<Loader2 className="w-5 h-5 animate-spin mb-2" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>加载中...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>暂无发布记录</p>
|
||||
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试切换状态或执行一次流水线</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((record) => {
|
||||
const status = statusConfig[record.status] || fallbackStatus;
|
||||
const StatusIcon = status.icon;
|
||||
const tp = typeConfig[record.type || 'native'] || fallbackType;
|
||||
const isExpanded = expandedId === record.runId;
|
||||
const durationText = record.status === 'running'
|
||||
? (record.durationMs && record.durationMs > 0 ? fmtDuration(record.durationMs) : '进行中')
|
||||
: fmtDuration(record.durationMs);
|
||||
|
||||
return (
|
||||
<div key={record.id} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{/* Summary row */}
|
||||
<div onClick={() => setExpandedId(isExpanded ? null : record.id)}
|
||||
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
|
||||
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
||||
<div className="w-[130px]">
|
||||
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.id}</div>
|
||||
return (
|
||||
<div key={record.runId} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{/* Summary row */}
|
||||
<div onClick={() => setExpandedId(isExpanded ? null : record.runId)}
|
||||
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
|
||||
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
|
||||
<div className="w-[130px]">
|
||||
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.runId}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
<GitBranch className="w-3 h-3" /> {record.branch}
|
||||
</div>
|
||||
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
||||
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy || '-'}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] w-[150px]" style={{ color: 'var(--text-tertiary)' }} title={timeAgo(record.startedAt)}><Calendar className="w-3 h-3" />{fmtTime(record.startedAt)}</div>
|
||||
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{durationText}</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
|
||||
<GitBranch className="w-3 h-3" /> {record.branch}
|
||||
</div>
|
||||
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
|
||||
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] w-[130px]" style={{ color: 'var(--text-tertiary)' }}><Calendar className="w-3 h-3" />{record.startTime}</div>
|
||||
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{record.duration}</div>
|
||||
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<ExpandedRecord
|
||||
record={record}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded detail */}
|
||||
{isExpanded && (
|
||||
<ExpandedRecord
|
||||
record={record}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Palette, Monitor, Moon, Sun,
|
||||
Save, RotateCcw, CheckCircle2, Globe, Bell
|
||||
Save, RotateCcw, CheckCircle2, Globe, Bell, Loader2
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
interface ThemeConfig {
|
||||
mode: 'light' | 'dark' | 'system';
|
||||
@@ -14,6 +16,43 @@ interface ThemeConfig {
|
||||
borderRadius: 'sharp' | 'soft' | 'round';
|
||||
}
|
||||
|
||||
interface NotificationConfig {
|
||||
email: boolean;
|
||||
browser: boolean;
|
||||
pipelineSuccess: boolean;
|
||||
pipelineFailed: boolean;
|
||||
securityAlert: boolean;
|
||||
}
|
||||
|
||||
const defaultTheme: ThemeConfig = {
|
||||
mode: 'light',
|
||||
primaryColor: '#0969da',
|
||||
fontSize: 'medium',
|
||||
density: 'comfortable',
|
||||
sidebarStyle: 'full',
|
||||
borderRadius: 'soft',
|
||||
};
|
||||
|
||||
const defaultNotifications: NotificationConfig = {
|
||||
email: true,
|
||||
browser: true,
|
||||
pipelineSuccess: false,
|
||||
pipelineFailed: true,
|
||||
securityAlert: true,
|
||||
};
|
||||
|
||||
// 后端 theme / notification 均以 JSON 字符串存储,安全解析并合并默认值
|
||||
function safeParse<T extends object>(raw: unknown, fallback: T): T {
|
||||
if (typeof raw !== 'string' || !raw) return fallback;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object') return { ...fallback, ...parsed };
|
||||
return fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const colorOptions = [
|
||||
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
|
||||
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
|
||||
@@ -24,50 +63,57 @@ const colorOptions = [
|
||||
];
|
||||
|
||||
export default function SystemSettings() {
|
||||
const [theme, setTheme] = useState<ThemeConfig>({
|
||||
mode: 'light',
|
||||
primaryColor: '#0969da',
|
||||
fontSize: 'medium',
|
||||
density: 'comfortable',
|
||||
sidebarStyle: 'full',
|
||||
borderRadius: 'soft',
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const [theme, setTheme] = useState<ThemeConfig>(defaultTheme);
|
||||
const [notifications, setNotifications] = useState<NotificationConfig>(defaultNotifications);
|
||||
|
||||
const [notifications, setNotifications] = useState({
|
||||
email: true,
|
||||
browser: true,
|
||||
pipelineSuccess: false,
|
||||
pipelineFailed: true,
|
||||
securityAlert: true,
|
||||
});
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||
|
||||
const handleSave = () => {
|
||||
toast.success('设置已保存', { description: '主题和偏好设置已更新' });
|
||||
};
|
||||
// 用后端数据初始化表单(仅在数据变化时覆盖本地编辑态)
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
setTheme(safeParse<ThemeConfig>(settings.theme, defaultTheme));
|
||||
setNotifications(safeParse<NotificationConfig>(settings.notification, defaultNotifications));
|
||||
}, [settings]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => apiClient.updateSettings({
|
||||
theme: JSON.stringify(theme),
|
||||
notification: JSON.stringify(notifications),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success('设置已保存', { description: '主题和通知设置已更新' });
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const handleReset = () => {
|
||||
setTheme({
|
||||
mode: 'light',
|
||||
primaryColor: '#0969da',
|
||||
fontSize: 'medium',
|
||||
density: 'comfortable',
|
||||
sidebarStyle: 'full',
|
||||
borderRadius: 'soft',
|
||||
});
|
||||
toast.info('已恢复默认设置');
|
||||
setTheme(defaultTheme);
|
||||
setNotifications(defaultNotifications);
|
||||
toast.info('已恢复默认设置', { description: '仅本地重置,点击「保存设置」后生效' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
|
||||
{!settings && (
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />加载中
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RotateCcw className="w-3.5 h-3.5" />恢复默认
|
||||
</button>
|
||||
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
<Save className="w-3.5 h-3.5" />保存设置
|
||||
<button onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||
{saveMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||
{saveMutation.isPending ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,9 +137,9 @@ export default function SystemSettings() {
|
||||
{ id: 'dark', label: '深色', icon: Moon },
|
||||
{ id: 'system', label: '跟随系统', icon: Monitor },
|
||||
].map(m => (
|
||||
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as any }))}
|
||||
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as ThemeConfig['mode'] }))}
|
||||
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
background: theme.mode === m.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
@@ -127,9 +173,9 @@ export default function SystemSettings() {
|
||||
{ id: 'medium', label: '中', size: '14px' },
|
||||
{ id: 'large', label: '大', size: '16px' },
|
||||
].map(f => (
|
||||
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as any }))}
|
||||
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as ThemeConfig['fontSize'] }))}
|
||||
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
@@ -150,9 +196,9 @@ export default function SystemSettings() {
|
||||
{ id: 'comfortable', label: '舒适' },
|
||||
{ id: 'spacious', label: '宽松' },
|
||||
].map(d => (
|
||||
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as any }))}
|
||||
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as ThemeConfig['density'] }))}
|
||||
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
@@ -172,9 +218,9 @@ export default function SystemSettings() {
|
||||
{ id: 'soft', label: '圆角', radius: '6px' },
|
||||
{ id: 'round', label: '大圆角', radius: '12px' },
|
||||
].map(r => (
|
||||
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as any }))}
|
||||
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as ThemeConfig['borderRadius'] }))}
|
||||
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
|
||||
style={{
|
||||
style={{
|
||||
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
@@ -203,7 +249,7 @@ export default function SystemSettings() {
|
||||
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
|
||||
].map(item => (
|
||||
<label key={item.key} className="flex items-start gap-3 cursor-pointer">
|
||||
<input type="checkbox" checked={(notifications as any)[item.key]}
|
||||
<input type="checkbox" checked={notifications[item.key as keyof NotificationConfig]}
|
||||
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
|
||||
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||
<div>
|
||||
@@ -232,6 +278,7 @@ export default function SystemSettings() {
|
||||
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>语言与时区暂未生效,仅作展示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+179
-242
@@ -1,142 +1,46 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Save,
|
||||
import {
|
||||
Download,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Monitor,
|
||||
Baby,
|
||||
RefreshCw,
|
||||
Terminal
|
||||
Terminal,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
interface DevOpsTool {
|
||||
id: string;
|
||||
name: string;
|
||||
// 与后端 model.DeployTool 对齐
|
||||
interface DeployTool {
|
||||
id: number;
|
||||
key: string;
|
||||
description: string;
|
||||
name: string;
|
||||
category: 'build' | 'deploy';
|
||||
status: 'not-installed' | 'installed' | 'installing';
|
||||
status: 'not-installed' | 'installed' | 'installing' | 'failed';
|
||||
version: string;
|
||||
installedVersion: string;
|
||||
installMode: 'preinstalled' | 'lazy' | 'disabled';
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
lastCheck: string | null;
|
||||
errorMsg: string;
|
||||
}
|
||||
|
||||
const initialTools: DevOpsTool[] = [
|
||||
{
|
||||
id: 'jdk',
|
||||
name: 'JDK (Java)',
|
||||
key: 'jdk',
|
||||
description: 'Java 开发工具包,流水线 Java/Maven 构建阶段使用',
|
||||
category: 'build',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/jdk',
|
||||
},
|
||||
{
|
||||
id: 'maven',
|
||||
name: 'Maven',
|
||||
key: 'maven',
|
||||
description: 'Java 项目构建工具,流水线 Java 构建阶段使用(依赖 JDK)',
|
||||
category: 'build',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/maven',
|
||||
},
|
||||
{
|
||||
id: 'golang',
|
||||
name: 'Go (Golang)',
|
||||
key: 'golang',
|
||||
description: 'Go 语言工具链,流水线 go build/test 阶段使用',
|
||||
category: 'build',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/go',
|
||||
},
|
||||
{
|
||||
id: 'nvm',
|
||||
name: 'NVM (Node 版本管理)',
|
||||
key: 'nvm',
|
||||
description: 'Node 版本管理器,流水线 Node.js 构建阶段使用(node 依赖此工具)',
|
||||
category: 'build',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/nvm',
|
||||
},
|
||||
{
|
||||
id: 'nodejs',
|
||||
name: 'Node.js + npm',
|
||||
key: 'nodejs',
|
||||
description: '前端构建工具,流水线 npm install/build 阶段使用(通过 NVM 安装)',
|
||||
category: 'build',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/node',
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
name: 'Git',
|
||||
key: 'git',
|
||||
description: '版本控制工具,流水线代码拉取使用(首次使用自动安装)',
|
||||
category: 'build',
|
||||
status: 'installed',
|
||||
version: '2.43.0',
|
||||
installMode: 'preinstalled',
|
||||
path: '/usr/bin/git',
|
||||
},
|
||||
{
|
||||
id: 'docker',
|
||||
name: 'Docker CLI',
|
||||
key: 'docker-cli',
|
||||
description: 'Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)',
|
||||
category: 'deploy',
|
||||
status: 'installed',
|
||||
version: '24.0.7',
|
||||
installMode: 'preinstalled',
|
||||
path: '/usr/bin/docker',
|
||||
},
|
||||
{
|
||||
id: 'kubectl',
|
||||
name: 'kubectl',
|
||||
key: 'kubectl',
|
||||
description: 'Kubernetes 命令行工具,流水线 K8s 部署阶段使用',
|
||||
category: 'deploy',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/kubectl',
|
||||
},
|
||||
{
|
||||
id: 'helm',
|
||||
name: 'Helm',
|
||||
key: 'helm',
|
||||
description: 'Kubernetes 包管理器,流水线 Helm 部署阶段使用',
|
||||
category: 'deploy',
|
||||
status: 'not-installed',
|
||||
version: '',
|
||||
installMode: 'lazy',
|
||||
path: '/opt/tools/helm',
|
||||
},
|
||||
];
|
||||
|
||||
const categoryConfig = {
|
||||
build: { label: '构建', color: '#9a6700', bg: '#fff8c5' },
|
||||
deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' },
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781', bg: '#f6f8fa' },
|
||||
'installed': { icon: CheckCircle2, label: '已安装', color: '#1a7f37', bg: '#dafbe1' },
|
||||
'installing': { icon: Loader2, label: '安装中', color: '#9a6700', bg: '#fff8c5' },
|
||||
const statusConfig: Record<string, { icon: typeof XCircle; label: string; color: string }> = {
|
||||
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781' },
|
||||
installed: { icon: CheckCircle2, label: '已安装', color: '#1a7f37' },
|
||||
installing: { icon: Loader2, label: '安装中', color: '#9a6700' },
|
||||
failed: { icon: AlertCircle, label: '失败', color: '#cf222e' },
|
||||
};
|
||||
|
||||
const installModeConfig = {
|
||||
@@ -145,54 +49,80 @@ const installModeConfig = {
|
||||
disabled: '禁用不安装',
|
||||
};
|
||||
|
||||
const formatTime = (v: string | null | undefined) => {
|
||||
if (!v) return '';
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
const firstLine = (s?: string, max = 120) => {
|
||||
const line = (s || '').trim().split('\n')[0] || '';
|
||||
return line.length > max ? line.slice(0, max) + '…' : line;
|
||||
};
|
||||
|
||||
export default function ToolConfig() {
|
||||
const [tools, setTools] = useState<DevOpsTool[]>(initialTools);
|
||||
const [mode, setMode] = useState<'local' | 'jenkins'>('local');
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const [dropdownOpen, setDropdownOpen] = useState<number | null>(null);
|
||||
|
||||
const handleInstall = (toolId: string) => {
|
||||
setInstallingId(toolId);
|
||||
setTools(prev => prev.map(t => t.id === toolId ? { ...t, status: 'installing' } : t));
|
||||
|
||||
setTimeout(() => {
|
||||
setTools(prev => prev.map(t =>
|
||||
t.id === toolId
|
||||
? { ...t, status: 'installed', version: getDefaultVersion(toolId) }
|
||||
: t
|
||||
));
|
||||
setInstallingId(null);
|
||||
toast.success('安装完成', { description: `${getToolName(toolId)} 安装成功` });
|
||||
}, 2000);
|
||||
};
|
||||
const { data: tools = [], isLoading } = useQuery({ queryKey: ['tools'], queryFn: apiClient.listTools });
|
||||
const { data: settings } = useQuery({ queryKey: ['settings'], queryFn: apiClient.getSettings });
|
||||
const toolMode: 'local' | 'jenkins' = settings?.toolMode === 'jenkins' ? 'jenkins' : 'local';
|
||||
|
||||
const handleTest = (toolId: string) => {
|
||||
const tool = tools.find(t => t.id === toolId);
|
||||
if (tool?.status === 'installed') {
|
||||
toast.success('测试通过', { description: `${tool.name} v${tool.version} 运行正常` });
|
||||
} else {
|
||||
toast.error('测试失败', { description: `${tool?.name || toolId} 未安装` });
|
||||
}
|
||||
};
|
||||
const invalidateTools = () => queryClient.invalidateQueries({ queryKey: ['tools'] });
|
||||
|
||||
const handleModeChange = (toolId: string, newMode: 'preinstalled' | 'lazy' | 'disabled') => {
|
||||
setTools(prev => prev.map(t => t.id === toolId ? { ...t, installMode: newMode } : t));
|
||||
setDropdownOpen(null);
|
||||
toast.info('策略已更新', { description: `${getToolName(toolId)} 安装策略改为: ${installModeConfig[newMode]}` });
|
||||
};
|
||||
// 本地 / Jenkins 模式切换(写入 settings.toolMode)
|
||||
const modeMutation = useMutation({
|
||||
mutationFn: (mode: 'local' | 'jenkins') => apiClient.updateSettings({ toolMode: mode }),
|
||||
onSuccess: (_data, mode) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
toast.success(mode === 'local' ? '已切换为本地模式' : '已切换为 Jenkins 模式');
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const getDefaultVersion = (id: string) => {
|
||||
const versions: Record<string, string> = {
|
||||
jdk: '17.0.9', maven: '3.9.6', golang: '1.22.0', nvm: '0.39.7',
|
||||
nodejs: '20.11.0', git: '2.43.0', docker: '24.0.7', kubectl: '1.28.3', helm: '3.14.0',
|
||||
};
|
||||
return versions[id] || '1.0.0';
|
||||
};
|
||||
// 安装策略切换
|
||||
const modeChangeMutation = useMutation({
|
||||
mutationFn: (input: { tool: DeployTool; installMode: DeployTool['installMode'] }) =>
|
||||
apiClient.updateTool(input.tool.id, { installMode: input.installMode }),
|
||||
onSuccess: (_data, input) => {
|
||||
invalidateTools();
|
||||
toast.success('策略已更新', { description: `${input.tool.name}: ${installModeConfig[input.installMode]}` });
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const getToolName = (id: string) => tools.find(t => t.id === id)?.name || id;
|
||||
// 检测(真实执行探测命令)
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: (tool: DeployTool) => apiClient.checkTool(tool.id),
|
||||
onSuccess: (data: any, tool) => {
|
||||
invalidateTools();
|
||||
if (data?.detected) {
|
||||
toast.success(`检测到版本 ${data.detected}`, { description: firstLine(data.output) || tool.name });
|
||||
} else {
|
||||
toast.error('未检测到', { description: firstLine(data?.output) || '检测命令未返回可用版本信息' });
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error('检测失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const installedCount = tools.filter(t => t.status === 'installed').length;
|
||||
const notInstalledCount = tools.filter(t => t.status === 'not-installed').length;
|
||||
// 安装(保守策略:后端只真实检测,未装会提示手动安装)
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (tool: DeployTool) => apiClient.installTool(tool.id),
|
||||
onSuccess: (data: any) => {
|
||||
invalidateTools();
|
||||
if (data?.success) {
|
||||
toast.success(data.message || '安装完成');
|
||||
} else {
|
||||
toast.warning(data.message || '未能完成安装,请参考输出信息手动安装');
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error('安装失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const installedCount = tools.filter((t: DeployTool) => t.status === 'installed').length;
|
||||
const notInstalledCount = tools.filter((t: DeployTool) => t.status === 'not-installed').length;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
@@ -208,44 +138,38 @@ export default function ToolConfig() {
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<button
|
||||
onClick={() => setMode('local')}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
background: mode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: mode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
onClick={() => modeMutation.mutate('local')}
|
||||
disabled={modeMutation.isPending || toolMode === 'local'}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors disabled:opacity-60"
|
||||
style={{
|
||||
background: toolMode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: toolMode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Monitor className="w-3.5 h-3.5" />
|
||||
本地模式
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('jenkins')}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
background: mode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: mode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
onClick={() => modeMutation.mutate('jenkins')}
|
||||
disabled={modeMutation.isPending || toolMode === 'jenkins'}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors disabled:opacity-60"
|
||||
style={{
|
||||
background: toolMode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: toolMode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
<Baby className="w-3.5 h-3.5" />
|
||||
Jenkins 模式
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toast.success('刷新完成', { description: '工具状态已更新' })}
|
||||
<button
|
||||
onClick={() => { invalidateTools(); }}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toast.success('配置已保存')}
|
||||
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -262,8 +186,8 @@ export default function ToolConfig() {
|
||||
{[
|
||||
{ label: '已安装', count: installedCount, color: '#1a7f37' },
|
||||
{ label: '未安装', count: notInstalledCount, color: '#6e7781' },
|
||||
{ label: '构建工具', count: tools.filter(t => t.category === 'build').length, color: '#9a6700' },
|
||||
{ label: '部署工具', count: tools.filter(t => t.category === 'deploy').length, color: '#0969da' },
|
||||
{ label: '构建工具', count: tools.filter((t: DeployTool) => t.category === 'build').length, color: '#9a6700' },
|
||||
{ label: '部署工具', count: tools.filter((t: DeployTool) => t.category === 'deploy').length, color: '#0969da' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
@@ -272,11 +196,11 @@ export default function ToolConfig() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tool list - screenshot style */}
|
||||
{/* Tool list */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{/* Table header */}
|
||||
<div
|
||||
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
||||
<div
|
||||
className="grid grid-cols-[1fr_100px_130px_300px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<div>工具配置</div>
|
||||
@@ -285,26 +209,39 @@ export default function ToolConfig() {
|
||||
<div className="text-right">操作</div>
|
||||
</div>
|
||||
|
||||
{tools.map((tool) => {
|
||||
const cat = categoryConfig[tool.category];
|
||||
const status = statusConfig[tool.status];
|
||||
{isLoading ? (
|
||||
<div className="px-4 py-10 flex items-center justify-center gap-2 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
正在加载工具列表...
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
暂无工具数据
|
||||
</div>
|
||||
) : tools.map((tool: DeployTool) => {
|
||||
const cat = categoryConfig[tool.category] || categoryConfig.build;
|
||||
const status = statusConfig[tool.status] || statusConfig['not-installed'];
|
||||
const StatusIcon = status.icon;
|
||||
const isInstalling = installingId === tool.id;
|
||||
const isInstalling = tool.status === 'installing';
|
||||
const isChecking = checkMutation.isPending && checkMutation.variables?.id === tool.id;
|
||||
const isInstallingNow = installMutation.isPending && installMutation.variables?.id === tool.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-3 border-t items-center"
|
||||
<div
|
||||
key={tool.id}
|
||||
className="grid grid-cols-[1fr_100px_130px_300px] gap-4 px-4 py-3 border-t items-center"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
{/* Tool info */}
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
|
||||
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
|
||||
<div className="text-[11px] leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{tool.description}</div>
|
||||
{tool.status === 'installed' && (
|
||||
{tool.status === 'installed' && tool.path && (
|
||||
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
|
||||
)}
|
||||
{tool.status === 'failed' && tool.errorMsg && (
|
||||
<div className="text-[10px] mt-1" style={{ color: '#cf222e' }}>{tool.errorMsg}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
@@ -320,25 +257,33 @@ export default function ToolConfig() {
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} />
|
||||
<span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span>
|
||||
</div>
|
||||
{tool.status === 'installed' && (
|
||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.version}</div>
|
||||
{tool.status === 'installed' ? (
|
||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.installedVersion || tool.version || '?'}</div>
|
||||
) : (
|
||||
tool.version && (
|
||||
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }} title={tool.errorMsg || undefined}>
|
||||
期望 v{tool.version}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{tool.lastCheck && (
|
||||
<div className="text-[10px] mt-0.5" style={{ color: 'var(--text-tertiary)', opacity: 0.7 }}>检测: {formatTime(tool.lastCheck)}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Install / Installing button */}
|
||||
{tool.status === 'not-installed' ? (
|
||||
{tool.status === 'installed' ? (
|
||||
<button
|
||||
onClick={() => handleInstall(tool.id)}
|
||||
disabled={isInstalling}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
disabled
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
|
||||
>
|
||||
<Download className={`w-3.5 h-3.5 ${isInstalling ? 'animate-bounce' : ''}`} />
|
||||
{isInstalling ? '安装中...' : '安装'}
|
||||
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
已安装
|
||||
</button>
|
||||
) : tool.status === 'installing' ? (
|
||||
) : isInstalling || isInstallingNow ? (
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50"
|
||||
@@ -349,72 +294,64 @@ export default function ToolConfig() {
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
|
||||
onClick={() => installMutation.mutate(tool)}
|
||||
disabled={installMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
title="后端采用保守策略:仅真实检测,未安装时会提示手动安装"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
已安装
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
安装
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Version / Install strategy dropdown */}
|
||||
{/* Install strategy dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)}
|
||||
className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{tool.status === 'installed' ? tool.version : installModeConfig[tool.installMode]}
|
||||
{installModeConfig[tool.installMode]}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
{dropdownOpen === tool.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} />
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[160px]"
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[170px]"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
{tool.status !== 'installed' && (
|
||||
<>
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
|
||||
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
|
||||
<button
|
||||
key={modeKey}
|
||||
onClick={() => handleModeChange(tool.id, modeKey)}
|
||||
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors"
|
||||
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
{installModeConfig[modeKey]}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
|
||||
</>
|
||||
)}
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本</div>
|
||||
<button className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors" style={{ color: 'var(--text-secondary)' }}>
|
||||
{getDefaultVersion(tool.id)} (默认)
|
||||
</button>
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
|
||||
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
|
||||
<button
|
||||
key={modeKey}
|
||||
onClick={() => { setDropdownOpen(null); modeChangeMutation.mutate({ tool, installMode: modeKey }); }}
|
||||
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors flex items-center justify-between gap-2"
|
||||
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
|
||||
>
|
||||
{installModeConfig[modeKey]}
|
||||
{tool.installMode === modeKey && <CheckCircle2 className="w-3 h-3" />}
|
||||
</button>
|
||||
))}
|
||||
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
|
||||
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本信息</div>
|
||||
<div className="px-3 py-[4px] text-[11px]" style={{ color: 'var(--text-secondary)' }}>期望版本: {tool.version || '—'}</div>
|
||||
<div className="px-3 py-[4px] text-[11px]" style={{ color: 'var(--text-secondary)' }}>已安装: {tool.installedVersion || '—'}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Test button */}
|
||||
{/* Check button */}
|
||||
<button
|
||||
onClick={() => handleTest(tool.id)}
|
||||
className="px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]"
|
||||
onClick={() => checkMutation.mutate(tool)}
|
||||
disabled={checkMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
|
||||
{/* Settings button */}
|
||||
<button
|
||||
onClick={() => toast.info('工具配置', { description: `${tool.name} 高级配置功能开发中...` })}
|
||||
className="p-[5px] rounded-md border transition-colors hover:bg-black/[0.02]"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<Settings className="w-3.5 h-3.5" />
|
||||
{isChecking ? <Loader2 className="w-3 h-3 animate-spin" /> : null}
|
||||
检测
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,55 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
import {
|
||||
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
|
||||
CheckCircle2, AlertTriangle,
|
||||
Send
|
||||
CheckCircle2, AlertTriangle, Send, Loader2, Lock
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 与后端 model.WebhookEndpoint 对齐(events 为 JSON 字符串,secret 只写不读)
|
||||
interface WebhookItem {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
secret: string;
|
||||
secretSet: boolean;
|
||||
events: string;
|
||||
active: boolean;
|
||||
lastTriggered: string;
|
||||
lastTriggered: string | null;
|
||||
lastStatus: 'success' | 'failed' | 'none';
|
||||
}
|
||||
|
||||
const initialWebhooks: WebhookItem[] = [
|
||||
{
|
||||
id: 'wh-1',
|
||||
name: '飞书群通知',
|
||||
url: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx',
|
||||
events: ['pipeline.success', 'pipeline.failed'],
|
||||
secret: 'whsec_xxxxxxxxxxxx',
|
||||
active: true,
|
||||
lastTriggered: '2024-10-26 14:30:15',
|
||||
lastStatus: 'success',
|
||||
},
|
||||
{
|
||||
id: 'wh-2',
|
||||
name: '钉钉群通知',
|
||||
url: 'https://oapi.dingtalk.com/robot/send?access_token=xxxxx',
|
||||
events: ['pipeline.success', 'pipeline.failed', 'pipeline.started'],
|
||||
secret: 'SEC_xxxxxxxxxxxx',
|
||||
active: true,
|
||||
lastTriggered: '2024-10-26 14:15:10',
|
||||
lastStatus: 'success',
|
||||
},
|
||||
{
|
||||
id: 'wh-3',
|
||||
name: '自定义回调',
|
||||
url: 'https://api.example.com/webhook/cicd',
|
||||
events: ['pipeline.failed'],
|
||||
secret: '',
|
||||
active: false,
|
||||
lastTriggered: '从未触发',
|
||||
lastStatus: 'none',
|
||||
},
|
||||
];
|
||||
|
||||
const eventOptions = [
|
||||
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
|
||||
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
|
||||
@@ -61,44 +30,105 @@ const eventOptions = [
|
||||
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
|
||||
];
|
||||
|
||||
const parseEvents = (raw: string): string[] => {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter(e => typeof e === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (v: string | null | undefined) => {
|
||||
if (!v) return '从未触发';
|
||||
const d = new Date(v);
|
||||
if (Number.isNaN(d.getTime())) return '从未触发';
|
||||
const p = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
};
|
||||
|
||||
const emptyForm = { name: '', url: '', secret: '', events: [] as string[] };
|
||||
|
||||
export default function WebhookConfig() {
|
||||
const [webhooks, setWebhooks] = useState<WebhookItem[]>(initialWebhooks);
|
||||
const queryClient = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({ name: '', url: '', secret: '', events: [] as string[] });
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editingSecretSet, setEditingSecretSet] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setWebhooks(prev => prev.map(w => w.id === id ? { ...w, active: !w.active } : w));
|
||||
};
|
||||
const { data: webhooks = [], isLoading } = useQuery({ queryKey: ['webhooks'], queryFn: apiClient.listWebhooks });
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['webhooks'] });
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setWebhooks(prev => prev.filter(w => w.id !== id));
|
||||
toast.success('Webhook 已删除');
|
||||
// 新建 / 编辑(secret 留空表示不修改)
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (input: { editingId: number | null; form: typeof emptyForm }) => {
|
||||
const body: Record<string, any> = { name: input.form.name, url: input.form.url, events: input.form.events };
|
||||
if (input.form.secret) body.secret = input.form.secret;
|
||||
return input.editingId != null
|
||||
? apiClient.updateWebhook(input.editingId, body)
|
||||
: apiClient.createWebhook(body);
|
||||
},
|
||||
onSuccess: (_data, input) => {
|
||||
toast.success(input.editingId != null ? 'Webhook 已更新' : 'Webhook 已创建');
|
||||
invalidate();
|
||||
closeForm();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => apiClient.deleteWebhook(id),
|
||||
onSuccess: () => { toast.success('Webhook 已删除'); invalidate(); },
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: (wh: WebhookItem) => apiClient.updateWebhook(wh.id, { active: !wh.active }),
|
||||
onSuccess: () => invalidate(),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
// 测试发送:真实 HTTP POST + HMAC-SHA256 签名
|
||||
const testMutation = useMutation({
|
||||
mutationFn: (id: number) => apiClient.testWebhook(id),
|
||||
onSuccess: (data: any) => {
|
||||
invalidate();
|
||||
if (data?.success) {
|
||||
toast.success('测试发送成功', { description: data.message || `响应 ${data.statusCode}` });
|
||||
} else {
|
||||
toast.error('测试发送失败', { description: data?.message || '目标地址返回异常' });
|
||||
}
|
||||
},
|
||||
onError: (err: Error) => toast.error('测试发送失败', { description: err.message }),
|
||||
});
|
||||
|
||||
const closeForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setEditingSecretSet(false);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.name || !form.url) {
|
||||
if (!form.name.trim() || !form.url.trim()) {
|
||||
toast.error('请填写名称和 URL');
|
||||
return;
|
||||
}
|
||||
if (editingId) {
|
||||
setWebhooks(prev => prev.map(w => w.id === editingId ? { ...w, ...form } : w));
|
||||
toast.success('Webhook 已更新');
|
||||
} else {
|
||||
setWebhooks(prev => [...prev, { ...form, id: `wh-${Date.now()}`, active: true, lastTriggered: '从未触发', lastStatus: 'none' }]);
|
||||
toast.success('Webhook 已创建');
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
setForm({ name: '', url: '', secret: '', events: [] });
|
||||
saveMutation.mutate({ editingId, form });
|
||||
};
|
||||
|
||||
const startEdit = (w: WebhookItem) => {
|
||||
setForm({ name: w.name, url: w.url, secret: w.secret, events: [...w.events] });
|
||||
setEditingId(w.id);
|
||||
const startEdit = (wh: WebhookItem) => {
|
||||
setForm({ name: wh.name, url: wh.url, secret: '', events: parseEvents(wh.events) });
|
||||
setEditingId(wh.id);
|
||||
setEditingSecretSet(wh.secretSet);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = (wh: WebhookItem) => {
|
||||
if (!window.confirm(`确定删除 Webhook「${wh.name}」?`)) return;
|
||||
deleteMutation.mutate(wh.id);
|
||||
};
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
setForm(prev => ({
|
||||
...prev,
|
||||
@@ -116,8 +146,8 @@ export default function WebhookConfig() {
|
||||
{webhooks.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowForm(true); setEditingId(null); setForm({ name: '', url: '', secret: '', events: [] }); }}
|
||||
<button
|
||||
onClick={() => { setShowForm(true); setEditingId(null); setEditingSecretSet(false); setForm(emptyForm); }}
|
||||
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
@@ -129,7 +159,7 @@ export default function WebhookConfig() {
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{showForm && (
|
||||
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
||||
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId != null ? '编辑 Webhook' : '新建 Webhook'}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
|
||||
@@ -141,84 +171,129 @@ export default function WebhookConfig() {
|
||||
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||
</div>
|
||||
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选)</label>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选,只写不读)</label>
|
||||
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
|
||||
placeholder="用于验证请求来源" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
|
||||
<div><label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{eventOptions.map(evt => (
|
||||
<label key={evt.value} className="flex items-start gap-2 p-2 rounded-md border cursor-pointer hover:bg-black/[0.02]"
|
||||
style={{ borderColor: 'var(--border-color-light)', background: form.events.includes(evt.value) ? 'var(--accent-blue-light)' : 'transparent' }}>
|
||||
<input type="checkbox" checked={form.events.includes(evt.value)} onChange={() => toggleEvent(evt.value)} className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||
<div><div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{evt.label}</div><div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{evt.desc}</div></div>
|
||||
</label>
|
||||
))}
|
||||
</div></div>
|
||||
placeholder={editingId != null && editingSecretSet ? '已设置密钥,留空保持不变' : '用于 HMAC-SHA256 签名验证,留空则不签名'}
|
||||
className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} />
|
||||
{editingId != null && editingSecretSet && (
|
||||
<p className="text-[11px] mt-1 flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Lock className="w-3 h-3" />已保存密钥不可读取;填写新值将覆盖,留空则不修改
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件(点击选择)</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{eventOptions.map(evt => {
|
||||
const on = form.events.includes(evt.value);
|
||||
return (
|
||||
<button key={evt.value} type="button" title={evt.desc} onClick={() => toggleEvent(evt.value)}
|
||||
className="text-[11px] px-2.5 py-[4px] rounded-full border font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: on ? 'var(--accent-blue)' : 'var(--border-color-light)',
|
||||
background: on ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: on ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}>
|
||||
{evt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={() => setShowForm(false)} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
||||
<button onClick={handleSave} className="px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>保存</button>
|
||||
<button onClick={closeForm} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
|
||||
<button onClick={handleSave} disabled={saveMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-60" style={{ background: 'var(--accent-blue)' }}>
|
||||
{saveMutation.isPending && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((wh) => (
|
||||
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
|
||||
{wh.active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[12px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Webhook className="w-3 h-3" /> {wh.url}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{wh.events.map(evt => {
|
||||
const opt = eventOptions.find(o => o.value === evt);
|
||||
return (
|
||||
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
{opt?.label || evt}
|
||||
{isLoading ? (
|
||||
<div className="py-12 flex items-center justify-center gap-2 text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />正在加载 Webhook 列表...
|
||||
</div>
|
||||
) : webhooks.length === 0 ? (
|
||||
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
暂无 Webhook,点击右上角「新建 Webhook」创建
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{webhooks.map((wh: WebhookItem) => (
|
||||
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
|
||||
{wh.active ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
{wh.secretSet && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<Lock className="w-2.5 h-2.5" />已签名
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[12px] font-mono mb-2 truncate" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Webhook className="w-3 h-3 flex-shrink-0" /> {wh.url}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{parseEvents(wh.events).map(evt => {
|
||||
const opt = eventOptions.find(o => o.value === evt);
|
||||
return (
|
||||
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
{opt?.label || evt}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{parseEvents(wh.events).length === 0 && (
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>未订阅任何事件</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => testMutation.mutate(wh.id)}
|
||||
disabled={testMutation.isPending && testMutation.variables === wh.id}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="测试发送">
|
||||
{testMutation.isPending && testMutation.variables === wh.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Send className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button onClick={() => startEdit(wh)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => toggleMutation.mutate(wh)} disabled={toggleMutation.isPending}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
|
||||
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(wh)} disabled={deleteMutation.isPending}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02] disabled:opacity-50" style={{ color: 'var(--text-tertiary)' }} title="删除">
|
||||
{deleteMutation.isPending && deleteMutation.variables === wh.id
|
||||
? <Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
: <Trash2 className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => toast.success('测试发送', { description: `${wh.name} 测试消息已发送` })}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="测试">
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => startEdit(wh)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleToggle(wh.id)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
|
||||
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(wh.id)}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="删除">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
|
||||
<span>最后触发: {formatTime(wh.lastTriggered)}</span>
|
||||
{wh.lastStatus !== 'none' && (
|
||||
<span className="flex items-center gap-1" style={{ color: wh.lastStatus === 'success' ? '#1a7f37' : '#cf222e' }}>
|
||||
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
|
||||
{wh.lastStatus === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
|
||||
<span>最后触发: {wh.lastTriggered}</span>
|
||||
{wh.lastStatus !== 'none' && (
|
||||
<span className="flex items-center gap-1">
|
||||
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
|
||||
{wh.lastStatus === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user