fixed automation parameter flow
This commit is contained in:
parent
80d4699f5f
commit
9185ea5208
10 changed files with 702 additions and 65 deletions
|
|
@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
|
@ -208,6 +208,7 @@ function App() {
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="languages" element={null} />
|
<Route path="languages" element={null} />
|
||||||
|
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import type { AutoStepLog } from '../../../api/workflowApi';
|
import type { AutoStepLog } from '../../../api/workflowApi';
|
||||||
|
import api from '../../../api';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface RunTracingPanelProps {
|
interface RunTracingPanelProps {
|
||||||
|
|
@ -114,8 +114,9 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||||
if (!runId || !instanceId) return;
|
if (!runId || !instanceId) return;
|
||||||
loadSteps();
|
loadSteps();
|
||||||
|
|
||||||
const url = `/api/workflows/${instanceId}/runs/${runId}/stream`;
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
const es = new EventSource(url);
|
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
|
||||||
|
const es = new EventSource(url, { withCredentials: true });
|
||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
es.onopen = () => setSseConnected(true);
|
es.onopen = () => setSseConnected(true);
|
||||||
|
|
|
||||||
|
|
@ -440,10 +440,9 @@ tbody .actionsColumn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
justify-content: center;
|
justify-content: flex-start;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actionButtonsWrap {
|
.actionButtonsWrap {
|
||||||
|
|
|
||||||
|
|
@ -138,26 +138,23 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
// Standard action buttons (edit, delete, view, copy, connect, play)
|
// Standard action buttons (edit, delete, view, copy, connect, play)
|
||||||
actionButtons?: {
|
actionButtons?: {
|
||||||
type: 'edit' | 'delete' | 'view' | 'copy' | 'connect' | 'play';
|
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 };
|
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
|
||||||
loading?: (row: T, hookData?: any) => boolean;
|
loading?: (row: T, hookData?: any) => boolean;
|
||||||
title?: string | ((row: T) => string);
|
title?: string | ((row: T) => string);
|
||||||
className?: string;
|
className?: string;
|
||||||
// For view buttons
|
|
||||||
isProcessing?: (row: T, hookData?: any) => boolean;
|
isProcessing?: (row: T, hookData?: any) => boolean;
|
||||||
// Field mappings for flexible data access
|
idField?: string;
|
||||||
idField?: string; // Field name for the unique identifier
|
nameField?: string;
|
||||||
nameField?: string; // Field name for display name
|
typeField?: string;
|
||||||
typeField?: string; // Field name for type/mime type
|
contentField?: string;
|
||||||
contentField?: string; // Field name for content (used by copy button)
|
statusField?: string;
|
||||||
statusField?: string; // Field name for status (used by connect action)
|
operationName?: string;
|
||||||
// Operation and loading state names
|
loadingStateName?: string;
|
||||||
operationName?: string; // Name of the operation function in hookData
|
fetchItemFunctionName?: string;
|
||||||
loadingStateName?: string; // Name of the loading state in hookData
|
navigateTo?: string;
|
||||||
fetchItemFunctionName?: string; // Name of the function to fetch a single item (for edit button)
|
mode?: string;
|
||||||
// Navigation and mode (for play action)
|
|
||||||
navigateTo?: string; // Path to navigate to after action
|
|
||||||
mode?: string; // Mode to set (e.g., 'prompt', 'workflow')
|
|
||||||
}[];
|
}[];
|
||||||
// Custom action buttons (entity-specific actions like download, connect, play, sendPasswordLink)
|
// Custom action buttons (entity-specific actions like download, connect, play, sendPasswordLink)
|
||||||
customActions?: {
|
customActions?: {
|
||||||
|
|
@ -2118,6 +2115,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||||
>
|
>
|
||||||
{actionButtons.map((actionButton, actionIndex) => {
|
{actionButtons.map((actionButton, actionIndex) => {
|
||||||
|
if (actionButton.visible && !actionButton.visible(row, hookData)) return null;
|
||||||
const actionTitle = typeof actionButton.title === 'function'
|
const actionTitle = typeof actionButton.title === 'function'
|
||||||
? actionButton.title(row)
|
? actionButton.title(row)
|
||||||
: actionButton.title;
|
: actionButton.title;
|
||||||
|
|
@ -2233,6 +2231,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||||
>
|
>
|
||||||
{actionButtons.map((actionButton, actionIndex) => {
|
{actionButtons.map((actionButton, actionIndex) => {
|
||||||
|
if (actionButton.visible && !actionButton.visible(row, hookData)) return null;
|
||||||
const actionTitle = typeof actionButton.title === 'function'
|
const actionTitle = typeof actionButton.title === 'function'
|
||||||
? actionButton.title(row)
|
? actionButton.title(row)
|
||||||
: actionButton.title;
|
: actionButton.title;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -10,6 +12,7 @@
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border-bottom: 2px solid var(--color-border, #e0e0e0);
|
border-bottom: 2px solid var(--color-border, #e0e0e0);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabButton {
|
.tabButton {
|
||||||
|
|
@ -39,6 +42,9 @@
|
||||||
|
|
||||||
.tabsContent {
|
.tabsContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.automation-logs': <FaClipboardList />,
|
'page.admin.automation-logs': <FaClipboardList />,
|
||||||
'page.admin.logs': <FaFileAlt />,
|
'page.admin.logs': <FaFileAlt />,
|
||||||
'page.admin.languages': <FaGlobe />,
|
'page.admin.languages': <FaGlobe />,
|
||||||
|
'page.admin.demoConfig': <FaCubes />,
|
||||||
|
'page.admin.demo-config': <FaCubes />,
|
||||||
'page.admin.mandate-wizard': <FaHatWizard />,
|
'page.admin.mandate-wizard': <FaHatWizard />,
|
||||||
'page.admin.mandateWizard': <FaHatWizard />,
|
'page.admin.mandateWizard': <FaHatWizard />,
|
||||||
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
'page.admin.invitation-wizard': <FaEnvelopeOpenText />,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@
|
||||||
* - Workflows: Central management of all RBAC-accessible workflows across instances
|
* - 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 { 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 { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator';
|
||||||
import { Tabs } from '../components/UiComponents/Tabs';
|
import { Tabs } from '../components/UiComponents/Tabs';
|
||||||
import { useToast } from '../contexts/ToastContext';
|
import { useToast } from '../contexts/ToastContext';
|
||||||
|
|
@ -38,6 +38,9 @@ interface WorkflowRun {
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
workflowLabel?: string;
|
workflowLabel?: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
|
mandateLabel?: string;
|
||||||
|
featureInstanceId?: string;
|
||||||
|
instanceLabel?: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
status: string;
|
status: string;
|
||||||
costTokens?: number;
|
costTokens?: number;
|
||||||
|
|
@ -53,6 +56,7 @@ interface SystemWorkflow {
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
|
activeRunId?: string;
|
||||||
stuckAtNodeLabel?: string;
|
stuckAtNodeLabel?: string;
|
||||||
stuckAtNodeId?: string;
|
stuckAtNodeId?: string;
|
||||||
createdAt?: number;
|
createdAt?: number;
|
||||||
|
|
@ -86,6 +90,7 @@ const _STATUS_COLORS: Record<string, string> = {
|
||||||
failed: 'var(--danger-color, #dc3545)',
|
failed: 'var(--danger-color, #dc3545)',
|
||||||
running: 'var(--primary-color, #007bff)',
|
running: 'var(--primary-color, #007bff)',
|
||||||
paused: 'var(--warning-color, #ffc107)',
|
paused: 'var(--warning-color, #ffc107)',
|
||||||
|
stopped: 'var(--warning-color, #ffc107)',
|
||||||
cancelled: 'var(--text-secondary, #666)',
|
cancelled: 'var(--text-secondary, #666)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -124,6 +129,256 @@ const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) =>
|
||||||
</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
|
// DashboardTab — Metrics + Runs table with backend pagination
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -136,6 +391,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
|
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
||||||
|
|
||||||
const _loadMetrics = useCallback(async () => {
|
const _loadMetrics = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -180,6 +436,16 @@ const _DashboardTab: React.FC = () => {
|
||||||
_loadRuns();
|
_loadRuns();
|
||||||
}, [_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) => {
|
const _downloadRunTracing = useCallback(async (run: WorkflowRun) => {
|
||||||
if (!run.id) return;
|
if (!run.id) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -217,13 +483,20 @@ const _DashboardTab: React.FC = () => {
|
||||||
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
formatter: (v: string, row: WorkflowRun) => v || row.workflowId || t('—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'mandateId',
|
key: 'mandateLabel',
|
||||||
label: t('Mandant'),
|
label: t('Mandant'),
|
||||||
type: 'string',
|
type: 'string',
|
||||||
width: 120,
|
width: 140,
|
||||||
|
sortable: true,
|
||||||
|
filterable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'instanceLabel',
|
||||||
|
label: t('Instanz'),
|
||||||
|
type: 'string',
|
||||||
|
width: 140,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
filterable: true,
|
filterable: true,
|
||||||
formatter: (v: string) => v ? v.slice(0, 8) + '…' : t('—'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
|
|
@ -253,27 +526,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
width: 150,
|
width: 150,
|
||||||
formatter: (v: number) => _formatTs(v),
|
formatter: (v: number) => _formatTs(v),
|
||||||
},
|
},
|
||||||
{
|
], [t]);
|
||||||
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]);
|
|
||||||
|
|
||||||
const _hookData = useMemo(() => ({
|
const _hookData = useMemo(() => ({
|
||||||
refetch: _loadRuns,
|
refetch: _loadRuns,
|
||||||
|
|
@ -293,14 +546,14 @@ const _DashboardTab: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</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={<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={<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('—')} />
|
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
|
{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>
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Läufe nach Status')}</h3>
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
|
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
|
||||||
|
|
@ -323,7 +576,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
|
{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 && (
|
{metrics.totalTokens > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
|
||||||
|
|
@ -339,7 +592,7 @@ const _DashboardTab: React.FC = () => {
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable<WorkflowRun>
|
<FormGeneratorTable<WorkflowRun>
|
||||||
data={runs}
|
data={runs}
|
||||||
|
|
@ -351,10 +604,27 @@ const _DashboardTab: React.FC = () => {
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={false}
|
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}
|
hookData={_hookData}
|
||||||
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
|
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{tracingRun && (
|
||||||
|
<_RunTracingModal run={tracingRun} onClose={() => setTracingRun(null)} />
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -409,6 +679,13 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
_load();
|
_load();
|
||||||
}, [_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) => {
|
const _handleEdit = useCallback((row: SystemWorkflow) => {
|
||||||
if (!row.mandateId || !row.featureInstanceId) return;
|
if (!row.mandateId || !row.featureInstanceId) return;
|
||||||
navigate(`/mandates/${row.mandateId}/graphicalEditor/${row.featureInstanceId}/editor?workflowId=${row.id}`);
|
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]);
|
}, [request, promptInput, showSuccess, showError, _load, t]);
|
||||||
|
|
||||||
const _handleExecute = useCallback(async (row: SystemWorkflow) => {
|
const _handleExecute = useCallback(async (row: SystemWorkflow) => {
|
||||||
if (!row.featureInstanceId || !row.graph) return;
|
if (!row.featureInstanceId) return;
|
||||||
setExecutingId(row.id);
|
setExecutingId(row.id);
|
||||||
try {
|
try {
|
||||||
const invs = row.invocations || [];
|
const invs = row.invocations || [];
|
||||||
const primary =
|
const primary =
|
||||||
invs.find((i) => i.enabled && i.kind === 'manual') ||
|
invs.find((i) => i.enabled && i.kind === 'manual') ||
|
||||||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
|
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 } : {}),
|
...(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) {
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
showSuccess(result?.paused
|
await _load();
|
||||||
? t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.')
|
showSuccess(t('Workflow gestartet'));
|
||||||
: 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') }));
|
|
||||||
} finally {
|
} finally {
|
||||||
setExecutingId(null);
|
setExecutingId(null);
|
||||||
}
|
}
|
||||||
}, [request, showSuccess, showError, _load, t]);
|
}, [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 _hasManualTrigger = useCallback((row: SystemWorkflow): boolean => {
|
||||||
const invs = row.invocations || [];
|
const invs = row.invocations || [];
|
||||||
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
|
||||||
|
|
@ -633,7 +932,15 @@ const _WorkflowsTab: React.FC = () => {
|
||||||
title: t('ausführen'),
|
title: t('ausführen'),
|
||||||
onClick: (row) => _handleExecute(row),
|
onClick: (row) => _handleExecute(row),
|
||||||
loading: (row) => executingId === row.id,
|
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)}
|
onDelete={(row) => _handleDelete(row.id)}
|
||||||
|
|
@ -668,7 +975,7 @@ export const AutomationsDashboardPage: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<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" />
|
<Tabs tabs={tabs} defaultTabId="dashboard" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
158
src/pages/admin/AdminDemoConfigPage.module.css
Normal file
158
src/pages/admin/AdminDemoConfigPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
163
src/pages/admin/AdminDemoConfigPage.tsx
Normal file
163
src/pages/admin/AdminDemoConfigPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,3 +17,4 @@ export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPa
|
||||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||||
export { AdminLogsPage } from './AdminLogsPage';
|
export { AdminLogsPage } from './AdminLogsPage';
|
||||||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||||
|
export { AdminDemoConfigPage } from './AdminDemoConfigPage';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue