增加团队管理页面

This commit is contained in:
dengqichen 2025-10-29 20:57:32 +08:00
parent a265275b97
commit e5f0f3bba4
3 changed files with 588 additions and 515 deletions

View File

@ -1,28 +1,12 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { PageContainer } from '@/components/ui/page-container';
import {
RefreshCw,
Layers,
Box,
Activity,
Search,
CheckCircle,
XCircle,
AlertTriangle,
Loader2,
ExternalLink,
GitBranch,
Calendar
} from 'lucide-react';
import { import {
Card, Card,
CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { import {
Select, Select,
SelectContent, SelectContent,
@ -30,12 +14,45 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { ScrollArea } from '@/components/ui/scroll-area';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import {
import type { JenkinsInstance, JenkinsInstanceDTO, JenkinsViewDTO, JenkinsJobDTO } from './types'; Tooltip,
import { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service'; TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Layers,
RefreshCw,
Search,
Loader2,
ExternalLink,
Box,
GitBranch,
CheckCircle,
XCircle,
Clock,
AlertTriangle,
Calendar,
Activity,
} from 'lucide-react';
import type {
JenkinsInstance,
JenkinsViewDTO,
JenkinsJobDTO,
JenkinsBuildDTO,
} from './types';
import {
getJenkinsInstances,
getJenkinsViews,
getJenkinsJobs,
getJenkinsBuilds,
syncJenkinsViews,
syncJenkinsJobs,
syncJenkinsBuilds,
} from './service';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';
@ -43,14 +60,35 @@ import 'dayjs/locale/zh-cn';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.locale('zh-cn'); dayjs.locale('zh-cn');
const JenkinsManagerList: React.FC = () => { // 构建状态配置
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]); const BUILD_STATUS_CONFIG: Record<string, {
const [selectedInstanceId, setSelectedInstanceId] = useState<number>(); label: string;
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>(); variant: 'default' | 'secondary' | 'destructive' | 'outline';
icon: React.ElementType;
color: string;
}> = {
SUCCESS: { label: '成功', variant: 'default', icon: CheckCircle, color: 'text-green-600' },
FAILURE: { label: '失败', variant: 'destructive', icon: XCircle, color: 'text-red-600' },
UNSTABLE: { label: '不稳定', variant: 'secondary', icon: AlertTriangle, color: 'text-yellow-600' },
ABORTED: { label: '已中止', variant: 'outline', icon: Clock, color: 'text-gray-600' },
NOT_BUILT: { label: '未构建', variant: 'outline', icon: Box, color: 'text-gray-400' },
};
// 三栏选中状态 const JenkinsManager: React.FC = () => {
const [selectedView, setSelectedView] = useState<JenkinsViewDTO | null>(null); const { toast } = useToast();
const [selectedJob, setSelectedJob] = useState<JenkinsJobDTO | null>(null);
// Jenkins 实例选择
const [instances, setInstances] = useState<JenkinsInstance[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState<number>();
// 数据状态
const [views, setViews] = useState<JenkinsViewDTO[]>([]);
const [jobs, setJobs] = useState<JenkinsJobDTO[]>([]);
const [builds, setBuilds] = useState<JenkinsBuildDTO[]>([]);
// 选中状态
const [selectedView, setSelectedView] = useState<JenkinsViewDTO>();
const [selectedJob, setSelectedJob] = useState<JenkinsJobDTO>();
// 加载状态 // 加载状态
const [loading, setLoading] = useState({ const [loading, setLoading] = useState({
@ -61,7 +99,6 @@ const JenkinsManagerList: React.FC = () => {
// 同步状态 // 同步状态
const [syncing, setSyncing] = useState({ const [syncing, setSyncing] = useState({
all: false,
views: false, views: false,
jobs: false, jobs: false,
builds: false, builds: false,
@ -72,322 +109,276 @@ const JenkinsManagerList: React.FC = () => {
const [jobSearch, setJobSearch] = useState(''); const [jobSearch, setJobSearch] = useState('');
const [buildSearch, setBuildSearch] = useState(''); const [buildSearch, setBuildSearch] = useState('');
const { toast } = useToast(); // 加载 Jenkins 实例列表
useEffect(() => {
// 获取 Jenkins 实例列表 const loadInstances = async () => {
const loadJenkinsList = async () => {
try { try {
const data = await getJenkinsInstances(); const data = await getJenkinsInstances();
setJenkinsList(data); setInstances(data);
if (!selectedInstanceId && data.length > 0) { if (data.length > 0 && !selectedInstanceId) {
setSelectedInstanceId(data[0].id); setSelectedInstanceId(data[0].id);
} }
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '获取 Jenkins 实例列表失败', title: '加载失败',
duration: 3000, description: '获取 Jenkins 实例列表失败',
}); });
} }
}; };
loadInstances();
}, []);
// 获取 Jenkins 实例详情 // 加载视图列表
const loadInstanceDetails = async () => { const loadViews = async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setLoading((prev) => ({ ...prev, views: true })); setLoading((prev) => ({ ...prev, views: true }));
try { try {
const data = await getJenkinsInstance(String(selectedInstanceId)); const data = await getJenkinsViews(selectedInstanceId);
setInstanceDetails(data); setViews(data || []);
// 自动选择第一个 View
if (data.jenkinsViewList.length > 0 && !selectedView) {
setSelectedView(data.jenkinsViewList[0]);
}
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '获取实例详情失败', title: '加载失败',
duration: 3000, description: '获取视图列表失败',
}); });
} finally { } finally {
setLoading((prev) => ({ ...prev, views: false })); setLoading((prev) => ({ ...prev, views: false }));
} }
}; };
// 切换 Jenkins 实例 // 加载任务列表
const handleInstanceChange = (id: string) => { const loadJobs = async (viewId?: number) => {
setSelectedInstanceId(Number(id)); if (!selectedInstanceId) return;
setInstanceDetails(undefined); setLoading((prev) => ({ ...prev, jobs: true }));
setSelectedView(null); try {
setSelectedJob(null); const data = await getJenkinsJobs({
setViewSearch(''); externalSystemId: selectedInstanceId,
setJobSearch(''); viewId,
setBuildSearch(''); });
setJobs(data || []);
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取任务列表失败',
});
} finally {
setLoading((prev) => ({ ...prev, jobs: false }));
}
}; };
// 选择 View // 加载构建列表
const handleSelectView = (view: JenkinsViewDTO) => { const loadBuilds = async (jobId?: number) => {
setSelectedView(view); if (!selectedInstanceId) return;
setSelectedJob(null); setLoading((prev) => ({ ...prev, builds: true }));
try {
const data = await getJenkinsBuilds({
externalSystemId: selectedInstanceId,
jobId,
});
setBuilds(data || []);
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: '获取构建列表失败',
});
} finally {
setLoading((prev) => ({ ...prev, builds: false }));
}
}; };
// 选择 Job // 监听实例变化
const handleSelectJob = (job: JenkinsJobDTO) => { useEffect(() => {
setSelectedJob(job); if (selectedInstanceId) {
// TODO: 加载 Builds 列表 setSelectedView(undefined);
}; setSelectedJob(undefined);
setJobs([]);
setBuilds([]);
loadViews();
}
}, [selectedInstanceId]);
// 同步 Views // 监听视图选择变化
useEffect(() => {
if (selectedView) {
setSelectedJob(undefined);
setBuilds([]);
loadJobs(selectedView.id);
}
}, [selectedView]);
// 监听任务选择变化
useEffect(() => {
if (selectedJob) {
loadBuilds(selectedJob.id);
}
}, [selectedJob]);
// 同步视图
const handleSyncViews = async () => { const handleSyncViews = async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, views: true })); setSyncing((prev) => ({ ...prev, views: true }));
try { try {
await syncViews(String(selectedInstanceId)); await syncJenkinsViews(selectedInstanceId);
toast({ toast({
title: '同步成功', title: '同步成功',
description: 'Views 同步任务已启动', description: '视图同步任务已启动',
duration: 2000,
}); });
setTimeout(() => loadViews(), 2000);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '同步失败', title: '同步失败',
duration: 3000, description: '视图同步失败',
}); });
} finally { } finally {
setSyncing((prev) => ({ ...prev, views: false })); setSyncing((prev) => ({ ...prev, views: false }));
} }
}; };
// 同步 Jobs // 同步任务
const handleSyncJobs = async () => { const handleSyncJobs = async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, jobs: true })); setSyncing((prev) => ({ ...prev, jobs: true }));
try { try {
await syncJobs(String(selectedInstanceId)); await syncJenkinsJobs({
externalSystemId: selectedInstanceId,
viewId: selectedView?.id,
});
toast({ toast({
title: '同步成功', title: '同步成功',
description: 'Jobs 同步任务已启动', description: '任务同步任务已启动',
duration: 2000,
}); });
setTimeout(() => {
if (selectedView) {
loadJobs(selectedView.id);
}
}, 2000);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '同步失败', title: '同步失败',
duration: 3000, description: '任务同步失败',
}); });
} finally { } finally {
setSyncing((prev) => ({ ...prev, jobs: false })); setSyncing((prev) => ({ ...prev, jobs: false }));
} }
}; };
// 同步 Builds // 同步构建
const handleSyncBuilds = async () => { const handleSyncBuilds = async () => {
if (!selectedInstanceId) return; if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, builds: true })); setSyncing((prev) => ({ ...prev, builds: true }));
try { try {
await syncBuilds(String(selectedInstanceId)); await syncJenkinsBuilds({
externalSystemId: selectedInstanceId,
jobId: selectedJob?.id,
});
toast({ toast({
title: '同步成功', title: '同步成功',
description: 'Builds 同步任务已启动', description: '构建同步任务已启动',
duration: 2000,
}); });
setTimeout(() => {
if (selectedJob) {
loadBuilds(selectedJob.id);
}
}, 2000);
} catch (error) { } catch (error) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '同步失败', title: '同步失败',
duration: 3000, description: '构建同步失败',
}); });
} finally { } finally {
setSyncing((prev) => ({ ...prev, builds: false })); setSyncing((prev) => ({ ...prev, builds: false }));
} }
}; };
// 全部同步 // 过滤视图
const handleSyncAll = async () => {
if (!selectedInstanceId) return;
setSyncing((prev) => ({ ...prev, all: true }));
try {
await Promise.all([
syncViews(String(selectedInstanceId)),
syncJobs(String(selectedInstanceId)),
syncBuilds(String(selectedInstanceId)),
]);
toast({
title: '全部同步任务已启动',
description: '请稍后刷新页面查看同步结果',
duration: 2000,
});
} catch (error) {
toast({
variant: 'destructive',
title: '同步失败',
description: '部分数据同步失败,请重试',
duration: 3000,
});
} finally {
setSyncing((prev) => ({ ...prev, all: false }));
}
};
// 格式化时间
const formatTime = (time?: string | null) => {
if (!time) return '-';
const diff = dayjs().diff(dayjs(time), 'day');
if (diff > 7) {
return dayjs(time).format('YYYY-MM-DD HH:mm');
}
return dayjs(time).fromNow();
};
// 获取构建状态信息
const getBuildStatusInfo = (status: string) => {
const statusMap: Record<string, {
variant: 'default' | 'destructive' | 'secondary' | 'outline' | 'success';
icon: React.ReactNode;
label: string;
}> = {
SUCCESS: {
variant: 'success',
icon: <CheckCircle className="h-3 w-3" />,
label: '成功',
},
FAILURE: {
variant: 'destructive',
icon: <XCircle className="h-3 w-3" />,
label: '失败',
},
UNSTABLE: {
variant: 'secondary',
icon: <AlertTriangle className="h-3 w-3" />,
label: '不稳定',
},
ABORTED: {
variant: 'outline',
icon: <XCircle className="h-3 w-3" />,
label: '已中止',
},
RUNNING: {
variant: 'outline',
icon: <Loader2 className="h-3 w-3 animate-spin" />,
label: '构建中',
},
};
return statusMap[status] || {
variant: 'outline' as const,
icon: <AlertTriangle className="h-3 w-3" />,
label: status || '未知',
};
};
// 获取健康度颜色
const getHealthColor = (score: number) => {
if (score >= 80) return 'bg-green-500';
if (score >= 60) return 'bg-green-400';
if (score >= 40) return 'bg-yellow-400';
if (score >= 20) return 'bg-orange-400';
return 'bg-red-500';
};
// 过滤 Views
const filteredViews = useMemo(() => { const filteredViews = useMemo(() => {
if (!instanceDetails) return []; return views.filter((view) =>
if (!viewSearch) return instanceDetails.jenkinsViewList; view.viewName.toLowerCase().includes(viewSearch.toLowerCase())
return instanceDetails.jenkinsViewList.filter((view) =>
view.viewName.toLowerCase().includes(viewSearch.toLowerCase()) ||
(view.description && view.description.toLowerCase().includes(viewSearch.toLowerCase()))
); );
}, [instanceDetails, viewSearch]); }, [views, viewSearch]);
// 过滤 Jobs基于选中的 View // 过滤任务
const filteredJobs = useMemo(() => { const filteredJobs = useMemo(() => {
if (!instanceDetails || !selectedView) return []; return jobs.filter((job) =>
let jobs = instanceDetails.jenkinsJobList.filter( job.jobName.toLowerCase().includes(jobSearch.toLowerCase())
(job) => job.viewId === selectedView.id
); );
if (jobSearch) { }, [jobs, jobSearch]);
jobs = jobs.filter((job) =>
job.jobName.toLowerCase().includes(jobSearch.toLowerCase()) || // 过滤构建
(job.description && job.description.toLowerCase().includes(jobSearch.toLowerCase())) const filteredBuilds = useMemo(() => {
if (!buildSearch) return builds;
const searchLower = buildSearch.toLowerCase();
return builds.filter((build) =>
build.buildNumber.toString().includes(searchLower) ||
build.buildStatus.toLowerCase().includes(searchLower)
); );
} }, [builds, buildSearch]);
return jobs;
}, [instanceDetails, selectedView, jobSearch]);
useEffect(() => { // 获取构建状态徽章
loadJenkinsList(); const getBuildStatusBadge = (status: string) => {
}, []); const config = BUILD_STATUS_CONFIG[status] || BUILD_STATUS_CONFIG.NOT_BUILT;
const Icon = config.icon;
useEffect(() => {
if (selectedInstanceId) {
loadInstanceDetails();
}
}, [selectedInstanceId]);
// 空状态:没有实例
if (jenkinsList.length === 0) {
return ( return (
<PageContainer> <Badge variant={config.variant} className="inline-flex items-center gap-1">
<div className="flex items-center justify-between mb-6"> <Icon className="h-3 w-3" />
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2> {config.label}
</div> </Badge>
<Card> );
<CardContent className="flex flex-col items-center justify-center py-16"> };
<Activity className="h-16 w-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2"> Jenkins </h3> // 格式化时长
<p className="text-sm text-muted-foreground mb-4"> const formatDuration = (duration?: number) => {
Jenkins if (!duration) return '-';
const seconds = Math.floor(duration / 1000);
if (seconds < 60) return `${seconds}`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}分钟`;
const hours = Math.floor(minutes / 60);
return `${hours}小时${minutes % 60}分钟`;
};
return (
<div className="p-6 flex flex-col h-screen">
{/* 页面标题 */}
<div className="flex items-center justify-between mb-6 flex-shrink-0">
<div>
<h1 className="text-3xl font-bold text-foreground">Jenkins </h1>
<p className="text-muted-foreground mt-2">
Jenkins
</p> </p>
<Button onClick={() => (window.location.href = '/deploy/external')}> </div>
</Button>
</CardContent>
</Card>
</PageContainer>
);
}
return (
<TooltipProvider>
<PageContainer className="h-full flex flex-col">
{/* 页面标题和实例选择 */}
<div className="flex items-center justify-between flex-shrink-0">
<h2 className="text-3xl font-bold tracking-tight">Jenkins </h2>
<div className="flex items-center gap-4">
<Select <Select
value={selectedInstanceId?.toString()} value={selectedInstanceId?.toString() || ''}
onValueChange={handleInstanceChange} onValueChange={(value) => setSelectedInstanceId(Number(value))}
> >
<SelectTrigger className="w-[300px]"> <SelectTrigger className="w-[240px]">
<SelectValue placeholder="选择 Jenkins 实例" /> <SelectValue placeholder="选择Jenkins实例" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{jenkinsList.map((jenkins) => ( {instances.map((instance) => (
<SelectItem key={jenkins.id} value={String(jenkins.id)}> <SelectItem key={instance.id} value={instance.id.toString()}>
{jenkins.name} {instance.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button onClick={handleSyncAll} disabled={syncing.all} variant="outline">
{syncing.all ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</div> </div>
{/* 三栏布局 */} {/* 三栏布局 */}
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden"> <div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden">
{/* 左栏Views 列表 */} {/* 左栏:视图列表 */}
<Card className="col-span-3 flex flex-col min-h-0"> <Card className="col-span-3 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"> <CardTitle className="text-base flex items-center gap-2">
Views {instanceDetails && `(${filteredViews.length})`} <Layers className="h-4 w-4" />
({filteredViews.length})
</CardTitle> </CardTitle>
<Button <Button
variant="ghost" variant="ghost"
@ -411,43 +402,39 @@ const JenkinsManagerList: React.FC = () => {
/> />
</div> </div>
</CardHeader> </CardHeader>
<div className="flex-1 overflow-auto"> <ScrollArea className="flex-1">
<div className="p-2 space-y-1">
{loading.views ? ( {loading.views ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : filteredViews.length === 0 ? ( ) : filteredViews.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Layers className="h-12 w-12 mb-2 opacity-20" /> <Layers className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm"></p>
</div> </div>
) : ( ) : (
<div className="p-2 space-y-2"> filteredViews.map((view) => (
{filteredViews.map((view) => (
<div <div
key={view.id} key={view.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${ className={`group p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedView?.id === view.id selectedView?.id === view.id
? 'bg-accent border-primary' ? 'bg-accent border-primary'
: '' : ''
}`} }`}
onClick={() => handleSelectView(view)} onClick={() => setSelectedView(view)}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-2 mb-2">
<div className="flex-1 min-w-0"> <span className="flex-1 truncate font-medium">
<div className="flex items-center gap-2 mb-1">
<Layers className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="font-medium truncate">
{view.viewName} {view.viewName}
</span> </span>
</div> {view.jobCount !== undefined && view.jobCount > 0 && (
{view.description && ( <Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
<p className="text-xs text-muted-foreground line-clamp-2"> {view.jobCount}
{view.description} </Badge>
</p>
)} )}
</div>
{view.viewUrl && ( {view.viewUrl && (
<TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<a <a
@ -455,13 +442,9 @@ const JenkinsManagerList: React.FC = () => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex-shrink-0" className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
> >
<Button variant="ghost" size="icon" className="h-5 w-5">
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</Button> </Button>
</a> </a>
@ -470,21 +453,27 @@ const JenkinsManagerList: React.FC = () => {
<p> Jenkins </p> <p> Jenkins </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
)} )}
</div> </div>
</div> {view.description && (
))} <p className="text-xs text-muted-foreground line-clamp-2">
</div> {view.description}
</p>
)} )}
</div> </div>
))
)}
</div>
</ScrollArea>
</Card> </Card>
{/* 中栏Jobs 列表 */} {/* 中栏:任务列表 */}
<Card className="col-span-5 flex flex-col min-h-0"> <Card className="col-span-5 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base"> <CardTitle className="text-base">
Jobs {selectedView && `(${filteredJobs.length})`} {selectedView && `(${filteredJobs.length})`}
</CardTitle> </CardTitle>
<Button <Button
variant="ghost" variant="ghost"
@ -525,64 +514,25 @@ const JenkinsManagerList: React.FC = () => {
</div> </div>
) : ( ) : (
<div className="p-2 space-y-2"> <div className="p-2 space-y-2">
{filteredJobs.map((job) => { {filteredJobs.map((job) => (
const statusInfo = getBuildStatusInfo(job.lastBuildStatus);
return (
<div <div
key={job.id} key={job.id}
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${ className={`group p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
selectedJob?.id === job.id selectedJob?.id === job.id
? 'bg-accent border-primary' ? 'bg-accent border-primary'
: '' : ''
}`} }`}
onClick={() => handleSelectJob(job)} onClick={() => setSelectedJob(job)}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-center gap-2 mb-2">
<div className="flex-1 min-w-0"> <span className="flex-1 truncate font-medium">{job.jobName}</span>
<div className="flex items-center gap-2 mb-1"> {job.buildCount !== undefined && job.buildCount > 0 && (
<Box className="h-4 w-4 flex-shrink-0 text-muted-foreground" /> <Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
<span className="font-medium truncate"> {job.buildCount}
{job.jobName}
</span>
</div>
{job.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{job.description}
</p>
)}
<div className="flex items-center gap-3 flex-wrap">
<Badge
variant={statusInfo.variant}
className="inline-flex items-center gap-1"
>
{statusInfo.icon}
{statusInfo.label}
</Badge> </Badge>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<GitBranch className="h-3 w-3" />
#{job.lastBuildNumber}
</span>
<div className="flex items-center gap-1">
<Progress
value={job.healthReportScore}
className="h-2 w-16"
indicatorClassName={getHealthColor(
job.healthReportScore
)} )}
/>
<span className="text-xs text-muted-foreground">
{job.healthReportScore}%
</span>
</div>
</div>
{job.lastBuildTime && (
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatTime(job.lastBuildTime)}
</div>
)}
</div>
{job.jobUrl && ( {job.jobUrl && (
<TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<a <a
@ -590,13 +540,9 @@ const JenkinsManagerList: React.FC = () => {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex-shrink-0" className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
> >
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" /> <ExternalLink className="h-3 w-3" />
</Button> </Button>
</a> </a>
@ -605,27 +551,67 @@ const JenkinsManagerList: React.FC = () => {
<p> Jenkins </p> <p> Jenkins </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider>
)} )}
</div> </div>
{job.description && (
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
{job.description}
</p>
)}
<div className="flex items-center gap-3 text-xs">
{job.lastBuildStatus && getBuildStatusBadge(job.lastBuildStatus)}
{job.healthReportScore !== undefined && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<Progress
value={job.healthReportScore}
className="w-16 h-2"
/>
<span className="text-muted-foreground">
{job.healthReportScore}%
</span>
</div> </div>
); </TooltipTrigger>
})} <TooltipContent>
: {job.healthReportScore}%
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{job.lastBuildTime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-2">
<Calendar className="h-3 w-3" />
: {dayjs(job.lastBuildTime).fromNow()}
</div>
)}
</div>
))}
</div> </div>
)} )}
</div> </div>
</Card> </Card>
{/* 右栏Builds 列表(待实现) */} {/* 右栏:构建列表 */}
<Card className="col-span-4 flex flex-col min-h-0"> <Card className="col-span-4 flex flex-col min-h-0">
<CardHeader className="border-b flex-shrink-0"> <CardHeader className="border-b flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle className="text-base">Builds</CardTitle> <CardTitle className="text-base">
{selectedJob && `(${filteredBuilds.length})`}
</CardTitle>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={handleSyncBuilds} onClick={handleSyncBuilds}
disabled={syncing.builds || !selectedJob} disabled={syncing.builds || !selectedInstanceId}
> >
<RefreshCw <RefreshCw
className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`} className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`}
@ -643,24 +629,79 @@ const JenkinsManagerList: React.FC = () => {
</div> </div>
</CardHeader> </CardHeader>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{!selectedJob ? ( {loading.builds ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : !selectedJob ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Activity className="h-12 w-12 mb-2 opacity-20" /> <GitBranch className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm"></p>
</div> </div>
) : ( ) : filteredBuilds.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Activity className="h-12 w-12 mb-2 opacity-20" /> <GitBranch className="h-12 w-12 mb-2 opacity-20" />
<p className="text-sm"></p> <p className="text-sm"></p>
<p className="text-xs mt-1"></p> </div>
) : (
<div className="divide-y">
{filteredBuilds.map((build) => (
<div
key={build.id}
className="group p-3 hover:bg-accent transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-sm">
#{build.buildNumber}
</span>
{getBuildStatusBadge(build.buildStatus)}
</div>
{build.buildUrl && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href={build.buildUrl}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button variant="ghost" size="icon" className="h-6 w-6">
<ExternalLink className="h-3 w-3" />
</Button>
</a>
</TooltipTrigger>
<TooltipContent>
<p> Jenkins </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{build.starttime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Calendar className="h-3 w-3" />
{dayjs(build.starttime).format('YYYY-MM-DD HH:mm:ss')}
</div>
)}
{build.duration !== undefined && (
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
<Clock className="h-3 w-3" />
: {formatDuration(build.duration)}
</div>
)}
</div>
))}
</div> </div>
)} )}
</div> </div>
</Card> </Card>
</div> </div>
</PageContainer> </div>
</TooltipProvider>
); );
}; };
export default JenkinsManagerList; export default JenkinsManager;

View File

@ -1,5 +1,5 @@
import request from '@/utils/request'; import request from '@/utils/request';
import type { JenkinsInstance, JenkinsInstanceDTO } from './types'; import type { JenkinsViewDTO, JenkinsJobDTO, JenkinsBuildDTO } from './types';
import { getExternalSystems } from '@/pages/Deploy/External/service'; import { getExternalSystems } from '@/pages/Deploy/External/service';
import { SystemType } from '@/pages/Deploy/External/types'; import { SystemType } from '@/pages/Deploy/External/types';
@ -10,18 +10,36 @@ export const getJenkinsInstances = () =>
enabled: true enabled: true
}).then(response => response.content); }).then(response => response.content);
// 获取 Jenkins 实例详情 // ==================== Jenkins 视图 ====================
export const getJenkinsInstance = (externalSystemId: string) =>
request.get<JenkinsInstanceDTO>(`/api/v1/jenkins-manager/${externalSystemId}/instance`); // 获取视图列表
export const getJenkinsViews = (externalSystemId: number) =>
request.get<JenkinsViewDTO[]>(`/api/v1/jenkins-view`, {
params: { externalSystemId }
});
// 同步视图 // 同步视图
export const syncViews = (externalSystemId: string) => export const syncJenkinsViews = (externalSystemId: number) =>
request.post<void>(`/api/v1/jenkins-manager/${externalSystemId}/sync-views`); request.post<void>(`/api/v1/jenkins-view/sync`, null, {
params: { externalSystemId }
});
// ==================== Jenkins 任务 ====================
// 获取任务列表
export const getJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) =>
request.get<JenkinsJobDTO[]>(`/api/v1/jenkins-job`, { params });
// 同步任务 // 同步任务
export const syncJobs = (externalSystemId: string) => export const syncJenkinsJobs = (params: { externalSystemId: number; viewId?: number }) =>
request.post<void>(`/api/v1/jenkins-manager/${externalSystemId}/sync-jobs`); request.post<void>(`/api/v1/jenkins-job/sync`, null, { params });
// 同步构建 // ==================== Jenkins 构建 ====================
export const syncBuilds = (externalSystemId: string) =>
request.post<void>(`/api/v1/jenkins-manager/${externalSystemId}/sync-builds`); // 获取构建列表
export const getJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) =>
request.get<JenkinsBuildDTO[]>(`/api/v1/jenkins-build`, { params });
// 同步构建根据jobId
export const syncJenkinsBuilds = (params: { externalSystemId: number; jobId?: number }) =>
request.post<void>(`/api/v1/jenkins-build/sync`, null, { params });

View File

@ -18,25 +18,39 @@ export interface JenkinsInstanceDTO extends BaseResponse {
// Jenkins 视图 // Jenkins 视图
export interface JenkinsViewDTO extends BaseResponse { export interface JenkinsViewDTO extends BaseResponse {
description: string; description?: string;
externalSystemId: number; externalSystemId: number;
viewName: string; viewName: string;
viewUrl: string; viewUrl: string;
jobCount?: number; // 关联的任务数量
} }
// Jenkins 任务 // Jenkins 任务
export interface JenkinsJobDTO extends BaseResponse { export interface JenkinsJobDTO extends BaseResponse {
buildable: boolean; buildable?: boolean;
description: string; description?: string;
jobName: string; jobName: string;
jobUrl: string; jobUrl: string;
nextBuildNumber: number; nextBuildNumber?: number;
lastBuildNumber: number; lastBuildNumber?: number;
lastBuildStatus: string; lastBuildStatus?: string;
healthReportScore: number; healthReportScore?: number;
lastBuildTime: string; lastBuildTime?: string;
externalSystemId: number; externalSystemId: number;
viewId: number; viewId?: number;
buildCount?: number; // 关联的构建数量
}
// Jenkins 构建
export interface JenkinsBuildDTO extends BaseResponse {
buildNumber: number;
buildStatus: string;
buildUrl: string;
duration?: number;
starttime?: string;
actions?: string;
externalSystemId: number;
jobId: number;
} }
// 同步类型 // 同步类型