frontend_nyla/src/pages/views/automation/AutomationDefinitionsView.tsx
ValueOn AG 77e7eba711 BREAKING CHANGE
API and persisted records use PowerOnModel system fields:
- sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy
Removed legacy JSON/DB field names:
- _createdAt, _createdBy, _modifiedAt, _modifiedBy
Frontend (frontend_nyla) and gateway call sites were updated accordingly.
Database:
- Bootstrap runs idempotent backfill (_migrateSystemFieldColumns) from old
  underscore columns and selected business duplicates into sys* where sys* IS NULL.
- Re-run app bootstrap against each PostgreSQL database after deploy.
- Optional: DROP INDEX IF EXISTS "idx_invitation_createdby" if an old index remains;
  new index: idx_invitation_syscreatedby on Invitation(sysCreatedBy).
Tests:
- RBAC integration tests aligned with current GROUP mandate filter and UserMandate-based
  UserConnection GROUP clause; buildRbacWhereClause(..., mandateId=...) must be passed
  explicitly (same as production request context).
2026-03-28 18:13:18 +01:00

623 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.

/**
* AutomationDefinitionsView
*
* View for viewing and managing workflow automation definitions.
* 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 { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import styles from '../../admin/Admin.module.css';
interface WorkflowLog {
id: string;
timestamp: number;
message: string;
status?: string;
progress?: number;
}
export const AutomationDefinitionsView: React.FC = () => {
const { instanceId: routeInstanceId, featureCode: routeFeatureCode } = useCurrentInstance();
const { getAllInstances } = useFeatureStore();
const instances = getAllInstances();
const chatbotInstance = instances.find(i => i.featureCode === 'chatbot') || instances[0];
const automationInstance = instances.find(i => i.featureCode === 'automation');
// When under automation feature route, use route context; otherwise use featureStore
const mandateId = routeFeatureCode === 'automation' && routeInstanceId
? (automationInstance?.mandateId ?? chatbotInstance?.mandateId)
: chatbotInstance?.mandateId;
const featureInstanceId = routeFeatureCode === 'automation' && routeInstanceId
? routeInstanceId
: (chatbotInstance?.id ?? automationInstance?.id);
const automationWorkflowInstanceId = routeFeatureCode === 'automation' ? routeInstanceId : undefined;
const {
data: automations,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchAutomationById,
updateOptimistically,
} = useAutomations();
const {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
executingAutomations,
} = useAutomationOperations();
const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest();
const [showEditor, setShowEditor] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null);
const [editorSaving, setEditorSaving] = useState(false);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [executionModal, setExecutionModal] = useState<{
visible: boolean;
automationId: string | null;
automationLabel: string;
featureInstanceId: string | null;
workflowId: string | null;
status: 'starting' | 'running' | 'completed' | 'stopped' | 'error';
logs: WorkflowLog[];
}>({
visible: false,
automationId: null,
automationLabel: '',
featureInstanceId: null,
workflowId: null,
status: 'starting',
logs: [],
});
const [logsModal, setLogsModal] = useState<{
visible: boolean;
automation: Automation | null;
}>({
visible: false,
automation: null,
});
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastLogIdRef = useRef<string | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { refetch(); }, []);
useEffect(() => () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
}, []);
useEffect(() => {
if (logContainerRef.current) logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}, [executionModal.logs]);
const columns = useMemo(() => {
const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt',
'template', 'executionLogs', 'placeholders',
'sysCreatedByUserName', '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,
}));
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: 'sysCreatedByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]);
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setEditingAutomation(fullAutomation as Automation || automation);
setShowEditor(true);
};
const handleCreateClick = () => {
setEditingAutomation({
mandateId: mandateId!,
featureInstanceId: featureInstanceId!,
label: '',
schedule: '0 22 * * *',
active: false,
placeholders: {},
} as Automation);
setShowEditor(true);
};
const handleEditorSave = async (data: Partial<Automation>) => {
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
setEditorSaving(true);
try {
const saveData = { ...data, mandateId, featureInstanceId };
if (editingAutomation?.id) {
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 {
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);
}
};
const handleEditorCancel = () => {
setShowEditor(false);
setEditingAutomation(null);
};
const handleDelete = async (automation: Automation) => {
const success = await handleAutomationDelete(automation.id);
if (success) {
showSuccess('Automatisierung gelöscht');
await refetch();
}
};
const handleDuplicate = async (automation: Automation) => {
try {
await request({ url: `/api/automations/${automation.id}/duplicate`, method: 'post' });
showSuccess('Automatisierung dupliziert');
await refetch();
} catch (err: any) {
showError(`Fehler beim Duplizieren: ${err.message}`);
}
};
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);
}
};
const handleTemplateSelect = (template: AutomationTemplate) => {
setShowTemplateModal(false);
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
let templateLabel = 'Neue Automatisierung';
if (template.label) {
templateLabel = typeof template.label === 'string'
? template.label
: (template.label as any).de || (template.label as any).en || 'Neue Automatisierung';
} else if (template.overview) {
templateLabel = typeof template.overview === 'string'
? template.overview
: ((template.overview as any).de || (template.overview as any).en || 'Neue Automatisierung');
}
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);
}
}
const prefillData: Partial<Automation> = {
mandateId,
featureInstanceId,
label: templateLabel,
template: typeof template.template === 'string' ? template.template : JSON.stringify(template.template, null, 2),
placeholders: convertedPlaceholders,
active: false,
schedule: '0 22 * * *',
};
setEditingAutomation(prefillData as Automation);
setShowEditor(true);
};
const pollWorkflowLogs = useCallback(async (workflowId: string, instanceId: string) => {
try {
const contextHeaders: Record<string, string> = {};
if (mandateId) contextHeaders['X-Mandate-Id'] = mandateId;
const logsUrl = `/api/automations/${instanceId}/workflows/${workflowId}/logs`;
const workflowUrl = `/api/automations/${instanceId}/workflows/${workflowId}`;
const response = await request({
url: logsUrl,
method: 'get',
params: lastLogIdRef.current ? { logId: lastLogIdRef.current } : {},
additionalConfig: { headers: contextHeaders },
});
const logs: WorkflowLog[] = response?.items || response || [];
if (logs.length > 0) {
setExecutionModal(prev => {
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;
}
const statusResponse = await request({
url: workflowUrl,
method: 'get',
additionalConfig: { headers: contextHeaders },
});
const workflowStatus = statusResponse?.status;
if (workflowStatus === 'completed' || workflowStatus === 'stopped' || workflowStatus === 'error' || workflowStatus === 'failed') {
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, mandateId]);
const handleExecute = async (automation: Automation) => {
lastLogIdRef.current = null;
setExecutionModal({
visible: true,
automationId: automation.id,
automationLabel: automation.label,
featureInstanceId: automation.featureInstanceId ?? automationWorkflowInstanceId ?? null,
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;
const instanceId = automation.featureInstanceId ?? automationWorkflowInstanceId;
if (workflowId && instanceId) {
setExecutionModal(prev => ({
...prev,
workflowId,
status: 'running',
logs: [...prev.logs, { id: 'started', timestamp: Date.now() / 1000, message: `Workflow ${workflowId} gestartet`, status: 'running' }],
}));
pollIntervalRef.current = setInterval(() => pollWorkflowLogs(workflowId, instanceId), 2000);
} else if (workflowId && !instanceId) {
setExecutionModal(prev => ({ ...prev, status: 'error', logs: [...prev.logs, { id: 'error', timestamp: Date.now() / 1000, message: 'Keine Feature-Instanz für Polling', status: 'error' }] }));
}
} 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}`);
}
};
const handleStopWorkflow = async () => {
if (!executionModal.workflowId) return;
const instanceId = executionModal.featureInstanceId ?? automationWorkflowInstanceId;
if (!instanceId) {
showError('Keine Feature-Instanz für Stopp verfügbar');
return;
}
try {
const stopHeaders: Record<string, string> = {};
if (mandateId) stopHeaders['X-Mandate-Id'] = mandateId;
const stopUrl = `/api/automations/${instanceId}/workflows/${executionModal.workflowId}/stop`;
await request({
url: stopUrl,
method: 'post',
additionalConfig: { headers: stopHeaders },
});
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}`);
}
};
const closeExecutionModal = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setExecutionModal({
visible: false,
automationId: null,
automationLabel: '',
featureInstanceId: null,
workflowId: null,
status: 'starting',
logs: [],
});
};
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setLogsModal({ visible: true, automation: fullAutomation as Automation || automation });
};
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '';
return new Date(timestamp * 1000).toLocaleString('de-DE');
};
const formatTime = (timestamp: number) => {
if (!timestamp) return '';
return new Date(timestamp * 1000).toLocaleTimeString('de-DE');
};
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} ${styles.adminPageFill}`}>
<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={[
...(canCreate ? [{ type: 'copy' as const, title: 'Duplizieren', onAction: handleDuplicate }] : []),
...(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>
{showEditor && editingAutomation && (
<AutomationEditor
mode="definition"
initialData={editingAutomation}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={editorSaving}
/>
)}
{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) => {
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}`;
const overviewText = template.overview ? (typeof template.overview === 'string' ? template.overview : (template.overview as any).de || (template.overview as any).en || '') : '';
let parsedTemplate: any = null;
try {
if (template.template) parsedTemplate = typeof template.template === 'string' ? JSON.parse(template.template) : template.template;
} catch { /* ignore */ }
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>
)}
{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} ${log.status === 'error' || log.status === 'failed' ? styles.logEntryError : ''}`}>
<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>
)}
{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>
);
};