feat(k8s): M2 Pod Metrics 详情页接入 — ListPods 注入 metrics
后端: - 新增 buildPodMetricsMap() 公共辅助函数,聚合多容器 CPU/Memory - ListPods / listPodsWithNS / ListPodsAllNS 三个 handler 全部注入 - PodSummary 增加 cpu_usage_m / mem_usage_mi 字段 前端: - KubernetesWorkloads Pod 表格增加 CPU/内存列 - NamespaceResourcePage Pod 表格增加 CPU/内存列 验证: - 17 个 Pod 全部返回真实 metrics (argocd/demo-jar) - curl /kubernetes-clusters/1/pods → cpu_usage_m='2m' mem_usage_mi='61Mi' 文档: - ROADMAP M1→done, M2→done, 验收标准全部勾选 - TDD_PLAN M2 通过标准补充 - PROJECT_EVALUATION P0 缺失项移除
This commit is contained in:
1 parent
8c272a6fa6
commit
4f1ffd1275
27 files changed
+2617
-352
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,6 +11,7 @@ import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/services/api';
|
||||
import CredentialFormDialog from './CredentialFormDialog';
|
||||
import ToolConfigTab from './ToolConfigTab';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; description: string; url: string;
|
||||
@@ -152,6 +153,7 @@ export default function DeployCredentials() {
|
||||
<TabsTrigger value="repos" className="gap-1"><GitBranch className="w-4 h-4" />Git 仓库 ({repos.length})</TabsTrigger>
|
||||
<TabsTrigger value="registries" className="gap-1"><Container className="w-4 h-4" />镜像仓库 ({registries.length})</TabsTrigger>
|
||||
<TabsTrigger value="environments" className="gap-1"><Globe className="w-4 h-4" />环境管理 ({environments.length})</TabsTrigger>
|
||||
<TabsTrigger value="tools" className="gap-1"><Wrench className="w-4 h-4" />工具配置</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="repos" className="mt-3">
|
||||
@@ -284,6 +286,11 @@ export default function DeployCredentials() {
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tools config tab */}
|
||||
<TabsContent value="tools" className="mt-3">
|
||||
<ToolConfigTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CredentialFormDialog
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Loader2, StopCircle, FileText, Trash2, ChevronDown,
|
||||
ChevronRight, Rocket, Box, Workflow, Code, Container, ShieldCheck, Tag,
|
||||
Globe, ArrowRight, RotateCcw, Layers, History, Server, Network,
|
||||
ChevronRight as ChevronRightSmall, Package,
|
||||
} from 'lucide-react';
|
||||
import { pipelineApi, deployApi } from '@/services/api';
|
||||
import http from '@/services/http';
|
||||
@@ -283,6 +284,35 @@ export function PipelineManager() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ══════════ 全流程可视化横幅 // Logan.Li ══════════ */}
|
||||
<div className="flex items-center gap-1.5 px-4 py-2.5 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<GitBranch className="h-3.5 w-3.5 text-indigo-400" />
|
||||
<span className="text-[11px] font-semibold text-indigo-400">Git 代码</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Box className="h-3.5 w-3.5 text-amber-400" />
|
||||
<span className="text-[11px] font-semibold text-amber-400">构建</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Container className="h-3.5 w-3.5 text-cyan-400" />
|
||||
<span className="text-[11px] font-semibold text-cyan-400">镜像仓库</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Rocket className="h-3.5 w-3.5 text-red-400" />
|
||||
<span className="text-[11px] font-semibold text-red-400">部署</span>
|
||||
</div>
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-30" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Network className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-[11px] font-semibold text-emerald-400">K8s</span>
|
||||
</div>
|
||||
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 tracking-wide">全流程可视化</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
|
||||
@@ -329,15 +359,43 @@ export function PipelineManager() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-[var(--dg-text-primary)]">{pipeline.name}</h3>
|
||||
{pipeline.run_count > 0 && <Badge variant="outline" className="text-[11px] text-[var(--dg-text-secondary)] border-[var(--dg-border)]">#{pipeline.run_count}</Badge>}
|
||||
{getStatusBadge(pipeline.last_status)}
|
||||
<Badge variant="outline" className="text-xs text-[var(--dg-text-secondary)] border-[var(--dg-border)]">
|
||||
{pipeline.run_count > 0 ? `运行 ${pipeline.run_count} 次` : '未执行'}
|
||||
</Badge>
|
||||
{pipeline.job_name && <Badge variant="secondary" className="text-xs font-mono opacity-60">{pipeline.job_name}</Badge>}
|
||||
{pipeline.environment && <Badge style={{backgroundColor: pipeline.environment.color + '20', color: pipeline.environment.color}} className="text-xs">{pipeline.environment.display_name}</Badge>}
|
||||
{pipeline.job_name && <Badge variant="secondary" className="text-xs font-mono opacity-60">{pipeline.job_name}</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1 line-clamp-1">{pipeline.description || '暂无描述'}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-[var(--dg-text-secondary)] opacity-60">
|
||||
{/* 最近运行摘要行:branch + commit → image:tag // Logan.Li */}
|
||||
{(pipeline.last_run_branch || pipeline.last_commit_id || pipeline.last_image_tag) && (
|
||||
<div className="flex items-center gap-1 mt-1.5 text-xs font-mono">
|
||||
{pipeline.last_run_branch && (
|
||||
<span className="flex items-center gap-0.5 text-indigo-400">
|
||||
<GitBranch className="h-3 w-3" />{pipeline.last_run_branch}
|
||||
</span>
|
||||
)}
|
||||
{pipeline.last_run_branch && pipeline.last_commit_id && (
|
||||
<span className="text-[var(--dg-text-secondary)] opacity-50 text-[10px] mx-0.5">+</span>
|
||||
)}
|
||||
{pipeline.last_commit_id && (
|
||||
<code className="text-amber-400 bg-amber-400/10 px-1 py-0.5 rounded text-[11px]">{pipeline.last_commit_id.slice(0, 8)}</code>
|
||||
)}
|
||||
{(pipeline.last_run_branch || pipeline.last_commit_id) && pipeline.last_image_tag && (
|
||||
<ArrowRight className="h-3 w-3 text-[var(--dg-text-secondary)] opacity-40 mx-0.5" />
|
||||
)}
|
||||
{pipeline.last_image_tag && (
|
||||
<span className="flex items-center gap-0.5 text-cyan-400">
|
||||
<Package className="h-3 w-3" />
|
||||
<code className="bg-cyan-400/10 px-1 py-0.5 rounded text-[11px]">{pipeline.last_image_tag}</code>
|
||||
</span>
|
||||
)}
|
||||
{pipeline.last_duration > 0 && (
|
||||
<span className="flex items-center gap-0.5 ml-auto text-[var(--dg-text-secondary)] opacity-50">
|
||||
<Clock className="h-3 w-3" />{fmtDuration(pipeline.last_duration)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-[var(--dg-text-secondary)] opacity-60">
|
||||
<span className="flex items-center gap-1"><GitBranch className="h-3 w-3" />{pipeline.deploy_mode === 'tag' ? 'Tag发版' : pipeline.branch || 'main'}</span>
|
||||
<span className="flex items-center gap-1">{(pipeline.deploy_target || 'ssh') === 'k8s' ? <><Network className="h-3 w-3" />K8s</> : <><Server className="h-3 w-3" />SSH</>}</span>
|
||||
{repo && <span className="flex items-center gap-1"><Globe className="h-3 w-3" />{repo.name}</span>}
|
||||
@@ -365,18 +423,36 @@ export function PipelineManager() {
|
||||
</div>
|
||||
</div>
|
||||
{stages.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
||||
<div className="flex items-center mt-2.5">
|
||||
{stages.map((s: any, i: number) => {
|
||||
const def = STAGE_DEFS.find(d => d.label === s.name);
|
||||
const color = def?.color || '#6B7280';
|
||||
const Icon = def?.icon || Tag;
|
||||
// 从 last_run_stages 获取最新运行状态 // Logan.Li
|
||||
let runStatus: string = '';
|
||||
if (pipeline.last_run_stages) {
|
||||
try {
|
||||
const rs = typeof pipeline.last_run_stages === 'string' ? JSON.parse(pipeline.last_run_stages) : pipeline.last_run_stages;
|
||||
const match = Array.isArray(rs) ? rs.find((r: any) => r.name === s.name || r.type === def?.key) : null;
|
||||
runStatus = match?.status || '';
|
||||
} catch {}
|
||||
}
|
||||
const isSuccess = runStatus === 'success' || runStatus === 'completed';
|
||||
const isRunning = runStatus === 'running' || runStatus === 'in_progress';
|
||||
const isFailed = runStatus === 'failed' || runStatus === 'error';
|
||||
const color = isFailed ? '#EF4444' : isRunning ? '#3B82F6' : isSuccess ? '#10B981' : '#6B7280';
|
||||
const bgColor = isFailed ? '#EF444528' : isRunning ? '#3B82F628' : isSuccess ? '#10B98128' : '#6B728018';
|
||||
const icon = isFailed ? <XCircle className="h-3 w-3" /> : isRunning ? <Loader2 className="h-3 w-3 animate-spin" /> : isSuccess ? <CheckCircle className="h-3 w-3" /> : <span className="text-[10px] opacity-40">○</span>;
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-xs"
|
||||
style={{ borderColor: color + '40', backgroundColor: color + '15', color: color }}>
|
||||
<Icon className="h-3 w-3" />{s.name}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-[11px] font-semibold border shadow-sm"
|
||||
style={{ backgroundColor: bgColor, borderColor: color + '70', color, borderRadius: i === 0 ? '8px 0 0 8px' : i === stages.length - 1 ? '0 8px 8px 0' : '0' }}>
|
||||
{icon}{s.name}
|
||||
</div>
|
||||
{i < stages.length - 1 && <ArrowRight className="h-3 w-3 text-[var(--dg-border)] mx-0.5" />}
|
||||
{i < stages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + 'CC' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -583,15 +659,20 @@ export function PipelineManager() {
|
||||
})}
|
||||
</div>
|
||||
{selectedStages.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
||||
<div className="flex items-center mt-2">
|
||||
{selectedStages.map((key, i) => {
|
||||
const def = STAGE_DEFS.find(t => t.key === key)!;
|
||||
return (
|
||||
<div key={i} className="flex items-center">
|
||||
<Badge className="text-[10px] px-2 py-0.5" style={{ backgroundColor: def.color + '20', color: def.color, borderColor: def.color + '40' }}>
|
||||
<def.icon className="h-3 w-3 mr-0.5" />{def.label}
|
||||
</Badge>
|
||||
{i < selectedStages.length - 1 && <ArrowRight className="h-3 w-3 text-[var(--dg-border)] mx-0.5" />}
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-[11px] font-medium border"
|
||||
style={{ backgroundColor: def.color + '20', borderColor: def.color + '40', color: def.color, borderRadius: i === 0 ? '6px 0 0 6px' : i === selectedStages.length - 1 ? '0 6px 6px 0' : '0' }}>
|
||||
<def.icon className="h-3 w-3" />{def.label}
|
||||
</div>
|
||||
{i < selectedStages.length - 1 && (
|
||||
<div className="flex items-center justify-center w-4 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3 w-3" style={{ color: def.color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2, Loader2, XCircle, History, FolderOpen, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2,
|
||||
Loader2, XCircle, History, FolderOpen, Trash2, ChevronRight as ChevronRightSmall,
|
||||
GitBranch, Box, Container, Rocket, ShieldCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import http from '@/services/http';
|
||||
import { FileBrowserDialog } from './FileBrowserDialog';
|
||||
|
||||
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
|
||||
const STAGE_DEFS = [
|
||||
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1 },
|
||||
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2 },
|
||||
{ key: 'test', label: '测试', icon: CheckCircle2,color: '#10B981', order: 3 },
|
||||
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4 },
|
||||
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5 },
|
||||
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6 },
|
||||
].sort((a, b) => a.order - b.order);
|
||||
|
||||
interface PipelineRun {
|
||||
id: number;
|
||||
pipeline_id: number;
|
||||
@@ -22,6 +35,7 @@ interface PipelineRun {
|
||||
triggered_by: string;
|
||||
image_tag: string;
|
||||
duration: number;
|
||||
stages: string;
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
created_at: string;
|
||||
@@ -34,6 +48,11 @@ interface LogEntry {
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface StageStatus {
|
||||
name: string;
|
||||
status: string; // success, failed, running, pending
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, string> = {
|
||||
success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
failed: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
@@ -50,13 +69,6 @@ const STATUS_ICONS: Record<string, any> = {
|
||||
cancelled: AlertCircle,
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
info: 'text-[var(--dg-text-secondary)]',
|
||||
success: 'text-emerald-400',
|
||||
error: 'text-red-400',
|
||||
warn: 'text-amber-400',
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '-';
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
@@ -68,16 +80,60 @@ function formatDuration(seconds: number): string {
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return '-';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
return d.toLocaleString('zh-CN', {
|
||||
month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function parseStageStatuses(raw: string): StageStatus[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const s = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||||
return Array.isArray(s) ? s : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** 从 run.stages (JSON) 推导每个阶段的运行状态 */
|
||||
function getRunStageStats(run: PipelineRun | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!run) return map;
|
||||
// 如果 run.stages 有数据,直接用
|
||||
const statuses = parseStageStatuses(run.stages);
|
||||
statuses.forEach(s => {
|
||||
if (s.name || (s as any).type) {
|
||||
const key = (s as any).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
|
||||
if (key) map.set(key, s.status);
|
||||
}
|
||||
});
|
||||
// 如果 stages 为空但 run 已结束,从整体状态推导
|
||||
if (map.size === 0 && run.status === 'success') {
|
||||
STAGE_DEFS.forEach(d => map.set(d.key, 'success'));
|
||||
} else if (map.size === 0 && run.status === 'failed') {
|
||||
STAGE_DEFS.forEach((d, i) => map.set(d.key, i === 0 ? 'success' : 'pending'));
|
||||
} else if (map.size === 0 && run.status === 'running') {
|
||||
map.set('checkout', 'running');
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 从日志中提取每个阶段的首条日志内容(阶段摘要) */
|
||||
function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
|
||||
const first = logs.find(l => l.stage === stageKey);
|
||||
if (first) return first.message;
|
||||
const def = STAGE_DEFS.find(d => d.key === stageKey);
|
||||
if (def) return `${def.label}...`;
|
||||
return '';
|
||||
}
|
||||
|
||||
export default function PipelineRunDetail() {
|
||||
const { code: pipelineCode } = useParams<{ code: string }>();
|
||||
const [pipeline, setPipeline] = useState<any>(null);
|
||||
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
||||
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('records');
|
||||
const [selectedStage, setSelectedStage] = useState<string | null>(null); // null = 全部
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -88,14 +144,21 @@ export default function PipelineRunDetail() {
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
const [browserRun, setBrowserRun] = useState<{ id: number; buildNumber: number } | null>(null);
|
||||
|
||||
// 获取执行记录 (http.get 已自动解包 json.data → {list, total})
|
||||
// 加载流水线详情
|
||||
const fetchPipeline = useCallback(async () => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}`);
|
||||
setPipeline(data?.data || data);
|
||||
} catch { /* silent, pipeline info optional */ }
|
||||
}, [pipelineCode]);
|
||||
|
||||
// 获取执行记录
|
||||
const fetchRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
|
||||
const list = data?.list || [];
|
||||
setRuns(list);
|
||||
// 自动选中最新运行
|
||||
if (!selectedRun && list.length > 0) {
|
||||
setSelectedRun(list[0]);
|
||||
}
|
||||
@@ -103,10 +166,10 @@ export default function PipelineRunDetail() {
|
||||
finally { setLoading(false); }
|
||||
}, [pipelineCode, pageSize]);
|
||||
|
||||
useEffect(() => { fetchRuns(); }, []);
|
||||
useEffect(() => { fetchPipeline(); fetchRuns(); }, []);
|
||||
useEffect(() => { fetchRuns(); }, [pageSize]);
|
||||
|
||||
// 自动刷新(有运行中的记录时每5秒刷新)
|
||||
// 自动刷新
|
||||
useEffect(() => {
|
||||
const hasRunning = runs.some(r => r.status === 'running');
|
||||
if (!hasRunning) return;
|
||||
@@ -114,7 +177,7 @@ export default function PipelineRunDetail() {
|
||||
return () => clearInterval(t);
|
||||
}, [runs, fetchRuns]);
|
||||
|
||||
// 加载日志 (http.get 已自动解包 json.data)
|
||||
// 加载日志
|
||||
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/runs/${run.id}/log-stream`);
|
||||
@@ -124,24 +187,16 @@ export default function PipelineRunDetail() {
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
// 如果运行中,连接 WebSocket
|
||||
if (run.status === 'running') {
|
||||
connectWS(run.id);
|
||||
}
|
||||
} catch {
|
||||
setLogs([]);
|
||||
}
|
||||
if (run.status === 'running') connectWS(run.id);
|
||||
} catch { setLogs([]); }
|
||||
}, []);
|
||||
|
||||
// WebSocket 连接
|
||||
// WebSocket
|
||||
const connectWS = (runId: number) => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('token');
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pipeline/${runId}/log?token=${token}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
@@ -154,9 +209,8 @@ export default function PipelineRunDetail() {
|
||||
} else if (data.time && data.level) {
|
||||
setLogs(prev => [...prev, data]);
|
||||
}
|
||||
} catch { /* binary frame, ignore */ }
|
||||
} catch { /* binary frame */ }
|
||||
};
|
||||
|
||||
ws.onclose = () => { wsRef.current = null; };
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
wsRef.current = ws;
|
||||
@@ -165,18 +219,17 @@ export default function PipelineRunDetail() {
|
||||
useEffect(() => {
|
||||
if (selectedRun) {
|
||||
fetchLogs(selectedRun);
|
||||
setSelectedStage(null); // reset stage filter on run change
|
||||
}
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
return () => { if (wsRef.current) wsRef.current.close(); };
|
||||
}, [selectedRun, fetchLogs]);
|
||||
|
||||
// 自动滚动到底部
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
// 触发流水线
|
||||
// 触发
|
||||
const triggerRun = async () => {
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/trigger`, {});
|
||||
@@ -185,7 +238,7 @@ export default function PipelineRunDetail() {
|
||||
} catch { toast.error('触发失败'); }
|
||||
};
|
||||
|
||||
// 停止流水线
|
||||
// 停止
|
||||
const stopRun = async (runId: number) => {
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/runs/${runId}/stop`, {});
|
||||
@@ -214,13 +267,9 @@ export default function PipelineRunDetail() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === runs.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(runs.map(r => r.id)));
|
||||
}
|
||||
if (selectedIds.size === runs.length) setSelectedIds(new Set());
|
||||
else setSelectedIds(new Set(runs.map(r => r.id)));
|
||||
};
|
||||
|
||||
const triggerLabel = (run: PipelineRun) => {
|
||||
@@ -229,16 +278,46 @@ export default function PipelineRunDetail() {
|
||||
return run.triggered_by || 'Manual';
|
||||
};
|
||||
|
||||
// ═══════════ 阶段状态映射 ═══════════
|
||||
const stageStats = getRunStageStats(selectedRun);
|
||||
|
||||
// ═══════════ 过滤日志(按阶段) ═══════════
|
||||
const filteredLogs = selectedStage
|
||||
? logs.filter(l => l.stage === selectedStage)
|
||||
: logs;
|
||||
|
||||
// 获取当前选中阶段的摘要日志
|
||||
const stageSummary = selectedStage ? getStageLogSummary(logs, selectedStage) : '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 h-full flex flex-col">
|
||||
{/* 头部 */}
|
||||
{/* ═══════════ 页面头部 ═══════════ */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">
|
||||
<History className="w-5 h-5 inline mr-2" />
|
||||
执行记录
|
||||
流水线详情
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">流水线 #{pipelineCode}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{pipeline?.name && (
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">
|
||||
{pipeline.name}
|
||||
</span>
|
||||
)}
|
||||
{pipeline?.environment && (
|
||||
<Badge className="text-xs" style={{
|
||||
backgroundColor: pipeline.environment.color + '20',
|
||||
color: pipeline.environment.color,
|
||||
}}>
|
||||
{pipeline.environment.display_name}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedRun && (
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">
|
||||
发布 #{selectedRun.build_number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{selectedIds.size > 0 && (
|
||||
@@ -248,7 +327,7 @@ export default function PipelineRunDetail() {
|
||||
)}
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); }}
|
||||
onChange={e => setPageSize(Number(e.target.value))}
|
||||
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<option value={10}>10条/页</option>
|
||||
@@ -264,204 +343,310 @@ export default function PipelineRunDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
|
||||
<TabsList>
|
||||
<TabsTrigger value="records">执行记录 ({runs.length})</TabsTrigger>
|
||||
<TabsTrigger value="log" disabled={!selectedRun}>执行日志</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* ═══════════ 阶段进度条 (交互式) ═══════════ */}
|
||||
<div className="flex items-center gap-0 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] overflow-x-auto">
|
||||
{STAGE_DEFS.map((def, i) => {
|
||||
const status = stageStats.get(def.key) || '';
|
||||
const isSuccess = status === 'success' || status === 'completed';
|
||||
const isRunning = status === 'running' || status === 'in_progress';
|
||||
const isFailed = status === 'failed' || status === 'error';
|
||||
const isActive = selectedStage === def.key;
|
||||
const isSelected = selectedStage === def.key;
|
||||
const hasData = stageStats.size > 0;
|
||||
|
||||
{/* 执行记录列表 */}
|
||||
<TabsContent value="records" className="flex-1 mt-3">
|
||||
// 颜色: 失败红 / 运行中蓝 / 成功绿 / 未运行灰
|
||||
const color = isFailed ? '#EF4444'
|
||||
: isRunning ? '#3B82F6'
|
||||
: isSuccess ? '#207060' // teal 绿 (OCR 设计稿色)
|
||||
: '#6B7280';
|
||||
const bgColor = isFailed ? '#EF444420'
|
||||
: isRunning ? '#3B82F620'
|
||||
: isSuccess ? '#20706020'
|
||||
: '#6B728010';
|
||||
const borderColor = isActive ? color : (color + '50');
|
||||
|
||||
const Icon = def.icon;
|
||||
const icon = isFailed ? <XCircle className="h-3.5 w-3.5" />
|
||||
: isRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
: isSuccess ? <CheckCircle2 className="h-3.5 w-3.5" />
|
||||
: hasData ? <span className="text-[10px] opacity-40">○</span>
|
||||
: <Icon className="h-3.5 w-3.5 opacity-40" />;
|
||||
|
||||
const isLast = i === STAGE_DEFS.length - 1;
|
||||
const borderRadius = i === 0 ? '8px 0 0 8px' : isLast ? '0 8px 8px 0' : '0';
|
||||
|
||||
return (
|
||||
<div key={def.key} className="flex items-center">
|
||||
<button
|
||||
onClick={() => setSelectedStage(selectedStage === def.key ? null : def.key)}
|
||||
title={`${def.label}${status ? ` (${status})` : ''} — 点击查看日志`}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-semibold border shadow-sm transition-all cursor-pointer
|
||||
${isActive ? 'ring-1 ring-offset-0 scale-[1.02]' : 'hover:brightness-110'}`}
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
borderColor,
|
||||
color,
|
||||
borderRadius,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span>{def.label}</span>
|
||||
</button>
|
||||
{!isLast && (
|
||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||
<ChevronRightSmall className="h-3.5 w-3.5" style={{ color: color + '99' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedRun && (
|
||||
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 shrink-0 pl-3 whitespace-nowrap">
|
||||
{selectedStage ? STAGE_DEFS.find(d => d.key === selectedStage)?.label : '全阶段'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══════════ 主内容区: 执行记录表 + 日志 ═══════════ */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* ─── 执行记录表 ─── */}
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden mb-4">
|
||||
{loading && runs.length === 0 ? (
|
||||
<div className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />加载中...</div>
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />
|
||||
加载中...
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
|
||||
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-[var(--dg-text-secondary)]">暂无执行记录,点击"立即执行"触发构建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-2 py-2.5 w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0} onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => {
|
||||
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
||||
return (
|
||||
<tr key={run.id}
|
||||
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
||||
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
||||
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
|
||||
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(run.id)} onChange={() => toggleSelect(run.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
||||
#{run.build_number}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{run.status === 'success' ? '成功' : run.status === 'failed' ? '失败' : run.status === 'running' ? '执行中' : run.status === 'cancelled' ? '已取消' : '待执行'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{run.triggered_by || 'admin'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{run.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-blue-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
执行中...
|
||||
</span>
|
||||
) : formatDuration(run.duration)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
||||
{formatTime(run.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
|
||||
执行日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />
|
||||
浏览
|
||||
</Button>
|
||||
{run.status === 'running' && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||
onClick={() => stopRun(run.id)}>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 执行日志 - Console 输出 */}
|
||||
<TabsContent value="log" className="flex-1 mt-3">
|
||||
{!selectedRun ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">请先选择一条执行记录</div>
|
||||
) : (
|
||||
<Card className="flex-1 flex flex-col border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-sm font-mono">
|
||||
aiops-backend #{selectedRun.build_number}
|
||||
</CardTitle>
|
||||
{selectedRun.status === 'running' && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>触发方式: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 400 }}>
|
||||
{logs.length === 0 && selectedRun.status !== 'running' ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无日志输出</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
let lastStage = '';
|
||||
const stageStyles: Record<string, string> = {
|
||||
checkout: 'border-indigo-500 bg-indigo-500/10',
|
||||
build: 'border-amber-500 bg-amber-500/10',
|
||||
test: 'border-emerald-500 bg-emerald-500/10',
|
||||
image: 'border-cyan-500 bg-cyan-500/10',
|
||||
deploy: 'border-red-500 bg-red-500/10',
|
||||
verify: 'border-blue-500 bg-blue-500/10',
|
||||
};
|
||||
const stageIcons: Record<string, string> = {
|
||||
checkout: '⬇', build: '⚙', test: '✓',
|
||||
image: '📦', deploy: '🚀', verify: '🛡',
|
||||
};
|
||||
return logs.map((entry, i) => {
|
||||
const showStage = entry.stage && entry.stage !== lastStage;
|
||||
if (showStage) lastStage = entry.stage || '';
|
||||
const isError = entry.level === 'error';
|
||||
const isWarn = entry.level === 'warn';
|
||||
const isSuccess = entry.level === 'success';
|
||||
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10' :
|
||||
isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10' :
|
||||
isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
||||
const msgColor = isError ? 'text-red-200' :
|
||||
isWarn ? 'text-amber-200' :
|
||||
isSuccess ? 'text-emerald-200' : 'text-slate-300';
|
||||
const levelBadge = isError
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
||||
: isWarn
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
||||
: isSuccess
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
||||
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{showStage && (
|
||||
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
|
||||
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
||||
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
|
||||
<span className="flex-1 border-t border-slate-700/50"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
||||
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
||||
{levelBadge}
|
||||
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{selectedRun.status === 'running' && (
|
||||
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
|
||||
<span className="flex gap-1 ml-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'0ms'}}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'150ms'}}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'300ms'}}></span>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-2 py-2.5 w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0}
|
||||
onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => {
|
||||
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
||||
return (
|
||||
<tr key={run.id}
|
||||
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
||||
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
||||
onClick={() => { setSelectedRun(run); }}>
|
||||
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(run.id)}
|
||||
onChange={() => toggleSelect(run.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
||||
#{run.build_number}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{run.status === 'success' ? '成功'
|
||||
: run.status === 'failed' ? '失败'
|
||||
: run.status === 'running' ? '执行中'
|
||||
: run.status === 'cancelled' ? '已取消'
|
||||
: '待执行'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{run.triggered_by || 'admin'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{run.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-blue-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />执行中...
|
||||
</span>
|
||||
) : formatDuration(run.duration)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
||||
{formatTime(run.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => setSelectedRun(run)}>
|
||||
日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />浏览
|
||||
</Button>
|
||||
{run.status === 'running' && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||
onClick={() => stopRun(run.id)}>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* 工作区文件浏览器 */}
|
||||
{/* ─── 阶段日志 Console ─── */}
|
||||
{selectedRun && (
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-sm font-mono">
|
||||
{pipeline?.name || `#${pipelineCode}`} #{selectedRun.build_number}
|
||||
</CardTitle>
|
||||
{selectedRun.status === 'running' && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStage && (
|
||||
<Badge className="text-xs border-[var(--dg-border)]" variant="outline">
|
||||
{STAGE_DEFS.find(d => d.key === selectedStage)?.label || selectedStage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>触发: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
<span className="opacity-40">|</span>
|
||||
<span>分支: {selectedRun.branch || '-'}</span>
|
||||
{selectedRun.commit_id && (
|
||||
<>
|
||||
<span className="opacity-40">|</span>
|
||||
<code className="bg-amber-400/10 text-amber-400 px-1 py-0.5 rounded text-[10px]">
|
||||
{selectedRun.commit_id.slice(0, 8)}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
{selectedRun.image_tag && (
|
||||
<>
|
||||
<span className="opacity-40">|</span>
|
||||
<code className="bg-cyan-400/10 text-cyan-400 px-1 py-0.5 rounded text-[10px]">
|
||||
📦 {selectedRun.image_tag}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden min-h-0">
|
||||
{/* ─── 阶段摘要 (OCR 设计稿特性: [代码检出] 命令 + 结果) ─── */}
|
||||
{selectedStage && stageSummary && (
|
||||
<div className="px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[var(--dg-text-secondary)]">
|
||||
[{STAGE_DEFS.find(d => d.key === selectedStage)?.label || selectedStage}]
|
||||
</span>
|
||||
{(() => {
|
||||
const st = stageStats.get(selectedStage);
|
||||
if (st === 'success' || st === 'completed') return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />;
|
||||
if (st === 'failed' || st === 'error') return <XCircle className="h-3.5 w-3.5 text-red-400" />;
|
||||
if (st === 'running' || st === 'in_progress') return <Loader2 className="h-3.5 w-3.5 text-blue-400 animate-spin" />;
|
||||
return null;
|
||||
})()}
|
||||
<span className="text-xs text-[var(--dg-text-muted)] truncate font-mono">
|
||||
{stageSummary}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
|
||||
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
{selectedStage ? '该阶段暂无日志输出' : '暂无日志输出'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
let lastStage = '';
|
||||
const stageStyles: Record<string, string> = {
|
||||
checkout: 'border-indigo-500 bg-indigo-500/10',
|
||||
build: 'border-amber-500 bg-amber-500/10',
|
||||
test: 'border-emerald-500 bg-emerald-500/10',
|
||||
image: 'border-cyan-500 bg-cyan-500/10',
|
||||
deploy: 'border-red-500 bg-red-500/10',
|
||||
verify: 'border-blue-500 bg-blue-500/10',
|
||||
};
|
||||
const stageIcons: Record<string, string> = {
|
||||
checkout: '⬇', build: '⚙', test: '✓',
|
||||
image: '📦', deploy: '🚀', verify: '🛡',
|
||||
};
|
||||
return filteredLogs.map((entry, i) => {
|
||||
const showStage = !selectedStage && entry.stage && entry.stage !== lastStage;
|
||||
if (showStage) lastStage = entry.stage || '';
|
||||
const isError = entry.level === 'error';
|
||||
const isWarn = entry.level === 'warn';
|
||||
const isSuccess = entry.level === 'success';
|
||||
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10'
|
||||
: isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10'
|
||||
: isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
||||
const msgColor = isError ? 'text-red-200'
|
||||
: isWarn ? 'text-amber-200'
|
||||
: isSuccess ? 'text-emerald-200' : 'text-slate-300';
|
||||
const levelBadge = isError
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
||||
: isWarn
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
||||
: isSuccess
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
||||
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{showStage && (
|
||||
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
|
||||
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
||||
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
|
||||
<span className="flex-1 border-t border-slate-700/50"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
||||
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
||||
{levelBadge}
|
||||
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{selectedRun.status === 'running' && (
|
||||
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
|
||||
<span className="flex gap-1 ml-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '300ms' }}></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══════════ 工作区文件浏览器 ═══════════ */}
|
||||
<FileBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Wrench, RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { deployApi } from '@/services/api';
|
||||
|
||||
interface ToolConfig {
|
||||
id: number; name: string; display_name: string; description: string;
|
||||
category: string; install_method: 'preinstall' | 'lazy' | 'disabled';
|
||||
binary_name: string; install_path: string; status: string;
|
||||
installed_version: string; error_msg: string; enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const categoryLabel: Record<string, string> = {
|
||||
k8s: 'K8s', build: '构建', deploy: '部署', automation: '自动化', other: '其他',
|
||||
};
|
||||
const categoryColor: Record<string, string> = {
|
||||
k8s: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
build: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
deploy: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
|
||||
automation: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
|
||||
other: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
const methodLabel: Record<string, string> = {
|
||||
preinstall: '预装', lazy: '懒加载', disabled: '禁用',
|
||||
};
|
||||
const methodColor: Record<string, string> = {
|
||||
preinstall: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
lazy: 'bg-violet-500/20 text-violet-400 border-violet-500/30',
|
||||
disabled: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
};
|
||||
|
||||
export default function ToolConfigTab() {
|
||||
const [tools, setTools] = useState<ToolConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [installing, setInstalling] = useState<number | null>(null);
|
||||
|
||||
const fetchTools = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res: any = await deployApi.getTools();
|
||||
setTools((res as any)?.list || []);
|
||||
} catch { toast.error('获取工具配置失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchTools(); }, []);
|
||||
|
||||
const handleInstall = async (tool: ToolConfig) => {
|
||||
setInstalling(tool.id);
|
||||
try {
|
||||
await deployApi.installTool(tool.id);
|
||||
toast.success(`${tool.display_name} 安装已触发`);
|
||||
// Poll for status
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await deployApi.checkTool(tool.id);
|
||||
fetchTools();
|
||||
} catch { /* ignore */ }
|
||||
setInstalling(null);
|
||||
}, 3000);
|
||||
} catch {
|
||||
toast.error('触发安装失败');
|
||||
setInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckStatus = async (tool: ToolConfig) => {
|
||||
try {
|
||||
const res: any = await deployApi.checkTool(tool.id);
|
||||
toast.success(res?.available ? `${tool.display_name} 可用` : `${tool.display_name} 不可用`);
|
||||
fetchTools();
|
||||
} catch { toast.error('检测失败'); }
|
||||
};
|
||||
|
||||
const handleMethodChange = async (tool: ToolConfig, method: 'preinstall' | 'lazy' | 'disabled') => {
|
||||
try {
|
||||
await deployApi.updateTool(tool.id, { install_method: method });
|
||||
toast.success('已更新');
|
||||
fetchTools();
|
||||
} catch { toast.error('更新失败'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">
|
||||
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchTools}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
暂无工具配置,请检查后端种子数据
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">工具</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">分类</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">安装方式</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tools.map(tool => (
|
||||
<tr key={tool.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-[var(--dg-text-primary)]">{tool.display_name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] font-mono">{tool.name}</div>
|
||||
{tool.installed_version && tool.status === 'installed' && (
|
||||
<div className="text-xs text-emerald-400 font-mono mt-0.5">v{tool.installed_version}</div>
|
||||
)}
|
||||
{tool.error_msg && (
|
||||
<div className="text-xs text-red-400 mt-0.5 max-w-[200px] truncate" title={tool.error_msg}>{tool.error_msg}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<Badge className={`text-xs ${categoryColor[tool.category] || categoryColor.other}`}>
|
||||
{categoryLabel[tool.category] || tool.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Select
|
||||
value={tool.install_method}
|
||||
onValueChange={(v) => handleMethodChange(tool, v as 'preinstall' | 'lazy' | 'disabled')}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[90px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="preinstall">预装</SelectItem>
|
||||
<SelectItem value="lazy">懒加载</SelectItem>
|
||||
<SelectItem value="disabled">禁用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{tool.status === 'installed' ? (
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs gap-1">
|
||||
<CheckCircle className="w-3 h-3" />已安装
|
||||
</Badge>
|
||||
) : tool.status === 'installing' ? (
|
||||
<Badge className="bg-amber-500/20 text-amber-400 border-amber-500/30 text-xs gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />安装中
|
||||
</Badge>
|
||||
) : tool.status === 'failed' ? (
|
||||
<Badge className="bg-red-500/20 text-red-400 border-red-500/30 text-xs gap-1">
|
||||
<XCircle className="w-3 h-3" />失败
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-xs gap-1">
|
||||
<Clock className="w-3 h-3" />未安装
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{tool.install_method === 'lazy' && tool.status !== 'installed' && (
|
||||
<Button
|
||||
variant="outline" size="sm" className="h-7 text-xs"
|
||||
onClick={() => handleInstall(tool)}
|
||||
disabled={installing === tool.id}
|
||||
>
|
||||
{installing === tool.id ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
安装
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title="检测">
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -275,15 +275,17 @@ export function KubernetesWorkloads() {
|
||||
<TableHead className="text-slate-400">命名空间</TableHead>
|
||||
<TableHead className="text-slate-400">状态</TableHead>
|
||||
<TableHead className="text-slate-400">节点</TableHead>
|
||||
<TableHead className="text-slate-400">CPU</TableHead>
|
||||
<TableHead className="text-slate-400">内存</TableHead>
|
||||
<TableHead className="text-slate-400">重启</TableHead>
|
||||
<TableHead className="text-slate-400">镜像</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />加载中...</TableCell></TableRow>
|
||||
) : filteredPods.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Pod</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Container className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 Pod</TableCell></TableRow>
|
||||
) : (
|
||||
filteredPods.map((p) => (
|
||||
<TableRow key={p.id} className="border-slate-800 hover:bg-slate-800/30">
|
||||
@@ -291,6 +293,8 @@ export function KubernetesWorkloads() {
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell>{getPodStatusBadge(p.status)}</TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
|
||||
<TableCell className={p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500'}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -64,7 +64,7 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
},
|
||||
pods: {
|
||||
title: 'Pods', subtitle: 'Pod 实例运行状态', icon: Container,
|
||||
columns: ['名称', '命名空间', '状态', '节点', '重启', '镜像', '操作'],
|
||||
columns: ['名称', '命名空间', '状态', '节点', 'CPU', '内存', '重启', '镜像', '操作'],
|
||||
renderRow: (p, opts) => {
|
||||
const statusMap: Record<string, string> = { Running: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', Pending: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', Failed: 'bg-red-500/20 text-red-400 border-red-500/30' };
|
||||
return (
|
||||
@@ -73,6 +73,8 @@ const RESOURCE_CONFIG: Record<ResourceType, {
|
||||
<TableCell><Badge variant="outline" className="border-slate-700 text-slate-400">{p.namespace}</Badge></TableCell>
|
||||
<TableCell><Badge className={statusMap[p.status] || 'bg-slate-500/20 text-slate-400'}>{p.status || 'Unknown'}</Badge></TableCell>
|
||||
<TableCell className="text-sm text-slate-400">{p.node_name || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-cyan-400">{p.cpu_usage_m || '-'}</TableCell>
|
||||
<TableCell className="text-sm text-amber-400">{p.mem_usage_mi || '-'}</TableCell>
|
||||
<TableCell className={(p.restart_count > 0 ? 'text-yellow-400' : 'text-slate-500')}>{p.restart_count || 0}</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 max-w-[200px] truncate">{p.image || '-'}</TableCell>
|
||||
<TableCell><Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400" title="查看YAML" onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5" /></Button></TableCell>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
|
||||
Terminal, Cpu, Database, FileCode, Shield, Users,
|
||||
Menu, Network, CloudCog, Globe, MapPin, Key,
|
||||
Brain, FileTerminal, Activity, BarChart3, GitBranch,
|
||||
Brain, Activity, BarChart3, GitBranch,
|
||||
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -93,7 +93,7 @@ const menuItems: MenuItem[] = [
|
||||
children: [
|
||||
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
|
||||
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
|
||||
{ id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' },
|
||||
{ id: 'ansible', name: '远程执行', icon: Terminal, path: '/remote-exec' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -904,6 +904,12 @@ export const deployApi = {
|
||||
getGroupMembers: (id: number | string) => http.get(`/deploy/groups/${id}/members`),
|
||||
addGroupMember: (id: number | string, data: { username: string }) => http.post(`/deploy/groups/${id}/members`, data),
|
||||
removeGroupMember: (id: number | string, memberId: number | string) => http.delete(`/deploy/groups/${id}/members/${memberId}`),
|
||||
// 工具配置 // Logan.Li
|
||||
getTools: () => http.get<ListResponse>('/deploy/tools'),
|
||||
getTool: (id: number | string) => http.get(`/deploy/tools/${id}`),
|
||||
updateTool: (id: number | string, data: any) => http.put(`/deploy/tools/${id}`, data),
|
||||
installTool: (id: number | string) => http.post(`/deploy/tools/${id}/install`),
|
||||
checkTool: (id: number | string) => http.post(`/deploy/tools/${id}/check`),
|
||||
};
|
||||
|
||||
// ==================== Ansible 自动化 API ====================
|
||||
|
||||
Reference in new issue
Block a user