diff --git a/src/App.tsx b/src/App.tsx index eb120e0..15019ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,6 +44,7 @@ import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage'; +import { WorkflowAutomationPage } from './pages/WorkflowAutomationPage'; import { RagInventoryPage } from './pages/RagInventoryPage'; import { ComplianceAuditPage } from './pages/ComplianceAuditPage'; function App() { @@ -128,6 +129,11 @@ function App() { {/* ============================================== */} } /> + {/* ============================================== */} + {/* WORKFLOW AUTOMATION (System-Komponente) */} + {/* ============================================== */} + } /> + {/* ============================================== */} {/* RAG INVENTORY */} {/* ============================================== */} @@ -170,13 +176,9 @@ function App() { } /> } /> - {/* Workspace + Automation2 Editor */} + {/* Workspace Editor */} } /> - {/* Automation2: legacy workflows URL → editor */} - } /> - } /> - {/* Teams Bot Feature Views */} } /> } /> diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 90763ba..42d0e32 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -228,6 +228,7 @@ export const MandateNavigation: React.FC = () => { let systemBlock: { title: string; items: NavigationItem[]; subgroups?: NavSubgroup[] } | null = null; let adminBlock: { title: string; items: NavigationItem[]; subgroups: NavSubgroup[] } | null = null; + const extraStaticBlocks: { id: string; title: string; items: NavigationItem[]; order: number }[] = []; for (const block of blocks) { if (block.type === 'static') { @@ -236,8 +237,7 @@ export const MandateNavigation: React.FC = () => { } else if (block.id === 'system') { systemBlock = { title: block.title, items: block.items || [], subgroups: block.subgroups }; } else if (block.items.length > 0) { - if (!systemBlock) systemBlock = { title: block.title, items: [], subgroups: [] }; - systemBlock.items.push(...block.items); + extraStaticBlocks.push({ id: block.id, title: block.title, items: block.items, order: block.order ?? 50 }); } } } @@ -267,6 +267,19 @@ export const MandateNavigation: React.FC = () => { } } + for (const extra of extraStaticBlocks.sort((a, b) => a.order - b.order)) { + const extraChildren = extra.items.map(i => _navigationItemToTreeNode(i)); + if (extraChildren.length > 0) { + if (items.length > 0) items.push({ type: 'separator' }); + items.push({ + id: extra.id, + label: extra.title, + children: extraChildren, + defaultExpanded: true, + }); + } + } + for (const block of blocks) { if (block.type === 'dynamic') { const mandateNodes = _dynamicBlockToTreeNodes(block, _handleRename, t); diff --git a/src/config/keepAliveRoutes.tsx b/src/config/keepAliveRoutes.tsx index 784c68f..80a57c3 100644 --- a/src/config/keepAliveRoutes.tsx +++ b/src/config/keepAliveRoutes.tsx @@ -2,7 +2,6 @@ import type { KeepAliveEntry } from '../types/keepAlive.types'; import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage'; import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage'; import { CommcoachSessionView } from '../pages/views/commcoach'; -import { GraphicalEditorPage } from '../pages/views/graphicalEditor/GraphicalEditorPage'; import { WorkspacePage } from '../pages/views/workspace/WorkspacePage'; export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [ @@ -22,18 +21,6 @@ export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [ shellOverflowHidden: false, render: ({ scopeKey }) => , }, - { - id: 'graphical-editor', - pathRegex: /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/, - scopeRegex: /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/, - render: ({ mandateId, instanceId, scopeKey }) => ( - - ), - }, { id: 'admin-languages', pathRegex: /\/admin\/languages(?:$|\/)/, diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index d2292cc..866d345 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -23,6 +23,7 @@ import { FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList, FaFileContract, FaRobot, FaGlobe, FaClipboardCheck, + FaSitemap, FaCopy, FaTasks, } from 'react-icons/fa'; // ============================================================================= @@ -55,6 +56,13 @@ export const PAGE_ICONS: Record = { 'page.system.automations': , 'page.system.ragInventory': , + // System pages - Workflow Automation + 'page.system.workflowAutomation.workflows': , + 'page.system.workflowAutomation.editor': , + 'page.system.workflowAutomation.templates': , + 'page.system.workflowAutomation.runs': , + 'page.system.workflowAutomation.tasks': , + // Billing pages (legacy compat) 'page.billing.dashboard': , 'page.billing.transactions': , @@ -132,9 +140,6 @@ export const PAGE_ICONS: Record = { 'feature.trustee': , 'feature.realestate': , 'feature.chatworkflow': , - 'feature.graphicalEditor': , - 'page.feature.graphicalEditor.editor': , - 'page.feature.graphicalEditor.workflows-tasks': , 'feature.teamsbot': , // Feature pages - Workspace diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 7138cd5..1a60022 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -1,1460 +1,22 @@ /** * 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 + * Legacy wrapper — redirects to /workflow-automation. + * The full automation hub now lives in WorkflowAutomationPage. */ -import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNavigate, useSearchParams } 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, fetchWorkspaceRunDetail } from '../api/workflowApi'; -import { fetchAttributes } from '../api/attributesApi'; -import type { AttributeDefinition } from '../api/attributesApi'; -import { resolveColumnTypes } from '../utils/columnTypeResolver'; -import api from '../api'; -import { useLanguage } from '../providers/language/LanguageContext'; -import { useNavigation, type DynamicBlock } from '../hooks/useNavigation'; -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; - featureCode?: 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; -} - -const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']); - -const _ROLE_PRIORITY: Record = { admin: 3, user: 2, viewer: 1 }; - -function _bestEditorInstance( - dynamicBlock: DynamicBlock | null, - mandateId: string, -): { instanceId: string; featureCode: string } | null { - if (!dynamicBlock) return null; - const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId); - if (!mandate) return null; - - let best: { instanceId: string; featureCode: string; score: number } | null = null; - for (const feat of mandate.features) { - for (const inst of feat.instances) { - const fc = inst.featureCode - || feat.uiComponent.replace(/^feature\./, ''); - if (!_FEATURES_WITH_EDITOR.has(fc)) continue; - let score = 0; - if (inst.isAdmin) { - score = 10; - } else { - for (const v of inst.views) { - const key = v.objectKey || ''; - for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) { - if (key.endsWith(suffix) && prio > score) score = prio; - } - } - } - if (!best || score > best.score) { - best = { instanceId: inst.id, featureCode: fc, score }; - } - } - } - return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null; -} - -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 ( -
-
-
-
-

- {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 -// =========================================================================== - -interface _DashboardTabProps { - workflowFilter?: string | null; - onRunClick?: (runId: string) => void; -} - -const _DashboardTab: React.FC<_DashboardTabProps> = ({ workflowFilter, onRunClick }) => { - const { t } = useLanguage(); - const { request } = useApiRequest(); - 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 [backendAttributes, setBackendAttributes] = useState([]); - - useEffect(() => { - fetchAttributes(request, 'AutoRun') - .then(setBackendAttributes) - .catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); }); - }, [request]); - - const _loadMetrics = useCallback(async () => { - try { - const resp = await api.get('/api/system/workflow-runs/metrics'); - setMetrics(resp.data); - } catch (e: any) { - const msg = e?.response?.data?.detail || e?.message || String(e); - console.error('[automations] metrics load failed', e); - showError(t('Metriken konnten nicht geladen werden: {msg}', { msg })); - } - }, [showError, t]); - - 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(); - }, [_loadMetrics]); - - 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 _initialFilters = useMemo(() => { - if (!workflowFilter) return undefined; - return { workflowId: workflowFilter }; - }, [workflowFilter]); - - const _rawRunColumns: ColumnConfig[] = useMemo(() => [ - { - key: 'workflowId', - label: t('Workflow'), - width: 200, - sortable: true, - filterable: true, - displayField: 'workflowLabel', - }, - { - key: 'mandateId', - label: t('Mandant'), - width: 140, - sortable: true, - filterable: true, - displayField: 'mandateLabel', - }, - { - key: 'featureInstanceId', - label: t('Instanz'), - width: 140, - sortable: true, - filterable: true, - displayField: 'instanceLabel', - }, - { key: 'status', width: 110, sortable: true, filterable: true }, - { - key: 'startedAt', - label: t('Gestartet'), - width: 150, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - { - key: 'completedAt', - label: t('Beendet'), - width: 150, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - ], [t]); - - const _runColumns = useMemo( - () => resolveColumnTypes(_rawRunColumns, backendAttributes), - [_rawRunColumns, backendAttributes], - ); - - 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: 'startedAt', direction: 'desc' }]} - initialFilters={_initialFilters} - apiEndpoint="/api/system/workflow-runs" - onRowClick={(row) => onRunClick?.(row.id)} - 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 -// =========================================================================== - -interface _WorkflowsTabProps { - onWorkflowClick?: (workflowId: string) => void; -} - -const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => { - const { t } = useLanguage(); - const navigate = useNavigate(); - const { request } = useApiRequest(); - const { showSuccess, showError } = useToast(); - const { prompt: promptInput, PromptDialog } = usePrompt(); - const { dynamicBlock } = useNavigation(); - - 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 [backendAttributes, setBackendAttributes] = useState([]); - - useEffect(() => { - fetchAttributes(request, 'Automation2WorkflowView') - .then(setBackendAttributes) - .catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); }); - }, [request]); - - 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: '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 } : {}), - }; - 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) return; - const fc = row.featureCode || ''; - if (_FEATURES_WITH_EDITOR.has(fc)) { - navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`); - return; - } - const editor = _bestEditorInstance(dynamicBlock, row.mandateId); - if (!editor) { - showError(t('Kein Editor verfügbar für diesen Mandanten')); - return; - } - navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`); - }, [navigate, showError, t, dynamicBlock]); - - 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); - // Track outcome of the fire-and-forget executeGraph promise so the - // intermediate "Workflow gestartet" toast is only shown when the call has - // not already failed/finished within the 1s observation window. Without - // this we always toasted "gestartet" — even when the run had already - // errored — producing contradictory toasts and hiding real failures. - let observedFailure = false; - let observedSuccess = false; - 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: [] }; - const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, { - ...(primary ? { entryPointId: primary.id } : {}), - }).then((result) => { - if (result?.success) { - observedSuccess = true; - showSuccess(result?.paused - ? t('Workflow pausiert bei Human Task.') - : t('Workflow abgeschlossen')); - } else { - observedFailure = true; - showError(result?.error || t('Ausführung fehlgeschlagen')); - } - _load(); - }).catch((e: any) => { - observedFailure = true; - showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') })); - _load(); - }); - await Promise.race([ - exec, - new Promise((r) => setTimeout(r, 1000)), - ]); - await _load(); - if (!observedFailure && !observedSuccess) { - 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 _rawColumns: ColumnConfig[] = useMemo(() => [ - { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, - { - key: 'mandateId', - label: t('Mandant'), - width: 140, - sortable: true, - filterable: true, - displayField: 'mandateLabel', - }, - { - key: 'featureInstanceId', - label: t('Instanz'), - width: 140, - sortable: true, - filterable: true, - displayField: 'instanceLabel', - }, - { - key: 'active', - label: t('Aktiv'), - width: 80, - sortable: true, - filterable: true, - }, - { - key: 'isRunning', - label: t('Läuft'), - width: 80, - sortable: true, - filterable: true, - }, - { - key: 'sysCreatedAt', - label: t('Erstellt'), - width: 140, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - { - key: 'lastStartedAt', - label: t('Zuletzt gestartet'), - width: 160, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - { - key: 'runCount', - label: t('Läufe'), - width: 80, - sortable: true, - filterable: true, - formatter: (v: number) => (v != null ? String(v) : '0'), - }, - ], [t]); - - const _columns = useMemo( - () => resolveColumnTypes(_rawColumns, backendAttributes), - [_rawColumns, backendAttributes], - ); - - 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: 'sysCreatedAt', 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)} - onRowClick={(row) => onWorkflowClick?.(row.id)} - hookData={_hookData} - emptyMessage={t('Keine Workflows gefunden.')} - /> -
- - - ); -}; - -// =========================================================================== -// Workspace Tab (run detail only — no table) -// =========================================================================== - -const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']); - -function _isPlainObject(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v); -} - -function _stripFileRefKeys(value: unknown): unknown { - if (_isPlainObject(value)) { - const out: Record = {}; - for (const [k, v] of Object.entries(value)) { - if (_FILE_REF_KEYS.has(k)) continue; - const stripped = _stripFileRefKeys(v); - if (stripped !== undefined) out[k] = stripped; - } - return Object.keys(out).length > 0 ? out : undefined; - } - if (Array.isArray(value)) { - const out = value.map((v) => _stripFileRefKeys(v)).filter((v) => v !== undefined); - return out.length > 0 ? out : undefined; - } - return value; -} - -function _formatScalar(v: unknown): string { - if (v === null || v === undefined) return '—'; - if (typeof v === 'string') return v; - if (typeof v === 'number' || typeof v === 'boolean') return String(v); - return JSON.stringify(v); -} - -const _DataBlock: React.FC<{ data: unknown; emptyHint?: string }> = ({ data, emptyHint }) => { - if (data === undefined || data === null) { - return emptyHint ?

