feat: 脚本/定时任务真实执行+输出捕获+执行历史 — 替换pending空壳为本地执行,采集stdout/stderr,更新统计;修复CronJobManager/Automation字段名不匹配(status→enabled,cron→cron_expr,command→content,language→type)

This commit is contained in:
attacker committed 2026-06-14 22:11:18 +08:00
1 parent 85bc2a8e05
commit a4fd5fc178
19 files changed
+2715 -114

No files matched your search

@@ -1,9 +1,8 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
@@ -35,21 +34,16 @@ import {
Search,
Clock,
Pause,
RotateCw,
Edit,
Trash2,
History,
CheckCircle,
XCircle,
Loader2,
Server,
Calendar,
Terminal,
MoreHorizontal,
Eye,
Copy,
} from 'lucide-react';
import { cronJobApi } from '@/services/api';
import { toast } from 'sonner';
import { cronJobApi, taskExecutionApi } from '@/services/api';
export function CronJobManager() {
const [searchQuery, setSearchQuery] = useState('');
@@ -57,13 +51,16 @@ export function CronJobManager() {
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<any | null>(null);
const [executions, setExecutions] = useState<any[]>([]);
const [executionsLoading, setExecutionsLoading] = useState(false);
const [expandedExec, setExpandedExec] = useState<number | null>(null);
const [executingId, setExecutingId] = useState<number | null>(null);
const [newJob, setNewJob] = useState({
name: '',
description: '',
cron: '',
command: '',
targetServers: '',
cron_expr: '',
type: 'command',
content: '',
});
const loadData = async () => {
@@ -82,6 +79,23 @@ export function CronJobManager() {
loadData();
}, []);
const loadExecutions = async () => {
try {
setExecutionsLoading(true);
const data = await taskExecutionApi.getExecutions({ task_type: 'cron', page_size: 50 });
setExecutions(data.list || []);
} catch (error) {
console.error('Failed to load executions:', error);
} finally {
setExecutionsLoading(false);
}
};
const openLogDialog = () => {
setIsLogDialogOpen(true);
loadExecutions();
};
const filteredJobs = jobs.filter(
(job) =>
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -132,18 +146,25 @@ export function CronJobManager() {
};
const handleCreateJob = async () => {
if (!newJob.name || !newJob.cron_expr) {
toast.error('请填写任务名称和 Cron 表达式');
return;
}
try {
await cronJobApi.createJob({
name: newJob.name,
description: newJob.description,
cron: newJob.cron,
command: newJob.command,
cron_expr: newJob.cron_expr,
type: newJob.type,
content: newJob.content,
});
toast.success('任务创建成功');
setIsCreateDialogOpen(false);
setNewJob({ name: '', description: '', cron: '', command: '', targetServers: '' });
setNewJob({ name: '', description: '', cron_expr: '', type: 'command', content: '' });
loadData();
} catch (error) {
console.error('Failed to create cron job:', error);
toast.error('创建失败');
}
};
@@ -153,24 +174,37 @@ export function CronJobManager() {
loadData();
} catch (error) {
console.error('Failed to toggle job:', error);
toast.error('操作失败');
}
};
const handleExecuteJob = async (id: any) => {
setExecutingId(id);
try {
await cronJobApi.executeJob(id);
const result = await cronJobApi.executeJob(id);
const status = result?.status || 'success';
if (status === 'success') {
toast.success('执行成功', { description: `耗时 ${result?.duration || 0}ms` });
} else {
toast.error('执行失败', { description: (result?.output || '').slice(0, 100) });
}
loadData();
} catch (error) {
console.error('Failed to execute job:', error);
toast.error('执行失败');
} finally {
setExecutingId(null);
}
};
const handleDeleteJob = async (id: any) => {
try {
await cronJobApi.deleteJob(id);
toast.success('已删除');
loadData();
} catch (error) {
console.error('Failed to delete job:', error);
toast.error('删除失败');
}
};
@@ -183,7 +217,7 @@ export function CronJobManager() {
<p className="text-sm text-slate-500 mt-1">Cron 任务管理与执行</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsLogDialogOpen(true)}>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={openLogDialog}>
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
@@ -206,14 +240,16 @@ export function CronJobManager() {
<CardContent className="p-4">
<p className="text-slate-400 text-sm">已启用</p>
<p className="text-2xl font-bold text-emerald-400">
{jobs.filter((j) => j.status === 1).length}
{jobs.filter((j) => j.enabled).length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">执行中</p>
<p className="text-2xl font-bold text-blue-400">--</p>
<p className="text-slate-400 text-sm">累计成功</p>
<p className="text-2xl font-bold text-emerald-400">
{jobs.reduce((sum, j) => sum + (j.success_count || 0), 0)}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
@@ -251,47 +287,59 @@ export function CronJobManager() {
<Card key={job.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="font-medium text-slate-100">{job.name}</h3>
<Badge
variant="outline"
className={
job.status === 1
job.enabled
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{job.status === 1 ? '已启用' : '已禁用'}
{job.enabled ? '已启用' : '已禁用'}
</Badge>
{getStatusBadge(job.last_status)}
<Badge variant="outline" className="border-slate-600 text-slate-400">
{job.type === 'script' ? '脚本' : job.type === 'playbook' ? 'Playbook' : '命令'}
</Badge>
</div>
<p className="text-sm text-slate-400 mt-1">{job.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-slate-400">
<Clock className="h-3 w-3" />
{parseCron(job.cron)}
</span>
<span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" />
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded">{job.command}</code>
{parseCron(job.cron_expr)}
</span>
{(job.content || job.script) && (
<span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" />
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded truncate max-w-xs">
{job.script ? job.script.name : job.content}
</code>
</span>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>上次执行: {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}</span>
<span>下次执行: {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}</span>
<span>上次执行: {job.last_run_at ? new Date(job.last_run_at).toLocaleString() : '--'}</span>
<span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
<span>累计: {job.run_count || 0} 次</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
disabled={executingId === job.id}
onClick={() => handleExecuteJob(job.id)}
>
<Play className="h-4 w-4 mr-1" />
{executingId === job.id ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
立即执行
</Button>
<Button
@@ -300,13 +348,9 @@ export function CronJobManager() {
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => handleToggleJob(job.id)}
>
{job.status === 1 ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{job.enabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit className="h-4 w-4" />
</Button>
<Button
@@ -354,12 +398,12 @@ export function CronJobManager() {
<Label className="text-slate-300">Cron 表达式</Label>
<div className="flex gap-2">
<Input
value={newJob.cron}
onChange={(e) => setNewJob({ ...newJob, cron: e.target.value })}
value={newJob.cron_expr}
onChange={(e) => setNewJob({ ...newJob, cron_expr: e.target.value })}
placeholder="0 2 * * *"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<Select onValueChange={(v) => setNewJob({ ...newJob, cron: v })}>
<Select onValueChange={(v) => setNewJob({ ...newJob, cron_expr: v })}>
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue placeholder="快速选择" />
</SelectTrigger>
@@ -377,21 +421,12 @@ export function CronJobManager() {
<div>
<Label className="text-slate-300">执行命令</Label>
<Textarea
value={newJob.command}
onChange={(e) => setNewJob({ ...newJob, command: e.target.value })}
value={newJob.content}
onChange={(e) => setNewJob({ ...newJob, content: e.target.value })}
placeholder="/scripts/backup.sh"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono"
/>
</div>
<div>
<Label className="text-slate-300">目标服务器</Label>
<Input
value={newJob.targetServers}
onChange={(e) => setNewJob({ ...newJob, targetServers: e.target.value })}
placeholder="server1,server2 或 all"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="border-slate-700 text-slate-300">
@@ -404,49 +439,62 @@ export function CronJobManager() {
</DialogContent>
</Dialog>
{/* Execution Log Dialog */}
{/* Execution History Dialog */}
<Dialog open={isLogDialogOpen} onOpenChange={setIsLogDialogOpen}>
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100">
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100 max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>执行历史</DialogTitle>
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
<DialogDescription className="text-slate-500">定时任务执行记录与输出日志</DialogDescription>
</DialogHeader>
<div className="mt-4">
{jobs.length === 0 ? (
{executionsLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : executions.length === 0 ? (
<div className="text-center text-slate-500 py-8">暂无执行记录</div>
) : (
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<TableHead className="text-slate-400">任务</TableHead>
<TableHead className="text-slate-400">上次执行</TableHead>
<TableHead className="text-slate-400">成功次数</TableHead>
<TableHead className="text-slate-400">失败次数</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">耗时</TableHead>
<TableHead className="text-slate-400">执行时间</TableHead>
<TableHead className="text-slate-400">输出</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => (
<TableRow key={job.id} className="border-slate-700">
<TableCell className="text-slate-200">{job.name}</TableCell>
<TableCell className="text-slate-400">
{job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
</TableCell>
<TableCell className="text-emerald-400">{job.success_count || 0}</TableCell>
<TableCell className="text-red-400">{job.fail_count || 0}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
(job.fail_count || 0) > 0
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{(job.fail_count || 0) > 0 ? '有失败' : '正常'}
</Badge>
</TableCell>
</TableRow>
{executions.map((exec) => (
<>
<TableRow key={exec.id} className="border-slate-700 cursor-pointer hover:bg-slate-800/50" onClick={() => setExpandedExec(expandedExec === exec.id ? null : exec.id)}>
<TableCell className="text-slate-200">{exec.task_name}</TableCell>
<TableCell>{getStatusBadge(exec.status)}</TableCell>
<TableCell className="text-slate-400">{exec.duration ? `${exec.duration}ms` : '--'}</TableCell>
<TableCell className="text-slate-400">
{exec.started_at ? new Date(exec.started_at).toLocaleString() : '--'}
</TableCell>
<TableCell className="text-slate-400">
{exec.output ? (
<span className="text-blue-400 text-xs flex items-center gap-1">
<Terminal className="h-3 w-3" />
{expandedExec === exec.id ? '收起' : '查看'}
</span>
) : (
'--'
)}
</TableCell>
</TableRow>
{expandedExec === exec.id && exec.output && (
<TableRow key={`${exec.id}-out`} className="border-slate-700">
<TableCell colSpan={5} className="bg-slate-950 p-0">
<pre className="p-4 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto">
{exec.output}
</pre>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>