增加团队管理页面
This commit is contained in:
parent
98cef3f68f
commit
945d827eb8
@ -477,6 +477,12 @@ const GitManager: React.FC = () => {
|
|||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{group.projectCount !== undefined && group.projectCount > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-xs px-1.5 py-0 h-5">
|
||||||
|
{group.projectCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{group.webUrl && (
|
{group.webUrl && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@ -739,6 +745,13 @@ const GitManager: React.FC = () => {
|
|||||||
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{project.branchCount !== undefined && project.branchCount > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<GitBranch className="h-3 w-3" />
|
||||||
|
{project.branchCount} 分支
|
||||||
|
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{project.lastActivityAt && (
|
{project.lastActivityAt && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
|
|||||||
@ -1,20 +1,26 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { PageContainer } from '@/components/ui/page-container';
|
import { PageContainer } from '@/components/ui/page-container';
|
||||||
import { RefreshCw, Layers, Box, Activity, Search, CheckCircle, XCircle, AlertTriangle, Loader2, ExternalLink } from 'lucide-react';
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
Layers,
|
||||||
|
Box,
|
||||||
|
Activity,
|
||||||
|
Search,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
|
GitBranch,
|
||||||
|
Calendar
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import {
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
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 {
|
import {
|
||||||
@ -27,25 +33,44 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
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 type { JenkinsInstance, JenkinsInstanceDTO } from './types';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import type { JenkinsInstance, JenkinsInstanceDTO, JenkinsViewDTO, JenkinsJobDTO } from './types';
|
||||||
import { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service';
|
import { getJenkinsInstances, getJenkinsInstance, syncViews, syncJobs, syncBuilds } from './service';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
const JenkinsManagerList: React.FC = () => {
|
const JenkinsManagerList: React.FC = () => {
|
||||||
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]);
|
const [jenkinsList, setJenkinsList] = useState<JenkinsInstance[]>([]);
|
||||||
const [currentJenkinsId, setCurrentJenkinsId] = useState<string>();
|
const [selectedInstanceId, setSelectedInstanceId] = useState<number>();
|
||||||
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>();
|
const [instanceDetails, setInstanceDetails] = useState<JenkinsInstanceDTO>();
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [currentView, setCurrentView] = useState<number>();
|
// 三栏选中状态
|
||||||
const [syncing, setSyncing] = useState<Record<string, boolean>>({
|
const [selectedView, setSelectedView] = useState<JenkinsViewDTO | null>(null);
|
||||||
|
const [selectedJob, setSelectedJob] = useState<JenkinsJobDTO | null>(null);
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const [loading, setLoading] = useState({
|
||||||
views: false,
|
views: false,
|
||||||
jobs: false,
|
jobs: false,
|
||||||
builds: false,
|
builds: false,
|
||||||
all: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 搜索和过滤状态
|
// 同步状态
|
||||||
const [searchKeyword, setSearchKeyword] = useState('');
|
const [syncing, setSyncing] = useState({
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
all: false,
|
||||||
|
views: false,
|
||||||
|
jobs: false,
|
||||||
|
builds: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 搜索状态
|
||||||
|
const [viewSearch, setViewSearch] = useState('');
|
||||||
|
const [jobSearch, setJobSearch] = useState('');
|
||||||
|
const [buildSearch, setBuildSearch] = useState('');
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@ -54,8 +79,8 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const data = await getJenkinsInstances();
|
const data = await getJenkinsInstances();
|
||||||
setJenkinsList(data);
|
setJenkinsList(data);
|
||||||
if (!currentJenkinsId && data.length > 0) {
|
if (!selectedInstanceId && data.length > 0) {
|
||||||
setCurrentJenkinsId(String(data[0].id));
|
setSelectedInstanceId(data[0].id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@ -68,14 +93,15 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
|
|
||||||
// 获取 Jenkins 实例详情
|
// 获取 Jenkins 实例详情
|
||||||
const loadInstanceDetails = async () => {
|
const loadInstanceDetails = async () => {
|
||||||
if (!currentJenkinsId) return;
|
if (!selectedInstanceId) return;
|
||||||
setLoading(true);
|
setLoading((prev) => ({ ...prev, views: true }));
|
||||||
try {
|
try {
|
||||||
const data = await getJenkinsInstance(currentJenkinsId);
|
const data = await getJenkinsInstance(String(selectedInstanceId));
|
||||||
setInstanceDetails(data);
|
setInstanceDetails(data);
|
||||||
// 设置默认视图
|
|
||||||
if (data.jenkinsViewList.length > 0 && !currentView) {
|
// 自动选择第一个 View
|
||||||
setCurrentView(data.jenkinsViewList[0].id);
|
if (data.jenkinsViewList.length > 0 && !selectedView) {
|
||||||
|
setSelectedView(data.jenkinsViewList[0]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
@ -84,38 +110,42 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading((prev) => ({ ...prev, views: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换 Jenkins 实例
|
// 切换 Jenkins 实例
|
||||||
const handleJenkinsChange = (id: string) => {
|
const handleInstanceChange = (id: string) => {
|
||||||
setCurrentJenkinsId(id);
|
setSelectedInstanceId(Number(id));
|
||||||
setInstanceDetails(undefined);
|
setInstanceDetails(undefined);
|
||||||
setCurrentView(undefined);
|
setSelectedView(null);
|
||||||
setSearchKeyword('');
|
setSelectedJob(null);
|
||||||
setStatusFilter('all');
|
setViewSearch('');
|
||||||
|
setJobSearch('');
|
||||||
|
setBuildSearch('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 单个同步
|
// 选择 View
|
||||||
const handleSync = async (type: 'views' | 'jobs' | 'builds') => {
|
const handleSelectView = (view: JenkinsViewDTO) => {
|
||||||
if (!currentJenkinsId) return;
|
setSelectedView(view);
|
||||||
setSyncing((prev) => ({ ...prev, [type]: true }));
|
setSelectedJob(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择 Job
|
||||||
|
const handleSelectJob = (job: JenkinsJobDTO) => {
|
||||||
|
setSelectedJob(job);
|
||||||
|
// TODO: 加载 Builds 列表
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步 Views
|
||||||
|
const handleSyncViews = async () => {
|
||||||
|
if (!selectedInstanceId) return;
|
||||||
|
setSyncing((prev) => ({ ...prev, views: true }));
|
||||||
try {
|
try {
|
||||||
switch (type) {
|
await syncViews(String(selectedInstanceId));
|
||||||
case 'views':
|
|
||||||
await syncViews(currentJenkinsId);
|
|
||||||
break;
|
|
||||||
case 'jobs':
|
|
||||||
await syncJobs(currentJenkinsId);
|
|
||||||
break;
|
|
||||||
case 'builds':
|
|
||||||
await syncBuilds(currentJenkinsId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
await loadInstanceDetails();
|
|
||||||
toast({
|
toast({
|
||||||
title: '同步成功',
|
title: '同步成功',
|
||||||
|
description: 'Views 同步任务已启动',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -125,23 +155,67 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
duration: 3000,
|
duration: 3000,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSyncing((prev) => ({ ...prev, [type]: false }));
|
setSyncing((prev) => ({ ...prev, views: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步 Jobs
|
||||||
|
const handleSyncJobs = async () => {
|
||||||
|
if (!selectedInstanceId) return;
|
||||||
|
setSyncing((prev) => ({ ...prev, jobs: true }));
|
||||||
|
try {
|
||||||
|
await syncJobs(String(selectedInstanceId));
|
||||||
|
toast({
|
||||||
|
title: '同步成功',
|
||||||
|
description: 'Jobs 同步任务已启动',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '同步失败',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSyncing((prev) => ({ ...prev, jobs: false }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 同步 Builds
|
||||||
|
const handleSyncBuilds = async () => {
|
||||||
|
if (!selectedInstanceId) return;
|
||||||
|
setSyncing((prev) => ({ ...prev, builds: true }));
|
||||||
|
try {
|
||||||
|
await syncBuilds(String(selectedInstanceId));
|
||||||
|
toast({
|
||||||
|
title: '同步成功',
|
||||||
|
description: 'Builds 同步任务已启动',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '同步失败',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSyncing((prev) => ({ ...prev, builds: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 全部同步
|
// 全部同步
|
||||||
const handleSyncAll = async () => {
|
const handleSyncAll = async () => {
|
||||||
if (!currentJenkinsId) return;
|
if (!selectedInstanceId) return;
|
||||||
setSyncing((prev) => ({ ...prev, all: true }));
|
setSyncing((prev) => ({ ...prev, all: true }));
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
syncViews(currentJenkinsId),
|
syncViews(String(selectedInstanceId)),
|
||||||
syncJobs(currentJenkinsId),
|
syncJobs(String(selectedInstanceId)),
|
||||||
syncBuilds(currentJenkinsId),
|
syncBuilds(String(selectedInstanceId)),
|
||||||
]);
|
]);
|
||||||
await loadInstanceDetails();
|
|
||||||
toast({
|
toast({
|
||||||
title: '全部同步成功',
|
title: '全部同步任务已启动',
|
||||||
|
description: '请稍后刷新页面查看同步结果',
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -156,26 +230,25 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// 格式化时间
|
||||||
loadJenkinsList();
|
const formatTime = (time?: string | null) => {
|
||||||
}, []);
|
if (!time) return '-';
|
||||||
|
const diff = dayjs().diff(dayjs(time), 'day');
|
||||||
useEffect(() => {
|
if (diff > 7) {
|
||||||
if (currentJenkinsId) {
|
return dayjs(time).format('YYYY-MM-DD HH:mm');
|
||||||
loadInstanceDetails();
|
|
||||||
}
|
}
|
||||||
}, [currentJenkinsId]);
|
return dayjs(time).fromNow();
|
||||||
|
|
||||||
const formatTime = (time: string | null | undefined) => {
|
|
||||||
if (!time) return 'Never';
|
|
||||||
return time;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取构建状态信息
|
// 获取构建状态信息
|
||||||
const getBuildStatusInfo = (status: string) => {
|
const getBuildStatusInfo = (status: string) => {
|
||||||
const statusMap: Record<string, { variant: 'default' | 'destructive' | 'secondary' | 'outline', icon: React.ReactNode, label: string }> = {
|
const statusMap: Record<string, {
|
||||||
|
variant: 'default' | 'destructive' | 'secondary' | 'outline' | 'success';
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
}> = {
|
||||||
SUCCESS: {
|
SUCCESS: {
|
||||||
variant: 'default',
|
variant: 'success',
|
||||||
icon: <CheckCircle className="h-3 w-3" />,
|
icon: <CheckCircle className="h-3 w-3" />,
|
||||||
label: '成功',
|
label: '成功',
|
||||||
},
|
},
|
||||||
@ -216,39 +289,47 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
return 'bg-red-500';
|
return 'bg-red-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 过滤和搜索 Jobs
|
// 过滤 Views
|
||||||
const filteredJobs = useMemo(() => {
|
const filteredViews = useMemo(() => {
|
||||||
if (!instanceDetails) return [];
|
if (!instanceDetails) return [];
|
||||||
|
if (!viewSearch) return instanceDetails.jenkinsViewList;
|
||||||
|
return instanceDetails.jenkinsViewList.filter((view) =>
|
||||||
|
view.viewName.toLowerCase().includes(viewSearch.toLowerCase()) ||
|
||||||
|
(view.description && view.description.toLowerCase().includes(viewSearch.toLowerCase()))
|
||||||
|
);
|
||||||
|
}, [instanceDetails, viewSearch]);
|
||||||
|
|
||||||
let jobs = instanceDetails.jenkinsJobList;
|
// 过滤 Jobs(基于选中的 View)
|
||||||
|
const filteredJobs = useMemo(() => {
|
||||||
// 按 View 过滤
|
if (!instanceDetails || !selectedView) return [];
|
||||||
if (currentView) {
|
let jobs = instanceDetails.jenkinsJobList.filter(
|
||||||
jobs = jobs.filter((job) => job.viewId === currentView);
|
(job) => job.viewId === selectedView.id
|
||||||
}
|
);
|
||||||
|
if (jobSearch) {
|
||||||
// 按搜索关键词过滤
|
|
||||||
if (searchKeyword) {
|
|
||||||
jobs = jobs.filter((job) =>
|
jobs = jobs.filter((job) =>
|
||||||
job.jobName.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
job.jobName.toLowerCase().includes(jobSearch.toLowerCase()) ||
|
||||||
job.description?.toLowerCase().includes(searchKeyword.toLowerCase())
|
(job.description && job.description.toLowerCase().includes(jobSearch.toLowerCase()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按状态过滤
|
|
||||||
if (statusFilter !== 'all') {
|
|
||||||
jobs = jobs.filter((job) => job.lastBuildStatus === statusFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jobs;
|
return jobs;
|
||||||
}, [instanceDetails, currentView, searchKeyword, statusFilter]);
|
}, [instanceDetails, selectedView, jobSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadJenkinsList();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedInstanceId) {
|
||||||
|
loadInstanceDetails();
|
||||||
|
}
|
||||||
|
}, [selectedInstanceId]);
|
||||||
|
|
||||||
// 空状态:没有实例
|
// 空状态:没有实例
|
||||||
if (jenkinsList.length === 0) {
|
if (jenkinsList.length === 0) {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Jenkins 三方管理</h2>
|
<h2 className="text-3xl font-bold tracking-tight">Jenkins 管理</h2>
|
||||||
</div>
|
</div>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
@ -257,7 +338,7 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
请先在外部服务管理中添加 Jenkins 系统
|
请先在外部服务管理中添加 Jenkins 系统
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => window.location.href = '/deploy/external'}>
|
<Button onClick={() => (window.location.href = '/deploy/external')}>
|
||||||
前往外部服务管理
|
前往外部服务管理
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -267,12 +348,16 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<TooltipProvider>
|
||||||
{/* 页面标题 */}
|
<PageContainer className="h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
{/* 页面标题和实例选择 */}
|
||||||
<h2 className="text-3xl font-bold tracking-tight">Jenkins 三方管理</h2>
|
<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">
|
<div className="flex items-center gap-4">
|
||||||
<Select value={currentJenkinsId} onValueChange={handleJenkinsChange}>
|
<Select
|
||||||
|
value={selectedInstanceId?.toString()}
|
||||||
|
onValueChange={handleInstanceChange}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-[300px]">
|
<SelectTrigger className="w-[300px]">
|
||||||
<SelectValue placeholder="选择 Jenkins 实例" />
|
<SelectValue placeholder="选择 Jenkins 实例" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -284,11 +369,7 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button onClick={handleSyncAll} disabled={syncing.all} variant="outline">
|
||||||
onClick={handleSyncAll}
|
|
||||||
disabled={syncing.all}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{syncing.all ? (
|
{syncing.all ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||||
) : (
|
) : (
|
||||||
@ -299,261 +380,286 @@ const JenkinsManagerList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* 三栏布局 */}
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="flex-1 grid grid-cols-12 gap-4 min-h-0 overflow-hidden">
|
||||||
<Card className="min-h-[140px]">
|
{/* 左栏:Views 列表 */}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<Card className="col-span-3 flex flex-col min-h-0">
|
||||||
<CardTitle className="text-sm font-medium">Views</CardTitle>
|
<CardHeader className="border-b flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Layers className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-base">
|
||||||
|
Views {instanceDetails && `(${filteredViews.length})`}
|
||||||
|
</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-6 w-6"
|
className="h-7 w-7"
|
||||||
onClick={() => handleSync('views')}
|
onClick={handleSyncViews}
|
||||||
disabled={syncing.views}
|
disabled={syncing.views || !selectedInstanceId}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`h-3 w-3 ${syncing.views ? 'animate-spin' : ''}`} />
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${syncing.views ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
<div className="relative mt-2">
|
||||||
<CardContent>
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<div className="text-2xl font-bold">{instanceDetails?.totalViews || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
最后同步: {formatTime(instanceDetails?.lastSyncViewsTime)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-h-[140px]">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Jobs</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Box className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleSync('jobs')}
|
|
||||||
disabled={syncing.jobs}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3 w-3 ${syncing.jobs ? 'animate-spin' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{instanceDetails?.totalJobs || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
最后同步: {formatTime(instanceDetails?.lastSyncJobsTime)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="min-h-[140px]">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Builds</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6"
|
|
||||||
onClick={() => handleSync('builds')}
|
|
||||||
disabled={syncing.builds}
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-3 w-3 ${syncing.builds ? 'animate-spin' : ''}`} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">{instanceDetails?.totalBuilds || 0}</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
最后同步: {formatTime(instanceDetails?.lastSyncBuildsTime)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View 切换 */}
|
|
||||||
{instanceDetails && instanceDetails.jenkinsViewList.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
|
||||||
{instanceDetails.jenkinsViewList.map((view) => (
|
|
||||||
<Button
|
|
||||||
key={view.id}
|
|
||||||
variant={currentView === view.id ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentView(view.id)}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{view.viewName}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 搜索和过滤 */}
|
|
||||||
{instanceDetails && instanceDetails.jenkinsViewList.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative flex-1 max-w-sm">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索 Job 名称或描述..."
|
placeholder="搜索视图..."
|
||||||
value={searchKeyword}
|
value={viewSearch}
|
||||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
onChange={(e) => setViewSearch(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-8 h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
</CardHeader>
|
||||||
<SelectTrigger className="w-[180px]">
|
<div className="flex-1 overflow-auto">
|
||||||
<SelectValue placeholder="构建状态" />
|
{loading.views ? (
|
||||||
</SelectTrigger>
|
<div className="flex items-center justify-center h-full">
|
||||||
<SelectContent>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<SelectItem value="all">全部状态</SelectItem>
|
</div>
|
||||||
<SelectItem value="SUCCESS">成功</SelectItem>
|
) : filteredViews.length === 0 ? (
|
||||||
<SelectItem value="FAILURE">失败</SelectItem>
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
<SelectItem value="UNSTABLE">不稳定</SelectItem>
|
<Layers className="h-12 w-12 mb-2 opacity-20" />
|
||||||
<SelectItem value="RUNNING">构建中</SelectItem>
|
<p className="text-sm">暂无视图</p>
|
||||||
</SelectContent>
|
</div>
|
||||||
</Select>
|
) : (
|
||||||
<Button
|
<div className="p-2 space-y-2">
|
||||||
variant="outline"
|
{filteredViews.map((view) => (
|
||||||
onClick={() => {
|
<div
|
||||||
setSearchKeyword('');
|
key={view.id}
|
||||||
setStatusFilter('all');
|
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
|
||||||
}}
|
selectedView?.id === view.id
|
||||||
|
? 'bg-accent border-primary'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectView(view)}
|
||||||
>
|
>
|
||||||
重置
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{view.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{view.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{view.viewUrl && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={view.viewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 中栏:Jobs 列表 */}
|
||||||
|
<Card className="col-span-5 flex flex-col min-h-0">
|
||||||
|
<CardHeader className="border-b flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
Jobs {selectedView && `(${filteredJobs.length})`}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleSyncJobs}
|
||||||
|
disabled={syncing.jobs || !selectedInstanceId}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${syncing.jobs ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索任务..."
|
||||||
|
value={jobSearch}
|
||||||
|
onChange={(e) => setJobSearch(e.target.value)}
|
||||||
|
className="pl-8 h-8"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Jobs 表格 */}
|
|
||||||
{loading ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex items-center justify-center py-16">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : instanceDetails && instanceDetails.jenkinsViewList.length > 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>
|
|
||||||
{instanceDetails.jenkinsViewList.find((v) => v.id === currentView)?.viewName || 'Jobs'} 列表
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<div className="flex-1 overflow-auto">
|
||||||
<div className="rounded-md border">
|
{loading.jobs ? (
|
||||||
<Table minWidth="1170px">
|
<div className="flex items-center justify-center h-full">
|
||||||
<TableHeader>
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
<TableRow>
|
</div>
|
||||||
<TableHead width="250px">Job 名称</TableHead>
|
) : !selectedView ? (
|
||||||
<TableHead width="300px">描述</TableHead>
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
<TableHead width="100px">最后构建</TableHead>
|
<Box className="h-12 w-12 mb-2 opacity-20" />
|
||||||
<TableHead width="120px">状态</TableHead>
|
<p className="text-sm">请先选择视图</p>
|
||||||
<TableHead width="120px">健康度</TableHead>
|
</div>
|
||||||
<TableHead width="180px">构建时间</TableHead>
|
) : filteredJobs.length === 0 ? (
|
||||||
<TableHead width="100px" sticky>操作</TableHead>
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
</TableRow>
|
<Box className="h-12 w-12 mb-2 opacity-20" />
|
||||||
</TableHeader>
|
<p className="text-sm">暂无任务</p>
|
||||||
<TableBody>
|
|
||||||
{filteredJobs.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7} className="h-24 text-center">
|
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground">
|
|
||||||
<Box className="h-8 w-8 mb-2" />
|
|
||||||
<p>暂无任务</p>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
filteredJobs.map((job) => {
|
<div className="p-2 space-y-2">
|
||||||
|
{filteredJobs.map((job) => {
|
||||||
const statusInfo = getBuildStatusInfo(job.lastBuildStatus);
|
const statusInfo = getBuildStatusInfo(job.lastBuildStatus);
|
||||||
return (
|
return (
|
||||||
<TableRow key={job.id}>
|
<div
|
||||||
<TableCell width="250px" className="font-medium">
|
key={job.id}
|
||||||
<div className="flex items-center gap-2">
|
className={`p-3 border rounded-lg cursor-pointer hover:bg-accent transition-colors ${
|
||||||
<span className="truncate">{job.jobName}</span>
|
selectedJob?.id === job.id
|
||||||
|
? 'bg-accent border-primary'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSelectJob(job)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Box className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||||
|
<span className="font-medium truncate">
|
||||||
|
{job.jobName}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
{job.description && (
|
||||||
<TableCell width="300px" className="text-muted-foreground text-sm">
|
<p className="text-xs text-muted-foreground line-clamp-2 mb-2">
|
||||||
{job.description || '-'}
|
{job.description}
|
||||||
</TableCell>
|
</p>
|
||||||
<TableCell width="100px">
|
)}
|
||||||
<span className="text-sm">#{job.lastBuildNumber}</span>
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
</TableCell>
|
<Badge
|
||||||
<TableCell width="120px">
|
variant={statusInfo.variant}
|
||||||
<Badge variant={statusInfo.variant} className="inline-flex items-center gap-1">
|
className="inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
{statusInfo.icon}
|
{statusInfo.icon}
|
||||||
{statusInfo.label}
|
{statusInfo.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
<TableCell width="120px">
|
<GitBranch className="h-3 w-3" />
|
||||||
<div className="space-y-1">
|
#{job.lastBuildNumber}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<Progress
|
<Progress
|
||||||
value={job.healthReportScore}
|
value={job.healthReportScore}
|
||||||
className="h-2"
|
className="h-2 w-16"
|
||||||
indicatorClassName={getHealthColor(job.healthReportScore)}
|
indicatorClassName={getHealthColor(
|
||||||
|
job.healthReportScore
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-muted-foreground">{job.healthReportScore}%</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{job.healthReportScore}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell width="180px" className="text-sm text-muted-foreground">
|
{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)}
|
{formatTime(job.lastBuildTime)}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell width="100px" sticky>
|
)}
|
||||||
|
</div>
|
||||||
|
{job.jobUrl && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<a
|
||||||
|
href={job.jobUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
onClick={(e) => {
|
className="h-6 w-6"
|
||||||
e.preventDefault();
|
|
||||||
// 确保使用绝对 URL
|
|
||||||
const url = job.jobUrl.startsWith('http')
|
|
||||||
? job.jobUrl
|
|
||||||
: `${jenkinsList.find(j => j.id === Number(currentJenkinsId))?.url || ''}${job.jobUrl}`;
|
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
查看
|
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</a>
|
||||||
</TableRow>
|
</TooltipTrigger>
|
||||||
);
|
<TooltipContent>
|
||||||
})
|
<p>在 Jenkins 中查看</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
);
|
||||||
) : (
|
})}
|
||||||
<Card>
|
</div>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
|
||||||
<Layers className="h-16 w-16 text-muted-foreground mb-4" />
|
|
||||||
<h3 className="text-lg font-semibold mb-2">暂无视图数据</h3>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
请点击上方的同步按钮获取 Jenkins 数据
|
|
||||||
</p>
|
|
||||||
<Button onClick={handleSyncAll} disabled={syncing.all}>
|
|
||||||
{syncing.all ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
|
||||||
)}
|
)}
|
||||||
立即同步
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 右栏:Builds 列表(待实现) */}
|
||||||
|
<Card className="col-span-4 flex flex-col min-h-0">
|
||||||
|
<CardHeader className="border-b flex-shrink-0">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-base">Builds</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleSyncBuilds}
|
||||||
|
disabled={syncing.builds || !selectedJob}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={`h-4 w-4 ${syncing.builds ? 'animate-spin' : ''}`}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
<div className="relative mt-2">
|
||||||
|
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索构建..."
|
||||||
|
value={buildSearch}
|
||||||
|
onChange={(e) => setBuildSearch(e.target.value)}
|
||||||
|
className="pl-8 h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{!selectedJob ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<Activity className="h-12 w-12 mb-2 opacity-20" />
|
||||||
|
<p className="text-sm">请先选择任务</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||||
|
<Activity className="h-12 w-12 mb-2 opacity-20" />
|
||||||
|
<p className="text-sm">构建列表待实现</p>
|
||||||
|
<p className="text-xs mt-1">等待接口定义</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,441 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Pause,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
TrendingUp,
|
||||||
|
AlertCircle,
|
||||||
|
Calendar,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getDashboard, getScheduleJobs } from '../service';
|
||||||
|
import type { DashboardResponse, ScheduleJobResponse, LogStatus } from '../types';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const [data, setData] = useState<DashboardResponse | null>(null);
|
||||||
|
const [allJobs, setAllJobs] = useState<ScheduleJobResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 加载仪表盘数据
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const [dashboardData, jobsData] = await Promise.all([
|
||||||
|
getDashboard(),
|
||||||
|
getScheduleJobs({ pageNum: 0, pageSize: 100 })
|
||||||
|
]);
|
||||||
|
setData(dashboardData);
|
||||||
|
setAllJobs(jobsData?.content || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载仪表盘数据失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始加载和5秒轮询
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
const interval = setInterval(loadData, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取日志状态徽章
|
||||||
|
const getLogStatusBadge = (status: LogStatus) => {
|
||||||
|
const statusMap: Record<LogStatus, {
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'success';
|
||||||
|
text: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}> = {
|
||||||
|
SUCCESS: { variant: 'success', text: '成功', icon: CheckCircle2 },
|
||||||
|
FAIL: { variant: 'destructive', text: '失败', icon: XCircle },
|
||||||
|
TIMEOUT: { variant: 'secondary', text: '超时', icon: Clock },
|
||||||
|
};
|
||||||
|
const info = statusMap[status];
|
||||||
|
const Icon = info.icon;
|
||||||
|
return (
|
||||||
|
<Badge variant={info.variant} className="inline-flex items-center gap-1">
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{info.text}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按状态分组任务
|
||||||
|
const groupedJobs = React.useMemo(() => {
|
||||||
|
return {
|
||||||
|
running: allJobs.filter(j => data?.runningJobs.some(r => r.jobId === j.id)),
|
||||||
|
paused: allJobs.filter(j => j.status === 'PAUSED'),
|
||||||
|
idle: allJobs.filter(j => j.status === 'ENABLED' && !data?.runningJobs.some(r => r.jobId === j.id)),
|
||||||
|
disabled: allJobs.filter(j => j.status === 'DISABLED'),
|
||||||
|
};
|
||||||
|
}, [allJobs, data]);
|
||||||
|
|
||||||
|
// 获取即将执行的任务(按下次执行时间排序)
|
||||||
|
const upcomingJobs = React.useMemo(() => {
|
||||||
|
return allJobs
|
||||||
|
.filter(j => j.nextExecuteTime && j.status === 'ENABLED')
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (!a.nextExecuteTime || !b.nextExecuteTime) return 0;
|
||||||
|
return dayjs(a.nextExecuteTime).valueOf() - dayjs(b.nextExecuteTime).valueOf();
|
||||||
|
})
|
||||||
|
.slice(0, 10);
|
||||||
|
}, [allJobs]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-96">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* 任务概览统计卡片 */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-5">
|
||||||
|
<Card className="border-l-4 border-l-blue-500">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">任务总数</CardTitle>
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.summary.total || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">所有定时任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-orange-500">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">正在执行</CardTitle>
|
||||||
|
<Loader2 className="h-4 w-4 text-muted-foreground animate-spin" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.summary.running || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">运行中的任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-green-500">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">已启用</CardTitle>
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.summary.enabled || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">活跃的任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-yellow-500">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">已暂停</CardTitle>
|
||||||
|
<Pause className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.summary.paused || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">暂停的任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-l-4 border-l-gray-500">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">已禁用</CardTitle>
|
||||||
|
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.summary.disabled || 0}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">停用的任务</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 正在执行的任务 */}
|
||||||
|
{data && data.runningJobs.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-orange-500" />
|
||||||
|
正在执行的任务 ({data.runningJobs.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{data.runningJobs.map((job) => (
|
||||||
|
<div key={job.jobId} className="p-4 border rounded-lg space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-lg">{job.jobName}</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
开始时间: {dayjs(job.startTime).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-orange-600 border-orange-600">
|
||||||
|
{job.progress}%
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Progress value={job.progress} className="h-3" />
|
||||||
|
{job.message && (
|
||||||
|
<p className="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
{job.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 多视图 Tabs */}
|
||||||
|
<Tabs defaultValue="timeline" className="space-y-4">
|
||||||
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
|
<TabsTrigger value="timeline">
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
时间线视图
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="status">
|
||||||
|
<TrendingUp className="h-4 w-4 mr-2" />
|
||||||
|
状态分组
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="logs">
|
||||||
|
<AlertCircle className="h-4 w-4 mr-2" />
|
||||||
|
最近日志
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 时间线视图 */}
|
||||||
|
<TabsContent value="timeline">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>任务执行时间线</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
即将执行的任务按时间顺序排列
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{upcomingJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Clock className="h-12 w-12 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>暂无即将执行的任务</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative pl-8">
|
||||||
|
{/* 时间线 */}
|
||||||
|
<div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" />
|
||||||
|
|
||||||
|
{upcomingJobs.map((job, index) => (
|
||||||
|
<div key={job.id} className="relative mb-6 last:mb-0">
|
||||||
|
{/* 时间点 */}
|
||||||
|
<div className="absolute -left-[1.65rem] top-1.5 w-3 h-3 rounded-full bg-primary border-2 border-background" />
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="min-w-[80px] text-sm font-medium text-muted-foreground">
|
||||||
|
{dayjs(job.nextExecuteTime).format('HH:mm')}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 p-3 border rounded-lg hover:bg-accent transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{job.jobName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{dayjs(job.nextExecuteTime).fromNow()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{index === 0 && data?.runningJobs.some(r => r.jobId === job.id) ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
执行中
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
待执行
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 状态分组视图 */}
|
||||||
|
<TabsContent value="status">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 运行中 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-orange-500 animate-pulse" />
|
||||||
|
运行中 ({groupedJobs.running.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{groupedJobs.running.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">无正在运行的任务</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{groupedJobs.running.map((job) => {
|
||||||
|
const runningJob = data?.runningJobs.find(r => r.jobId === job.id);
|
||||||
|
return (
|
||||||
|
<div key={job.id} className="flex items-center gap-3 p-2 border rounded">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{job.jobName}</p>
|
||||||
|
{runningJob && (
|
||||||
|
<Progress value={runningJob.progress} className="h-2 mt-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{runningJob && (
|
||||||
|
<Badge variant="outline">{runningJob.progress}%</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 已暂停 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<Pause className="h-4 w-4 text-yellow-600" />
|
||||||
|
已暂停 ({groupedJobs.paused.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{groupedJobs.paused.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">无暂停的任务</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{groupedJobs.paused.map((job) => (
|
||||||
|
<div key={job.id} className="flex items-center justify-between p-2 border rounded">
|
||||||
|
<p className="font-medium">{job.jobName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
上次: {job.lastExecuteTime ? dayjs(job.lastExecuteTime).fromNow() : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 空闲中 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
空闲中 ({groupedJobs.idle.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{groupedJobs.idle.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">无空闲任务</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{groupedJobs.idle.map((job) => (
|
||||||
|
<div key={job.id} className="p-2 border rounded">
|
||||||
|
<p className="font-medium truncate">{job.jobName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
下次: {job.nextExecuteTime ? dayjs(job.nextExecuteTime).format('MM-DD HH:mm') : '-'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 已禁用 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base flex items-center gap-2">
|
||||||
|
<XCircle className="h-4 w-4 text-gray-500" />
|
||||||
|
已禁用 ({groupedJobs.disabled.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{groupedJobs.disabled.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">无禁用的任务</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{groupedJobs.disabled.map((job) => (
|
||||||
|
<div key={job.id} className="p-2 border rounded opacity-60">
|
||||||
|
<p className="font-medium truncate">{job.jobName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">已停用</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 最近日志 */}
|
||||||
|
<TabsContent value="logs">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近执行日志 (10条)</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{!data || data.recentLogs.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<AlertCircle className="h-12 w-12 mx-auto mb-2 opacity-20" />
|
||||||
|
<p>暂无执行日志</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.recentLogs.map((log) => (
|
||||||
|
<div key={log.id} className="p-3 border rounded-lg hover:bg-accent transition-colors">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{log.jobName}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{dayjs(log.executeTime).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{getLogStatusBadge(log.status)}
|
||||||
|
</div>
|
||||||
|
{log.resultMessage && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{log.resultMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{log.exceptionInfo && (
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
{log.exceptionInfo}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
|
|
||||||
@ -4,37 +4,37 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { DataTablePagination } from '@/components/ui/pagination';
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
Search,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Server,
|
Server,
|
||||||
Calendar,
|
Calendar,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Timer,
|
||||||
|
Code,
|
||||||
|
Info,
|
||||||
|
Activity,
|
||||||
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getJobLogs } from '../service';
|
import { getJobLogs } from '../service';
|
||||||
import type { ScheduleJobResponse, ScheduleJobLogResponse, ScheduleJobLogQuery, LogStatus } from '../types';
|
import type { ScheduleJobResponse, ScheduleJobLogResponse, ScheduleJobLogQuery, LogStatus } from '../types';
|
||||||
import type { Page } from '@/types/base';
|
import type { Page } from '@/types/base';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface JobLogDialogProps {
|
interface JobLogDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -131,112 +131,94 @@ const JobLogDialog: React.FC<JobLogDialogProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-7xl max-h-[90vh] overflow-hidden flex flex-col">
|
<DialogContent className="max-w-7xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5" />
|
<FileText className="h-5 w-5 text-primary" />
|
||||||
执行日志 - {job?.jobName}
|
执行日志 - {job?.jobName}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden flex flex-col space-y-4">
|
<DialogBody className="space-y-4">
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<Select
|
<Select
|
||||||
value={query.status || 'all'}
|
value={query.status || 'all'}
|
||||||
onValueChange={(value) => setQuery({ ...query, status: value !== 'all' ? value as LogStatus : undefined, pageNum: 0 })}
|
onValueChange={(value) => setQuery({ ...query, status: value !== 'all' ? value as LogStatus : undefined, pageNum: 0 })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[160px]">
|
||||||
<SelectValue placeholder="全部状态" />
|
<SelectValue placeholder="全部状态" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">全部状态</SelectItem>
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
<SelectItem value="SUCCESS">成功</SelectItem>
|
<SelectItem value="SUCCESS">✓ 成功</SelectItem>
|
||||||
<SelectItem value="FAIL">失败</SelectItem>
|
<SelectItem value="FAIL">✗ 失败</SelectItem>
|
||||||
<SelectItem value="TIMEOUT">超时</SelectItem>
|
<SelectItem value="TIMEOUT">⏱ 超时</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button variant="outline" size="sm" onClick={loadLogs}>
|
</div>
|
||||||
<Search className="h-4 w-4 mr-2" />
|
<Button variant="outline" size="sm" onClick={loadLogs} disabled={loading}>
|
||||||
|
<RefreshCw className={cn("h-4 w-4 mr-2", loading && "animate-spin")} />
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 日志列表 */}
|
{/* 日志列表 */}
|
||||||
<div className="flex-1 overflow-auto rounded-md border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[180px]">执行时间</TableHead>
|
|
||||||
<TableHead className="w-[180px]">完成时间</TableHead>
|
|
||||||
<TableHead className="w-[100px]">耗时</TableHead>
|
|
||||||
<TableHead className="w-[100px]">状态</TableHead>
|
|
||||||
<TableHead className="w-[120px]">服务器IP</TableHead>
|
|
||||||
<TableHead>结果消息</TableHead>
|
|
||||||
<TableHead className="w-[80px]">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<TableRow>
|
<div className="flex items-center justify-center h-64">
|
||||||
<TableCell colSpan={7} className="h-32 text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-2 text-primary" />
|
||||||
</TableCell>
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
</TableRow>
|
</div>
|
||||||
|
</div>
|
||||||
) : data && data.content.length > 0 ? (
|
) : data && data.content.length > 0 ? (
|
||||||
data.content.map((log) => (
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<TableRow
|
{/* 左侧:日志列表 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<ScrollArea className="h-[500px] pr-4">
|
||||||
|
{data.content.map((log) => (
|
||||||
|
<Card
|
||||||
key={log.id}
|
key={log.id}
|
||||||
className={selectedLog?.id === log.id ? 'bg-accent' : ''}
|
className={cn(
|
||||||
>
|
"mb-3 cursor-pointer transition-all hover:shadow-md",
|
||||||
<TableCell className="text-xs">
|
selectedLog?.id === log.id
|
||||||
<div className="flex items-center gap-1">
|
? "border-primary bg-accent"
|
||||||
<Calendar className="h-3 w-3 text-muted-foreground" />
|
: "hover:border-muted-foreground/30"
|
||||||
{dayjs(log.executeTime).format('YYYY-MM-DD HH:mm:ss')}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{log.finishTime ? dayjs(log.finishTime).format('YYYY-MM-DD HH:mm:ss') : '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-xs font-mono">
|
|
||||||
{formatDuration(log.duration)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{getStatusBadge(log.status)}</TableCell>
|
|
||||||
<TableCell className="text-xs">
|
|
||||||
{log.serverIp ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Server className="h-3 w-3 text-muted-foreground" />
|
|
||||||
{log.serverIp}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'-'
|
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
|
||||||
<TableCell className="max-w-[300px] truncate text-xs" title={log.resultMessage}>
|
|
||||||
{log.resultMessage || '-'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs"
|
|
||||||
onClick={() => setSelectedLog(log)}
|
onClick={() => setSelectedLog(log)}
|
||||||
>
|
>
|
||||||
详情
|
<CardContent className="p-4">
|
||||||
</Button>
|
<div className="flex items-start justify-between mb-3">
|
||||||
</TableCell>
|
<div className="flex items-center gap-2">
|
||||||
</TableRow>
|
{getStatusBadge(log.status)}
|
||||||
))
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
) : (
|
{formatDuration(log.duration)}
|
||||||
<TableRow>
|
</span>
|
||||||
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
|
|
||||||
暂无执行日志
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<Calendar className="h-3 w-3" />
|
||||||
|
{dayjs(log.executeTime).format('MM-DD HH:mm:ss')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{log.serverIp && (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Server className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="font-mono">{log.serverIp}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{log.resultMessage && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{log.resultMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
{data && data.content.length > 0 && (
|
{data && data.content.length > 0 && (
|
||||||
@ -247,77 +229,162 @@ const JobLogDialog: React.FC<JobLogDialogProps> = ({
|
|||||||
onPageChange={(pageIndex) => setQuery({ ...query, pageNum: pageIndex })}
|
onPageChange={(pageIndex) => setQuery({ ...query, pageNum: pageIndex })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 日志详情 */}
|
{/* 右侧:详情面板 */}
|
||||||
{selectedLog && (
|
<div className="border rounded-lg">
|
||||||
<div className="border-t pt-4 space-y-3">
|
{selectedLog ? (
|
||||||
|
<ScrollArea className="h-[556px]">
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<Info className="h-4 w-4 text-primary" />
|
||||||
执行详情
|
执行详情
|
||||||
</h3>
|
</h3>
|
||||||
<Button variant="ghost" size="sm" onClick={() => setSelectedLog(null)}>
|
{getStatusBadge(selectedLog.status)}
|
||||||
关闭
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<Separator />
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">Bean名称:</span>
|
{/* 基本信息 */}
|
||||||
<code className="ml-2 text-xs bg-muted px-1 py-0.5 rounded">{selectedLog.beanName}</code>
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Calendar className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">执行时间</p>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{dayjs(selectedLog.executeTime).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">方法名称:</span>
|
|
||||||
<code className="ml-2 text-xs bg-muted px-1 py-0.5 rounded">{selectedLog.methodName}</code>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-muted-foreground">服务器主机:</span>
|
{selectedLog.finishTime && (
|
||||||
<span className="ml-2">{selectedLog.serverHost || '-'}</span>
|
<div className="flex items-start gap-3">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">完成时间</p>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{dayjs(selectedLog.finishTime).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<span className="text-muted-foreground">服务器IP:</span>
|
)}
|
||||||
<span className="ml-2">{selectedLog.serverIp || '-'}</span>
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Timer className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">执行耗时</p>
|
||||||
|
<p className="text-sm font-medium font-mono">
|
||||||
|
{formatDuration(selectedLog.duration)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedLog.serverIp && (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Server className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">服务器信息</p>
|
||||||
|
<p className="text-sm font-medium font-mono">{selectedLog.serverIp}</p>
|
||||||
|
{selectedLog.serverHost && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{selectedLog.serverHost}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* 技术信息 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Code className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">Bean名称</p>
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded block overflow-x-auto">
|
||||||
|
{selectedLog.beanName}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Activity className="h-4 w-4 text-muted-foreground mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">方法名称</p>
|
||||||
|
<code className="text-xs bg-muted px-2 py-1 rounded block overflow-x-auto">
|
||||||
|
{selectedLog.methodName}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedLog.methodParams && (
|
{selectedLog.methodParams && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-muted-foreground mb-1 block">方法参数:</label>
|
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||||
<Textarea
|
<Code className="h-3.5 w-3.5" />
|
||||||
value={selectedLog.methodParams}
|
方法参数
|
||||||
readOnly
|
</p>
|
||||||
className="font-mono text-xs h-20"
|
<pre className="text-xs bg-muted p-3 rounded overflow-x-auto font-mono">
|
||||||
/>
|
{selectedLog.methodParams}
|
||||||
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 结果信息 */}
|
||||||
{selectedLog.resultMessage && (
|
{selectedLog.resultMessage && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-muted-foreground mb-1 block">结果消息:</label>
|
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||||
<Textarea
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
value={selectedLog.resultMessage}
|
结果消息
|
||||||
readOnly
|
</p>
|
||||||
className="text-xs h-20"
|
<div className="text-sm bg-muted/50 p-3 rounded border">
|
||||||
/>
|
{selectedLog.resultMessage}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 异常信息 */}
|
||||||
{selectedLog.exceptionInfo && (
|
{selectedLog.exceptionInfo && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-destructive mb-1 block flex items-center gap-1">
|
<p className="text-xs text-destructive mb-2 flex items-center gap-1 font-medium">
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
异常信息:
|
异常信息
|
||||||
</label>
|
</p>
|
||||||
<Textarea
|
<pre className="text-xs bg-destructive/5 border border-destructive/20 p-3 rounded overflow-x-auto font-mono text-destructive max-h-60">
|
||||||
value={selectedLog.exceptionInfo}
|
{selectedLog.exceptionInfo}
|
||||||
readOnly
|
</pre>
|
||||||
className="font-mono text-xs h-40 text-destructive"
|
</div>
|
||||||
/>
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-[556px] text-center">
|
||||||
|
<div>
|
||||||
|
<Info className="h-12 w-12 mx-auto mb-3 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
选择左侧日志查看详情
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-64 border rounded-lg">
|
||||||
|
<div className="text-center">
|
||||||
|
<AlertCircle className="h-12 w-12 mx-auto mb-3 text-muted-foreground/30" />
|
||||||
|
<p className="text-sm text-muted-foreground">暂无执行日志</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,18 +6,20 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { DataTablePagination } from '@/components/ui/pagination';
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Loader2, Plus, Search, Edit, Trash2, Play, Pause,
|
Loader2, Plus, Search, Edit, Trash2, Play, Pause,
|
||||||
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText
|
Clock, Activity, CheckCircle2, XCircle, FolderKanban, PlayCircle, FileText, BarChart3, List
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { getScheduleJobs, getJobCategoryList, executeJobNow, pauseJob, resumeJob, deleteScheduleJob } from './service';
|
import { getScheduleJobs, getJobCategoryList, startJob, pauseJob, resumeJob, stopJob, triggerJob, deleteScheduleJob } from './service';
|
||||||
import type { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types';
|
import type { ScheduleJobResponse, ScheduleJobQuery, JobCategoryResponse, JobStatus } from './types';
|
||||||
import type { Page } from '@/types/base';
|
import type { Page } from '@/types/base';
|
||||||
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
import CategoryManageDialog from './components/CategoryManageDialog';
|
import CategoryManageDialog from './components/CategoryManageDialog';
|
||||||
import JobLogDialog from './components/JobLogDialog';
|
import JobLogDialog from './components/JobLogDialog';
|
||||||
import JobEditDialog from './components/JobEditDialog';
|
import JobEditDialog from './components/JobEditDialog';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -139,20 +141,20 @@ const ScheduleJobList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 立即执行
|
// 启动任务
|
||||||
const handleExecute = async (record: ScheduleJobResponse) => {
|
const handleStart = async (record: ScheduleJobResponse) => {
|
||||||
try {
|
try {
|
||||||
await executeJobNow(record.id);
|
await startJob(record.id);
|
||||||
toast({
|
toast({
|
||||||
title: '执行成功',
|
title: '启动成功',
|
||||||
description: `任务 "${record.jobName}" 已开始执行`,
|
description: `任务 "${record.jobName}" 已启动`,
|
||||||
});
|
});
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('执行失败:', error);
|
console.error('启动失败:', error);
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
title: '执行失败',
|
title: '启动失败',
|
||||||
description: error instanceof Error ? error.message : '未知错误',
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -196,6 +198,44 @@ const ScheduleJobList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 停止任务
|
||||||
|
const handleStop = async (record: ScheduleJobResponse) => {
|
||||||
|
try {
|
||||||
|
await stopJob(record.id);
|
||||||
|
toast({
|
||||||
|
title: '停止成功',
|
||||||
|
description: `任务 "${record.jobName}" 已停止`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('停止失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '停止失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即触发任务
|
||||||
|
const handleTrigger = async (record: ScheduleJobResponse) => {
|
||||||
|
try {
|
||||||
|
await triggerJob(record.id);
|
||||||
|
toast({
|
||||||
|
title: '触发成功',
|
||||||
|
description: `任务 "${record.jobName}" 已立即触发执行`,
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('触发失败:', error);
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '触发失败',
|
||||||
|
description: error instanceof Error ? error.message : '未知错误',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 查看日志
|
// 查看日志
|
||||||
const handleViewLog = (record: ScheduleJobResponse) => {
|
const handleViewLog = (record: ScheduleJobResponse) => {
|
||||||
setSelectedJob(record);
|
setSelectedJob(record);
|
||||||
@ -240,8 +280,23 @@ const ScheduleJobList: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 视图切换 */}
|
||||||
|
<Tabs defaultValue="list" className="space-y-6">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="list" className="gap-2">
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
列表视图
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="dashboard" className="gap-2">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
仪表盘
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 列表视图 */}
|
||||||
|
<TabsContent value="list" className="space-y-6">
|
||||||
{/* 统计卡片 */}
|
{/* 统计卡片 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
|
<Card className="bg-gradient-to-br from-blue-500/10 to-blue-500/5 border-blue-500/20">
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium text-blue-700">总任务</CardTitle>
|
<CardTitle className="text-sm font-medium text-blue-700">总任务</CardTitle>
|
||||||
@ -408,8 +463,8 @@ const ScheduleJobList: React.FC = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={() => handleExecute(record)}
|
onClick={() => handleTrigger(record)}
|
||||||
title="立即执行"
|
title="立即触发"
|
||||||
>
|
>
|
||||||
<PlayCircle className="h-4 w-4" />
|
<PlayCircle className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@ -564,6 +619,13 @@ const ScheduleJobList: React.FC = () => {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 仪表盘视图 */}
|
||||||
|
<TabsContent value="dashboard">
|
||||||
|
<Dashboard />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import type {
|
|||||||
ScheduleJobQuery,
|
ScheduleJobQuery,
|
||||||
ScheduleJobLogResponse,
|
ScheduleJobLogResponse,
|
||||||
ScheduleJobLogQuery,
|
ScheduleJobLogQuery,
|
||||||
|
DashboardResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const JOB_CATEGORY_URL = '/api/v1/schedule/job-categories';
|
const JOB_CATEGORY_URL = '/api/v1/schedule/job-categories';
|
||||||
@ -92,10 +93,10 @@ export const batchDeleteScheduleJobs = (ids: number[]) =>
|
|||||||
request.post(`${SCHEDULE_JOB_URL}/batch-delete`, { ids });
|
request.post(`${SCHEDULE_JOB_URL}/batch-delete`, { ids });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 立即执行任务
|
* 启动任务
|
||||||
*/
|
*/
|
||||||
export const executeJobNow = (id: number) =>
|
export const startJob = (id: number) =>
|
||||||
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/execute`);
|
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/start`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 暂停任务
|
* 暂停任务
|
||||||
@ -109,6 +110,26 @@ export const pauseJob = (id: number) =>
|
|||||||
export const resumeJob = (id: number) =>
|
export const resumeJob = (id: number) =>
|
||||||
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/resume`);
|
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/resume`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止任务
|
||||||
|
*/
|
||||||
|
export const stopJob = (id: number) =>
|
||||||
|
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/stop`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 立即触发任务
|
||||||
|
*/
|
||||||
|
export const triggerJob = (id: number) =>
|
||||||
|
request.post<void>(`${SCHEDULE_JOB_URL}/${id}/trigger`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新Cron表达式
|
||||||
|
*/
|
||||||
|
export const updateJobCron = (id: number, cronExpression: string) =>
|
||||||
|
request.put<void>(`${SCHEDULE_JOB_URL}/${id}/cron`, null, {
|
||||||
|
params: { cronExpression }
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 任务日志 ====================
|
// ==================== 任务日志 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -117,3 +138,11 @@ export const resumeJob = (id: number) =>
|
|||||||
export const getJobLogs = (params?: ScheduleJobLogQuery) =>
|
export const getJobLogs = (params?: ScheduleJobLogQuery) =>
|
||||||
request.get<Page<ScheduleJobLogResponse>>(`${JOB_LOG_URL}/page`, { params });
|
request.get<Page<ScheduleJobLogResponse>>(`${JOB_LOG_URL}/page`, { params });
|
||||||
|
|
||||||
|
// ==================== 仪表盘 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取仪表盘数据
|
||||||
|
*/
|
||||||
|
export const getDashboard = () =>
|
||||||
|
request.get<DashboardResponse>(`${SCHEDULE_JOB_URL}/dashboard`);
|
||||||
|
|
||||||
|
|||||||
@ -148,3 +148,35 @@ export interface ScheduleJobLogQuery extends BaseQuery {
|
|||||||
executeTimeEnd?: string;
|
executeTimeEnd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘概览统计
|
||||||
|
*/
|
||||||
|
export interface DashboardSummary {
|
||||||
|
total: number;
|
||||||
|
enabled: number;
|
||||||
|
disabled: number;
|
||||||
|
paused: number;
|
||||||
|
running: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正在运行的任务
|
||||||
|
*/
|
||||||
|
export interface RunningJob {
|
||||||
|
jobId: number;
|
||||||
|
jobName: string;
|
||||||
|
startTime: string;
|
||||||
|
progress: number;
|
||||||
|
message?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仪表盘响应
|
||||||
|
*/
|
||||||
|
export interface DashboardResponse {
|
||||||
|
summary: DashboardSummary;
|
||||||
|
runningJobs: RunningJob[];
|
||||||
|
recentLogs: ScheduleJobLogResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user