{emptyHint}

: null; - } - - if (_isPlainObject(data)) { - const entries = Object.entries(data); - if (entries.length === 0) { - return emptyHint ?

{emptyHint}

: null; - } - return ( -
- {entries.map(([k, v]) => { - const isComplex = _isPlainObject(v) || Array.isArray(v); - if (isComplex) { - return ( -
- - {k} - -
-                  {JSON.stringify(v, null, 2)}
-                
-
- ); - } - return ( -
- {k} - {_formatScalar(v)} -
- ); - })} -
- ); - } - - return ( -
-      {JSON.stringify(data, null, 2)}
-    
- ); -}; - -const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => { - if (!files.length) return null; - const baseUrl = api.defaults.baseURL || ''; - return ( -
- {files.map((f) => ( - - - {f.fileName || f.id} - - ))} -
- ); -}; - -const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient'; - -/** Hide persisted transient extract JSON from user-facing Workspace file lists */ -function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean { - return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR); -} - -const _ProducedFilesSection: React.FC<{ - steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>; - unassignedFiles?: Array<{ id: string; fileName?: string }>; -}> = ({ steps, unassignedFiles }) => { - const { t } = useLanguage(); - const seen = new Set(); - const allFiles: Array<{ id: string; fileName?: string }> = []; - for (const step of steps) { - for (const f of step.outputFiles ?? []) { - if (_isHiddenWorkflowArtifactFile(f)) continue; - if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } - } - } - for (const f of unassignedFiles ?? []) { - if (_isHiddenWorkflowArtifactFile(f)) continue; - if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } - } - if (!allFiles.length) return null; - const baseUrl = api.defaults.baseURL || ''; - return ( -
-
- - {t('Ergebnisse')} ({allFiles.length}) -
-
- {allFiles.map(f => ( - - - {f.fileName || f.id} - - ))} -
-
- ); -}; - -function _downloadJson(data: unknown, fileName: string) { - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); -} - -interface _WorkspaceTabProps { - runId: string | null; - onBack: () => void; -} - -const _TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'error', 'stopped']); -const _POLL_INTERVAL_MS = 3000; - -const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { - const { t } = useLanguage(); - const { request } = useApiRequest(); - const [runDetail, setRunDetail] = useState> | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - - const _loadDetail = useCallback(async (id: string) => { - setDetailLoading(true); - try { - const detail = await fetchWorkspaceRunDetail(request, id); - setRunDetail(detail); - } catch (e) { - console.error('Workspace run detail failed', e); - } finally { - setDetailLoading(false); - } - }, [request]); - - useEffect(() => { - if (runId) _loadDetail(runId); - else setRunDetail(null); - }, [runId, _loadDetail]); - - useEffect(() => { - if (!runId || !runDetail) return; - const status = runDetail.run?.status; - if (status && _TERMINAL_STATUSES.has(status)) return; - const timer = setInterval(() => { - fetchWorkspaceRunDetail(request, runId) - .then(detail => setRunDetail(detail)) - .catch(() => {}); - }, _POLL_INTERVAL_MS); - return () => clearInterval(timer); - }, [runId, runDetail, request]); - - if (!runId) { - return ( -
-

{t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}

-
- ); - } - - if (detailLoading || !runDetail) { - return

{t('Laden…')}

; - } - - const { run, steps, workflow, unassignedFiles } = runDetail; - - return ( -
- -

{run.workflowLabel || run.workflowId}

-
- {t('Status')}: {run.status} - {run.startedAt && {t('Start')}: {_formatTs(run.startedAt)}} - {run.completedAt && {t('Ende')}: {_formatTs(run.completedAt)}} - {workflow?.targetFeatureInstanceId && {t('Ziel-Instanz')}: {run.targetInstanceLabel || workflow.targetFeatureInstanceId}} - {(run.costTokens ?? 0) > 0 && Tokens: {run.costTokens}} -
- {run.error && ( -
- {run.error} -
- )} - <_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} /> -

{t('Schritte')}

- {steps.length === 0 ? ( -

{t('Keine Schritte protokolliert.')}

- ) : ( -
- {steps.map((step) => { - const inputData = _stripFileRefKeys(step.inputSnapshot ?? {}); - const outputData = _stripFileRefKeys(step.output ?? {}); - const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); - const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); - const hasInput = inputData !== undefined || inputFiles.length > 0; - const hasOutput = outputData !== undefined || outputFiles.length > 0; - return ( -
- - - {step.status} - - {step.nodeType} ({step.nodeId}) - {step.durationMs != null && {step.durationMs}ms} - {(step.tokensUsed ?? 0) > 0 && {step.tokensUsed} tokens} - -
- {hasInput && ( -
-
- {t('Input')} - {inputData !== undefined && inputData !== null && ( - - )} -
- <_DataBlock data={inputData} /> - <_FileLinkList files={inputFiles} /> -
- )} - {hasOutput && ( -
-
- {t('Output')} - {outputData !== undefined && outputData !== null && ( - - )} -
- <_DataBlock data={outputData} /> - <_FileLinkList files={outputFiles} /> -
- )} - {step.error && ( -
-
- {t('Fehler')} -
-

{step.error}

-
- )} -
- {step.startedAt && {t('Start')}: {_formatTs(step.startedAt)}} - {step.completedAt && {t('Ende')}: {_formatTs(step.completedAt)}} - {(step.retryCount ?? 0) > 0 && {t('Wiederholungen')}: {step.retryCount}} -
-
-
- ); - })} -
- )} - {(() => { - const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); - if (!visibleUnassigned.length) return null; - return ( - <> -

{t('Sonstige Dokumente')}

- <_FileLinkList files={visibleUnassigned} /> - - ); - })()} -
- ); -}; - -// =========================================================================== -// Main page with Tabs (Workflows → Dashboard → Workspace) -// =========================================================================== +import React from 'react'; +import { Navigate, useSearchParams } from 'react-router-dom'; export const AutomationsDashboardPage: React.FC = () => { - const { t } = useLanguage(); const [searchParams] = useSearchParams(); - - const initialTab = searchParams.get('tab') || 'workflows'; - const initialRunId = searchParams.get('runId') || null; - - const [activeTab, setActiveTab] = useState(initialRunId ? 'workspace' : initialTab); - const [selectedRunId, setSelectedRunId] = useState(initialRunId); - const [workflowFilter, setWorkflowFilter] = useState(null); - - const _handleWorkflowClick = useCallback((workflowId: string) => { - setWorkflowFilter(workflowId); - setActiveTab('dashboard'); - }, []); - - useEffect(() => { - if (workflowFilter) setWorkflowFilter(null); - }, [workflowFilter]); - - const _handleRunClick = useCallback((runId: string) => { - setSelectedRunId(runId); - setActiveTab('workspace'); - }, []); - - const _handleBackFromWorkspace = useCallback(() => { - setSelectedRunId(null); - setActiveTab('dashboard'); - }, []); - - const tabs = useMemo(() => [ - { - id: 'workflows', - label: t('Workflows'), - content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />, - }, - { - id: 'dashboard', - label: t('Workflow-Durchläufe'), - content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />, - }, - { - id: 'workspace', - label: t('Durchlauf-Details'), - content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />, - }, - ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]); - - return ( -
-

