fixed automation parameter flow

This commit is contained in:
ValueOn AG 2026-04-13 00:38:51 +02:00
parent 80d4699f5f
commit 9185ea5208
10 changed files with 702 additions and 65 deletions

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -208,6 +208,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
</Route>

View file

@ -8,7 +8,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi';
import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface RunTracingPanelProps {
@ -114,8 +114,9 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
if (!runId || !instanceId) return;
loadSteps();
const url = `/api/workflows/${instanceId}/runs/${runId}/stream`;
const es = new EventSource(url);
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
const es = new EventSource(url, { withCredentials: true });
eventSourceRef.current = es;
es.onopen = () => setSseConnected(true);

View file

@ -440,10 +440,9 @@ tbody .actionsColumn {
display: flex;
flex-wrap: nowrap;
gap: 4px;
justify-content: center;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 0 auto;
}
.actionButtonsWrap {

View file

@ -138,26 +138,23 @@ export interface FormGeneratorTableProps<T = any> {
// Standard action buttons (edit, delete, view, copy, connect, play)
actionButtons?: {
type: 'edit' | 'delete' | 'view' | 'copy' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
onAction?: (row: T) => Promise<void> | void;
visible?: (row: T, hookData?: any) => boolean;
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
loading?: (row: T, hookData?: any) => boolean;
title?: string | ((row: T) => string);
className?: string;
// For view buttons
isProcessing?: (row: T, hookData?: any) => boolean;
// Field mappings for flexible data access
idField?: string; // Field name for the unique identifier
nameField?: string; // Field name for display name
typeField?: string; // Field name for type/mime type
contentField?: string; // Field name for content (used by copy button)
statusField?: string; // Field name for status (used by connect action)
// Operation and loading state names
operationName?: string; // Name of the operation function in hookData
loadingStateName?: string; // Name of the loading state in hookData
fetchItemFunctionName?: string; // Name of the function to fetch a single item (for edit button)
// Navigation and mode (for play action)
navigateTo?: string; // Path to navigate to after action
mode?: string; // Mode to set (e.g., 'prompt', 'workflow')
idField?: string;
nameField?: string;
typeField?: string;
contentField?: string;
statusField?: string;
operationName?: string;
loadingStateName?: string;
fetchItemFunctionName?: string;
navigateTo?: string;
mode?: string;
}[];
// Custom action buttons (entity-specific actions like download, connect, play, sendPasswordLink)
customActions?: {
@ -2118,6 +2115,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
>
{actionButtons.map((actionButton, actionIndex) => {
if (actionButton.visible && !actionButton.visible(row, hookData)) return null;
const actionTitle = typeof actionButton.title === 'function'
? actionButton.title(row)
: actionButton.title;
@ -2233,6 +2231,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
>
{actionButtons.map((actionButton, actionIndex) => {
if (actionButton.visible && !actionButton.visible(row, hookData)) return null;
const actionTitle = typeof actionButton.title === 'function'
? actionButton.title(row)
: actionButton.title;

View file

@ -2,6 +2,8 @@
display: flex;
flex-direction: column;
width: 100%;
flex: 1;
min-height: 0;
gap: 0;
}
@ -10,6 +12,7 @@
gap: 0;
border-bottom: 2px solid var(--color-border, #e0e0e0);
margin-bottom: 1rem;
flex-shrink: 0;
}
.tabButton {
@ -39,6 +42,9 @@
.tabsContent {
flex: 1;
min-height: 0;
width: 100%;
display: flex;
flex-direction: column;
}

View file

@ -80,6 +80,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.automation-logs': <FaClipboardList />,
'page.admin.logs': <FaFileAlt />,
'page.admin.languages': <FaGlobe />,
'page.admin.demoConfig': <FaCubes />,
'page.admin.demo-config': <FaCubes />,
'page.admin.mandate-wizard': <FaHatWizard />,
'page.admin.mandateWizard': <FaHatWizard />,
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,

View file

@ -6,9 +6,9 @@
* - Workflows: Central management of all RBAC-accessible workflows across instances
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
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 } from 'react-icons/fa';
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';
@ -38,6 +38,9 @@ interface WorkflowRun {
workflowId: string;
workflowLabel?: string;
mandateId?: string;
mandateLabel?: string;
featureInstanceId?: string;
instanceLabel?: string;
ownerId?: string;
status: string;
costTokens?: number;
@ -53,6 +56,7 @@ interface SystemWorkflow {
label: string;
active: boolean;
isRunning?: boolean;
activeRunId?: string;
stuckAtNodeLabel?: string;
stuckAtNodeId?: string;
createdAt?: number;
@ -86,6 +90,7 @@ const _STATUS_COLORS: Record<string, string> = {
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)',
};
@ -124,6 +129,256 @@ const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) =>
</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
// ===========================================================================
@ -136,6 +391,7 @@ const _DashboardTab: React.FC = () => {
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 {
@ -180,6 +436,16 @@ const _DashboardTab: React.FC = () => {
_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 {
@ -217,13 +483,20 @@ const _DashboardTab: React.FC = () => {
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
},
{
key: 'mandateId',
key: 'mandateLabel',
label: t('Mandant'),
type: 'string',
width: 120,
width: 140,
sortable: true,
filterable: true,
},
{
key: 'instanceLabel',
label: t('Instanz'),
type: 'string',
width: 140,
sortable: true,
filterable: true,
formatter: (v: string) => v ? v.slice(0, 8) + '…' : t('—'),
},
{
key: 'status',
@ -253,27 +526,7 @@ const _DashboardTab: React.FC = () => {
width: 150,
formatter: (v: number) => _formatTs(v),
},
{
key: 'id',
label: '',
type: 'string',
width: 50,
sortable: false,
formatter: (_v: string, row: WorkflowRun) => (
<button
onClick={(e) => { e.stopPropagation(); _downloadRunTracing(row); }}
title={t('Tracing-Protokoll herunterladen')}
style={{
border: 'none', background: 'transparent', cursor: 'pointer',
color: 'var(--text-secondary, #666)', fontSize: 14, padding: 4,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<FaDownload />
</button>
),
},
], [t, _downloadRunTracing]);
], [t]);
const _hookData = useMemo(() => ({
refetch: _loadRuns,
@ -293,14 +546,14 @@ const _DashboardTab: React.FC = () => {
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24 }}>
<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 }}>
<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]) => (
@ -323,7 +576,7 @@ const _DashboardTab: React.FC = () => {
)}
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
<div style={{ marginBottom: 24, display: 'flex', gap: 24 }}>
<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>
@ -339,7 +592,7 @@ const _DashboardTab: React.FC = () => {
</div>
)}
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Letzte Runs')}</h3>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8, flexShrink: 0 }}>{t('Letzte Runs')}</h3>
<div className={styles.tableContainer}>
<FormGeneratorTable<WorkflowRun>
data={runs}
@ -351,10 +604,27 @@ const _DashboardTab: React.FC = () => {
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)} />
)}
</>
);
};
@ -409,6 +679,13 @@ const _WorkflowsTab: React.FC = () => {
_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}`);
@ -461,31 +738,53 @@ const _WorkflowsTab: React.FC = () => {
}, [request, promptInput, showSuccess, showError, _load, t]);
const _handleExecute = useCallback(async (row: SystemWorkflow) => {
if (!row.featureInstanceId || !row.graph) return;
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 result = await executeGraph(request, row.featureInstanceId, row.graph, row.id, {
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();
});
if (result?.success) {
showSuccess(result?.paused
? t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.')
: t('Workflow ausgeführt'));
await _load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
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'));
@ -633,7 +932,15 @@ const _WorkflowsTab: React.FC = () => {
title: t('ausführen'),
onClick: (row) => _handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => row.canExecute === true && _hasManualTrigger(row),
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)}
@ -668,7 +975,7 @@ export const AutomationsDashboardPage: React.FC = () => {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<h1 className={styles.pageTitle}>{t('Automatisierung')}</h1>
<h1 className={styles.pageTitle} style={{ flexShrink: 0 }}>{t('Automatisierung')}</h1>
<Tabs tabs={tabs} defaultTabId="dashboard" />
</div>
);

View file

@ -0,0 +1,158 @@
/* AdminDemoConfigPage styles */
.configGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1rem;
}
.configCard {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
border-radius: var(--object-radius-medium, 10px);
border: 1px solid var(--border-color, #e2e8f0);
background: var(--bg-secondary, #fff);
transition: box-shadow 0.15s ease;
}
.configCard:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
:global(.dark-theme) .configCard {
background: var(--bg-secondary, #1e1e2e);
border-color: var(--border-color, #2d2d3d);
}
.cardIcon {
font-size: 1.5rem;
color: var(--primary-color, #f25843);
flex-shrink: 0;
margin-top: 2px;
}
.cardContent {
flex: 1;
min-width: 0;
}
.cardTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 0.25rem 0;
}
.cardDescription {
font-size: 0.825rem;
color: var(--text-secondary);
margin: 0 0 0.5rem 0;
line-height: 1.4;
}
.cardCode {
font-size: 0.7rem;
font-family: var(--font-mono, monospace);
color: var(--text-tertiary);
background: var(--bg-tertiary, #f7f7f8);
padding: 2px 6px;
border-radius: 4px;
}
:global(.dark-theme) .cardCode {
background: var(--bg-tertiary, #2a2a3a);
}
.cardActions {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
}
.loadButton,
.removeButton {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.85rem;
border-radius: var(--object-radius-small, 6px);
font-size: 0.8rem;
font-weight: 500;
border: 1px solid transparent;
cursor: pointer;
transition: background 0.15s ease, opacity 0.15s ease;
white-space: nowrap;
}
.loadButton {
background: #16a34a;
color: #fff;
}
.loadButton:hover:not(:disabled) {
background: #15803d;
}
.removeButton {
background: transparent;
color: #dc2626;
border-color: #dc2626;
}
.removeButton:hover:not(:disabled) {
background: rgba(220, 38, 38, 0.06);
}
.loadButton:disabled,
.removeButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.successBanner {
padding: 0.75rem 1rem;
border-radius: var(--object-radius-small, 6px);
background: rgba(22, 163, 74, 0.08);
border: 1px solid rgba(22, 163, 74, 0.2);
color: #16a34a;
font-size: 0.85rem;
margin-bottom: 1rem;
}
:global(.dark-theme) .successBanner {
background: rgba(22, 163, 74, 0.12);
}
.errorBanner {
padding: 0.75rem 1rem;
border-radius: var(--object-radius-small, 6px);
background: rgba(220, 38, 38, 0.06);
border: 1px solid rgba(220, 38, 38, 0.2);
color: #dc2626;
font-size: 0.85rem;
margin-bottom: 1rem;
}
:global(.dark-theme) .errorBanner {
background: rgba(220, 38, 38, 0.12);
}
.loadingState,
.emptyState {
text-align: center;
padding: 3rem 1rem;
color: var(--text-tertiary);
font-size: 0.9rem;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
}

View file

@ -0,0 +1,163 @@
/**
* AdminDemoConfigPage
*
* SysAdmin page for managing demo configurations.
* Lists available demo configs with Load / Remove actions.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { FaPlay, FaTrash, FaSync, FaCubes } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import demoStyles from './AdminDemoConfigPage.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface _DemoConfig {
code: string;
label: string;
description: string;
}
interface _ActionResult {
code: string;
action: 'load' | 'remove';
status: 'ok' | 'error';
summary?: Record<string, unknown>;
error?: string;
}
export const AdminDemoConfigPage: React.FC = () => {
const { t } = useLanguage();
const [configs, setConfigs] = useState<_DemoConfig[]>([]);
const [loading, setLoading] = useState(false);
const [actionInProgress, setActionInProgress] = useState<string | null>(null);
const [lastResult, setLastResult] = useState<_ActionResult | null>(null);
const [error, setError] = useState<string | null>(null);
const _fetchConfigs = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await api.get('/api/admin/demo-config');
setConfigs(response.data.configs || []);
} catch (err: any) {
setError(err.response?.data?.detail || t('Error loading demo configs'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
_fetchConfigs();
}, [_fetchConfigs]);
const _handleLoad = async (code: string) => {
if (actionInProgress) return;
setActionInProgress(code);
setLastResult(null);
try {
const response = await api.post(`/api/admin/demo-config/${code}/load`);
setLastResult({ code, action: 'load', status: 'ok', summary: response.data.summary });
} catch (err: any) {
setLastResult({ code, action: 'load', status: 'error', error: err.response?.data?.detail || String(err) });
} finally {
setActionInProgress(null);
}
};
const _handleRemove = async (code: string) => {
if (actionInProgress) return;
if (!window.confirm(t('Are you sure you want to remove all demo data for this configuration?'))) return;
setActionInProgress(code);
setLastResult(null);
try {
const response = await api.post(`/api/admin/demo-config/${code}/remove`);
setLastResult({ code, action: 'remove', status: 'ok', summary: response.data.summary });
} catch (err: any) {
setLastResult({ code, action: 'remove', status: 'error', error: err.response?.data?.detail || String(err) });
} finally {
setActionInProgress(null);
}
};
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Demo Configurations')}</h1>
<p className={styles.pageSubtitle}>{t('Load or remove demo environments for presentations and testing.')}</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={_fetchConfigs} disabled={loading}>
<FaSync /> {t('Refresh')}
</button>
</div>
</div>
{error && <div className={demoStyles.errorBanner}>{error}</div>}
{lastResult && (
<div className={lastResult.status === 'ok' ? demoStyles.successBanner : demoStyles.errorBanner}>
<strong>{lastResult.action === 'load' ? t('Loaded') : t('Removed')}:</strong>{' '}
{lastResult.status === 'ok' ? (
<_SummaryDisplay summary={lastResult.summary} />
) : (
<span>{lastResult.error}</span>
)}
</div>
)}
{loading && configs.length === 0 ? (
<div className={demoStyles.loadingState}>{t('Loading...')}</div>
) : configs.length === 0 ? (
<div className={demoStyles.emptyState}>{t('No demo configurations found.')}</div>
) : (
<div className={demoStyles.configGrid}>
{configs.map((cfg) => (
<div key={cfg.code} className={demoStyles.configCard}>
<div className={demoStyles.cardIcon}><FaCubes /></div>
<div className={demoStyles.cardContent}>
<h3 className={demoStyles.cardTitle}>{cfg.label}</h3>
<p className={demoStyles.cardDescription}>{cfg.description}</p>
<span className={demoStyles.cardCode}>{cfg.code}</span>
</div>
<div className={demoStyles.cardActions}>
<button
className={demoStyles.loadButton}
onClick={() => _handleLoad(cfg.code)}
disabled={actionInProgress !== null}
>
{actionInProgress === cfg.code ? <FaSync className={demoStyles.spin} /> : <FaPlay />}
{t('Load')}
</button>
<button
className={demoStyles.removeButton}
onClick={() => _handleRemove(cfg.code)}
disabled={actionInProgress !== null}
>
<FaTrash />
{t('Remove')}
</button>
</div>
</div>
))}
</div>
)}
</div>
);
};
function _SummaryDisplay({ summary }: { summary?: Record<string, unknown> }) {
if (!summary) return null;
const sections = Object.entries(summary).filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0);
if (sections.length === 0) return <span>Done (no changes)</span>;
return (
<span>
{sections.map(([key, items]) => (
<span key={key} style={{ marginRight: 12 }}>
<strong>{key}:</strong> {(items as string[]).length}
</span>
))}
</span>
);
}

View file

@ -17,3 +17,4 @@ export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPa
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';
export { AdminDemoConfigPage } from './AdminDemoConfigPage';