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:
IGNY8 VPS (Salman)
2025-11-17 21:04:46 +00:00
parent aa74fb0d65
commit 0818dfe385
10 changed files with 765 additions and 8 deletions

View 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>
)}
</>
);
}

View 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>
</>
);
}