FastAPI backend with auth, user, instance, ticket, query, dashboard APIs. React + TypeScript frontend with mock data UI (10 pages). SQLite + async SQLAlchemy, JWT auth, role-based access control. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
294 lines
19 KiB
TypeScript
294 lines
19 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Clock, Plus, Play, Pause, Trash2, AlertTriangle,
|
|
Calendar, Zap, Database, ChevronDown, ChevronUp, X, Save, Eye
|
|
} from 'lucide-react';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { Switch } from '@/components/ui/switch';
|
|
|
|
type TaskFrequency = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
|
type TaskStatus = 'running' | 'paused' | 'completed' | 'failed';
|
|
|
|
type AutoTask = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
frequency: TaskFrequency;
|
|
cron: string;
|
|
nextRun: string;
|
|
lastRun: string;
|
|
lastStatus: 'success' | 'failed' | 'none';
|
|
dbType: string;
|
|
instance: string;
|
|
sql: string;
|
|
status: TaskStatus;
|
|
notifyOnFail: boolean;
|
|
createdBy: string;
|
|
};
|
|
|
|
const frequencyLabels: Record<TaskFrequency, string> = {
|
|
once: '一次性',
|
|
hourly: '每小时',
|
|
daily: '每天',
|
|
weekly: '每周',
|
|
monthly: '每月',
|
|
};
|
|
|
|
const initialTasks: AutoTask[] = [
|
|
{ id: 'task-1', name: '每日数据归档', description: '将 30 天前的日志数据归档到历史表', enabled: true, frequency: 'daily', cron: '0 2 * * *', nextRun: '2024-01-16 02:00', lastRun: '2024-01-15 02:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO logs_archive SELECT * FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);\nDELETE FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
|
{ id: 'task-2', name: '每周统计报表', description: '生成周活跃用户统计报表', enabled: true, frequency: 'weekly', cron: '0 6 * * 1', nextRun: '2024-01-22 06:00', lastRun: '2024-01-08 06:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO report_weekly_active (week_start, week_end, active_users, new_users)\nSELECT \n DATE_SUB(NOW(), INTERVAL 7 DAY) as week_start,\n NOW() as week_end,\n COUNT(DISTINCT user_id) as active_users,\n COUNT(DISTINCT CASE WHEN created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) THEN user_id END) as new_users\nFROM user_activity\nWHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
|
{ id: 'task-3', name: '索引健康检查', description: '检查慢查询并建议优化索引', enabled: true, frequency: 'daily', cron: '0 3 * * *', nextRun: '2024-01-16 03:00', lastRun: '2024-01-15 03:00', lastStatus: 'failed', dbType: 'mysql', instance: '生产主库', sql: 'SELECT \n table_schema,\n table_name,\n index_name,\n cardinality,\n table_rows,\n ROUND(cardinality / table_rows * 100, 2) as selectivity\nFROM information_schema.statistics\nWHERE table_schema NOT IN ("information_schema", "mysql", "performance_schema")\n AND index_name != "PRIMARY"\n AND cardinality / table_rows < 0.1\nORDER BY table_rows DESC\nLIMIT 100;', status: 'paused', notifyOnFail: true, createdBy: 'zhangsan' },
|
|
{ id: 'task-4', name: 'Redis 键过期清理', description: '清理 Redis 中已过期的会话键', enabled: false, frequency: 'hourly', cron: '0 * * * *', nextRun: '-', lastRun: '2024-01-14 12:00', lastStatus: 'success', dbType: 'redis', instance: 'Redis集群-01', sql: 'EVAL "local keys = redis.call(\'keys\', \'session:*\')\nfor i=1,#keys,5000 do\n redis.call(\'del\', unpack(keys, i, math.min(i+4999, #keys)))\nend\nreturn #keys" 0', status: 'paused', notifyOnFail: false, createdBy: 'lisi' },
|
|
{ id: 'task-5', name: '月度数据备份', description: '每月全量备份核心数据表', enabled: true, frequency: 'monthly', cron: '0 1 1 * *', nextRun: '2024-02-01 01:00', lastRun: '2024-01-01 01:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: '-- 调用备份脚本\nBACKUP DATABASE user_db, order_db TO S3://backups/monthly/;', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
|
];
|
|
|
|
export default function AutoTaskPage() {
|
|
const [tasks, setTasks] = useState<AutoTask[]>(initialTasks);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
const [previewTask, setPreviewTask] = useState<AutoTask | null>(null);
|
|
|
|
// 表单状态
|
|
const [formName, setFormName] = useState('');
|
|
const [formDesc, setFormDesc] = useState('');
|
|
const [formFreq, setFormFreq] = useState<TaskFrequency>('daily');
|
|
const [formCron, setFormCron] = useState('0 2 * * *');
|
|
const [formDbType, setFormDbType] = useState('mysql');
|
|
const [formInstance, setFormInstance] = useState('生产主库');
|
|
const [formSql, setFormSql] = useState('');
|
|
const [formNotify, setFormNotify] = useState(true);
|
|
|
|
const toggleTask = (id: string) => {
|
|
setTasks(prev => prev.map(t => t.id === id ? { ...t, enabled: !t.enabled, status: !t.enabled ? 'running' : 'paused' } : t));
|
|
};
|
|
|
|
const deleteTask = (id: string) => {
|
|
setTasks(prev => prev.filter(t => t.id !== id));
|
|
};
|
|
|
|
const openCreate = () => {
|
|
setFormName('');
|
|
setFormDesc('');
|
|
setFormFreq('daily');
|
|
setFormCron('0 2 * * *');
|
|
setFormDbType('mysql');
|
|
setFormInstance('生产主库');
|
|
setFormSql('');
|
|
setFormNotify(true);
|
|
setShowCreate(true);
|
|
};
|
|
|
|
const saveTask = () => {
|
|
if (!formName.trim() || !formSql.trim()) return;
|
|
const newTask: AutoTask = {
|
|
id: `task-${Date.now()}`,
|
|
name: formName,
|
|
description: formDesc,
|
|
enabled: true,
|
|
frequency: formFreq,
|
|
cron: formCron,
|
|
nextRun: '即将计算...',
|
|
lastRun: '-',
|
|
lastStatus: 'none',
|
|
dbType: formDbType,
|
|
instance: formInstance,
|
|
sql: formSql,
|
|
status: 'running',
|
|
notifyOnFail: formNotify,
|
|
createdBy: 'admin',
|
|
};
|
|
setTasks(prev => [...prev, newTask]);
|
|
setShowCreate(false);
|
|
};
|
|
|
|
const statusColors: Record<string, string> = {
|
|
running: 'bg-green-500/15 text-green-400',
|
|
paused: 'bg-yellow-500/15 text-yellow-400',
|
|
completed: 'bg-blue-500/15 text-blue-400',
|
|
failed: 'bg-red-500/15 text-red-400',
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">自动任务</h1>
|
|
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置定时执行的 SQL 任务,支持 Cron 表达式</p>
|
|
</div>
|
|
<button onClick={openCreate} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
<Plus className="w-4 h-4" /> 新建任务
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-4">
|
|
{[
|
|
{ label: '任务总数', value: tasks.length, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
{ label: '运行中', value: tasks.filter(t => t.status === 'running').length, icon: Play, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
{ label: '已暂停', value: tasks.filter(t => t.status === 'paused').length, icon: Pause, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
|
{ label: '上次失败', value: tasks.filter(t => t.lastStatus === 'failed').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
].map((s, i) => (
|
|
<Card key={i} className="dg-card">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{tasks.map(task => {
|
|
const isExpanded = expandedId === task.id;
|
|
return (
|
|
<Card key={task.id} className={`dg-card ${!task.enabled ? 'opacity-60' : ''}`}>
|
|
<CardContent className="p-5">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<h3 className="font-semibold">{task.name}</h3>
|
|
<span className={`text-[11px] px-2 py-0.5 rounded-full ${statusColors[task.status]}`}>
|
|
{task.status === 'running' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status === 'failed' ? '失败' : '完成'}
|
|
</span>
|
|
<span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
|
{frequencyLabels[task.frequency]}
|
|
</span>
|
|
<span className="text-[11px] font-mono bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] px-2 py-0.5 rounded">
|
|
{task.cron}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{task.description}</p>
|
|
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
|
<span className="flex items-center gap-1"><Database className="w-3.5 h-3.5" /> {task.instance} ({task.dbType.toUpperCase()})</span>
|
|
<span className="flex items-center gap-1"><Calendar className="w-3.5 h-3.5" /> 下次执行: {task.nextRun}</span>
|
|
<span className="flex items-center gap-1">
|
|
<Clock className="w-3.5 h-3.5" /> 上次执行:
|
|
<span className={task.lastStatus === 'success' ? 'text-green-400' : task.lastStatus === 'failed' ? 'text-red-400' : ''}>{task.lastRun}</span>
|
|
</span>
|
|
<span className="flex items-center gap-1"><Zap className="w-3.5 h-3.5" /> 创建者: {task.createdBy}</span>
|
|
</div>
|
|
{isExpanded && (
|
|
<div className="mt-4 p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
|
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
|
|
<pre className="text-sm text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap overflow-x-auto">{task.sql}</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1 ml-4">
|
|
<button onClick={() => setPreviewTask(task)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
|
|
<Eye className="w-4 h-4" />
|
|
</button>
|
|
<button onClick={() => setExpandedId(isExpanded ? null : task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
|
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
</button>
|
|
<button onClick={() => toggleTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={task.enabled ? '暂停' : '启动'}>
|
|
{task.enabled ? <Pause className="w-4 h-4 text-yellow-400" /> : <Play className="w-4 h-4 text-green-400" />}
|
|
</button>
|
|
<button onClick={() => deleteTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* 预览弹窗 */}
|
|
{previewTask && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewTask(null)}>
|
|
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-xl font-semibold">{previewTask.name}</h3>
|
|
<button onClick={() => setPreviewTask(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-3 text-sm">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div><span className="text-[var(--dg-text-muted)]">执行频率:</span> {frequencyLabels[previewTask.frequency]} ({previewTask.cron})</div>
|
|
<div><span className="text-[var(--dg-text-muted)]">数据库:</span> {previewTask.instance} ({previewTask.dbType.toUpperCase()})</div>
|
|
<div><span className="text-[var(--dg-text-muted)]">下次执行:</span> {previewTask.nextRun}</div>
|
|
<div><span className="text-[var(--dg-text-muted)]">上次状态:</span> <span className={previewTask.lastStatus === 'success' ? 'text-green-400' : previewTask.lastStatus === 'failed' ? 'text-red-400' : ''}>{previewTask.lastStatus === 'success' ? '成功' : previewTask.lastStatus === 'failed' ? '失败' : '未执行'}</span></div>
|
|
</div>
|
|
<div className="p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
|
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
|
|
<pre className="text-sm font-mono whitespace-pre-wrap overflow-x-auto">{previewTask.sql}</pre>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end mt-4">
|
|
<button onClick={() => setPreviewTask(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">关闭</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 创建弹窗 */}
|
|
{showCreate && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
|
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-xl font-semibold">新建自动任务</h3>
|
|
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务名称 <span className="text-red-400">*</span></label>
|
|
<input type="text" value={formName} onChange={e => setFormName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="例如:每日数据归档" />
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务描述</label>
|
|
<textarea value={formDesc} onChange={e => setFormDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={2} placeholder="描述任务用途" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">执行频率</label>
|
|
<select value={formFreq} onChange={e => setFormFreq(e.target.value as TaskFrequency)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
|
{Object.entries(frequencyLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Cron 表达式</label>
|
|
<input type="text" value={formCron} onChange={e => setFormCron(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500" placeholder="0 2 * * *" />
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">数据库类型</label>
|
|
<select value={formDbType} onChange={e => setFormDbType(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
|
<option value="mysql">MySQL</option>
|
|
<option value="postgres">PostgreSQL</option>
|
|
<option value="redis">Redis</option>
|
|
<option value="mongo">MongoDB</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">目标实例</label>
|
|
<input type="text" value={formInstance} onChange={e => setFormInstance(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="生产主库" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">SQL 语句 <span className="text-red-400">*</span></label>
|
|
<textarea value={formSql} onChange={e => setFormSql(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500 resize-none" rows={6} placeholder="输入要执行的 SQL 语句..." />
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Switch checked={formNotify} onCheckedChange={setFormNotify} />
|
|
<span className="text-sm">执行失败时发送通知</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
|
<button onClick={() => setShowCreate(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
|
<button onClick={saveTask} disabled={!formName.trim() || !formSql.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
|
|
<Save className="w-4 h-4" /> 创建任务
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|