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 ✅
382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
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>
|
||
);
|
||
}
|