{t('Automatisierung')}

- -
- ); + const tab = searchParams.get('tab'); + const runId = searchParams.get('runId'); + const params = new URLSearchParams(); + if (tab) params.set('tab', tab); + if (runId) params.set('runId', runId); + const qs = params.toString(); + return ; }; export default AutomationsDashboardPage; diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index bfe6921..1e6cc2e 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -25,10 +25,6 @@ import { TrusteeDataTablesView } from './views/trustee/TrusteeDataTablesView'; // RealEstate Views import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate'; -// GraphicalEditor Views -import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage'; -import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage'; -import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage'; // Workspace Views import { WorkspacePage } from './views/workspace/WorkspacePage'; import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage'; @@ -129,11 +125,6 @@ const VIEW_COMPONENTS: Record> = { dashboard: RealEstatePekView, 'instance-roles': RealEstateInstanceRolesPlaceholder, }, - graphicalEditor: { - editor: GraphicalEditorPage, - 'workflows-tasks': GraphicalEditorWorkflowsTasksPage, - templates: GraphicalEditorTemplatesPage, - }, workspace: { dashboard: WorkspacePage, editor: WorkspaceEditorPage, diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index ff3077e..baccc97 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -5,7 +5,7 @@ */ import React from 'react'; -import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa'; +import { FaCogs, FaComments, FaHeadset, FaShieldAlt } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { useStore, _storeActionKey } from '../hooks/useStore'; @@ -15,7 +15,6 @@ import styles from './Store.module.css'; const FEATURE_ICONS: Record = { automation: , - graphicalEditor: , teamsbot: , workspace: , commcoach: , @@ -25,7 +24,6 @@ const FEATURE_ICONS: Record = { /** Fallback when GET /store/features omits description (German i18n keys). */ const STORE_FEATURE_DESCRIPTION_FALLBACK: Record = { automation: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.', - graphicalEditor: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.', teamsbot: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.', workspace: 'Nutze den gemeinsamen AI Workspace: Chats, Tools und Kontext pro Instanz.', commcoach: 'CommCoach: Kommunikation trainieren mit KI-gestütztem Coaching und Feedback.', diff --git a/src/pages/WorkflowAutomationPage.tsx b/src/pages/WorkflowAutomationPage.tsx new file mode 100644 index 0000000..00a4a0a --- /dev/null +++ b/src/pages/WorkflowAutomationPage.tsx @@ -0,0 +1,1565 @@ +/** + * WorkflowAutomationPage + * + * System-level hub for WorkflowAutomation (mandatsweite Sicht). + * Tabs: Workflows · Editor · Vorlagen · Läufe · Details + * + * Replaces the former AutomationsDashboardPage at /automations. + * Uses /api/system/workflow-runs/* endpoints (proven, RBAC-filtered). + * Editor + Templates tabs embed the existing graphicalEditor components. + */ + +import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useNavigate, useSearchParams } 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, fetchWorkspaceRunDetail } from '../api/workflowApi'; +import { fetchAttributes } from '../api/attributesApi'; +import type { AttributeDefinition } from '../api/attributesApi'; +import { resolveColumnTypes } from '../utils/columnTypeResolver'; +import api from '../api'; +import { useLanguage } from '../providers/language/LanguageContext'; +import { useNavigation, type DynamicBlock } from '../hooks/useNavigation'; +import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage'; +import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage'; +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; + ownerLabel?: string; + status: string; + costTokens?: number; + costCredits?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; +} + +interface SystemWorkflow { + id: string; + mandateId: string; + featureInstanceId: string; + featureCode?: 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; + ownerId?: string; + ownerLabel?: string; + canEdit?: boolean; + canDelete?: boolean; + canExecute?: boolean; + invocations?: Array<{ id: string; enabled: boolean; kind: string }>; + graph?: Record; +} + +const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']); + +const _ROLE_PRIORITY: Record = { admin: 3, user: 2, viewer: 1 }; + +function _bestEditorInstance( + dynamicBlock: DynamicBlock | null, + mandateId: string, +): { instanceId: string; featureCode: string } | null { + if (!dynamicBlock) return null; + const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId); + if (!mandate) return null; + + let best: { instanceId: string; featureCode: string; score: number } | null = null; + for (const feat of mandate.features) { + for (const inst of feat.instances) { + const fc = inst.featureCode + || feat.uiComponent.replace(/^feature\./, ''); + if (!_FEATURES_WITH_EDITOR.has(fc)) continue; + let score = 0; + if (inst.isAdmin) { + score = 10; + } else { + for (const v of inst.views) { + const key = v.objectKey || ''; + for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) { + if (key.endsWith(suffix) && prio > score) score = prio; + } + } + } + if (!best || score > best.score) { + best = { instanceId: inst.id, featureCode: fc, score }; + } + } + } + return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null; +} + +/** Find the first available editor instance across all mandates. */ +function _findAnyEditorInstance( + dynamicBlock: DynamicBlock | null, +): { instanceId: string; mandateId: string; featureCode: string } | null { + if (!dynamicBlock) return null; + for (const mandate of dynamicBlock.mandates) { + const result = _bestEditorInstance(dynamicBlock, mandate.id); + if (result) return { ...result, mandateId: mandate.id }; + } + return null; +} + +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 + + 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 ( +
+
+
+
+

