diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index 8f847b78..9c9ad4b9 100644 Binary files a/backend/celerybeat-schedule and b/backend/celerybeat-schedule differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f25559f..a2a35795 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -60,6 +60,8 @@ const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries")); // Other Pages - Lazy loaded const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard")); +const AutomationRules = lazy(() => import("./pages/Automation/Rules")); +const AutomationTasks = lazy(() => import("./pages/Automation/Tasks")); // Settings - Lazy loaded const GeneralSettings = lazy(() => import("./pages/Settings/General")); @@ -341,6 +343,20 @@ export default function App() { } /> + + + + + + } /> + + + + + + } /> {/* Settings */} ; + actions: Array<{ + type: string; + params: Record; + }>; + is_active: boolean; + status: 'active' | 'inactive' | 'paused'; + execution_count: number; + last_executed_at?: string; + created_at: string; + updated_at: string; +} + +export interface ScheduledTask { + id: number; + rule_id?: number; + rule_name?: string; + task_type: string; + status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'; + scheduled_at: string; + started_at?: string; + completed_at?: string; + result?: Record; + error?: string; + retry_count: number; + created_at: string; +} + +export interface AutomationRuleCreateData { + name: string; + description?: string; + trigger: 'schedule' | 'event' | 'manual'; + schedule?: string; + conditions?: Array<{ + field: string; + operator: string; + value: any; + }>; + actions: Array<{ + type: string; + params: Record; + }>; + is_active?: boolean; +} + +export interface AutomationRuleUpdateData extends Partial {} + +export const automationApi = { + /** + * List automation rules + */ + listRules: async (filters?: { + search?: string; + trigger?: string; + is_active?: boolean; + status?: string; + ordering?: string; + page?: number; + page_size?: number; + }) => { + const params = new URLSearchParams(); + if (filters?.search) params.append('search', filters.search); + if (filters?.trigger) params.append('trigger', filters.trigger); + if (filters?.is_active !== undefined) params.append('is_active', String(filters.is_active)); + if (filters?.status) params.append('status', filters.status); + if (filters?.ordering) params.append('ordering', filters.ordering); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.page_size) params.append('page_size', String(filters.page_size)); + + const query = params.toString(); + return await fetchAPI(`/v1/automation/rules/${query ? `?${query}` : ''}`); + }, + + /** + * Get a single automation rule + */ + getRule: async (id: number) => { + return await fetchAPI(`/v1/automation/rules/${id}/`) as AutomationRule; + }, + + /** + * Create a new automation rule + */ + createRule: async (data: AutomationRuleCreateData) => { + return await fetchAPI('/v1/automation/rules/', { + method: 'POST', + body: JSON.stringify(data), + }) as AutomationRule; + }, + + /** + * Update an automation rule + */ + updateRule: async (id: number, data: AutomationRuleUpdateData) => { + return await fetchAPI(`/v1/automation/rules/${id}/`, { + method: 'PATCH', + body: JSON.stringify(data), + }) as AutomationRule; + }, + + /** + * Delete an automation rule + */ + deleteRule: async (id: number) => { + return await fetchAPI(`/v1/automation/rules/${id}/`, { + method: 'DELETE', + }); + }, + + /** + * Execute an automation rule manually + */ + executeRule: async (id: number, context?: Record) => { + return await fetchAPI(`/v1/automation/rules/${id}/execute/`, { + method: 'POST', + body: JSON.stringify({ context: context || {} }), + }); + }, + + /** + * List scheduled tasks + */ + listTasks: async (filters?: { + rule_id?: number; + status?: string; + task_type?: string; + ordering?: string; + page?: number; + page_size?: number; + }) => { + const params = new URLSearchParams(); + if (filters?.rule_id) params.append('rule_id', String(filters.rule_id)); + if (filters?.status) params.append('status', filters.status); + if (filters?.task_type) params.append('task_type', filters.task_type); + if (filters?.ordering) params.append('ordering', filters.ordering); + if (filters?.page) params.append('page', String(filters.page)); + if (filters?.page_size) params.append('page_size', String(filters.page_size)); + + const query = params.toString(); + return await fetchAPI(`/v1/automation/scheduled-tasks/${query ? `?${query}` : ''}`); + }, + + /** + * Get a single scheduled task + */ + getTask: async (id: number) => { + return await fetchAPI(`/v1/automation/scheduled-tasks/${id}/`) as ScheduledTask; + }, + + /** + * Retry a failed scheduled task + */ + retryTask: async (id: number) => { + return await fetchAPI(`/v1/automation/scheduled-tasks/${id}/retry/`, { + method: 'POST', + }); + }, +}; + diff --git a/frontend/src/components/header/HeaderMetrics.tsx b/frontend/src/components/header/HeaderMetrics.tsx index d33019ab..1d530936 100644 --- a/frontend/src/components/header/HeaderMetrics.tsx +++ b/frontend/src/components/header/HeaderMetrics.tsx @@ -7,7 +7,7 @@ export const HeaderMetrics: React.FC = () => { if (!metrics || metrics.length === 0) return null; return ( -
+
{metrics.map((metric, index) => (
diff --git a/frontend/src/context/HeaderMetricsContext.tsx b/frontend/src/context/HeaderMetricsContext.tsx index 1e462116..a2a38ab6 100644 --- a/frontend/src/context/HeaderMetricsContext.tsx +++ b/frontend/src/context/HeaderMetricsContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useState, ReactNode, useRef, useCallback } from 'react'; interface HeaderMetric { label: string; @@ -28,13 +28,66 @@ interface HeaderMetricsProviderProps { export const HeaderMetricsProvider: React.FC = ({ children }) => { const [metrics, setMetrics] = useState([]); + const creditMetricRef = useRef(null); + const pageMetricsRef = useRef([]); - const clearMetrics = () => { + // Update combined metrics (credit + page metrics) + const updateCombinedMetrics = useCallback(() => { + const combined: HeaderMetric[] = []; + + // Add credit balance first if it exists + if (creditMetricRef.current) { + combined.push(creditMetricRef.current); + } + + // Add page metrics (filter out any credit metric to avoid duplicates) + const pageMetricsFiltered = pageMetricsRef.current.filter( + m => m.label.toLowerCase() !== 'credits' + ); + combined.push(...pageMetricsFiltered); + + setMetrics(combined); + }, []); + + // Track last setter to distinguish between AppLayout and TablePageTemplate + const lastSetterRef = useRef<'credit' | 'page' | null>(null); + + // Handle setMetrics - determine if it's credit balance or page metrics + const handleSetMetrics = useCallback((newMetrics: HeaderMetric[]) => { + // Check if this is a credit metric (single metric with label "Credits") + const creditMetric = newMetrics.find(m => m.label.toLowerCase() === 'credits'); + + if (newMetrics.length === 0) { + // Empty array - use last setter to determine what to clear + if (lastSetterRef.current === 'credit') { + // AppLayout is clearing credit balance + creditMetricRef.current = null; + } else { + // TablePageTemplate is clearing page metrics + pageMetricsRef.current = []; + } + updateCombinedMetrics(); + } else if (creditMetric && newMetrics.length === 1) { + // This is just credit balance - update credit ref + creditMetricRef.current = creditMetric; + lastSetterRef.current = 'credit'; + updateCombinedMetrics(); + } else { + // This is page metrics - update page metrics ref (filter out credits) + pageMetricsRef.current = newMetrics.filter(m => m.label.toLowerCase() !== 'credits'); + lastSetterRef.current = 'page'; + updateCombinedMetrics(); + } + }, [updateCombinedMetrics]); + + const clearMetrics = useCallback(() => { setMetrics([]); - }; + pageMetricsRef.current = []; + creditMetricRef.current = null; + }, []); return ( - + {children} ); diff --git a/frontend/src/layout/AppLayout.tsx b/frontend/src/layout/AppLayout.tsx index 8e57e4fd..c04fcfd1 100644 --- a/frontend/src/layout/AppLayout.tsx +++ b/frontend/src/layout/AppLayout.tsx @@ -224,8 +224,10 @@ const LayoutContent: React.FC = () => { }, [isAuthenticated, balance, loadBalance, setMetrics]); // Update header metrics when balance changes + // This sets credit balance which will be merged with page metrics by HeaderMetricsContext useEffect(() => { if (!isAuthenticated || !balance) { + // Clear credit balance but keep page metrics setMetrics([]); return; } @@ -242,6 +244,7 @@ const LayoutContent: React.FC = () => { accentColor = 'purple'; } + // Set credit balance (single metric with label "Credits" - HeaderMetricsContext will merge it) setMetrics([{ label: 'Credits', value: balance.credits, diff --git a/frontend/src/layout/AppSidebar.tsx b/frontend/src/layout/AppSidebar.tsx index 8adf74cd..db7f7e5a 100644 --- a/frontend/src/layout/AppSidebar.tsx +++ b/frontend/src/layout/AppSidebar.tsx @@ -163,7 +163,11 @@ const AppSidebar: React.FC = () => { workflowItems.push({ icon: , name: "Automation", - path: "/automation", + subItems: [ + { name: "Dashboard", path: "/automation" }, + { name: "Rules", path: "/automation/rules" }, + { name: "Tasks", path: "/automation/tasks" }, + ], }); } diff --git a/frontend/src/pages/Automation/Rules.tsx b/frontend/src/pages/Automation/Rules.tsx new file mode 100644 index 00000000..90f176c0 --- /dev/null +++ b/frontend/src/pages/Automation/Rules.tsx @@ -0,0 +1,254 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router'; +import PageMeta from '../../components/common/PageMeta'; +import PageHeader from '../../components/common/PageHeader'; +import ComponentCard from '../../components/common/ComponentCard'; +import { automationApi, AutomationRule } from '../../api/automation.api'; +import { useToast } from '../../components/ui/toast/ToastContainer'; +import { BoltIcon, PlusIcon, PlayIcon, PauseIcon, TrashIcon, EditIcon } from '../../icons'; +import { useSiteStore } from '../../store/siteStore'; +import { useSectorStore } from '../../store/sectorStore'; + +export default function AutomationRules() { + const navigate = useNavigate(); + const toast = useToast(); + const { activeSite } = useSiteStore(); + const { activeSector } = useSectorStore(); + + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedRule, setSelectedRule] = useState(null); + const [isWizardOpen, setIsWizardOpen] = useState(false); + const [isEditMode, setIsEditMode] = useState(false); + + const loadRules = useCallback(async () => { + try { + setLoading(true); + const response = await automationApi.listRules({ + page_size: 100, + }); + setRules(response.results || []); + } catch (error: any) { + console.error('Error loading rules:', error); + toast.error(`Failed to load rules: ${error.message}`); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + loadRules(); + }, [loadRules]); + + const handleCreate = () => { + setSelectedRule(null); + setIsEditMode(false); + setIsWizardOpen(true); + }; + + const handleEdit = (rule: AutomationRule) => { + setSelectedRule(rule); + setIsEditMode(true); + setIsWizardOpen(true); + }; + + const handleDelete = async (id: number) => { + if (!confirm('Are you sure you want to delete this rule?')) return; + + try { + await automationApi.deleteRule(id); + toast.success('Rule deleted successfully'); + loadRules(); + } catch (error: any) { + toast.error(`Failed to delete rule: ${error.message}`); + } + }; + + const handleToggleActive = async (rule: AutomationRule) => { + try { + await automationApi.updateRule(rule.id, { + is_active: !rule.is_active, + }); + toast.success(`Rule ${rule.is_active ? 'deactivated' : 'activated'}`); + loadRules(); + } catch (error: any) { + toast.error(`Failed to update rule: ${error.message}`); + } + }; + + const handleExecute = async (id: number) => { + try { + await automationApi.executeRule(id); + toast.success('Rule executed successfully'); + loadRules(); + } catch (error: any) { + toast.error(`Failed to execute rule: ${error.message}`); + } + }; + + const getStatusBadge = (rule: AutomationRule) => { + if (!rule.is_active) { + return Inactive; + } + if (rule.status === 'paused') { + return Paused; + } + return Active; + }; + + const getTriggerBadge = (trigger: string) => { + const colors = { + schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + event: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300', + manual: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + }; + return ( + + {trigger.charAt(0).toUpperCase() + trigger.slice(1)} + + ); + }; + + return ( + <> + +
+ , + color: 'purple', + }} + /> + +
+

+ Create and manage automation rules to automate your workflows +

+ +
+ + {loading ? ( +
+
Loading rules...
+
+ ) : rules.length === 0 ? ( + +
+

+ You haven't created any automation rules yet. +

+ +
+
+ ) : ( +
+ {rules.map((rule) => ( + +
+
+ {getStatusBadge(rule)} + {getTriggerBadge(rule.trigger)} +
+ + {rule.schedule && ( +
+ Schedule: {rule.schedule} +
+ )} + +
+ Actions: {rule.actions.length} +
+ + {rule.execution_count > 0 && ( +
+ Executions: {rule.execution_count} + {rule.last_executed_at && ( + + (Last: {new Date(rule.last_executed_at).toLocaleDateString()}) + + )} +
+ )} + +
+ + {rule.trigger === 'manual' && ( + + )} + + +
+
+
+ ))} +
+ )} +
+ + {/* Rule Creation/Edit Wizard Modal - TODO: Implement full wizard */} + {isWizardOpen && ( +
+
+

+ {isEditMode ? 'Edit Rule' : 'Create Rule'} +

+

+ Rule wizard coming soon. For now, use the API directly or create rules programmatically. +

+
+ +
+
+
+ )} + + ); +} + diff --git a/frontend/src/pages/Automation/Tasks.tsx b/frontend/src/pages/Automation/Tasks.tsx new file mode 100644 index 00000000..b094b0b5 --- /dev/null +++ b/frontend/src/pages/Automation/Tasks.tsx @@ -0,0 +1,250 @@ +import { useState, useEffect, useCallback } from 'react'; +import PageMeta from '../../components/common/PageMeta'; +import PageHeader from '../../components/common/PageHeader'; +import ComponentCard from '../../components/common/ComponentCard'; +import { automationApi, ScheduledTask } from '../../api/automation.api'; +import { useToast } from '../../components/ui/toast/ToastContainer'; +import { ClockIcon, CheckCircleIcon, XCircleIcon, RefreshCwIcon } from '../../icons'; +import { useSiteStore } from '../../store/siteStore'; +import { useSectorStore } from '../../store/sectorStore'; + +export default function AutomationTasks() { + const toast = useToast(); + const { activeSite } = useSiteStore(); + const { activeSector } = useSectorStore(); + + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState('all'); + const [ruleFilter, setRuleFilter] = useState(null); + const [rules, setRules] = useState>([]); + + const loadTasks = useCallback(async () => { + try { + setLoading(true); + const filters: any = { + page_size: 100, + ordering: '-scheduled_at', + }; + if (statusFilter !== 'all') { + filters.status = statusFilter; + } + if (ruleFilter) { + filters.rule_id = ruleFilter; + } + const response = await automationApi.listTasks(filters); + setTasks(response.results || []); + } catch (error: any) { + console.error('Error loading tasks:', error); + toast.error(`Failed to load tasks: ${error.message}`); + } finally { + setLoading(false); + } + }, [statusFilter, ruleFilter, toast]); + + const loadRules = useCallback(async () => { + try { + const response = await automationApi.listRules({ page_size: 100 }); + setRules((response.results || []).map(r => ({ id: r.id, name: r.name }))); + } catch (error: any) { + console.error('Error loading rules:', error); + } + }, []); + + useEffect(() => { + loadRules(); + }, [loadRules]); + + useEffect(() => { + loadTasks(); + }, [loadTasks]); + + const handleRetry = async (id: number) => { + try { + await automationApi.retryTask(id); + toast.success('Task retry initiated'); + loadTasks(); + } catch (error: any) { + toast.error(`Failed to retry task: ${error.message}`); + } + }; + + const getStatusBadge = (status: string) => { + const badges = { + pending: { bg: 'bg-yellow-100', text: 'text-yellow-700', darkBg: 'dark:bg-yellow-900', darkText: 'dark:text-yellow-300', label: 'Pending' }, + running: { bg: 'bg-blue-100', text: 'text-blue-700', darkBg: 'dark:bg-blue-900', darkText: 'dark:text-blue-300', label: 'Running' }, + completed: { bg: 'bg-green-100', text: 'text-green-700', darkBg: 'dark:bg-green-900', darkText: 'dark:text-green-300', label: 'Completed' }, + failed: { bg: 'bg-red-100', text: 'text-red-700', darkBg: 'dark:bg-red-900', darkText: 'dark:text-red-300', label: 'Failed' }, + cancelled: { bg: 'bg-gray-100', text: 'text-gray-700', darkBg: 'dark:bg-gray-700', darkText: 'dark:text-gray-300', label: 'Cancelled' }, + }; + const badge = badges[status as keyof typeof badges] || badges.pending; + return ( + + {badge.label} + + ); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + const filteredTasks = tasks.filter(task => { + if (statusFilter !== 'all' && task.status !== statusFilter) return false; + if (ruleFilter && task.rule_id !== ruleFilter) return false; + return true; + }); + + return ( + <> + +
+ , + color: 'blue', + }} + /> + +
+
+ + +
+ +
+ + +
+
+ + {loading ? ( +
+
Loading tasks...
+
+ ) : filteredTasks.length === 0 ? ( + +
+

+ {tasks.length === 0 + ? 'No scheduled tasks have been created yet.' + : 'No tasks match the current filters.'} +

+
+
+ ) : ( +
+ {filteredTasks.map((task) => ( + +
+
+ {getStatusBadge(task.status)} + {task.retry_count > 0 && ( + + Retries: {task.retry_count} + + )} +
+ +
+
+ Scheduled: +
+ {formatDate(task.scheduled_at)} +
+
+ {task.started_at && ( +
+ Started: +
+ {formatDate(task.started_at)} +
+
+ )} + {task.completed_at && ( +
+ Completed: +
+ {formatDate(task.completed_at)} +
+
+ )} +
+ + {task.error && ( +
+
+ +
+ Error: +

{task.error}

+
+
+
+ )} + + {task.result && task.status === 'completed' && ( +
+
+ +
+ Result: +
+                            {JSON.stringify(task.result, null, 2)}
+                          
+
+
+
+ )} + + {task.status === 'failed' && ( +
+ +
+ )} +
+
+ ))} +
+ )} +
+ + ); +} + diff --git a/frontend/src/templates/TablePageTemplate.tsx b/frontend/src/templates/TablePageTemplate.tsx index 29962284..7441b35c 100644 --- a/frontend/src/templates/TablePageTemplate.tsx +++ b/frontend/src/templates/TablePageTemplate.tsx @@ -473,18 +473,19 @@ export default function TablePageTemplate({ } // Update metrics if we have new values + // HeaderMetricsContext will automatically merge these with credit balance if (metricsKey) { setMetrics(headerMetrics); hasSetMetricsRef.current = true; prevMetricsRef.current = metricsKey; } else if (hasSetMetricsRef.current) { - // Only clear if we previously set metrics + // Clear page metrics (credit balance will be preserved by HeaderMetricsContext) setMetrics([]); hasSetMetricsRef.current = false; prevMetricsRef.current = ''; } - // Cleanup: clear metrics when component unmounts (only if we set them) + // Cleanup: clear page metrics when component unmounts (credit balance preserved) return () => { if (hasSetMetricsRef.current) { setMetrics([]);