frontend_nyla/src/pages/workflows/AutomationsPage.tsx
2026-02-09 23:45:05 +01:00

848 lines
30 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 { AutomationEditor } from '../../components/AutomationEditor';
import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { useFeatureStore } from '../../stores/featureStore';
import styles from '../admin/Admin.module.css';
interface WorkflowLog {
id: string;
timestamp: number;
message: string;
status?: string;
progress?: number;
}
export const AutomationsPage: React.FC = () => {
// Get mandate and feature instance from store (first chatbot instance or first available)
const { getAllInstances } = useFeatureStore();
const instances = getAllInstances();
// Find first chatbot instance, or fall back to first available instance
const chatbotInstance = instances.find(i => i.featureCode === 'chatbot') || instances[0];
const mandateId = chatbotInstance?.mandateId;
const featureInstanceId = chatbotInstance?.id;
// Data hook
const {
data: automations,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchAutomationById,
updateOptimistically,
} = useAutomations();
// Operations hook
const {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
executingAutomations,
} = useAutomationOperations();
const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest();
// Editor states
const [showEditor, setShowEditor] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null);
const [editorSaving, setEditorSaving] = useState(false);
// Template selection states
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 internal fields, add enriched display columns
const columns = useMemo(() => {
const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
'template', 'executionLogs', 'placeholders',
// Hide enriched fields from attribute list (added manually below)
'_createdByUserName', 'mandateName', 'featureInstanceName',
];
const attrColumns = (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,
}));
// Add enriched display columns (from backend enrichment)
const enrichedColumns = [
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click - open editor with automation data
const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setEditingAutomation(fullAutomation as Automation || automation);
setShowEditor(true);
};
// Handle create click - open editor for new automation
const handleCreateClick = () => {
// Pre-fill with context
const newAutomation: Partial<Automation> = {
mandateId: mandateId,
featureInstanceId: featureInstanceId,
label: '',
schedule: '0 22 * * *',
active: false,
placeholders: {},
};
setEditingAutomation(newAutomation as Automation);
setShowEditor(true);
};
// Handle editor save
const handleEditorSave = async (data: Partial<Automation>) => {
// Validate context
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
setEditorSaving(true);
try {
// Add required context fields
const saveData = {
...data,
mandateId: mandateId,
featureInstanceId: featureInstanceId,
};
if (editingAutomation?.id) {
// Update existing - include id in payload for backend validation
saveData.id = editingAutomation.id;
const success = await handleAutomationUpdate(editingAutomation.id, saveData as any);
if (success) {
showSuccess('Automatisierung aktualisiert');
setShowEditor(false);
setEditingAutomation(null);
await refetch();
}
} else {
// Create new
const result = await handleAutomationCreate(saveData as any);
if (result) {
showSuccess('Automatisierung erstellt');
setShowEditor(false);
setEditingAutomation(null);
await refetch();
}
}
} catch (err: any) {
showError(`Fehler: ${err.message}`);
} finally {
setEditorSaving(false);
}
};
// Handle editor cancel
const handleEditorCancel = () => {
setShowEditor(false);
setEditingAutomation(null);
};
// Handle delete single automation (confirmation handled by DeleteActionButton)
const handleDelete = async (automation: Automation) => {
const success = await handleAutomationDelete(automation.id);
if (success) {
showSuccess('Automatisierung gelöscht');
await 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 - open editor with template data pre-filled
const handleTemplateSelect = (template: AutomationTemplate) => {
setShowTemplateModal(false);
// Validate context - mandateId and featureInstanceId are required
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
// Get label from template (can be multilingual)
let templateLabel = 'Neue Automatisierung';
if (template.label) {
if (typeof template.label === 'string') {
templateLabel = template.label;
} else if (typeof template.label === 'object') {
// TextMultilingual - use German or English
templateLabel = (template.label as any).de || (template.label as any).en || 'Neue Automatisierung';
}
} else if (template.overview) {
// TextMultilingual - use German or English
templateLabel = typeof template.overview === 'string'
? template.overview
: ((template.overview as any).de || (template.overview as any).en || 'Neue Automatisierung');
}
// Convert placeholder values to strings (backend expects Dict[str, str])
const convertedPlaceholders: Record<string, string> = {};
const templateParams = (template as any).parameters || {};
for (const [key, value] of Object.entries(templateParams)) {
if (value === null || value === undefined) {
convertedPlaceholders[key] = '';
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
convertedPlaceholders[key] = JSON.stringify(value);
} else {
convertedPlaceholders[key] = String(value);
}
}
// Pre-fill form with template data and open editor for user to customize
const prefillData: Partial<Automation> = {
mandateId: mandateId,
featureInstanceId: featureInstanceId,
label: templateLabel,
template: typeof template.template === 'string'
? template.template
: JSON.stringify(template.template, null, 2),
placeholders: convertedPlaceholders,
active: false,
schedule: '0 22 * * *',
};
// Open editor with pre-filled data (no id = create mode)
setEditingAutomation(prefillData as Automation);
setShowEditor(true);
};
// 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 => {
// Deduplicate logs by ID
const existingIds = new Set(prev.logs.map(l => l.id));
const newLogs = logs.filter(l => !existingIds.has(l.id));
return {
...prev,
logs: [...prev.logs, ...newLogs],
};
});
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: [],
});
};
// Show logs modal
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setLogsModal({
visible: true,
automation: fullAutomation as Automation || automation,
});
};
// 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={handleCreateClick}
>
<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={handleCreateClick}
>
<FaPlus /> Manuell erstellen
</button>
</div>
)}
</div>
) : (
<FormGeneratorTable
data={automations as any[]}
columns={columns}
apiEndpoint="/api/automations"
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: <FaRocket />,
onClick: handleExecute,
title: 'Ausführen',
loading: (row: any) => executingAutomations.has(row.id),
},
{
id: 'logs',
icon: <FaList />,
onClick: handleShowLogs,
title: 'Ausführungsverlauf',
},
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleAutomationDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Automatisierungen gefunden"
/>
)}
</div>
{/* Automation Editor */}
{showEditor && editingAutomation && (
<AutomationEditor
mode="definition"
initialData={editingAutomation}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={editorSaving}
/>
)}
{/* 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) => {
// Get label from TextMultilingual
const labelText = template.label
? (typeof template.label === 'string'
? template.label
: (template.label as any).de || (template.label as any).en || `Vorlage ${index + 1}`)
: `Vorlage ${index + 1}`;
// Get overview from TextMultilingual
const overviewText = template.overview
? (typeof template.overview === 'string'
? template.overview
: (template.overview as any).de || (template.overview as any).en || '')
: '';
// Try to parse template JSON for additional info
let parsedTemplate: any = null;
try {
if (template.template) {
parsedTemplate = typeof template.template === 'string'
? JSON.parse(template.template)
: template.template;
}
} catch { /* ignore parse errors */ }
const description = overviewText
|| parsedTemplate?.overview
|| parsedTemplate?.tasks?.[0]?.objective
|| 'Keine Beschreibung';
return (
<div key={template.id || index} className={styles.templateItem}>
<div className={styles.templateHeader}>
<h4 className={styles.templateTitle}>{labelText}</h4>
</div>
<p className={styles.templateDescription}>{description}</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;