+ {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 +// =========================================================================== + +interface _DashboardTabProps { + workflowFilter?: string | null; + onRunClick?: (runId: string) => void; +} + +const _DashboardTab: React.FC<_DashboardTabProps> = ({ workflowFilter, onRunClick }) => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + 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 [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'AutoRun') + .then(setBackendAttributes) + .catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); }); + }, [request]); + + const _loadMetrics = useCallback(async () => { + try { + const resp = await api.get('/api/system/workflow-runs/metrics'); + setMetrics(resp.data); + } catch (e: any) { + const msg = e?.response?.data?.detail || e?.message || String(e); + console.error('[automations] metrics load failed', e); + showError(t('Metriken konnten nicht geladen werden: {msg}', { msg })); + } + }, [showError, t]); + + 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(); + }, [_loadMetrics]); + + 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 _initialFilters = useMemo(() => { + if (!workflowFilter) return undefined; + return { workflowId: workflowFilter }; + }, [workflowFilter]); + + const _rawRunColumns: ColumnConfig[] = useMemo(() => [ + { + key: 'workflowId', + label: t('Workflow'), + width: 200, + sortable: true, + filterable: true, + displayField: 'workflowLabel', + }, + { + key: 'mandateId', + label: t('Mandant'), + width: 140, + sortable: true, + filterable: true, + displayField: 'mandateLabel', + }, + { + key: 'featureInstanceId', + label: t('Instanz'), + width: 140, + sortable: true, + filterable: true, + displayField: 'instanceLabel', + }, + { + key: 'ownerId', + label: t('Benutzer'), + width: 140, + sortable: true, + filterable: true, + displayField: 'ownerLabel', + }, + { key: 'status', width: 110, sortable: true, filterable: true }, + { + key: 'startedAt', + label: t('Gestartet'), + width: 150, + sortable: true, + filterable: true, + formatter: (v: number) => _formatTs(v), + }, + { + key: 'completedAt', + label: t('Beendet'), + width: 150, + sortable: true, + filterable: true, + formatter: (v: number) => _formatTs(v), + }, + ], [t]); + + const _runColumns = useMemo( + () => resolveColumnTypes(_rawRunColumns, backendAttributes), + [_rawRunColumns, backendAttributes], + ); + + 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: 'startedAt', direction: 'desc' }]} + initialFilters={_initialFilters} + apiEndpoint="/api/system/workflow-runs" + onRowClick={(row) => onRunClick?.(row.id)} + 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 +// =========================================================================== + +interface _WorkflowsTabProps { + onWorkflowClick?: (workflowId: string) => void; +} + +const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => { + const { t } = useLanguage(); + const navigate = useNavigate(); + const { request } = useApiRequest(); + const { showSuccess, showError } = useToast(); + const { prompt: promptInput, PromptDialog } = usePrompt(); + const { dynamicBlock } = useNavigation(); + + 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 [backendAttributes, setBackendAttributes] = useState([]); + + useEffect(() => { + fetchAttributes(request, 'Automation2WorkflowView') + .then(setBackendAttributes) + .catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); }); + }, [request]); + + 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: '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 } : {}), + }; + 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) return; + const fc = row.featureCode || ''; + if (_FEATURES_WITH_EDITOR.has(fc)) { + navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`); + return; + } + const editor = _bestEditorInstance(dynamicBlock, row.mandateId); + if (!editor) { + showError(t('Kein Editor verfügbar für diesen Mandanten')); + return; + } + navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`); + }, [navigate, showError, t, dynamicBlock]); + + 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); + let observedFailure = false; + let observedSuccess = false; + 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: [] }; + const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, { + ...(primary ? { entryPointId: primary.id } : {}), + }).then((result) => { + if (result?.success) { + observedSuccess = true; + showSuccess(result?.paused + ? t('Workflow pausiert bei Human Task.') + : t('Workflow abgeschlossen')); + } else { + observedFailure = true; + showError(result?.error || t('Ausführung fehlgeschlagen')); + } + _load(); + }).catch((e: any) => { + observedFailure = true; + showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') })); + _load(); + }); + await Promise.race([ + exec, + new Promise((r) => setTimeout(r, 1000)), + ]); + await _load(); + if (!observedFailure && !observedSuccess) { + 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 _rawColumns: ColumnConfig[] = useMemo(() => [ + { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, + { + key: 'mandateId', + label: t('Mandant'), + width: 140, + sortable: true, + filterable: true, + displayField: 'mandateLabel', + }, + { + key: 'featureInstanceId', + label: t('Instanz'), + width: 140, + sortable: true, + filterable: true, + displayField: 'instanceLabel', + }, + { + key: 'ownerId', + label: t('Benutzer'), + width: 140, + sortable: true, + filterable: true, + displayField: 'ownerLabel', + }, + { + key: 'active', + label: t('Aktiv'), + width: 80, + sortable: true, + filterable: true, + }, + { + key: 'isRunning', + label: t('Läuft'), + width: 80, + sortable: true, + filterable: true, + }, + { + key: 'sysCreatedAt', + label: t('Erstellt'), + width: 140, + sortable: true, + filterable: true, + formatter: (v: number) => _formatTs(v), + }, + { + key: 'lastStartedAt', + label: t('Zuletzt gestartet'), + width: 160, + sortable: true, + filterable: true, + formatter: (v: number) => _formatTs(v), + }, + { + key: 'runCount', + label: t('Läufe'), + width: 80, + sortable: true, + filterable: true, + formatter: (v: number) => (v != null ? String(v) : '0'), + }, + ], [t]); + + const _columns = useMemo( + () => resolveColumnTypes(_rawColumns, backendAttributes), + [_rawColumns, backendAttributes], + ); + + 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: 'sysCreatedAt', 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)} + onRowClick={(row) => onWorkflowClick?.(row.id)} + hookData={_hookData} + emptyMessage={t('Keine Workflows gefunden.')} + /> +
+ + + ); +}; + +// =========================================================================== +// Workspace Tab (run detail only — no table) +// =========================================================================== + +const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']); + +function _isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function _stripFileRefKeys(value: unknown): unknown { + if (_isPlainObject(value)) { + const out: Record = {}; + for (const [k, v] of Object.entries(value)) { + if (_FILE_REF_KEYS.has(k)) continue; + const stripped = _stripFileRefKeys(v); + if (stripped !== undefined) out[k] = stripped; + } + return Object.keys(out).length > 0 ? out : undefined; + } + if (Array.isArray(value)) { + const out = value.map((v) => _stripFileRefKeys(v)).filter((v) => v !== undefined); + return out.length > 0 ? out : undefined; + } + return value; +} + +function _formatScalar(v: unknown): string { + if (v === null || v === undefined) return '—'; + if (typeof v === 'string') return v; + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + return JSON.stringify(v); +} + +const _DataBlock: React.FC<{ data: unknown; emptyHint?: string }> = ({ data, emptyHint }) => { + if (data === undefined || data === null) { + return emptyHint ?

