feat: Add plugin configuration editing with template variable parsing
## Backend Changes:
- Enhanced PluginTemplate.save() to auto-parse template variables from plugin code
- Updated PluginInstallationSerializer to expose template metadata (description, category, version, author, logo, template_variables)
- Fixed template variable parser to handle nested {{ }} braces in default values
- Added brace-counting algorithm to properly extract variables with insertion codes
- Fixed explicit type parameter detection (textarea, text, email, etc.)
- Made scheduled_task optional on PluginInstallation model
- Added EventPlugin through model for event-plugin relationships
- Added Event.execute_plugins() method for plugin automation
## Frontend Changes:
- Created Tasks.tsx page for managing scheduled tasks
- Enhanced MyPlugins page with clickable plugin cards
- Added edit configuration modal with dynamic form generation
- Implemented escape sequence handling (convert \n, \', etc. for display)
- Added plugin logos to My Plugins page
- Updated type definitions for PluginInstallation interface
- Added insertion code documentation to Plugin Docs
## Plugin System:
- All platform plugins now have editable email templates with textarea support
- Template variables properly parsed with full default values
- Insertion codes ({{CUSTOMER_NAME}}, {{BUSINESS_NAME}}, etc.) documented
- Plugin logos displayed in marketplace and My Plugins
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
365
frontend/src/pages/Tasks.tsx
Normal file
365
frontend/src/pages/Tasks.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from '../api/client';
|
||||
import {
|
||||
Plus,
|
||||
Play,
|
||||
Pause,
|
||||
Trash2,
|
||||
Edit,
|
||||
Clock,
|
||||
Calendar,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
// Types
|
||||
interface ScheduledTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
plugin_name: string;
|
||||
plugin_display_name: string;
|
||||
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
|
||||
cron_expression?: string;
|
||||
interval_minutes?: number;
|
||||
run_at?: string;
|
||||
next_run_time?: string;
|
||||
last_run_time?: string;
|
||||
is_active: boolean;
|
||||
plugin_config: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const Tasks: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Fetch tasks
|
||||
const { data: tasks = [], isLoading } = useQuery<ScheduledTask[]>({
|
||||
queryKey: ['scheduled-tasks'],
|
||||
queryFn: async () => {
|
||||
const { data } = await axios.get('/api/scheduled-tasks/');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
// Delete task
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
await axios.delete(`/api/scheduled-tasks/${taskId}/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||
toast.success('Task deleted successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete task');
|
||||
},
|
||||
});
|
||||
|
||||
// Toggle task active status
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: async ({ taskId, isActive }: { taskId: string; isActive: boolean }) => {
|
||||
await axios.patch(`/api/scheduled-tasks/${taskId}/`, { is_active: isActive });
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
|
||||
toast.success('Task updated successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update task');
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger task manually
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: async (taskId: string) => {
|
||||
await axios.post(`/api/scheduled-tasks/${taskId}/trigger/`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Task triggered successfully');
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.detail || 'Failed to trigger task');
|
||||
},
|
||||
});
|
||||
|
||||
const getScheduleDisplay = (task: ScheduledTask): string => {
|
||||
if (task.schedule_type === 'ONE_TIME') {
|
||||
return `Once on ${new Date(task.run_at!).toLocaleString()}`;
|
||||
} else if (task.schedule_type === 'INTERVAL') {
|
||||
const hours = Math.floor(task.interval_minutes! / 60);
|
||||
const mins = task.interval_minutes! % 60;
|
||||
if (hours > 0 && mins > 0) {
|
||||
return `Every ${hours}h ${mins}m`;
|
||||
} else if (hours > 0) {
|
||||
return `Every ${hours} hour${hours > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `Every ${mins} minute${mins > 1 ? 's' : ''}`;
|
||||
}
|
||||
} else {
|
||||
return task.cron_expression || 'Custom schedule';
|
||||
}
|
||||
};
|
||||
|
||||
const getScheduleIcon = (task: ScheduledTask) => {
|
||||
if (task.schedule_type === 'ONE_TIME') return Calendar;
|
||||
if (task.schedule_type === 'INTERVAL') return RotateCw;
|
||||
return Clock;
|
||||
};
|
||||
|
||||
const getStatusColor = (task: ScheduledTask): string => {
|
||||
if (!task.is_active) return 'text-gray-400 dark:text-gray-500';
|
||||
if (task.last_run_time) return 'text-green-600 dark:text-green-400';
|
||||
return 'text-blue-600 dark:text-blue-400';
|
||||
};
|
||||
|
||||
const getStatusIcon = (task: ScheduledTask) => {
|
||||
if (!task.is_active) return Pause;
|
||||
if (task.last_run_time) return CheckCircle2;
|
||||
return AlertCircle;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{t('Tasks')}
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Schedule and manage automated plugin executions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Total Tasks</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{tasks.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Active</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{tasks.filter(t => t.is_active).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
|
||||
<Pause className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Paused</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{tasks.filter(t => !t.is_active).length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<RotateCw className="w-5 h-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Recurring</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{tasks.filter(t => t.schedule_type !== 'ONE_TIME').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks List */}
|
||||
{tasks.length === 0 ? (
|
||||
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<Zap className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No tasks yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Create your first automated task to get started
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{tasks.map((task) => {
|
||||
const ScheduleIcon = getScheduleIcon(task);
|
||||
const StatusIcon = getStatusIcon(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{task.name}
|
||||
</h3>
|
||||
<StatusIcon className={`w-5 h-5 ${getStatusColor(task)}`} />
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<Zap className="w-4 h-4" />
|
||||
<span>{task.plugin_display_name}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<ScheduleIcon className="w-4 h-4" />
|
||||
<span>{getScheduleDisplay(task)}</span>
|
||||
</div>
|
||||
|
||||
{task.next_run_time && task.is_active && (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>Next: {new Date(task.next_run_time).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.last_run_time && (
|
||||
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Last: {new Date(task.last_run_time).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => triggerMutation.mutate(task.id)}
|
||||
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Run now"
|
||||
>
|
||||
<Play className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => toggleActiveMutation.mutate({
|
||||
taskId: task.id,
|
||||
isActive: !task.is_active
|
||||
})}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
task.is_active
|
||||
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
|
||||
: 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
|
||||
}`}
|
||||
title={task.is_active ? 'Pause' : 'Resume'}
|
||||
>
|
||||
{task.is_active ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {/* TODO: Edit modal */}}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('Are you sure you want to delete this task?')) {
|
||||
deleteMutation.mutate(task.id);
|
||||
}
|
||||
}}
|
||||
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TODO: Create/Edit Task Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Create New Task
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Task creation form coming soon...
|
||||
</p>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tasks;
|
||||
Reference in New Issue
Block a user