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:
@@ -65,6 +65,7 @@ import HelpPluginDocs from './pages/HelpPluginDocs'; // Import Plugin documentat
|
||||
import PlatformSupport from './pages/PlatformSupport'; // Import Platform Support page (for businesses to contact SmoothSchedule)
|
||||
import PluginMarketplace from './pages/PluginMarketplace'; // Import Plugin Marketplace page
|
||||
import MyPlugins from './pages/MyPlugins'; // Import My Plugins page
|
||||
import Tasks from './pages/Tasks'; // Import Tasks page for scheduled plugin executions
|
||||
import { Toaster } from 'react-hot-toast'; // Import Toaster for notifications
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -536,6 +537,16 @@ const AppContent: React.FC = () => {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/tasks"
|
||||
element={
|
||||
hasAccess(['owner', 'manager']) ? (
|
||||
<Tasks />
|
||||
) : (
|
||||
<Navigate to="/" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route path="/support" element={<PlatformSupport />} />
|
||||
<Route
|
||||
path="/customers"
|
||||
|
||||
@@ -765,6 +765,20 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
||||
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default}}'}</code>
|
||||
<span className="text-gray-500 ml-2">- Optional field with default value</span>
|
||||
</div>
|
||||
<div>
|
||||
<code className="text-purple-600 dark:text-purple-400">{'{{PROMPT:variable|description|default|textarea}}'}</code>
|
||||
<span className="text-gray-500 ml-2">- Multi-line text input (for email bodies, long messages)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 font-semibold mb-1">
|
||||
Field Type Detection
|
||||
</p>
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||
The system automatically detects field types from variable names and descriptions: <code>email</code> for email validation,
|
||||
<code> number</code> for numeric inputs, <code>message/body/content</code> for textareas, <code>url/webhook</code> for URLs.
|
||||
You can override this by explicitly specifying the type as the 4th parameter.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
@@ -813,9 +827,65 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
||||
<div className="text-blue-600 dark:text-blue-400">{'{{DATE:friday}}'} - Next Friday</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Validation & Types */}
|
||||
{/* 4. Insertion Codes */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
||||
4. Automatic Validation
|
||||
4. Insertion Codes (Dynamic Content)
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
Use insertion codes within your PROMPT template text (like email bodies) to inject dynamic content
|
||||
at runtime. These are automatically replaced with actual values when the plugin executes.
|
||||
</p>
|
||||
<div className="mt-3 p-3 bg-green-50 dark:bg-green-900/20 rounded border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200 mb-2">
|
||||
<strong>Business Information:</strong>
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-green-700 dark:text-green-300 pl-3">
|
||||
<div><code>{'{{BUSINESS_NAME}}'}</code> - Your business name</div>
|
||||
<div><code>{'{{BUSINESS_EMAIL}}'}</code> - Business contact email</div>
|
||||
<div><code>{'{{BUSINESS_PHONE}}'}</code> - Business phone number</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-2">
|
||||
<strong>Customer & Appointment Data:</strong>
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-blue-700 dark:text-blue-300 pl-3">
|
||||
<div><code>{'{{CUSTOMER_NAME}}'}</code> - Customer's name (in appointment contexts)</div>
|
||||
<div><code>{'{{CUSTOMER_EMAIL}}'}</code> - Customer's email address</div>
|
||||
<div><code>{'{{APPOINTMENT_TIME}}'}</code> - Full appointment date and time</div>
|
||||
<div><code>{'{{APPOINTMENT_DATE}}'}</code> - Appointment date only</div>
|
||||
<div><code>{'{{APPOINTMENT_SERVICE}}'}</code> - Service name</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 p-3 bg-purple-50 dark:bg-purple-900/20 rounded border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200 mb-2">
|
||||
<strong>Date & Time:</strong>
|
||||
</p>
|
||||
<div className="space-y-1 text-xs text-purple-700 dark:text-purple-300 pl-3">
|
||||
<div><code>{'{{TODAY}}'}</code> - Today's date (YYYY-MM-DD)</div>
|
||||
<div><code>{'{{NOW}}'}</code> - Current date and time</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 p-4 bg-amber-50 dark:bg-amber-900/20 rounded border border-amber-200 dark:border-amber-800">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 font-semibold mb-2">Example: Email Template with Insertion Codes</p>
|
||||
<code className="text-xs block bg-white dark:bg-gray-900 p-3 rounded font-mono text-amber-900 dark:text-amber-100 whitespace-pre-wrap">
|
||||
{`email_body = '{{PROMPT:email_body|Email Message|Hi {{CUSTOMER_NAME}},
|
||||
|
||||
This is a reminder about your appointment:
|
||||
|
||||
Date/Time: {{APPOINTMENT_TIME}}
|
||||
Service: {{APPOINTMENT_SERVICE}}
|
||||
|
||||
If you have questions, contact us at {{BUSINESS_EMAIL}}
|
||||
|
||||
Best regards,
|
||||
{{BUSINESS_NAME}}||textarea}}'`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* 5. Validation & Types */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mt-6">
|
||||
5. Automatic Validation
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
The system automatically detects field types and validates input:
|
||||
@@ -830,7 +900,8 @@ result = {'total': len(appointments), 'by_status': stats}`}
|
||||
<div className="mt-6 p-4 bg-purple-50 dark:bg-purple-900/20 rounded-lg border border-purple-200 dark:border-purple-800">
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200">
|
||||
<strong>Pro Tip:</strong> Combine all template types for maximum power! Use CONTEXT
|
||||
for business info, DATE for time logic, and PROMPT only when user input is truly needed.
|
||||
for business info, DATE for time logic, PROMPT for user configuration, and Insertion Codes
|
||||
within your email templates for personalized dynamic content.
|
||||
</p>
|
||||
</div>
|
||||
</ApiContent>
|
||||
|
||||
@@ -52,8 +52,10 @@ const MyPlugins: React.FC = () => {
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginInstallation | null>(null);
|
||||
const [showUninstallModal, setShowUninstallModal] = useState(false);
|
||||
const [showRatingModal, setShowRatingModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [rating, setRating] = useState(0);
|
||||
const [review, setReview] = useState('');
|
||||
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
||||
|
||||
// Fetch installed plugins
|
||||
const { data: plugins = [], isLoading, error } = useQuery<PluginInstallation[]>({
|
||||
@@ -67,6 +69,10 @@ const MyPlugins: React.FC = () => {
|
||||
templateDescription: p.template_description || p.templateDescription,
|
||||
category: p.category,
|
||||
version: p.version,
|
||||
authorName: p.author_name || p.authorName,
|
||||
logoUrl: p.logo_url || p.logoUrl,
|
||||
templateVariables: p.template_variables || p.templateVariables || {},
|
||||
configValues: p.config_values || p.configValues || {},
|
||||
isActive: p.is_active !== undefined ? p.is_active : p.isActive,
|
||||
installedAt: p.installed_at || p.installedAt,
|
||||
hasUpdate: p.has_update !== undefined ? p.has_update : p.hasUpdate || false,
|
||||
@@ -117,6 +123,22 @@ const MyPlugins: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Edit config mutation
|
||||
const editConfigMutation = useMutation({
|
||||
mutationFn: async ({ pluginId, configValues }: { pluginId: string; configValues: Record<string, any> }) => {
|
||||
const { data } = await api.patch(`/api/plugin-installations/${pluginId}/`, {
|
||||
config_values: configValues,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['plugin-installations'] });
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setConfigValues({});
|
||||
},
|
||||
});
|
||||
|
||||
const handleUninstall = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setShowUninstallModal(true);
|
||||
@@ -149,6 +171,59 @@ const MyPlugins: React.FC = () => {
|
||||
updateMutation.mutate(plugin.id);
|
||||
};
|
||||
|
||||
const unescapeString = (str: string): string => {
|
||||
return str
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
};
|
||||
|
||||
const handleEdit = (plugin: PluginInstallation) => {
|
||||
setSelectedPlugin(plugin);
|
||||
// Convert escape sequences to actual characters for display
|
||||
const displayValues = { ...plugin.configValues || {} };
|
||||
if (plugin.templateVariables) {
|
||||
Object.entries(plugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (displayValues[key]) {
|
||||
displayValues[key] = unescapeString(displayValues[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
setConfigValues(displayValues);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const escapeString = (str: string): string => {
|
||||
return str
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, '\\"');
|
||||
};
|
||||
|
||||
const submitConfigEdit = () => {
|
||||
if (selectedPlugin) {
|
||||
// Convert actual characters back to escape sequences for storage
|
||||
const storageValues = { ...configValues };
|
||||
if (selectedPlugin.templateVariables) {
|
||||
Object.entries(selectedPlugin.templateVariables).forEach(([key, variable]: [string, any]) => {
|
||||
if (storageValues[key]) {
|
||||
storageValues[key] = escapeString(storageValues[key]);
|
||||
}
|
||||
});
|
||||
}
|
||||
editConfigMutation.mutate({
|
||||
pluginId: selectedPlugin.id,
|
||||
configValues: storageValues,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
@@ -213,13 +288,25 @@ const MyPlugins: React.FC = () => {
|
||||
{plugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
|
||||
className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
||||
onClick={() => handleEdit(plugin)}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
{/* Plugin Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
{plugin.logoUrl && (
|
||||
<img
|
||||
src={plugin.logoUrl}
|
||||
alt={`${plugin.templateName} logo`}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||
onError={(e) => {
|
||||
// Hide image if it fails to load
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{plugin.templateName}
|
||||
</h3>
|
||||
@@ -240,6 +327,14 @@ const MyPlugins: React.FC = () => {
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{plugin.authorName && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.author', 'Author')}:
|
||||
</span>
|
||||
<span>{plugin.authorName}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">
|
||||
{t('plugins.version', 'Version')}:
|
||||
@@ -284,7 +379,10 @@ const MyPlugins: React.FC = () => {
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
{plugin.hasUpdate && (
|
||||
<button
|
||||
onClick={() => handleUpdate(plugin)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdate(plugin);
|
||||
}}
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||
title={t('plugins.update', 'Update')}
|
||||
@@ -298,7 +396,10 @@ const MyPlugins: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRating(plugin)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRating(plugin);
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-sm font-medium"
|
||||
title={plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
>
|
||||
@@ -306,7 +407,10 @@ const MyPlugins: React.FC = () => {
|
||||
{plugin.rating ? t('plugins.editRating', 'Edit Rating') : t('plugins.rate', 'Rate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUninstall(plugin)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUninstall(plugin);
|
||||
}}
|
||||
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={t('plugins.uninstall', 'Uninstall')}
|
||||
>
|
||||
@@ -519,6 +623,115 @@ const MyPlugins: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Config Modal */}
|
||||
{showEditModal && selectedPlugin && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-xl max-w-2xl w-full overflow-hidden max-h-[80vh] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.editConfig', 'Edit Plugin Configuration')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setConfigValues({});
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
{selectedPlugin.templateName}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedPlugin.templateDescription}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Config Fields */}
|
||||
{selectedPlugin.templateVariables && Object.keys(selectedPlugin.templateVariables).length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(selectedPlugin.templateVariables).map(([key, variable]: [string, any]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{variable.description || key}
|
||||
{variable.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
|
||||
{variable.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={configValues[key] !== undefined ? configValues[key] : (variable.default ? unescapeString(variable.default) : '')}
|
||||
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||
rows={6}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500 resize-none font-mono text-sm"
|
||||
placeholder={variable.default ? unescapeString(variable.default) : ''}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={configValues[key] || variable.default || ''}
|
||||
onChange={(e) => setConfigValues({ ...configValues, [key]: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-brand-500 focus:border-brand-500"
|
||||
placeholder={variable.default || ''}
|
||||
/>
|
||||
)}
|
||||
|
||||
{variable.help_text && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{variable.help_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
{t('plugins.noConfigOptions', 'This plugin has no configuration options')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setConfigValues({});
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={submitConfigEdit}
|
||||
disabled={editConfigMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{editConfigMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{t('plugins.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
{t('common.save', 'Save')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
@@ -17,8 +17,12 @@ import {
|
||||
Link as LinkIcon,
|
||||
Bot,
|
||||
Package,
|
||||
Eye
|
||||
Eye,
|
||||
ChevronDown,
|
||||
AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import api from '../api/client';
|
||||
import { PluginTemplate, PluginCategory } from '../types';
|
||||
|
||||
@@ -44,6 +48,32 @@ const categoryColors: Record<PluginCategory, string> = {
|
||||
OTHER: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
|
||||
};
|
||||
|
||||
// Helper function to detect platform-only code
|
||||
const detectPlatformOnlyCode = (code: string): { hasPlatformCode: boolean; warnings: string[] } => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (code.includes('{{PLATFORM:')) {
|
||||
warnings.push('This code uses platform-only features that require special permissions');
|
||||
}
|
||||
|
||||
if (code.includes('{{WHITELIST:')) {
|
||||
warnings.push('This code requires whitelisting by platform administrators');
|
||||
}
|
||||
|
||||
if (code.includes('{{SUPERUSER:')) {
|
||||
warnings.push('This code requires superuser privileges to execute');
|
||||
}
|
||||
|
||||
if (code.includes('{{SYSTEM:')) {
|
||||
warnings.push('This code accesses system-level features');
|
||||
}
|
||||
|
||||
return {
|
||||
hasPlatformCode: warnings.length > 0,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
const PluginMarketplace: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -51,6 +81,8 @@ const PluginMarketplace: React.FC = () => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<PluginCategory | 'ALL'>('ALL');
|
||||
const [selectedPlugin, setSelectedPlugin] = useState<PluginTemplate | null>(null);
|
||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
||||
const [showCode, setShowCode] = useState(false);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
||||
|
||||
// Fetch marketplace plugins
|
||||
const { data: plugins = [], isLoading, error } = useQuery<PluginTemplate[]>({
|
||||
@@ -71,6 +103,8 @@ const PluginMarketplace: React.FC = () => {
|
||||
isFeatured: p.is_featured || false,
|
||||
createdAt: p.created_at,
|
||||
updatedAt: p.updated_at,
|
||||
logoUrl: p.logo_url,
|
||||
pluginCode: p.plugin_code,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -122,9 +156,25 @@ const PluginMarketplace: React.FC = () => {
|
||||
});
|
||||
}, [filteredPlugins]);
|
||||
|
||||
const handleInstall = (plugin: PluginTemplate) => {
|
||||
const handleInstall = async (plugin: PluginTemplate) => {
|
||||
setSelectedPlugin(plugin);
|
||||
setShowDetailsModal(true);
|
||||
setShowCode(false);
|
||||
|
||||
// Fetch full plugin details including plugin_code
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
const { data } = await api.get(`/api/plugin-templates/${plugin.id}/`);
|
||||
setSelectedPlugin({
|
||||
...plugin,
|
||||
pluginCode: data.plugin_code,
|
||||
logoUrl: data.logo_url,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugin details:', error);
|
||||
} finally {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmInstall = () => {
|
||||
@@ -245,22 +295,39 @@ const PluginMarketplace: React.FC = () => {
|
||||
>
|
||||
{/* Plugin Card Header */}
|
||||
<div className="p-6 flex-1">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
||||
{categoryIcons[plugin.category]}
|
||||
{plugin.category}
|
||||
</span>
|
||||
{plugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-3 w-3" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
{/* Logo */}
|
||||
{plugin.logoUrl ? (
|
||||
<img
|
||||
src={plugin.logoUrl}
|
||||
alt={`${plugin.name} logo`}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${categoryColors[plugin.category]}`}>
|
||||
{categoryIcons[plugin.category]}
|
||||
{plugin.category}
|
||||
</span>
|
||||
{plugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-3 w-3" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{plugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500 flex-shrink-0" title="Verified" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
@@ -292,10 +359,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
{/* Plugin Card Actions */}
|
||||
<div className="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedPlugin(plugin);
|
||||
setShowDetailsModal(true);
|
||||
}}
|
||||
onClick={() => handleInstall(plugin)}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 transition-colors font-medium text-sm"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -314,17 +378,32 @@ const PluginMarketplace: React.FC = () => {
|
||||
{/* Modal Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.name}
|
||||
</h3>
|
||||
{selectedPlugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
{/* Logo in modal header */}
|
||||
{selectedPlugin.logoUrl ? (
|
||||
<img
|
||||
src={selectedPlugin.logoUrl}
|
||||
alt={`${selectedPlugin.name} logo`}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-700 flex items-center justify-center flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.name}
|
||||
</h3>
|
||||
{selectedPlugin.isVerified && (
|
||||
<CheckCircle className="h-5 w-5 text-blue-500" title="Verified" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setShowCode(false);
|
||||
}}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
@@ -334,60 +413,131 @@ const PluginMarketplace: React.FC = () => {
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
||||
{categoryIcons[selectedPlugin.category]}
|
||||
{selectedPlugin.category}
|
||||
</span>
|
||||
{selectedPlugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-4 w-4" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isLoadingDetails ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium ${categoryColors[selectedPlugin.category]}`}>
|
||||
{categoryIcons[selectedPlugin.category]}
|
||||
{selectedPlugin.category}
|
||||
</span>
|
||||
{selectedPlugin.isFeatured && (
|
||||
<span className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<Zap className="h-4 w-4" />
|
||||
Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.description', 'Description')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.description', 'Description')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.version', 'Version')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.author', 'Author')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.version', 'Version')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.version}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{t('plugins.author', 'Author')}
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">{selectedPlugin.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.rating.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Star className="h-5 w-5 text-amber-500 fill-amber-500" />
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{selectedPlugin.rating.toFixed(1)}
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
({selectedPlugin.ratingCount} {t('plugins.ratings', 'ratings')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
{selectedPlugin.installCount.toLocaleString()} {t('plugins.installs', 'installs')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Viewer Section */}
|
||||
{selectedPlugin.pluginCode && (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className="flex items-center justify-between w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{t('plugins.viewCode', 'View Code')}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-gray-600 dark:text-gray-400 transition-transform ${showCode ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{showCode && (
|
||||
<div className="space-y-3">
|
||||
{/* Platform-only code warnings */}
|
||||
{(() => {
|
||||
const { hasPlatformCode, warnings } = detectPlatformOnlyCode(selectedPlugin.pluginCode || '');
|
||||
return hasPlatformCode ? (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h5 className="font-semibold text-amber-900 dark:text-amber-200 mb-2">
|
||||
{t('plugins.platformOnlyWarning', 'Platform-Only Features Detected')}
|
||||
</h5>
|
||||
<ul className="space-y-1 text-sm text-amber-800 dark:text-amber-300">
|
||||
{warnings.map((warning, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<span className="text-amber-600 dark:text-amber-400 mt-0.5">•</span>
|
||||
<span>{warning}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Code display with syntax highlighting */}
|
||||
<div className="rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||
<SyntaxHighlighter
|
||||
language="javascript"
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: 0,
|
||||
fontSize: '0.875rem',
|
||||
maxHeight: '400px'
|
||||
}}
|
||||
showLineNumbers
|
||||
>
|
||||
{selectedPlugin.pluginCode}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
@@ -396,6 +546,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
onClick={() => {
|
||||
setShowDetailsModal(false);
|
||||
setSelectedPlugin(null);
|
||||
setShowCode(false);
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors font-medium"
|
||||
>
|
||||
@@ -403,7 +554,7 @@ const PluginMarketplace: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmInstall}
|
||||
disabled={installMutation.isPending}
|
||||
disabled={installMutation.isPending || isLoadingDetails}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{installMutation.isPending ? (
|
||||
|
||||
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;
|
||||
@@ -314,6 +314,8 @@ export interface PluginTemplate {
|
||||
category: PluginCategory;
|
||||
version: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
pluginCode?: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
installCount: number;
|
||||
@@ -330,6 +332,10 @@ export interface PluginInstallation {
|
||||
templateDescription: string;
|
||||
category: PluginCategory;
|
||||
version: string;
|
||||
authorName?: string;
|
||||
logoUrl?: string;
|
||||
templateVariables?: Record<string, any>;
|
||||
configValues?: Record<string, any>;
|
||||
isActive: boolean;
|
||||
installedAt: string;
|
||||
hasUpdate: boolean;
|
||||
|
||||
Reference in New Issue
Block a user