{emptyHint}

: null; + } + + if (_isPlainObject(data)) { + const entries = Object.entries(data); + if (entries.length === 0) { + return emptyHint ?

{emptyHint}

: null; + } + return ( +
+ {entries.map(([k, v]) => { + const isComplex = _isPlainObject(v) || Array.isArray(v); + if (isComplex) { + return ( +
+ + {k} + +
+                  {JSON.stringify(v, null, 2)}
+                
+
+ ); + } + return ( +
+ {k} + {_formatScalar(v)} +
+ ); + })} +
+ ); + } + + return ( +
+      {JSON.stringify(data, null, 2)}
+    
+ ); +}; + +const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => { + if (!files.length) return null; + const baseUrl = api.defaults.baseURL || ''; + return ( +
+ {files.map((f) => ( + + + {f.fileName || f.id} + + ))} +
+ ); +}; + +const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient'; + +function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean { + return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR); +} + +const _ProducedFilesSection: React.FC<{ + steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>; + unassignedFiles?: Array<{ id: string; fileName?: string }>; +}> = ({ steps, unassignedFiles }) => { + const { t } = useLanguage(); + const seen = new Set(); + const allFiles: Array<{ id: string; fileName?: string }> = []; + for (const step of steps) { + for (const f of step.outputFiles ?? []) { + if (_isHiddenWorkflowArtifactFile(f)) continue; + if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } + } + } + for (const f of unassignedFiles ?? []) { + if (_isHiddenWorkflowArtifactFile(f)) continue; + if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } + } + if (!allFiles.length) return null; + const baseUrl = api.defaults.baseURL || ''; + return ( +
+
+ + {t('Ergebnisse')} ({allFiles.length}) +
+
+ {allFiles.map(f => ( + + + {f.fileName || f.id} + + ))} +
+
+ ); +}; + +function _downloadJson(data: unknown, fileName: string) { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} + +interface _WorkspaceTabProps { + runId: string | null; + onBack: () => void; +} + +const _TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'error', 'stopped']); +const _POLL_INTERVAL_MS = 3000; + +const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { + const { t } = useLanguage(); + const { request } = useApiRequest(); + const [runDetail, setRunDetail] = useState> | null>(null); + const [detailLoading, setDetailLoading] = useState(false); + + const _loadDetail = useCallback(async (id: string) => { + setDetailLoading(true); + try { + const detail = await fetchWorkspaceRunDetail(request, id); + setRunDetail(detail); + } catch (e) { + console.error('Workspace run detail failed', e); + } finally { + setDetailLoading(false); + } + }, [request]); + + useEffect(() => { + if (runId) _loadDetail(runId); + else setRunDetail(null); + }, [runId, _loadDetail]); + + useEffect(() => { + if (!runId || !runDetail) return; + const status = runDetail.run?.status; + if (status && _TERMINAL_STATUSES.has(status)) return; + const timer = setInterval(() => { + fetchWorkspaceRunDetail(request, runId) + .then(detail => setRunDetail(detail)) + .catch(() => {}); + }, _POLL_INTERVAL_MS); + return () => clearInterval(timer); + }, [runId, runDetail, request]); + + if (!runId) { + return ( +
+

{t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}

+
+ ); + } + + if (detailLoading || !runDetail) { + return

{t('Laden…')}

; + } + + const { run, steps, workflow, unassignedFiles } = runDetail; + + return ( +
+ +

{run.workflowLabel || run.workflowId}

+
+ {t('Status')}: {run.status} + {run.startedAt && {t('Start')}: {_formatTs(run.startedAt)}} + {run.completedAt && {t('Ende')}: {_formatTs(run.completedAt)}} + {workflow?.targetFeatureInstanceId && {t('Ziel-Instanz')}: {run.targetInstanceLabel || workflow.targetFeatureInstanceId}} + {(run.costTokens ?? 0) > 0 && Tokens: {run.costTokens}} +
+ {run.error && ( +
+ {run.error} +
+ )} + <_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} /> +

{t('Schritte')}

+ {steps.length === 0 ? ( +

{t('Keine Schritte protokolliert.')}

+ ) : ( +
+ {steps.map((step) => { + const inputData = _stripFileRefKeys(step.inputSnapshot ?? {}); + const outputData = _stripFileRefKeys(step.output ?? {}); + const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); + const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); + const hasInput = inputData !== undefined || inputFiles.length > 0; + const hasOutput = outputData !== undefined || outputFiles.length > 0; + return ( +
+ + + {step.status} + + {step.nodeType} ({step.nodeId}) + {step.durationMs != null && {step.durationMs}ms} + {(step.tokensUsed ?? 0) > 0 && {step.tokensUsed} tokens} + +
+ {hasInput && ( +
+
+ {t('Input')} + {inputData !== undefined && inputData !== null && ( + + )} +
+ <_DataBlock data={inputData} /> + <_FileLinkList files={inputFiles} /> +
+ )} + {hasOutput && ( +
+
+ {t('Output')} + {outputData !== undefined && outputData !== null && ( + + )} +
+ <_DataBlock data={outputData} /> + <_FileLinkList files={outputFiles} /> +
+ )} + {step.error && ( +
+
+ {t('Fehler')} +
+

{step.error}

+
+ )} +
+ {step.startedAt && {t('Start')}: {_formatTs(step.startedAt)}} + {step.completedAt && {t('Ende')}: {_formatTs(step.completedAt)}} + {(step.retryCount ?? 0) > 0 && {t('Wiederholungen')}: {step.retryCount}} +
+
+
+ ); + })} +
+ )} + {(() => { + const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); + if (!visibleUnassigned.length) return null; + return ( + <> +

{t('Sonstige Dokumente')}

+ <_FileLinkList files={visibleUnassigned} /> + + ); + })()} +
+ ); +}; + +// =========================================================================== +// Editor Tab — wraps GraphicalEditorPage with auto-resolved instanceId +// =========================================================================== + +const _EditorTab: React.FC = () => { + const { t } = useLanguage(); + const { dynamicBlock } = useNavigation(); + + const editorInstance = useMemo( + () => _findAnyEditorInstance(dynamicBlock), + [dynamicBlock], + ); + + if (!editorInstance) { + return ( +
+

