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:
poduck
2025-11-28 23:45:55 -05:00
parent 0f46862125
commit 9b106bf129
38 changed files with 2859 additions and 116 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>
);
};

View File

@@ -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 ? (

View 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;

View File

@@ -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;