diff --git a/src/App.tsx b/src/App.tsx index 9ad5e01..9ef841f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> } /> + } /> } /> } /> diff --git a/src/components/FlowEditor/editor/RunTracingPanel.tsx b/src/components/FlowEditor/editor/RunTracingPanel.tsx index a92030b..238ae63 100644 --- a/src/components/FlowEditor/editor/RunTracingPanel.tsx +++ b/src/components/FlowEditor/editor/RunTracingPanel.tsx @@ -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 = ({ 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); diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css index c22552c..368e952 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.module.css @@ -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 { diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index dd8987d..6f7a6aa 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -138,26 +138,23 @@ export interface FormGeneratorTableProps { // Standard action buttons (edit, delete, view, copy, connect, play) actionButtons?: { type: 'edit' | 'delete' | 'view' | 'copy' | 'connect' | 'play'; - onAction?: (row: T) => Promise | void; // Optional for delete buttons since they handle their own logic + onAction?: (row: T) => Promise | 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>({ 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>({ 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; diff --git a/src/components/UiComponents/Tabs/Tabs.module.css b/src/components/UiComponents/Tabs/Tabs.module.css index 9348d1e..6963558 100644 --- a/src/components/UiComponents/Tabs/Tabs.module.css +++ b/src/components/UiComponents/Tabs/Tabs.module.css @@ -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; } diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 6bcd858..f39bacf 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -80,6 +80,8 @@ export const PAGE_ICONS: Record = { 'page.admin.automation-logs': , 'page.admin.logs': , 'page.admin.languages': , + 'page.admin.demoConfig': , + 'page.admin.demo-config': , 'page.admin.mandate-wizard': , 'page.admin.mandateWizard': , 'page.admin.invitation-wizard': , diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 26e4539..afcdbc7 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -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 = { 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 = ({ icon, label, value, color }) => ); +// =========================================================================== +// Live Run Tracing Modal (SSE-based, can be opened/closed freely) +// =========================================================================== + +interface _TracingStep { + id: string; + nodeId: string; + nodeType: string; + status: string; + startedAt?: number; + completedAt?: number; + durationMs?: number; + error?: string; + tokensUsed?: number; + inputSnapshot?: Record; + output?: Record; + retryCount?: number; +} + +const _STATUS_ICONS: Record = { + pending: '○', running: '◉', completed: '✓', failed: '✗', stopped: '■', skipped: '—', +}; + +function _formatStepTs(ts: number | string | null | undefined): string { + if (!ts) return ''; + const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); + if (isNaN(d.getTime())) return ''; + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function _truncateJson(obj: unknown, maxLen = 300): string { + if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return ''; + try { + const s = JSON.stringify(obj, null, 2); + return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s; + } catch { + return String(obj); + } +} + +const _CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => { + const [open, setOpen] = useState(false); + if (!content) return null; + return ( +
+ + {open && ( +
+          {content}
+        
+ )} +
+ ); +}; + +interface _RunTracingModalProps { + run: WorkflowRun; + onClose: () => void; +} + +const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => { + const { t } = useLanguage(); + const [steps, setSteps] = useState<_TracingStep[]>([]); + const [loading, setLoading] = useState(false); + const [sseConnected, setSseConnected] = useState(false); + const eventSourceRef = useRef(null); + const scrollRef = useRef(null); + + const _loadSteps = useCallback(async () => { + setLoading(true); + try { + const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`); + setSteps(resp.data?.steps || []); + } catch (e) { + console.error('[RunTracing] Failed to load steps:', e); + } finally { + setLoading(false); + } + }, [run.id]); + + const isRunning = run.status === 'running' || run.status === 'paused'; + + useEffect(() => { + _loadSteps(); + + if (!isRunning) return; + + const baseUrl = api.defaults.baseURL || ''; + const url = `${baseUrl}/api/system/workflow-runs/${run.id}/stream`; + const es = new EventSource(url, { withCredentials: true }); + eventSourceRef.current = es; + + es.onopen = () => setSseConnected(true); + es.onmessage = (event) => { + try { + const payload = JSON.parse(event.data); + if (payload.type === 'keepalive') return; + if (payload.type === 'run_complete' || payload.type === 'run_failed') { + _loadSteps(); + es.close(); + setSseConnected(false); + return; + } + if (payload.status === 'running') { + setSteps((prev) => { + const exists = prev.some((s) => s.id === payload.id); + if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s); + return [...prev, payload as _TracingStep]; + }); + } else { + setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s)); + } + } catch { /* ignore parse errors */ } + }; + es.onerror = () => { + setSseConnected(false); + es.close(); + }; + + return () => { + es.close(); + eventSourceRef.current = null; + setSseConnected(false); + }; + }, [run.id, run.status]); // eslint-disable-line react-hooks/exhaustive-deps + + // Polling fallback: reload steps periodically while run is active + useEffect(() => { + if (!isRunning) return; + const interval = setInterval(() => { _loadSteps(); }, 5000); + return () => clearInterval(interval); + }, [isRunning, _loadSteps]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [steps]); + + return ( +
+
e.stopPropagation()} + > +
+
+

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

+
+ + {run.status} + + {sseConnected && ( + ● {t('Live')} + )} +
+
+ +
+
+ {loading && steps.length === 0 && ( +
{t('Wird geladen…')}
+ )} + {!loading && steps.length === 0 && ( +
{t('Noch keine Schritte aufgezeichnet')}
+ )} + {steps.map((step) => { + const startStr = _formatStepTs(step.startedAt); + const endStr = _formatStepTs(step.completedAt); + const inputStr = _truncateJson(step.inputSnapshot); + const outputStr = _truncateJson(step.output); + const isLoop = step.inputSnapshot?._loopIndex != null; + + return ( +
+
+ + + {_STATUS_ICONS[step.status] || '?'} + + {step.nodeType} + ({step.nodeId}) + {isLoop && ( + + [iter {step.inputSnapshot!._loopIndex}] + + )} + + + {(step.retryCount ?? 0) > 0 && ( + + {step.retryCount}x {t('Wiederholung')} + + )} + {step.durationMs != null && ( + {step.durationMs}ms + )} + +
+ {(startStr || endStr) && ( +
+ {startStr && {startStr}} + {startStr && endStr && } + {endStr && {endStr}} +
+ )} + {step.error && ( +
{step.error}
+ )} + {(step.tokensUsed ?? 0) > 0 && ( +
+ {step.tokensUsed} {t('Tokens')} +
+ )} + <_CollapsibleSection label={t('Eingabe')} content={inputStr} /> + <_CollapsibleSection label={t('Ausgabe')} content={outputStr} /> +
+ ); + })} +
+
+
+ ); +}; + // =========================================================================== // DashboardTab — Metrics + Runs table with backend pagination // =========================================================================== @@ -136,6 +391,7 @@ const _DashboardTab: React.FC = () => { const [runs, setRuns] = useState([]); const [loading, setLoading] = useState(true); const [paginationMeta, setPaginationMeta] = useState(null); + const [tracingRun, setTracingRun] = useState(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) => ( - - ), - }, - ], [t, _downloadRunTracing]); + ], [t]); const _hookData = useMemo(() => ({ refetch: _loadRuns, @@ -293,14 +546,14 @@ const _DashboardTab: React.FC = () => { -
+
} 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]) => ( @@ -323,7 +576,7 @@ const _DashboardTab: React.FC = () => { )} {metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && ( -
+
{metrics.totalTokens > 0 && (
{t('Tokens gesamt:')} @@ -339,7 +592,7 @@ const _DashboardTab: React.FC = () => {
)} -

{t('Letzte Runs')}

+

{t('Letzte Runs')}

data={runs} @@ -351,10 +604,27 @@ const _DashboardTab: React.FC = () => { filterable={true} sortable={true} selectable={false} + 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)} /> + )} ); }; @@ -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(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: , + 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 (
-

{t('Automatisierung')}

+

{t('Automatisierung')}

); diff --git a/src/pages/admin/AdminDemoConfigPage.module.css b/src/pages/admin/AdminDemoConfigPage.module.css new file mode 100644 index 0000000..d11aa0f --- /dev/null +++ b/src/pages/admin/AdminDemoConfigPage.module.css @@ -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; +} diff --git a/src/pages/admin/AdminDemoConfigPage.tsx b/src/pages/admin/AdminDemoConfigPage.tsx new file mode 100644 index 0000000..243c50f --- /dev/null +++ b/src/pages/admin/AdminDemoConfigPage.tsx @@ -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; + error?: string; +} + +export const AdminDemoConfigPage: React.FC = () => { + const { t } = useLanguage(); + const [configs, setConfigs] = useState<_DemoConfig[]>([]); + const [loading, setLoading] = useState(false); + const [actionInProgress, setActionInProgress] = useState(null); + const [lastResult, setLastResult] = useState<_ActionResult | null>(null); + const [error, setError] = useState(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 ( +
+
+
+

{t('Demo Configurations')}

+

{t('Load or remove demo environments for presentations and testing.')}

+
+
+ +
+
+ + {error &&
{error}
} + + {lastResult && ( +
+ {lastResult.action === 'load' ? t('Loaded') : t('Removed')}:{' '} + {lastResult.status === 'ok' ? ( + <_SummaryDisplay summary={lastResult.summary} /> + ) : ( + {lastResult.error} + )} +
+ )} + + {loading && configs.length === 0 ? ( +
{t('Loading...')}
+ ) : configs.length === 0 ? ( +
{t('No demo configurations found.')}
+ ) : ( +
+ {configs.map((cfg) => ( +
+
+
+

{cfg.label}

+

{cfg.description}

+ {cfg.code} +
+
+ + +
+
+ ))} +
+ )} +
+ ); +}; + +function _SummaryDisplay({ summary }: { summary?: Record }) { + if (!summary) return null; + const sections = Object.entries(summary).filter(([, v]) => Array.isArray(v) && (v as unknown[]).length > 0); + if (sections.length === 0) return Done (no changes); + return ( + + {sections.map(([key, items]) => ( + + {key}: {(items as string[]).length} + + ))} + + ); +} diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index a1c6ac0..dc67667 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -17,3 +17,4 @@ export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPa export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage'; export { AdminLogsPage } from './AdminLogsPage'; export { AdminLanguagesPage } from './AdminLanguagesPage'; +export { AdminDemoConfigPage } from './AdminDemoConfigPage';