{t('Kein Editor verfügbar. Bitte erstelle zuerst eine Feature-Instanz mit Editor (Workspace oder Graphical Editor).')}

+
+ ); + } + + return ( +
+ +
+ ); +}; + +// =========================================================================== +// Templates Tab — wraps GraphicalEditorTemplatesPage with auto-resolved instanceId +// =========================================================================== + +const _TemplatesTab: React.FC = () => { + const { t } = useLanguage(); + const { dynamicBlock } = useNavigation(); + + const editorInstance = useMemo( + () => _findAnyEditorInstance(dynamicBlock), + [dynamicBlock], + ); + + if (!editorInstance) { + return ( +
+

{t('Kein Editor verfügbar. Bitte erstelle zuerst eine Feature-Instanz mit Editor.')}

+
+ ); + } + + return ( + + ); +}; + +// =========================================================================== +// Main page with Tabs +// =========================================================================== + +const _TAB_ALIASES: Record = { + dashboard: 'runs', + workspace: 'detail', +}; + +export const WorkflowAutomationPage: React.FC = () => { + const { t } = useLanguage(); + const [searchParams] = useSearchParams(); + + const rawTab = searchParams.get('tab') || 'workflows'; + const initialTab = _TAB_ALIASES[rawTab] || rawTab; + const initialRunId = searchParams.get('runId') || null; + + const [activeTab, setActiveTab] = useState(initialRunId ? 'detail' : initialTab); + const [selectedRunId, setSelectedRunId] = useState(initialRunId); + const [workflowFilter, setWorkflowFilter] = useState(null); + + const _handleWorkflowClick = useCallback((workflowId: string) => { + setWorkflowFilter(workflowId); + setActiveTab('runs'); + }, []); + + useEffect(() => { + if (workflowFilter) setWorkflowFilter(null); + }, [workflowFilter]); + + const _handleRunClick = useCallback((runId: string) => { + setSelectedRunId(runId); + setActiveTab('detail'); + }, []); + + const _handleBackFromWorkspace = useCallback(() => { + setSelectedRunId(null); + setActiveTab('runs'); + }, []); + + const tabs = useMemo(() => [ + { + id: 'workflows', + label: t('Workflows'), + content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />, + }, + { + id: 'editor', + label: t('Editor'), + content: <_EditorTab />, + }, + { + id: 'templates', + label: t('Vorlagen'), + content: <_TemplatesTab />, + }, + { + id: 'runs', + label: t('Workflow-Durchläufe'), + content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />, + }, + { + id: 'detail', + label: t('Durchlauf-Details'), + content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />, + }, + ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]); + + return ( +
+

