frontend_nyla/src/pages/AutomationsDashboardPage.tsx
2026-04-13 00:38:51 +02:00

984 lines
34 KiB
TypeScript

/**
* 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, deleteWorkflow } 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<string, number>;
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<string, any>;
}
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<string, string> = {
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<MetricCardProps> = ({ icon, label, value, color }) => (
<div
style={{
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
gap: 14,
minWidth: 180,
flex: '1 1 180px',
}}
>
<div style={{ fontSize: 22, color: color || 'var(--primary-color, #007bff)', display: 'flex', alignItems: 'center' }}>
{icon}
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: '1.3rem', fontWeight: 700 }}>{value}</div>
</div>
</div>
);
// ===========================================================================
// 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<string, any>;
output?: Record<string, any>;
retryCount?: number;
}
const _STATUS_ICONS: Record<string, string> = {
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 (
<div style={{ marginTop: 4 }}>
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: 'var(--text-link, #0969da)', fontSize: 11, textDecoration: 'underline',
}}
>
{open ? '▾' : '▸'} {label}
</button>
{open && (
<pre style={{
margin: '4px 0 0', padding: 6, borderRadius: 4,
background: 'var(--bg-secondary, #f6f8fa)', fontSize: 11,
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 200, overflowY: 'auto',
}}>
{content}
</pre>
)}
</div>
);
};
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<EventSource | null>(null);
const scrollRef = useRef<HTMLDivElement>(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 (
<div className={styles.modalOverlay} onClick={onClose}>
<div
className={styles.modal}
style={{ maxWidth: 800, height: '80vh' }}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.modalHeader}>
<div>
<h3 className={styles.modalTitle}>
{t('Run-Tracing')}: {run.workflowLabel || run.workflowId}
</h3>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginTop: 2 }}>
<span style={{ color: _STATUS_COLORS[run.status] || 'inherit', fontWeight: 600 }}>
{run.status}
</span>
{sseConnected && (
<span style={{ marginLeft: 8, color: 'var(--success-color, #28a745)' }}> {t('Live')}</span>
)}
</div>
</div>
<button className={styles.modalClose} onClick={onClose} title={t('Schliessen')}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent} ref={scrollRef} style={{ overflowY: 'auto', flex: 1 }}>
{loading && steps.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13 }}>{t('Wird geladen…')}</div>
)}
{!loading && steps.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13 }}>{t('Noch keine Schritte aufgezeichnet')}</div>
)}
{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 (
<div
key={step.id}
style={{
padding: '8px 12px', marginBottom: 6, borderRadius: 6,
border: `1px solid ${_STATUS_COLORS[step.status] || '#ddd'}`,
background: 'var(--bg-primary, #fff)', fontSize: 13,
marginLeft: isLoop ? 16 : 0,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<span style={{ color: _STATUS_COLORS[step.status] || '#999', marginRight: 6 }}>
{_STATUS_ICONS[step.status] || '?'}
</span>
<strong>{step.nodeType}</strong>
<span style={{ color: '#888', marginLeft: 6 }}>({step.nodeId})</span>
{isLoop && (
<span style={{ color: '#666', marginLeft: 6, fontSize: 11 }}>
[iter {step.inputSnapshot!._loopIndex}]
</span>
)}
</span>
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{(step.retryCount ?? 0) > 0 && (
<span style={{ color: '#f0ad4e', fontSize: 11 }}>
{step.retryCount}x {t('Wiederholung')}
</span>
)}
{step.durationMs != null && (
<span style={{ color: '#888', fontSize: 12 }}>{step.durationMs}ms</span>
)}
</span>
</div>
{(startStr || endStr) && (
<div style={{ color: '#888', fontSize: 11, marginTop: 2 }}>
{startStr && <span>{startStr}</span>}
{startStr && endStr && <span> </span>}
{endStr && <span>{endStr}</span>}
</div>
)}
{step.error && (
<div style={{ color: '#dc3545', fontSize: 12, marginTop: 4 }}>{step.error}</div>
)}
{(step.tokensUsed ?? 0) > 0 && (
<div style={{ color: '#888', fontSize: 11, marginTop: 2 }}>
{step.tokensUsed} {t('Tokens')}
</div>
)}
<_CollapsibleSection label={t('Eingabe')} content={inputStr} />
<_CollapsibleSection label={t('Ausgabe')} content={outputStr} />
</div>
);
})}
</div>
</div>
</div>
);
};
// ===========================================================================
// DashboardTab — Metrics + Runs table with backend pagination
// ===========================================================================
const _DashboardTab: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [loading, setLoading] = useState(true);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(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) => {
setLoading(true);
try {
const params: Record<string, any> = { limit: paginationParams?.pageSize || 25 };
if (paginationParams?.page) {
params.offset = ((paginationParams.page - 1) * (paginationParams.pageSize || 25));
}
if (paginationParams?.search) {
params.search = paginationParams.search;
}
const resp = await api.get('/api/system/workflow-runs', { params });
const data = resp.data;
setRuns(data?.runs || []);
const total = data?.total ?? 0;
const pageSize = params.limit;
setPaginationMeta({
currentPage: paginationParams?.page || 1,
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 _runColumns: ColumnConfig[] = useMemo(() => [
{
key: 'workflowLabel',
label: t('Workflow'),
type: 'string',
width: 200,
sortable: true,
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
},
{
key: 'mandateLabel',
label: t('Mandant'),
type: 'string',
width: 140,
sortable: true,
filterable: true,
},
{
key: 'instanceLabel',
label: t('Instanz'),
type: 'string',
width: 140,
sortable: true,
filterable: true,
},
{
key: 'status',
label: t('Status'),
type: 'string',
width: 110,
sortable: true,
filterable: true,
formatter: (v: string) => (
<span style={{ color: _STATUS_COLORS[v] || 'inherit', fontWeight: 600 }}>
{v === 'completed' ? t('Abgeschlossen') : v === 'failed' ? t('Fehlgeschlagen') : v === 'running' ? t('Laufend') : v}
</span>
),
},
{
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,
formatter: (v: number) => _formatTs(v),
},
], [t]);
const _hookData = useMemo(() => ({
refetch: _loadRuns,
pagination: paginationMeta,
}), [_loadRuns, paginationMeta]);
return (
<>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24, flexShrink: 0 }}>
<MetricCard icon={<FaCog />} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
<MetricCard icon={<FaPlay />} label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
</div>
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
<div style={{ marginBottom: 24, flexShrink: 0 }}>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Läufe nach Status')}</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
<span
key={status}
style={{
padding: '4px 12px',
borderRadius: 12,
fontSize: '0.85rem',
fontWeight: 600,
background: 'var(--bg-secondary, #f5f5f5)',
color: _STATUS_COLORS[status] || 'inherit',
}}
>
{status}: {count}
</span>
))}
</div>
</div>
)}
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
<div style={{ marginBottom: 24, display: 'flex', gap: 24, flexShrink: 0 }}>
{metrics.totalTokens > 0 && (
<div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
<strong>{metrics.totalTokens.toLocaleString('de-DE')}</strong>
</div>
)}
{metrics.totalCredits > 0 && (
<div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Credits gesamt:')} </span>
<strong>{metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}</strong>
</div>
)}
</div>
)}
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8, flexShrink: 0 }}>{t('Letzte Runs')}</h3>
<div className={styles.tableContainer}>
<FormGeneratorTable<WorkflowRun>
data={runs}
columns={_runColumns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
customActions={[
{
id: 'tracing',
icon: <FaStream />,
title: t('Run-Tracing anzeigen'),
onClick: (row) => setTracingRun(row),
},
{
id: 'download',
icon: <FaDownload />,
title: t('Tracing-Protokoll herunterladen'),
onClick: (row) => _downloadRunTracing(row),
},
]}
hookData={_hookData}
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
/>
</div>
{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<SystemWorkflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const _load = useCallback(async (paginationParams?: any) => {
setLoading(true);
try {
const params: Record<string, any> = {};
if (activeFilter === 'active') params.active = true;
if (activeFilter === 'inactive') params.active = false;
const pag = {
page: paginationParams?.page || 1,
pageSize: paginationParams?.pageSize || 25,
...(paginationParams?.sort ? { sort: paginationParams.sort } : {}),
...(paginationParams?.search ? { search: paginationParams.search } : {}),
...(paginationParams?.filters ? { filters: paginationParams.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;
navigate(`/mandates/${row.mandateId}/graphicalEditor/${row.featureInstanceId}/editor?workflowId=${row.id}`);
}, [navigate]);
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
const wf = workflows.find(w => w.id === workflowId);
if (!wf?.featureInstanceId) return false;
try {
await deleteWorkflow(request, wf.featureInstanceId, 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;
}
}, [workflows, 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<string | null>(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 },
{ key: 'mandateLabel', label: t('Mandant'), type: 'string', width: 140, sortable: true, filterable: true },
{ key: 'instanceLabel', label: t('Instanz'), type: 'string', width: 140, sortable: true, filterable: true },
{
key: 'active',
label: t('Aktiv (Spalte)'),
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value !== false
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
},
{
key: 'isRunning',
label: t('läuft'),
type: 'boolean',
width: 80,
formatter: (value: boolean) =>
value
? <span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>{t('Ja')}</span>
: <span style={{ color: 'var(--text-secondary, #666)' }}>{t('Nein')}</span>,
},
{
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 (
<>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>
{t('Alle Workflows über alle Features und Mandanten')}
</p>
</div>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'active', 'inactive'] as const).map((f) => (
<button
key={f}
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveFilter(f)}
disabled={loading}
>
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
</button>
))}
</div>
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<SystemWorkflow>
data={workflows}
columns={_columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
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: <FaEye />,
title: t('anzeigen'),
onClick: (row) => _handleEdit(row),
visible: (row) => row.canEdit !== true,
},
{
id: 'rename',
icon: <FaPen />,
title: t('umbenennen'),
onClick: (row) => _handleRename(row),
visible: (row) => row.canEdit === true,
},
{
id: 'activate',
icon: <FaCheck />,
title: t('aktivieren'),
onClick: (row) => _handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.canEdit === true && row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: t('deaktivieren'),
onClick: (row) => _handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.canEdit === true && row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
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: <FaStop />,
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.')}
/>
</div>
<PromptDialog />
</>
);
};
// ===========================================================================
// 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 (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<h1 className={styles.pageTitle} style={{ flexShrink: 0 }}>{t('Automatisierung')}</h1>
<Tabs tabs={tabs} defaultTabId="dashboard" />
</div>
);
};
export default AutomationsDashboardPage;