Add automation routes and enhance header metrics management
- Introduced new routes for Automation Rules and Automation Tasks in the frontend. - Updated the AppSidebar to include sub-items for Automation navigation. - Enhanced HeaderMetricsContext to manage credit and page metrics more effectively, ensuring proper merging and clearing of metrics. - Adjusted AppLayout and TablePageTemplate to maintain credit balance while managing page metrics.
This commit is contained in:
Binary file not shown.
@@ -60,6 +60,8 @@ const ReferenceIndustries = lazy(() => import("./pages/Reference/Industries"));
|
|||||||
|
|
||||||
// Other Pages - Lazy loaded
|
// Other Pages - Lazy loaded
|
||||||
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
const AutomationDashboard = lazy(() => import("./pages/Automation/Dashboard"));
|
||||||
|
const AutomationRules = lazy(() => import("./pages/Automation/Rules"));
|
||||||
|
const AutomationTasks = lazy(() => import("./pages/Automation/Tasks"));
|
||||||
|
|
||||||
// Settings - Lazy loaded
|
// Settings - Lazy loaded
|
||||||
const GeneralSettings = lazy(() => import("./pages/Settings/General"));
|
const GeneralSettings = lazy(() => import("./pages/Settings/General"));
|
||||||
@@ -341,6 +343,20 @@ export default function App() {
|
|||||||
</ModuleGuard>
|
</ModuleGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/automation/rules" element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="automation">
|
||||||
|
<AutomationRules />
|
||||||
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
<Route path="/automation/tasks" element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="automation">
|
||||||
|
<AutomationTasks />
|
||||||
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route path="/settings" element={
|
<Route path="/settings" element={
|
||||||
|
|||||||
176
frontend/src/api/automation.api.ts
Normal file
176
frontend/src/api/automation.api.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { fetchAPI } from '../services/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automation API Client
|
||||||
|
* Functions for automation rules and scheduled tasks
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface AutomationRule {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
trigger: 'schedule' | 'event' | 'manual';
|
||||||
|
schedule?: string; // Cron-like string
|
||||||
|
conditions: Array<{
|
||||||
|
field: string;
|
||||||
|
operator: string;
|
||||||
|
value: any;
|
||||||
|
}>;
|
||||||
|
actions: Array<{
|
||||||
|
type: string;
|
||||||
|
params: Record<string, any>;
|
||||||
|
}>;
|
||||||
|
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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
}>;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationRuleUpdateData extends Partial<AutomationRuleCreateData> {}
|
||||||
|
|
||||||
|
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<string, any>) => {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ export const HeaderMetrics: React.FC = () => {
|
|||||||
if (!metrics || metrics.length === 0) return null;
|
if (!metrics || metrics.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="igny8-header-metrics hidden lg:flex">
|
<div className="igny8-header-metrics flex">
|
||||||
{metrics.map((metric, index) => (
|
{metrics.map((metric, index) => (
|
||||||
<React.Fragment key={index}>
|
<React.Fragment key={index}>
|
||||||
<div className="igny8-header-metric">
|
<div className="igny8-header-metric">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
import { createContext, useContext, useState, ReactNode, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
interface HeaderMetric {
|
interface HeaderMetric {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,13 +28,66 @@ interface HeaderMetricsProviderProps {
|
|||||||
|
|
||||||
export const HeaderMetricsProvider: React.FC<HeaderMetricsProviderProps> = ({ children }) => {
|
export const HeaderMetricsProvider: React.FC<HeaderMetricsProviderProps> = ({ children }) => {
|
||||||
const [metrics, setMetrics] = useState<HeaderMetric[]>([]);
|
const [metrics, setMetrics] = useState<HeaderMetric[]>([]);
|
||||||
|
const creditMetricRef = useRef<HeaderMetric | null>(null);
|
||||||
|
const pageMetricsRef = useRef<HeaderMetric[]>([]);
|
||||||
|
|
||||||
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([]);
|
setMetrics([]);
|
||||||
};
|
pageMetricsRef.current = [];
|
||||||
|
creditMetricRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderMetricsContext.Provider value={{ metrics, setMetrics, clearMetrics }}>
|
<HeaderMetricsContext.Provider value={{ metrics, setMetrics: handleSetMetrics, clearMetrics }}>
|
||||||
{children}
|
{children}
|
||||||
</HeaderMetricsContext.Provider>
|
</HeaderMetricsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -224,8 +224,10 @@ const LayoutContent: React.FC = () => {
|
|||||||
}, [isAuthenticated, balance, loadBalance, setMetrics]);
|
}, [isAuthenticated, balance, loadBalance, setMetrics]);
|
||||||
|
|
||||||
// Update header metrics when balance changes
|
// Update header metrics when balance changes
|
||||||
|
// This sets credit balance which will be merged with page metrics by HeaderMetricsContext
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !balance) {
|
if (!isAuthenticated || !balance) {
|
||||||
|
// Clear credit balance but keep page metrics
|
||||||
setMetrics([]);
|
setMetrics([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -242,6 +244,7 @@ const LayoutContent: React.FC = () => {
|
|||||||
accentColor = 'purple';
|
accentColor = 'purple';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set credit balance (single metric with label "Credits" - HeaderMetricsContext will merge it)
|
||||||
setMetrics([{
|
setMetrics([{
|
||||||
label: 'Credits',
|
label: 'Credits',
|
||||||
value: balance.credits,
|
value: balance.credits,
|
||||||
|
|||||||
@@ -163,7 +163,11 @@ const AppSidebar: React.FC = () => {
|
|||||||
workflowItems.push({
|
workflowItems.push({
|
||||||
icon: <BoltIcon />,
|
icon: <BoltIcon />,
|
||||||
name: "Automation",
|
name: "Automation",
|
||||||
path: "/automation",
|
subItems: [
|
||||||
|
{ name: "Dashboard", path: "/automation" },
|
||||||
|
{ name: "Rules", path: "/automation/rules" },
|
||||||
|
{ name: "Tasks", path: "/automation/tasks" },
|
||||||
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
254
frontend/src/pages/Automation/Rules.tsx
Normal file
254
frontend/src/pages/Automation/Rules.tsx
Normal file
@@ -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<AutomationRule[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedRule, setSelectedRule] = useState<AutomationRule | null>(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 <span className="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">Inactive</span>;
|
||||||
|
}
|
||||||
|
if (rule.status === 'paused') {
|
||||||
|
return <span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Paused</span>;
|
||||||
|
}
|
||||||
|
return <span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">Active</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${colors[trigger as keyof typeof colors] || colors.manual}`}>
|
||||||
|
{trigger.charAt(0).toUpperCase() + trigger.slice(1)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Automation Rules" />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Automation Rules"
|
||||||
|
lastUpdated={new Date()}
|
||||||
|
badge={{
|
||||||
|
icon: <BoltIcon />,
|
||||||
|
color: 'purple',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Create and manage automation rules to automate your workflows
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
Create Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading rules...</div>
|
||||||
|
</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<ComponentCard title="No Rules" desc="Create your first automation rule to get started">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
You haven't created any automation rules yet.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||||
|
>
|
||||||
|
Create Your First Rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<ComponentCard
|
||||||
|
key={rule.id}
|
||||||
|
title={rule.name}
|
||||||
|
desc={rule.description || 'No description'}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{getStatusBadge(rule)}
|
||||||
|
{getTriggerBadge(rule.trigger)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rule.schedule && (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Schedule:</strong> {rule.schedule}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Actions:</strong> {rule.actions.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rule.execution_count > 0 && (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Executions:</strong> {rule.execution_count}
|
||||||
|
{rule.last_executed_at && (
|
||||||
|
<span className="ml-2">
|
||||||
|
(Last: {new Date(rule.last_executed_at).toLocaleDateString()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleActive(rule)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
title={rule.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
>
|
||||||
|
{rule.is_active ? <PauseIcon /> : <PlayIcon />}
|
||||||
|
</button>
|
||||||
|
{rule.trigger === 'manual' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleExecute(rule.id)}
|
||||||
|
className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
title="Execute Now"
|
||||||
|
>
|
||||||
|
<PlayIcon />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(rule)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(rule.id)}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule Creation/Edit Wizard Modal - TODO: Implement full wizard */}
|
||||||
|
{isWizardOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-2xl w-full mx-4">
|
||||||
|
<h3 className="text-xl font-bold mb-4">
|
||||||
|
{isEditMode ? 'Edit Rule' : 'Create Rule'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Rule wizard coming soon. For now, use the API directly or create rules programmatically.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsWizardOpen(false)}
|
||||||
|
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
250
frontend/src/pages/Automation/Tasks.tsx
Normal file
250
frontend/src/pages/Automation/Tasks.tsx
Normal file
@@ -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<ScheduledTask[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
|
const [ruleFilter, setRuleFilter] = useState<number | null>(null);
|
||||||
|
const [rules, setRules] = useState<Array<{ id: number; name: string }>>([]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${badge.bg} ${badge.text} ${badge.darkBg} ${badge.darkText}`}>
|
||||||
|
{badge.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PageMeta title="Scheduled Tasks" />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Scheduled Tasks"
|
||||||
|
lastUpdated={new Date()}
|
||||||
|
badge={{
|
||||||
|
icon: <ClockIcon />,
|
||||||
|
color: 'blue',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Filter by Status
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(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-800"
|
||||||
|
>
|
||||||
|
<option value="all">All Statuses</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="running">Running</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Filter by Rule
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={ruleFilter || ''}
|
||||||
|
onChange={(e) => setRuleFilter(e.target.value ? Number(e.target.value) : null)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<option value="">All Rules</option>
|
||||||
|
{rules.map(rule => (
|
||||||
|
<option key={rule.id} value={rule.id}>{rule.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-gray-500">Loading tasks...</div>
|
||||||
|
</div>
|
||||||
|
) : filteredTasks.length === 0 ? (
|
||||||
|
<ComponentCard title="No Tasks" desc="No scheduled tasks found">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">
|
||||||
|
{tasks.length === 0
|
||||||
|
? 'No scheduled tasks have been created yet.'
|
||||||
|
: 'No tasks match the current filters.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredTasks.map((task) => (
|
||||||
|
<ComponentCard
|
||||||
|
key={task.id}
|
||||||
|
title={`Task #${task.id} - ${task.task_type}`}
|
||||||
|
desc={task.rule_name ? `Rule: ${task.rule_name}` : 'Manual task'}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{getStatusBadge(task.status)}
|
||||||
|
{task.retry_count > 0 && (
|
||||||
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Retries: {task.retry_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong className="text-gray-700 dark:text-gray-300">Scheduled:</strong>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(task.scheduled_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{task.started_at && (
|
||||||
|
<div>
|
||||||
|
<strong className="text-gray-700 dark:text-gray-300">Started:</strong>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(task.started_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.completed_at && (
|
||||||
|
<div>
|
||||||
|
<strong className="text-gray-700 dark:text-gray-300">Completed:</strong>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
{formatDate(task.completed_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.error && (
|
||||||
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<XCircleIcon className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong className="text-red-800 dark:text-red-300">Error:</strong>
|
||||||
|
<p className="text-red-700 dark:text-red-400 text-sm mt-1">{task.error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.result && task.status === 'completed' && (
|
||||||
|
<div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<strong className="text-green-800 dark:text-green-300">Result:</strong>
|
||||||
|
<pre className="text-green-700 dark:text-green-400 text-xs mt-1 overflow-auto">
|
||||||
|
{JSON.stringify(task.result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{task.status === 'failed' && (
|
||||||
|
<div className="flex justify-end pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => handleRetry(task.id)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCwIcon />
|
||||||
|
Retry Task
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComponentCard>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -473,18 +473,19 @@ export default function TablePageTemplate({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update metrics if we have new values
|
// Update metrics if we have new values
|
||||||
|
// HeaderMetricsContext will automatically merge these with credit balance
|
||||||
if (metricsKey) {
|
if (metricsKey) {
|
||||||
setMetrics(headerMetrics);
|
setMetrics(headerMetrics);
|
||||||
hasSetMetricsRef.current = true;
|
hasSetMetricsRef.current = true;
|
||||||
prevMetricsRef.current = metricsKey;
|
prevMetricsRef.current = metricsKey;
|
||||||
} else if (hasSetMetricsRef.current) {
|
} else if (hasSetMetricsRef.current) {
|
||||||
// Only clear if we previously set metrics
|
// Clear page metrics (credit balance will be preserved by HeaderMetricsContext)
|
||||||
setMetrics([]);
|
setMetrics([]);
|
||||||
hasSetMetricsRef.current = false;
|
hasSetMetricsRef.current = false;
|
||||||
prevMetricsRef.current = '';
|
prevMetricsRef.current = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup: clear metrics when component unmounts (only if we set them)
|
// Cleanup: clear page metrics when component unmounts (credit balance preserved)
|
||||||
return () => {
|
return () => {
|
||||||
if (hasSetMetricsRef.current) {
|
if (hasSetMetricsRef.current) {
|
||||||
setMetrics([]);
|
setMetrics([]);
|
||||||
|
|||||||
Reference in New Issue
Block a user