{t('Workflow-Automation')}

+ +
+ ); +}; + +export default WorkflowAutomationPage; diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx index 8cb5615..6faf39c 100644 --- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx +++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx @@ -44,7 +44,15 @@ function _formatTs(ts?: number): string { return time; } -export const GraphicalEditorTemplatesPage: React.FC = () => { +interface GraphicalEditorTemplatesPageProps { + persistentInstanceId?: string; + persistentMandateId?: string; +} + +export const GraphicalEditorTemplatesPage: React.FC = ({ + persistentInstanceId, + persistentMandateId, +}) => { const { t } = useLanguage(); const scopeLabels = useMemo( @@ -57,8 +65,10 @@ export const GraphicalEditorTemplatesPage: React.FC = () => { [t], ); - const instanceId = useInstanceId(); - const { mandateId } = useParams<{ mandateId: string }>(); + const urlInstanceId = useInstanceId(); + const { mandateId: urlMandateId } = useParams<{ mandateId: string }>(); + const instanceId = persistentInstanceId || urlInstanceId; + const mandateId = persistentMandateId || urlMandateId; const { request } = useApiRequest(); const navigate = useNavigate(); const { showSuccess, showError } = useToast(); diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 3ba7f92..f112c36 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -182,113 +182,9 @@ export interface FeatureConfig { deprecated?: boolean; } -// ============================================================================= -// FEATURE REGISTRY (DEPRECATED) -// ============================================================================= - -/** - * @deprecated Since Navigation-API-Konzept implementation. - * - * Navigation is now provided by the backend via GET /api/navigation. - * The backend is the Single Source of Truth for navigation structure. - * - * Icon mapping is now handled by src/config/pageRegistry.ts using uiComponent codes. - * - * This registry is kept for backward compatibility with existing code that may - * still reference it. It will be removed in a future version. - * - * TODO: Remove after all references are migrated to use backend navigation. - */ -export const FEATURE_REGISTRY: Record = { - trustee: { - code: 'trustee', - label: 'Treuhand', - icon: 'briefcase', - views: [ - { code: 'dashboard', label: 'Übersicht', path: 'dashboard' }, - { code: 'data-tables', label: 'Daten-Tabellen', path: 'data-tables' }, - { code: 'position-documents', label: 'Zuordnungen', path: 'position-documents' }, - { code: 'import-process', label: 'Import & Verarbeitung', path: 'import-process' }, - { code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true }, - { code: 'settings', label: 'Buchhaltungseinstellungen', path: 'settings' }, - ] - }, - chatworkflow: { - code: 'chatworkflow', - label: 'Workflow', - icon: 'play_circle', - views: [ - { code: 'dashboard', label: 'Übersicht', path: 'dashboard' }, - { code: 'runs', label: 'Runs', path: 'runs' }, - { code: 'files', label: 'Dateien', path: 'files' }, - ] - }, - realestate: { - code: 'realestate', - label: 'Immobilien', - icon: 'home', - views: [ - { code: 'dashboard', label: 'Karte', path: 'dashboard' }, - { code: 'instance-roles', label: 'Rollen & Rechte', path: 'instance-roles', adminOnly: true }, - ] - }, - teamsbot: { - code: 'teamsbot', - label: 'Teams Bot', - icon: 'headset_mic', - views: [ - { code: 'dashboard', label: 'Dashboard', path: 'dashboard' }, - { code: 'assistant', label: 'Assistent', path: 'assistant' }, - { code: 'modules', label: 'Module', path: 'modules' }, - { code: 'sessions', label: 'Live-Session', path: 'sessions' }, - { code: 'settings', label: 'Einstellungen', path: 'settings' }, - ] - }, - graphicalEditor: { - code: 'graphicalEditor', - label: 'Grafischer Editor', - icon: 'sitemap', - views: [ - { code: 'editor', label: 'Editor', path: 'editor' }, - { code: 'templates', label: 'Vorlagen', path: 'templates' }, - { code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' }, - { code: 'dashboard', label: 'Dashboard', path: 'dashboard' }, - ] - }, - neutralization: { - code: 'neutralization', - label: 'Neutralisierung', - icon: 'shield_check', - views: [ - { code: 'dashboard', label: 'Neutralisierung testen', path: 'playground' }, - { code: 'playground', label: 'Neutralisierung testen', path: 'playground' }, - { code: 'config', label: 'Einstellungen', path: 'config' }, - { code: 'attributes', label: 'Attribute', path: 'attributes' }, - ] - }, - commcoach: { - code: 'commcoach', - label: 'Kommunikations-Coach', - icon: 'account_voice', - views: [ - { code: 'dashboard', label: 'Dashboard', path: 'dashboard' }, - { code: 'assistant', label: 'Assistent', path: 'assistant' }, - { code: 'modules', label: 'Module', path: 'modules' }, - { code: 'session', label: 'Session', path: 'session' }, - { code: 'settings', label: 'Einstellungen', path: 'settings' }, - ] - }, - workspace: { - code: 'workspace', - label: 'AI Workspace', - icon: 'psychology', - views: [ - { code: 'dashboard', label: 'Dashboard', path: 'dashboard' }, - { code: 'editor', label: 'Editor', path: 'editor' }, - { code: 'settings', label: 'Einstellungen', path: 'settings' }, - ] - }, -}; +// FEATURE_REGISTRY removed (2026-06-07). +// Navigation is provided by the backend via GET /api/navigation. +// Icon mapping is handled by src/config/pageRegistry.ts using uiComponent codes. // ============================================================================= // HELPERS