Files
aiops/frontend/src/components/automation/CronJobManager.tsx
T
attacker 77c234b060 feat(audit): F1+F2 告警静默规则标记完成 + 审计日志CSV导出实现
F1 告警静默规则 [done]:
- 后端 alert_silence.go: CRUD + Toggle (onetime/daily/weekly 三模式)
- 告警引擎 isSilenced 集成 (alert_engine.go)
- 前端 SilenceRuleTab + SilenceDialog
- 路由 /alerts/silences 已注册

F2 审计日志导出 [done]:
- 重写 ExportAuditLogs: 真正的 CSV 导出 (encoding/csv + UTF-8 BOM)
- 支持 module/action/username/startTime/endTime 筛选
- 导出限制 10000 条防溢出,自动记录审计日志
- JSON 格式向后兼容 (format=json)
- 前端 exportLogs 传递查询参数

同时包含累积的前期工作:
- 目录重构 internal/ -> controller/model/router (v0.55-v0.57)
- 后端分层架构 dto/repository/service/utils
- 审计模块全功能 (终端审计/操作审计/登录日志)
- asciicast v2 录制格式
- SFTP 文件浏览器
- StatStrip 紧凑统计条
- 在线会话自动刷新
- LiveMonitor WebSocket 诊断修复

验证: go build ✅ | npm run build ✅ | Docker 部署 ✅ | 10 回归端点 ✅ | CSV 导出 ✅ | 静默 CRUD ✅
2026-06-25 20:16:30 +08:00

