/** * AutomationsDashboardPage * * System-level automation page with two tabs: * - Dashboard: Metrics + workflow runs table (backend-paginated) * - Workflows: Central management of all RBAC-accessible workflows across instances */ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa'; import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator'; import { Tabs } from '../components/UiComponents/Tabs'; import { useToast } from '../contexts/ToastContext'; import { usePrompt } from '../hooks/usePrompt'; import { useApiRequest } from '../hooks/useApi'; import { formatUnixTimestamp } from '../utils/time'; import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi'; import api from '../api'; import { useLanguage } from '../providers/language/LanguageContext'; import styles from './admin/Admin.module.css'; // --------------------------------------------------------------------------- // Shared types & helpers // --------------------------------------------------------------------------- interface WorkflowRunMetrics { totalRuns: number; runsByStatus: Record; totalTokens: number; totalCredits: number; workflowCount: number; activeWorkflows: number; } interface WorkflowRun { id: string; workflowId: string; workflowLabel?: string; mandateId?: string; mandateLabel?: string; featureInstanceId?: string; instanceLabel?: string; ownerId?: string; status: string; costTokens?: number; costCredits?: number; sysCreatedAt?: number; sysModifiedAt?: number; } interface SystemWorkflow { id: string; mandateId: string; featureInstanceId: string; label: string; active: boolean; isRunning?: boolean; activeRunId?: string; stuckAtNodeLabel?: string; stuckAtNodeId?: string; createdAt?: number; sysCreatedAt?: number; lastStartedAt?: number; runCount?: number; mandateLabel?: string; instanceLabel?: string; canEdit?: boolean; canDelete?: boolean; canExecute?: boolean; invocations?: Array<{ id: string; enabled: boolean; kind: string }>; graph?: Record; } function _formatTs(ts?: number): string { if (ts == null || ts <= 0) return '—'; const sec = ts < 1e12 ? ts : ts / 1000; const { time } = formatUnixTimestamp(sec, undefined, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }); return time; } const _STATUS_COLORS: Record = { completed: 'var(--success-color, #28a745)', failed: 'var(--danger-color, #dc3545)', running: 'var(--primary-color, #007bff)', paused: 'var(--warning-color, #ffc107)', stopped: 'var(--warning-color, #ffc107)', cancelled: 'var(--text-secondary, #666)', }; // --------------------------------------------------------------------------- // MetricCard // --------------------------------------------------------------------------- interface MetricCardProps { icon: React.ReactNode; label: string; value: string | number; color?: string; } const MetricCard: React.FC = ({ icon, label, value, color }) => (
{icon}
{label}
{value}
); // =========================================================================== // Live Run Tracing Modal (SSE-based, can be opened/closed freely) // =========================================================================== interface _TracingStep { id: string; nodeId: string; nodeType: string; status: string; startedAt?: number; completedAt?: number; durationMs?: number; error?: string; tokensUsed?: number; inputSnapshot?: Record; output?: Record; retryCount?: number; } const _STATUS_ICONS: Record = { pending: '○', running: '◉', completed: '✓', failed: '✗', stopped: '■', skipped: '—', }; function _formatStepTs(ts: number | string | null | undefined): string { if (!ts) return ''; const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); if (isNaN(d.getTime())) return ''; return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); } function _truncateJson(obj: unknown, maxLen = 300): string { if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return ''; try { const s = JSON.stringify(obj, null, 2); return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s; } catch { return String(obj); } } const _CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => { const [open, setOpen] = useState(false); if (!content) return null; return (
{open && (
          {content}
        
)}
); }; interface _RunTracingModalProps { run: WorkflowRun; onClose: () => void; } const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => { const { t } = useLanguage(); const [steps, setSteps] = useState<_TracingStep[]>([]); const [loading, setLoading] = useState(false); const [sseConnected, setSseConnected] = useState(false); const eventSourceRef = useRef(null); const scrollRef = useRef(null); const _loadSteps = useCallback(async () => { setLoading(true); try { const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`); setSteps(resp.data?.steps || []); } catch (e) { console.error('[RunTracing] Failed to load steps:', e); } finally { setLoading(false); } }, [run.id]); const isRunning = run.status === 'running' || run.status === 'paused'; useEffect(() => { _loadSteps(); if (!isRunning) return; const baseUrl = api.defaults.baseURL || ''; const url = `${baseUrl}/api/system/workflow-runs/${run.id}/stream`; const es = new EventSource(url, { withCredentials: true }); eventSourceRef.current = es; es.onopen = () => setSseConnected(true); es.onmessage = (event) => { try { const payload = JSON.parse(event.data); if (payload.type === 'keepalive') return; if (payload.type === 'run_complete' || payload.type === 'run_failed') { _loadSteps(); es.close(); setSseConnected(false); return; } if (payload.status === 'running') { setSteps((prev) => { const exists = prev.some((s) => s.id === payload.id); if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s); return [...prev, payload as _TracingStep]; }); } else { setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s)); } } catch { /* ignore parse errors */ } }; es.onerror = () => { setSseConnected(false); es.close(); }; return () => { es.close(); eventSourceRef.current = null; setSseConnected(false); }; }, [run.id, run.status]); // eslint-disable-line react-hooks/exhaustive-deps // Polling fallback: reload steps periodically while run is active useEffect(() => { if (!isRunning) return; const interval = setInterval(() => { _loadSteps(); }, 5000); return () => clearInterval(interval); }, [isRunning, _loadSteps]); useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [steps]); return (
e.stopPropagation()} >

{t('Run-Tracing')}: {run.workflowLabel || run.workflowId}

{run.status} {sseConnected && ( ● {t('Live')} )}
{loading && steps.length === 0 && (
{t('Wird geladen…')}
)} {!loading && steps.length === 0 && (
{t('Noch keine Schritte aufgezeichnet')}
)} {steps.map((step) => { const startStr = _formatStepTs(step.startedAt); const endStr = _formatStepTs(step.completedAt); const inputStr = _truncateJson(step.inputSnapshot); const outputStr = _truncateJson(step.output); const isLoop = step.inputSnapshot?._loopIndex != null; return (
{_STATUS_ICONS[step.status] || '?'} {step.nodeType} ({step.nodeId}) {isLoop && ( [iter {step.inputSnapshot!._loopIndex}] )} {(step.retryCount ?? 0) > 0 && ( {step.retryCount}x {t('Wiederholung')} )} {step.durationMs != null && ( {step.durationMs}ms )}
{(startStr || endStr) && (
{startStr && {startStr}} {startStr && endStr && } {endStr && {endStr}}
)} {step.error && (
{step.error}
)} {(step.tokensUsed ?? 0) > 0 && (
{step.tokensUsed} {t('Tokens')}
)} <_CollapsibleSection label={t('Eingabe')} content={inputStr} /> <_CollapsibleSection label={t('Ausgabe')} content={outputStr} />
); })}
); }; // =========================================================================== // DashboardTab — Metrics + Runs table with backend pagination // =========================================================================== const _DashboardTab: React.FC = () => { const { t } = useLanguage(); const { showError } = useToast(); const [metrics, setMetrics] = useState(null); const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(true); const [paginationMeta, setPaginationMeta] = useState(null); const [tracingRun, setTracingRun] = useState(null); const lastPaginationParamsRef = useRef(null); const _loadMetrics = useCallback(async () => { try { const resp = await api.get('/api/system/workflow-runs/metrics'); setMetrics(resp.data); } catch (e) { console.error('[automations] metrics load failed', e); } }, []); const _loadRuns = useCallback(async (paginationParams?: any) => { if (paginationParams !== undefined) { lastPaginationParamsRef.current = paginationParams; } const effectiveParams = paginationParams ?? lastPaginationParamsRef.current; setLoading(true); try { const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }]; const pag = { page: effectiveParams?.page || 1, pageSize: effectiveParams?.pageSize || 25, sort: effectiveParams?.sort || defaultSort, ...(effectiveParams?.search ? { search: effectiveParams.search } : {}), ...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}), }; const params: Record = { pagination: JSON.stringify(pag) }; const resp = await api.get('/api/system/workflow-runs', { params }); const data = resp.data; setRuns(data?.runs || []); const total = data?.total ?? 0; const pageSize = pag.pageSize; setPaginationMeta({ currentPage: pag.page, pageSize, totalItems: total, totalPages: Math.ceil(total / pageSize), }); } catch (e) { console.error('[automations] runs load failed', e); showError(t('Fehler beim Laden der Workflow-Runs')); } finally { setLoading(false); } }, [showError, t]); useEffect(() => { _loadMetrics(); _loadRuns(); }, [_loadMetrics, _loadRuns]); const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused'); useEffect(() => { if (!hasRunningRuns) return; const interval = setInterval(() => { _loadRuns(); _loadMetrics(); }, 5000); return () => clearInterval(interval); }, [hasRunningRuns, _loadRuns, _loadMetrics]); const _downloadRunTracing = useCallback(async (run: WorkflowRun) => { if (!run.id) return; try { const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`); const steps = resp.data?.steps || []; const report = { runId: run.id, workflowId: run.workflowId, workflowLabel: run.workflowLabel, status: run.status, startedAt: _formatTs(run.sysCreatedAt), endedAt: _formatTs(run.sysModifiedAt), steps, }; const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `run-tracing-${run.id.slice(0, 8)}.json`; a.click(); URL.revokeObjectURL(url); } catch (e) { console.error('[automations] download tracing failed', e); showError(t('Download fehlgeschlagen')); } }, [showError, t]); const _STATUS_LABELS: Record = useMemo(() => ({ running: t('Laufend'), completed: t('Abgeschlossen'), failed: t('Fehlgeschlagen'), cancelled: t('Abgebrochen'), paused: t('Pausiert'), stopped: t('Gestoppt'), }), [t]); const _runColumns: ColumnConfig[] = useMemo(() => [ { key: 'workflowLabel', label: t('Workflow'), type: 'string', width: 200, sortable: true, formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'), }, { key: 'mandateId', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/mandates/', fkDisplayField: 'label', }, { key: 'featureInstanceId', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/features/instances', fkDisplayField: 'label', }, { key: 'status', label: t('Status'), type: 'string', width: 110, sortable: true, filterable: true, filterOptions: ['running', 'completed', 'failed', 'cancelled', 'paused'], filterLabelResolver: (v: string) => _STATUS_LABELS[v] || v, formatter: (v: string) => ( {_STATUS_LABELS[v] || v} ), }, { key: 'sysCreatedAt', label: t('Gestartet'), type: 'number', width: 150, sortable: true, formatter: (v: number) => _formatTs(v), }, { key: 'sysModifiedAt', label: t('Beendet'), type: 'number', width: 150, sortable: true, formatter: (v: number) => _formatTs(v), }, ], [t, _STATUS_LABELS]); const _hookData = useMemo(() => ({ refetch: _loadRuns, pagination: paginationMeta, }), [_loadRuns, paginationMeta]); return ( <>

{t('Workflow-Runs über alle Features und Mandanten')}

} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} /> } label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" /> } label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (

{t('Läufe nach Status')}

{Object.entries(metrics.runsByStatus).map(([status, count]) => ( {status}: {count} ))}
)} {metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
{metrics.totalTokens > 0 && (
{t('Tokens gesamt:')} {metrics.totalTokens.toLocaleString('de-DE')}
)} {metrics.totalCredits > 0 && (
{t('Credits gesamt:')} {metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}
)}
)}

{t('Letzte Runs')}

data={runs} columns={_runColumns} loading={loading} pagination={true} pageSize={25} searchable={true} filterable={true} sortable={true} selectable={true} initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]} apiEndpoint="/api/system/workflow-runs" customActions={[ { id: 'tracing', icon: , title: t('Run-Tracing anzeigen'), onClick: (row) => setTracingRun(row), }, { id: 'download', icon: , title: t('Tracing-Protokoll herunterladen'), onClick: (row) => _downloadRunTracing(row), }, ]} hookData={_hookData} emptyMessage={t('Noch keine Workflow-Runs vorhanden.')} />
{tracingRun && ( <_RunTracingModal run={tracingRun} onClose={() => setTracingRun(null)} /> )} ); }; // =========================================================================== // WorkflowsTab — Central workflow management across all instances // =========================================================================== const _WorkflowsTab: React.FC = () => { const { t } = useLanguage(); const navigate = useNavigate(); const { request } = useApiRequest(); const { showSuccess, showError } = useToast(); const { prompt: promptInput, PromptDialog } = usePrompt(); const [workflows, setWorkflows] = useState([]); const [loading, setLoading] = useState(true); const [executingId, setExecutingId] = useState(null); const [togglingId, setTogglingId] = useState(null); const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); const [paginationMeta, setPaginationMeta] = useState(null); const lastPaginationParamsRef = useRef(null); const _load = useCallback(async (paginationParams?: any) => { if (paginationParams !== undefined) { lastPaginationParamsRef.current = paginationParams; } const effectiveParams = paginationParams ?? lastPaginationParamsRef.current; setLoading(true); try { const params: Record = {}; if (activeFilter === 'active') params.active = true; if (activeFilter === 'inactive') params.active = false; const defaultSort = [{ field: 'createdAt', direction: 'desc' }]; const pag = { page: effectiveParams?.page || 1, pageSize: effectiveParams?.pageSize || 25, sort: effectiveParams?.sort || defaultSort, ...(effectiveParams?.search ? { search: effectiveParams.search } : {}), ...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}), }; params.pagination = JSON.stringify(pag); const resp = await api.get('/api/system/workflow-runs/workflows', { params }); const data = resp.data; setWorkflows(data?.items || []); setPaginationMeta(data?.pagination || null); } catch (e) { console.error('[automations] load system workflows failed', e); showError(t('Fehler beim Laden der Workflows')); } finally { setLoading(false); } }, [activeFilter, showError, t]); useEffect(() => { _load(); }, [_load]); const hasRunningWorkflows = workflows.some((w) => w.isRunning); useEffect(() => { if (!hasRunningWorkflows) return; const interval = setInterval(() => { _load(); }, 5000); return () => clearInterval(interval); }, [hasRunningWorkflows, _load]); const _handleEdit = useCallback((row: SystemWorkflow) => { if (!row.mandateId || !row.featureInstanceId) return; const fc = (row as any).featureCode || 'graphicalEditor'; navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`); }, [navigate]); const _handleDelete = useCallback(async (workflowId: string): Promise => { try { await deleteSystemWorkflow(request, workflowId); showSuccess(t('Workflow gelöscht')); await _load(); return true; } catch (e: any) { showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') })); return false; } }, [request, showSuccess, showError, _load, t]); const _handleToggleActive = useCallback(async (row: SystemWorkflow) => { if (!row.featureInstanceId) return; const next = !(row.active !== false); setTogglingId(row.id); try { await updateWorkflow(request, row.featureInstanceId, row.id, { active: next }); showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert')); await _load(); } catch (e: any) { showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') })); } finally { setTogglingId(null); } }, [request, showSuccess, showError, _load, t]); const _handleRename = useCallback(async (row: SystemWorkflow) => { if (!row.featureInstanceId) return; const newLabel = await promptInput(t('Neuer Name:'), { title: t('Workflow umbenennen'), defaultValue: row.label, placeholder: t('Workflow-Name'), }); if (!newLabel || newLabel.trim() === row.label) return; try { await updateWorkflow(request, row.featureInstanceId, row.id, { label: newLabel.trim() }); showSuccess(t('Workflow umbenannt')); await _load(); } catch (e: any) { showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') })); } }, [request, promptInput, showSuccess, showError, _load, t]); const _handleExecute = useCallback(async (row: SystemWorkflow) => { if (!row.featureInstanceId) return; setExecutingId(row.id); try { const invs = row.invocations || []; const primary = invs.find((i) => i.enabled && i.kind === 'manual') || invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api')); const emptyGraph = { nodes: [], connections: [] }; executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, { ...(primary ? { entryPointId: primary.id } : {}), }).then((result) => { if (result?.success) { showSuccess(result?.paused ? t('Workflow pausiert bei Human Task.') : t('Workflow abgeschlossen')); } else { showError(result?.error || t('Ausführung fehlgeschlagen')); } _load(); }).catch((e: any) => { showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') })); _load(); }); await new Promise((r) => setTimeout(r, 1000)); await _load(); showSuccess(t('Workflow gestartet')); } finally { setExecutingId(null); } }, [request, showSuccess, showError, _load, t]); const [stoppingId, setStoppingId] = useState(null); const _handleStop = useCallback(async (row: SystemWorkflow) => { if (!row.activeRunId) return; setStoppingId(row.id); try { await api.post(`/api/system/workflow-runs/${row.activeRunId}/stop`); showSuccess(t('Stop-Signal gesendet')); await _load(); } catch (e: any) { showError(t('Fehler: {msg}', { msg: e?.message || t('Stoppen fehlgeschlagen') })); } finally { setStoppingId(null); } }, [showSuccess, showError, _load, t]); const _hasManualTrigger = useCallback((row: SystemWorkflow): boolean => { const invs = row.invocations || []; return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api')); }, []); const _columns: ColumnConfig[] = useMemo(() => [ { key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true, filterable: true }, { key: 'mandateId', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/mandates/', fkDisplayField: 'label' }, { key: 'featureInstanceId', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true, fkSource: '/api/features/instances', fkDisplayField: 'label' }, { key: 'active', label: t('Aktiv'), type: 'boolean', width: 80, sortable: true, filterable: true, }, { key: 'isRunning', label: t('Läuft'), type: 'boolean', width: 80, }, { key: 'sysCreatedAt', label: t('Erstellt'), type: 'number', width: 140, sortable: true, formatter: (v: number) => _formatTs(v), }, { key: 'lastStartedAt', label: t('Zuletzt gestartet'), type: 'number', width: 160, formatter: (v: number) => _formatTs(v), }, { key: 'runCount', label: t('Läufe'), type: 'number', width: 80, formatter: (v: number) => (v != null ? String(v) : '0'), }, ], [t]); const _hookData = useMemo(() => ({ refetch: _load, handleDelete: (id: string) => _handleDelete(id), pagination: paginationMeta, }), [_load, _handleDelete, paginationMeta]); return ( <>

