ui-nyla/src/pages/workflows/AutomationsPage.tsx
2026-01-24 00:42:13 +01:00

791 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AutomationsPage
*
* Page for viewing and managing workflow automations using FormGeneratorTable.
* Includes template selection, execution modal with live logs, and execution history.
*/
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaRobot, FaPlay, FaPlus, FaToggleOn, FaToggleOff, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import styles from '../admin/Admin.module.css';
interface WorkflowLog {
id: string;
timestamp: number;
message: string;
status?: string;
progress?: number;
}
export const AutomationsPage: React.FC = () => {
// Data hook
const {
data: automations,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchAutomationById,
updateOptimistically,
} = useAutomations();
// Operations hook
const {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleAutomationToggleActive,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
executingAutomations,
} = useAutomationOperations();
const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest();
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
// Execution modal state
const [executionModal, setExecutionModal] = useState<{
visible: boolean;
automationId: string | null;
automationLabel: string;
workflowId: string | null;
status: 'starting' | 'running' | 'completed' | 'stopped' | 'error';
logs: WorkflowLog[];
}>({
visible: false,
automationId: null,
automationLabel: '',
workflowId: null,
status: 'starting',
logs: [],
});
// Logs modal state
const [logsModal, setLogsModal] = useState<{
visible: boolean;
automation: Automation | null;
}>({
visible: false,
automation: null,
});
// Refs for polling
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastLogIdRef = useRef<string | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
// Initial fetch
useEffect(() => {
refetch();
}, []);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// Auto-scroll logs
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [executionModal.logs]);
// Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => {
const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'template', 'executionLogs'];
return (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
}, [attributes]);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click
const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
if (fullAutomation) {
setEditingAutomation(fullAutomation as Automation);
}
};
// Handle create submit
const handleCreateSubmit = async (data: Partial<Automation>) => {
const result = await handleAutomationCreate(data as any);
if (result) {
setShowCreateModal(false);
showSuccess('Automatisierung erstellt');
refetch();
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<Automation>) => {
if (!editingAutomation) return;
const success = await handleAutomationUpdate(editingAutomation.id, data as any);
if (success) {
setEditingAutomation(null);
showSuccess('Automatisierung aktualisiert');
refetch();
}
};
// Handle delete single automation
const handleDelete = async (automation: Automation) => {
if (window.confirm(`Möchten Sie die Automatisierung "${automation.label}" wirklich löschen?`)) {
const success = await handleAutomationDelete(automation.id);
if (success) {
showSuccess('Automatisierung gelöscht');
refetch();
}
}
};
// Load templates
const handleLoadTemplates = async () => {
setLoadingTemplates(true);
try {
const loadedTemplates = await fetchTemplates();
setTemplates(loadedTemplates);
if (loadedTemplates.length === 0) {
showInfo('Keine Vorlagen verfügbar');
} else {
setShowTemplateModal(true);
}
} catch (err) {
showError('Fehler beim Laden der Vorlagen');
} finally {
setLoadingTemplates(false);
}
};
// Handle template selection
const handleTemplateSelect = async (template: AutomationTemplate) => {
setShowTemplateModal(false);
// Pre-fill form with template data
const prefillData: Partial<Automation> = {
label: template.template?.overview || 'Neue Automatisierung',
template: JSON.stringify(template.template, null, 2),
placeholders: template.parameters || {},
active: false,
schedule: '0 */4 * * *',
};
// Create automation directly
const result = await handleAutomationCreate(prefillData as any);
if (result) {
showSuccess('Automatisierung aus Vorlage erstellt');
refetch();
}
};
// Poll workflow logs
const pollWorkflowLogs = useCallback(async (workflowId: string) => {
try {
const response = await request({
url: `/api/workflows/${workflowId}/logs`,
method: 'get',
params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {},
});
const logs: WorkflowLog[] = response?.items || response || [];
if (logs.length > 0) {
setExecutionModal(prev => ({
...prev,
logs: [...prev.logs, ...logs],
}));
lastLogIdRef.current = logs[logs.length - 1].id;
}
// Check workflow status
const statusResponse = await request({
url: `/api/workflows/${workflowId}`,
method: 'get',
});
const workflowStatus = statusResponse?.status;
if (workflowStatus === 'completed' || workflowStatus === 'stopped' || workflowStatus === 'error' || workflowStatus === 'failed') {
// Stop polling
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setExecutionModal(prev => ({
...prev,
status: workflowStatus === 'completed' ? 'completed' :
workflowStatus === 'error' || workflowStatus === 'failed' ? 'error' : 'stopped',
}));
if (workflowStatus === 'completed') {
showSuccess('Automatisierung erfolgreich abgeschlossen');
} else if (workflowStatus === 'error' || workflowStatus === 'failed') {
showError('Automatisierung fehlgeschlagen');
} else {
showInfo('Automatisierung gestoppt');
}
refetch();
}
} catch (err) {
console.error('Error polling workflow logs:', err);
}
}, [request, refetch, showSuccess, showError, showInfo]);
// Handle execute automation with modal
const handleExecute = async (automation: Automation) => {
// Reset and show modal
lastLogIdRef.current = null;
setExecutionModal({
visible: true,
automationId: automation.id,
automationLabel: automation.label,
workflowId: null,
status: 'starting',
logs: [{
id: 'init',
timestamp: Date.now() / 1000,
message: 'Automatisierung wird gestartet...',
}],
});
try {
const result = await handleAutomationExecute(automation.id);
const workflowId = result?.id;
if (workflowId) {
setExecutionModal(prev => ({
...prev,
workflowId,
status: 'running',
logs: [...prev.logs, {
id: 'started',
timestamp: Date.now() / 1000,
message: `Workflow ${workflowId} gestartet`,
status: 'running',
}],
}));
// Start polling
pollIntervalRef.current = setInterval(() => {
pollWorkflowLogs(workflowId);
}, 2000);
}
} catch (err: any) {
setExecutionModal(prev => ({
...prev,
status: 'error',
logs: [...prev.logs, {
id: 'error',
timestamp: Date.now() / 1000,
message: `Fehler: ${err.message || 'Unbekannter Fehler'}`,
status: 'error',
}],
}));
showError(`Fehler beim Ausführen: ${err.message}`);
}
};
// Handle stop workflow
const handleStopWorkflow = async () => {
if (!executionModal.workflowId) return;
try {
await request({
url: `/api/workflows/${executionModal.workflowId}/stop`,
method: 'post',
});
setExecutionModal(prev => ({
...prev,
logs: [...prev.logs, {
id: 'stopping',
timestamp: Date.now() / 1000,
message: 'Workflow wird gestoppt...',
}],
}));
} catch (err: any) {
showError(`Fehler beim Stoppen: ${err.message}`);
}
};
// Close execution modal
const closeExecutionModal = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setExecutionModal({
visible: false,
automationId: null,
automationLabel: '',
workflowId: null,
status: 'starting',
logs: [],
});
};
// Handle toggle active
const handleToggleActive = async (automation: Automation) => {
updateOptimistically(automation.id, { active: !automation.active });
const success = await handleAutomationToggleActive(automation.id, automation.active);
if (success) {
showSuccess(automation.active ? 'Automatisierung deaktiviert' : 'Automatisierung aktiviert');
} else {
updateOptimistically(automation.id, { active: automation.active });
showError('Fehler beim Ändern des Status');
}
};
// Show logs modal
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setLogsModal({
visible: true,
automation: fullAutomation as Automation || automation,
});
};
// Form attributes for create/edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status', 'executionLogs'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
// Format timestamp
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString('de-DE');
};
// Format time only
const formatTime = (timestamp: number) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('de-DE');
};
// Get status icon
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <FaCheck className={styles.successIcon} />;
case 'error':
case 'failed':
return <FaExclamationCircle className={styles.errorIcon} />;
case 'running':
case 'starting':
return <FaSpinner className={`${styles.spinningIcon} spinning`} />;
case 'stopped':
return <FaStop className={styles.warningIcon} />;
default:
return null;
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Automatisierungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Automatisierungen</h1>
<p className={styles.pageSubtitle}>Geplante und automatisierte Workflows</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<>
<button
className={styles.secondaryButton}
onClick={handleLoadTemplates}
disabled={loadingTemplates}
>
<FaFileAlt /> {loadingTemplates ? 'Lädt...' : 'Aus Vorlage'}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Automatisierung
</button>
</>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!automations || automations.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Automatisierungen...</span>
</div>
) : !automations || automations.length === 0 ? (
<div className={styles.emptyState}>
<FaRobot className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Automatisierungen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.
</p>
{canCreate && (
<div className={styles.emptyActions}>
<button
className={styles.secondaryButton}
onClick={handleLoadTemplates}
>
<FaFileAlt /> Aus Vorlage erstellen
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Manuell erstellen
</button>
</div>
)}
</div>
) : (
<FormGeneratorTable
data={automations as any[]}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: any) => deletingAutomations.has(row.id),
}] : []),
]}
customActions={[
{
id: 'execute',
icon: <FaPlay />,
onClick: handleExecute,
title: 'Ausführen',
loading: (row: any) => executingAutomations.has(row.id),
},
{
id: 'toggleActive',
icon: (row: any) => row.active ? <FaToggleOn /> : <FaToggleOff />,
onClick: handleToggleActive,
title: (row: any) => row.active ? 'Deaktivieren' : 'Aktivieren',
} as any,
{
id: 'logs',
icon: <FaList />,
onClick: handleShowLogs,
title: 'Ausführungsverlauf',
},
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleAutomationDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Automatisierungen gefunden"
/>
)}
</div>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Automatisierung</h2>
<button className={styles.modalClose} onClick={() => setShowCreateModal(false)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingAutomation && (
<div className={styles.modalOverlay} onClick={() => setEditingAutomation(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Automatisierung bearbeiten</h2>
<button className={styles.modalClose} onClick={() => setEditingAutomation(null)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingAutomation}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingAutomation(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Template Selection Modal */}
{showTemplateModal && (
<div className={styles.modalOverlay} onClick={() => setShowTemplateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Vorlage auswählen</h2>
<button className={styles.modalClose} onClick={() => setShowTemplateModal(false)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.templateList}>
{templates.map((template, index) => (
<div key={index} className={styles.templateItem}>
<div className={styles.templateHeader}>
<h4 className={styles.templateTitle}>
{template.template?.overview || `Vorlage ${index + 1}`}
</h4>
</div>
<p className={styles.templateDescription}>
{template.template?.tasks?.[0]?.description ||
template.template?.tasks?.[0]?.objective ||
'Keine Beschreibung'}
</p>
<button
className={styles.primaryButton}
onClick={() => handleTemplateSelect(template)}
>
<FaCheck /> Verwenden
</button>
</div>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={() => setShowTemplateModal(false)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Execution Modal */}
{executionModal.visible && (
<div className={styles.modalOverlay} onClick={closeExecutionModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel}
</h2>
<button className={styles.modalClose} onClick={closeExecutionModal}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.executionStatus}>
<span className={`${styles.statusBadge} ${styles[executionModal.status]}`}>
{executionModal.status === 'starting' && 'Wird gestartet...'}
{executionModal.status === 'running' && 'Läuft...'}
{executionModal.status === 'completed' && 'Abgeschlossen'}
{executionModal.status === 'stopped' && 'Gestoppt'}
{executionModal.status === 'error' && 'Fehler'}
</span>
{executionModal.workflowId && (
<span className={styles.workflowId}>
Workflow: <code>{executionModal.workflowId}</code>
</span>
)}
</div>
<div
ref={logContainerRef}
className={styles.executionLogs}
style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{executionModal.logs.map((log, index) => (
<div key={log.id || index} className={styles.logEntry}>
<span className={styles.logTime}>[{formatTime(log.timestamp)}]</span>
{log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>}
<span className={styles.logMessage}>{log.message}</span>
{log.progress !== undefined && log.progress !== null && log.progress < 1 && (
<span className={styles.logProgress}>({Math.round(log.progress * 100)}%)</span>
)}
</div>
))}
</div>
</div>
<div className={styles.modalFooter}>
{executionModal.status === 'running' && (
<button className={styles.dangerButton} onClick={handleStopWorkflow}>
<FaStop /> Stoppen
</button>
)}
<button className={styles.secondaryButton} onClick={closeExecutionModal}>
Schliessen
</button>
</div>
</div>
</div>
)}
{/* Logs History Modal */}
{logsModal.visible && logsModal.automation && (
<div className={styles.modalOverlay} onClick={() => setLogsModal({ visible: false, automation: null })}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
Ausführungsverlauf: {logsModal.automation.label}
</h2>
<button
className={styles.modalClose}
onClick={() => setLogsModal({ visible: false, automation: null })}
>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{(!logsModal.automation.executionLogs || logsModal.automation.executionLogs.length === 0) ? (
<div className={styles.emptyState}>
<p>Keine Ausführungen vorhanden</p>
</div>
) : (
<div className={styles.logsHistory}>
{[...logsModal.automation.executionLogs].reverse().map((log, index) => (
<div key={index} className={`${styles.logHistoryItem} ${styles[log.status || 'unknown']}`}>
<div className={styles.logHistoryHeader}>
<span className={styles.logHistoryDate}>{formatTimestamp(log.timestamp)}</span>
<span className={`${styles.statusBadge} ${styles[log.status || 'unknown']}`}>
{log.status || 'Unbekannt'}
</span>
{log.workflowId && (
<span className={styles.workflowId}>
Workflow: <code>{log.workflowId}</code>
</span>
)}
</div>
{log.messages && log.messages.length > 0 && (
<div className={styles.logHistoryMessages}>
{log.messages.map((msg, msgIndex) => (
<div key={msgIndex} className={styles.logHistoryMessage}>{msg}</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
<div className={styles.modalFooter}>
<button
className={styles.secondaryButton}
onClick={() => setLogsModal({ visible: false, automation: null })}
>
Schliessen
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default AutomationsPage;