382 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { StatStrip } from '@/components/ui/stat-strip';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Play,
Plus,
Search,
Clock,
Pause,
Edit,
Trash2,
History,
Loader2,
Terminal,
FileCode,
RefreshCw,
PackageOpen,
} from 'lucide-react';
import { toast } from 'sonner';
import { cronJobApi } from '@/services/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { getStatusBadge, parseCron } from './cronJobHelpers';
import { CronJobFormDialog } from './CronJobFormDialog';
import { ExecutionHistoryDialog } from './ExecutionHistoryDialog';
export function CronJobManager() {
const [searchQuery, setSearchQuery] = useState('');
const [jobs, setJobs] = useState<any[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [isFormDialogOpen, setIsFormDialogOpen] = useState(false);
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
const [executingId, setExecutingId] = useState<number | null>(null);
// editJob=null 表示新建模式;非 null 表示编辑已有任务 // Logan.Li
const [editJob, setEditJob] = useState<any | null>(null);
// 批量选择(批量删除) // Logan.Li
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const _confirm = useConfirm();
const openCreate = () => {
setEditJob(null);
setIsFormDialogOpen(true);
};
const openEdit = ( job: any) => {
setEditJob(job);
setIsFormDialogOpen(true);
};
// 加载任务列表(双 loading state:首次全屏 spinner,手动刷新只 spin icon) // Logan.Li
const refreshData = useCallback(async () => {
setRefreshing(true);
try {
const data = await cronJobApi.getJobs();
setJobs(data.list || []);
} catch (error) {
console.error('Failed to load cron jobs:', error);
toast.error('加载失败');
} finally {
setRefreshing(false);
setInitialLoading(false);
}
}, []);
useEffect(() => {
refreshData();
}, [refreshData]);
const filteredJobs = jobs.filter(
(job) =>
(job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
(job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
);
const handleToggleJob = async ( id: any) => {
try {
await cronJobApi.toggleJob(id);
toast.success('状态已切换');
refreshData();
} catch (error) {
console.error('Failed to toggle job:', error);
toast.error('操作失败');
}
};
const handleExecuteJob = async ( id: any) => {
setExecutingId(id);
try {
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) });
}
refreshData();
} catch (error) {
console.error('Failed to execute job:', error);
toast.error('执行失败');
} finally {
setExecutingId(null);
}
};
// 删除任务(带确认框) // Logan.Li
const handleDeleteJob = async ( job: any) => {
const ok = await _confirm({
title: '删除定时任务',
description: `确定删除「${job.name}」吗?此操作不可恢复。`,
variant: 'destructive',
});
if (!ok) return;
try {
await cronJobApi.deleteJob(job.id);
toast.success('任务已删除');
refreshData();
} catch (error) {
console.error('Failed to delete job:', error);
toast.error('删除失败');
}
};
// 批量选择相关 // Logan.Li
const toggleSelect = (id: number) => {
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id]
);
};
const toggleSelectAll = () => {
if (selectedIds.length === filteredJobs.length && filteredJobs.length > 0) {
setSelectedIds([]);
} else {
setSelectedIds(filteredJobs.map((j) => j.id));
}
};
// 批量删除任务(带确认框) // Logan.Li
const handleBatchDelete = async () => {
if (selectedIds.length === 0) return;
const ok = await _confirm({
title: '批量删除定时任务',
description: `确定删除选中的 ${selectedIds.length} 个任务吗?此操作不可恢复。`,
confirmText: '批量删除',
variant: 'destructive',
});
if (!ok) return;
try {
await cronJobApi.batchDelete(selectedIds);
toast.success(`已删除 ${selectedIds.length} 个任务`);
setSelectedIds([]);
refreshData();
} catch (error) {
console.error('Failed to batch delete jobs:', error);
toast.error('批量删除失败');
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">定时任务</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">Cron 任务管理与执行</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={() => setIsLogDialogOpen(true)}
>
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
<Button
variant="outline"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
onClick={() => refreshData()}
disabled={refreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
新建任务
</Button>
</div>
</div>
{/* Stats */}
<StatStrip
items={[
{ label: '任务总数', value: jobs.length, color: 'blue' },
{ label: '已启用', value: jobs.filter((j) => j.enabled).length, color: 'emerald' },
{ label: '累计成功', value: jobs.reduce((sum, j) => sum + (j.success_count || 0), 0), color: 'emerald' },
{ label: '累计失败', value: jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0), color: 'red' },
]}
/>
{/* Filters */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
<Input
placeholder="搜索任务..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
{filteredJobs.length > 0 && (
<label className="flex items-center gap-2 text-sm text-[var(--dg-text-secondary)] cursor-pointer select-none">
<Checkbox
checked={
selectedIds.length === filteredJobs.length && filteredJobs.length > 0
}
onCheckedChange={toggleSelectAll}
className="border-[var(--dg-border)]"
/>
全选
</label>
)}
</div>
{selectedIds.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedIds.length} 项</span>
<Button
variant="outline"
size="sm"
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
onClick={handleBatchDelete}
>
<Trash2 className="h-4 w-4 mr-2" />
批量删除
</Button>
</div>
)}
</div>
{initialLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" />
</div>
) : filteredJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-secondary)]">
<PackageOpen className="h-12 w-12 mb-3 opacity-40" />
<p className="text-sm">暂无定时任务</p>
</div>
) : null}
{/* Job List */}
<div className="space-y-3">
{filteredJobs.map((job) => (
<Card key={job.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center pt-1">
<Checkbox
checked={selectedIds.includes(job.id)}
onCheckedChange={() => toggleSelect(job.id)}
className="border-[var(--dg-border)]"
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h3 className="font-medium text-[var(--dg-text-primary)]">{job.name}</h3>
<Badge
variant="outline"
className={
job.enabled
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-[var(--dg-border)] text-[var(--dg-text-muted)]'
}
>
{job.enabled ? '已启用' : '已禁用'}
</Badge>
{getStatusBadge(job.last_status)}
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
{job.type === 'shell' ? 'Shell' : job.type === 'python' ? 'Python' : '命令'}
</Badge>
</div>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{job.description || '—'}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-[var(--dg-text-secondary)]">
<Clock className="h-3 w-3" />
{parseCron(job.cron_expr)}
</span>
{(job.type === 'shell' || job.type === 'python') && job.script?.name ? (
<span className="flex items-center gap-1 text-[var(--dg-text-secondary)]">
<FileCode className="h-3 w-3" />
<span className="text-xs">{job.script.name}</span>
</span>
) : job.content && (
<span className="flex items-center gap-1 text-[var(--dg-text-secondary)]">
<Terminal className="h-3 w-3" />
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded truncate max-w-xs">
{job.content}
</code>
</span>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-[var(--dg-text-muted)]">
<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 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)}
>
{executingId === job.id ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Play className="h-4 w-4 mr-1" />
)}
立即执行
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-blue-400"
title={job.enabled ? '禁用' : '启用'}
onClick={() => handleToggleJob(job.id)}
>
{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-[var(--dg-text-secondary)] hover:text-blue-400"
title="编辑"
onClick={() => openEdit(job)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-red-400"
title="删除"
onClick={() => handleDeleteJob(job)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* 新建/编辑定时任务弹窗 */}
<CronJobFormDialog
open={isFormDialogOpen}
onOpenChange={setIsFormDialogOpen}
editJob={editJob}
onSuccess={refreshData}
/>
{/* 执行历史弹窗 */}
<ExecutionHistoryDialog
open={isLogDialogOpen}
onOpenChange={setIsLogDialogOpen}
/>
</div>
);
}