{t('Alle Workflows über alle Features und Mandanten')}

{(['all', 'active', 'inactive'] as const).map((f) => ( ))}
data={workflows} columns={_columns} loading={loading} pagination={true} pageSize={25} searchable={true} filterable={true} sortable={true} selectable={true} initialSort={[{ key: 'createdAt', direction: 'desc' }]} apiEndpoint="/api/system/workflow-runs/workflows" actionButtons={[ { type: 'edit', title: t('bearbeiten'), onAction: _handleEdit, visible: (row: SystemWorkflow) => row.canEdit === true, }, { type: 'delete', title: t('löschen'), visible: (row: SystemWorkflow) => row.canDelete === true, }, ]} customActions={[ { id: 'view', icon: , title: t('anzeigen'), onClick: (row) => _handleEdit(row), visible: (row) => row.canEdit !== true, }, { id: 'rename', icon: , title: t('umbenennen'), onClick: (row) => _handleRename(row), visible: (row) => row.canEdit === true, }, { id: 'activate', icon: , title: t('aktivieren'), onClick: (row) => _handleToggleActive(row), loading: (row) => togglingId === row.id, visible: (row) => row.canEdit === true && row.active === false, }, { id: 'deactivate', icon: , title: t('deaktivieren'), onClick: (row) => _handleToggleActive(row), loading: (row) => togglingId === row.id, visible: (row) => row.canEdit === true && row.active !== false, }, { id: 'execute', icon: , title: t('ausführen'), onClick: (row) => _handleExecute(row), loading: (row) => executingId === row.id, visible: (row) => row.canExecute === true && _hasManualTrigger(row) && !row.isRunning, }, { id: 'stop', icon: , title: t('stoppen'), onClick: (row) => _handleStop(row), loading: (row) => stoppingId === row.id, visible: (row) => row.isRunning === true && !!row.activeRunId, }, ]} onDelete={(row) => _handleDelete(row.id)} hookData={_hookData} emptyMessage={t('Keine Workflows gefunden.')} />
); }; // =========================================================================== // Main page with Tabs // =========================================================================== export const AutomationsDashboardPage: React.FC = () => { const { t } = useLanguage(); const tabs = useMemo(() => [ { id: 'dashboard', label: t('Dashboard'), content: <_DashboardTab />, }, { id: 'workflows', label: t('Workflows'), content: <_WorkflowsTab />, }, ], [t]); return (

{t('Automatisierung')}

); }; export default AutomationsDashboardPage;