diff --git a/src/hooks/playground/useDashboardInputForm.ts b/src/hooks/playground/useDashboardInputForm.ts
index 809e1f2..5a9c76e 100644
--- a/src/hooks/playground/useDashboardInputForm.ts
+++ b/src/hooks/playground/useDashboardInputForm.ts
@@ -62,6 +62,7 @@ export function useDashboardInputForm() {
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
+ toggleRoundExpanded,
updateCurrentRound,
getChildOperations
} = useDashboardLogTree();
@@ -480,19 +481,46 @@ export function useDashboardInputForm() {
setInputValue(value);
}, []);
+ // Separate stop handler - only stops the workflow without sending new input
+ const handleStop = useCallback(async () => {
+ if (!workflowId) return { success: false, error: 'No workflow to stop' };
+
+ try {
+ const result = await stopWorkflow();
+ return result;
+ } catch (error: any) {
+ return { success: false, error: error.message || 'Failed to stop workflow' };
+ }
+ }, [workflowId, stopWorkflow]);
+
const handleSubmit = useCallback(async () => {
- if (isRunning && workflowId) {
+ const trimmedInput = inputValue.trim();
+
+ // If running and no new input, just stop
+ if (isRunning && workflowId && !trimmedInput) {
try {
- const result = await stopWorkflow();
- if (result.success) {
- resetWorkflow();
- }
+ await stopWorkflow();
} catch (error) {
+ // Ignore stop errors
}
return;
}
- const trimmedInput = inputValue.trim();
+ // If running with new input, stop first then continue with new input
+ if (isRunning && workflowId && trimmedInput) {
+ try {
+ // Stop the current workflow
+ await stopWorkflow();
+ // Continue below to send new input
+ } catch (error) {
+ // Ignore stop errors, try to continue anyway
+ }
+ }
+
+ // No input and not running = nothing to do
+ if (!trimmedInput || startingWorkflow) {
+ return;
+ }
if (!trimmedInput || startingWorkflow) {
return;
}
@@ -737,7 +765,9 @@ export function useDashboardInputForm() {
inputValue,
onInputChange,
handleSubmit,
+ handleStop,
isSubmitting: startingWorkflow || isStopping,
+ isStopping,
workflowId: workflowId || undefined,
workflowStatus,
currentRound,
@@ -746,6 +776,7 @@ export function useDashboardInputForm() {
logs: unifiedContentLogs || [], // Unified content logs (without operationId)
dashboardTree, // Dashboard log tree (logs with operationId)
onToggleOperationExpanded: toggleOperationExpanded,
+ onToggleRoundExpanded: toggleRoundExpanded,
getChildOperations,
workflowItems,
selectedWorkflowId: workflowId || selectedWorkflowId || null,
diff --git a/src/hooks/playground/useDashboardLogTree.ts b/src/hooks/playground/useDashboardLogTree.ts
index b698229..31258dc 100644
--- a/src/hooks/playground/useDashboardLogTree.ts
+++ b/src/hooks/playground/useDashboardLogTree.ts
@@ -9,6 +9,14 @@ interface OperationData {
latestStatus: string | null;
operationName: string | null; // Stable name from first log
latestMessage: string | null; // Latest status message that updates
+ roundNumber: number | null; // Track which round this operation belongs to
+}
+
+interface RoundData {
+ operations: Map;
+ rootOperations: string[];
+ expanded: boolean;
+ isCompleted: boolean;
}
interface DashboardLogTree {
@@ -16,6 +24,7 @@ interface DashboardLogTree {
rootOperations: string[];
logExpandedStates: Map;
currentRound: number | null;
+ rounds: Map;
}
export function useDashboardLogTree() {
@@ -23,7 +32,8 @@ export function useDashboardLogTree() {
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
- currentRound: null
+ currentRound: null,
+ rounds: new Map()
});
const treeRef = useRef(tree);
@@ -42,7 +52,8 @@ export function useDashboardLogTree() {
operations: new Map(prevTree.operations),
rootOperations: [...prevTree.rootOperations],
logExpandedStates: new Map(prevTree.logExpandedStates),
- currentRound: prevTree.currentRound
+ currentRound: prevTree.currentRound,
+ rounds: new Map(prevTree.rounds)
};
// Process each log
@@ -53,6 +64,14 @@ export function useDashboardLogTree() {
const operationId = log.operationId;
const logId = generateLogId(log);
+ const logRoundNumber = (log as any).roundNumber as number | null | undefined;
+
+ // Update current round tracking
+ if (logRoundNumber !== null && logRoundNumber !== undefined) {
+ if (newTree.currentRound === null || logRoundNumber > newTree.currentRound) {
+ newTree.currentRound = logRoundNumber;
+ }
+ }
// Get or create operation
const existingOperation = newTree.operations.get(operationId);
@@ -106,6 +125,11 @@ export function useDashboardLogTree() {
const latestStatus = log.status !== undefined && log.status !== null
? log.status
: existingOperation?.latestStatus ?? null;
+
+ // Get round number for this operation (from log or existing)
+ const roundNumber = logRoundNumber !== null && logRoundNumber !== undefined
+ ? logRoundNumber
+ : existingOperation?.roundNumber ?? null;
// Create new operation object to ensure React detects the change
const operation: OperationData = {
@@ -115,14 +139,74 @@ export function useDashboardLogTree() {
latestProgress,
latestStatus,
operationName,
- latestMessage
+ latestMessage,
+ roundNumber
};
newTree.operations.set(operationId, operation);
+
+ // Add operation to its round
+ if (roundNumber !== null) {
+ if (!newTree.rounds.has(roundNumber)) {
+ newTree.rounds.set(roundNumber, {
+ operations: new Map(),
+ rootOperations: [],
+ expanded: true, // New rounds start expanded
+ isCompleted: false
+ });
+ }
+ const round = newTree.rounds.get(roundNumber)!;
+ round.operations.set(operationId, operation);
+ }
});
- // Rebuild root operations list (operations without parentId)
- // Use Set to ensure uniqueness, then convert back to array
+ // Rebuild root operations list per round
+ newTree.rounds.forEach((round, roundNumber) => {
+ const rootOpsSet = new Set();
+ round.operations.forEach((op, opId) => {
+ if (op.parentId === null) {
+ rootOpsSet.add(opId);
+ } else {
+ // Check if parent is in a different round - then this is a root in THIS round
+ const parentOp = newTree.operations.get(op.parentId);
+ if (!parentOp || parentOp.roundNumber !== roundNumber) {
+ rootOpsSet.add(opId);
+ }
+ }
+ });
+
+ // Sort by timestamp
+ round.rootOperations = Array.from(rootOpsSet).sort((opIdA, opIdB) => {
+ const opA = round.operations.get(opIdA);
+ const opB = round.operations.get(opIdB);
+ if (!opA || !opB) return 0;
+
+ const logsA = Array.from(opA.logs.values());
+ const logsB = Array.from(opB.logs.values());
+
+ if (logsA.length === 0 && logsB.length === 0) return 0;
+ if (logsA.length === 0) return 1;
+ if (logsB.length === 0) return -1;
+
+ const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
+ const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
+
+ return earliestA - earliestB;
+ });
+
+ // Update completion status
+ const allOpsCompleted = Array.from(round.operations.values()).every(op =>
+ op.latestStatus === 'completed' || op.latestStatus === 'success'
+ );
+ round.isCompleted = allOpsCompleted;
+
+ // Auto-collapse completed rounds (except current)
+ if (round.isCompleted && roundNumber !== newTree.currentRound) {
+ round.expanded = false;
+ }
+ });
+
+ // Rebuild global root operations list (operations without parentId)
const rootOpsSet = new Set();
newTree.operations.forEach((op, opId) => {
if (op.parentId === null) {
@@ -135,18 +219,17 @@ export function useDashboardLogTree() {
const opB = newTree.operations.get(opIdB);
if (!opA || !opB) return 0;
- // Get earliest log timestamp for each operation
const logsA = Array.from(opA.logs.values());
const logsB = Array.from(opB.logs.values());
if (logsA.length === 0 && logsB.length === 0) return 0;
- if (logsA.length === 0) return 1; // Put operations without logs at the end
+ if (logsA.length === 0) return 1;
if (logsB.length === 0) return -1;
const earliestA = Math.min(...logsA.map(log => log.timestamp || 0));
const earliestB = Math.min(...logsB.map(log => log.timestamp || 0));
- return earliestA - earliestB; // Ascending order (oldest first)
+ return earliestA - earliestB;
});
return newTree;
@@ -158,7 +241,8 @@ export function useDashboardLogTree() {
operations: new Map(),
rootOperations: [],
logExpandedStates: new Map(),
- currentRound: resetRound ? null : treeRef.current.currentRound
+ currentRound: resetRound ? null : treeRef.current.currentRound,
+ rounds: new Map()
});
}, []);
@@ -187,13 +271,24 @@ export function useDashboardLogTree() {
const updateCurrentRound = useCallback((round: number | null) => {
setTree(prevTree => {
- // Clear dashboard if round changes
+ // Only update current round, keep all rounds data
+ // Auto-collapse previous rounds when new round starts
if (prevTree.currentRound !== null && round !== null && prevTree.currentRound !== round) {
+ const newRounds = new Map(prevTree.rounds);
+
+ // Collapse the old current round
+ const oldRound = newRounds.get(prevTree.currentRound);
+ if (oldRound) {
+ newRounds.set(prevTree.currentRound, {
+ ...oldRound,
+ expanded: false
+ });
+ }
+
return {
- operations: new Map(),
- rootOperations: [],
- logExpandedStates: new Map(),
- currentRound: round
+ ...prevTree,
+ currentRound: round,
+ rounds: newRounds
};
}
@@ -203,6 +298,26 @@ export function useDashboardLogTree() {
};
});
}, []);
+
+ const toggleRoundExpanded = useCallback((roundNumber: number) => {
+ setTree(prevTree => {
+ const round = prevTree.rounds.get(roundNumber);
+ if (!round) {
+ return prevTree;
+ }
+
+ const newRounds = new Map(prevTree.rounds);
+ newRounds.set(roundNumber, {
+ ...round,
+ expanded: !round.expanded
+ });
+
+ return {
+ ...prevTree,
+ rounds: newRounds
+ };
+ });
+ }, []);
const getChildOperations = useCallback((parentId: string | null): string[] => {
const currentTree = treeRef.current;
@@ -231,6 +346,7 @@ export function useDashboardLogTree() {
processDashboardLogs,
clearDashboard,
toggleOperationExpanded,
+ toggleRoundExpanded,
updateCurrentRound,
getChildOperations
};
diff --git a/src/hooks/playground/useWorkflowLifecycle.ts b/src/hooks/playground/useWorkflowLifecycle.ts
index 9e2b997..6690934 100644
--- a/src/hooks/playground/useWorkflowLifecycle.ts
+++ b/src/hooks/playground/useWorkflowLifecycle.ts
@@ -32,6 +32,10 @@ export function useWorkflowLifecycle() {
const statusRef = useRef('idle');
const statusChangedFromRunningAtRef = useRef(null);
const lastRenderedTimestampRef = useRef(null);
+ // Track processed stat IDs to avoid double-counting
+ const processedStatIdsRef = useRef>(new Set());
+ // Track cumulative stats
+ const cumulativeStatsRef = useRef({ priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 });
const { startWorkflow, stopWorkflow, startingWorkflow, stoppingWorkflows } = useWorkflowOperations();
const { request } = useApiRequest();
const pollingController = useWorkflowPolling();
@@ -268,21 +272,52 @@ export function useWorkflowLifecycle() {
return [...allLogs].sort(sortLogs);
});
- // Process stats and keep the latest one (highest createdAt)
+ // Process stats - aggregate only NEW stat entries (avoid double-counting)
const statsItems = timeline.filter(item => item.type === 'stat');
if (statsItems.length > 0) {
- // Sort by createdAt descending to get the latest
- const sortedStats = [...statsItems].sort((a, b) => b.createdAt - a.createdAt);
- const latestStatItem = sortedStats[0];
- const statData = latestStatItem.item || latestStatItem;
+ let hasNewStats = false;
- if (statData && (statData.priceUsd !== undefined || statData.processingTime !== undefined ||
- statData.bytesSent !== undefined || statData.bytesReceived !== undefined)) {
+ statsItems.forEach(statItem => {
+ const statData = statItem.item || statItem;
+ const statId = statData?.id || statItem.id;
+
+ // Skip if already processed
+ if (statId && processedStatIdsRef.current.has(statId)) {
+ return;
+ }
+
+ if (statData) {
+ hasNewStats = true;
+
+ // Mark as processed
+ if (statId) {
+ processedStatIdsRef.current.add(statId);
+ }
+
+ // Add to cumulative stats
+ if (statData.priceUsd !== undefined && statData.priceUsd !== null) {
+ cumulativeStatsRef.current.priceUsd += statData.priceUsd;
+ }
+ if (statData.processingTime !== undefined && statData.processingTime !== null) {
+ cumulativeStatsRef.current.processingTime += statData.processingTime;
+ }
+ if (statData.bytesSent !== undefined && statData.bytesSent !== null) {
+ cumulativeStatsRef.current.bytesSent += statData.bytesSent;
+ }
+ if (statData.bytesReceived !== undefined && statData.bytesReceived !== null) {
+ cumulativeStatsRef.current.bytesReceived += statData.bytesReceived;
+ }
+ }
+ });
+
+ // Update state with cumulative totals
+ if (hasNewStats || (cumulativeStatsRef.current.bytesSent > 0 || cumulativeStatsRef.current.bytesReceived > 0 ||
+ cumulativeStatsRef.current.processingTime > 0 || cumulativeStatsRef.current.priceUsd > 0)) {
setLatestStats({
- priceUsd: statData.priceUsd,
- processingTime: statData.processingTime,
- bytesSent: statData.bytesSent,
- bytesReceived: statData.bytesReceived
+ priceUsd: cumulativeStatsRef.current.priceUsd,
+ processingTime: cumulativeStatsRef.current.processingTime,
+ bytesSent: cumulativeStatsRef.current.bytesSent,
+ bytesReceived: cumulativeStatsRef.current.bytesReceived
});
}
}
@@ -366,6 +401,9 @@ export function useWorkflowLifecycle() {
setDashboardLogs([]);
setUnifiedContentLogs([]);
setLatestStats(null);
+ // Reset stats tracking
+ processedStatIdsRef.current.clear();
+ cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
return;
}
@@ -426,6 +464,9 @@ export function useWorkflowLifecycle() {
setDashboardLogs(prev => prev.length > 0 ? [] : prev);
setUnifiedContentLogs(prev => prev.length > 0 ? [] : prev);
setLatestStats(null);
+ // Reset stats tracking
+ processedStatIdsRef.current.clear();
+ cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setCurrentRound(prev => prev !== undefined ? undefined : prev);
if (statusChangedFromRunningAt !== null) {
setStatusChangedFromRunningAt(null);
@@ -516,6 +557,9 @@ export function useWorkflowLifecycle() {
updateWorkflowStatus('idle');
setCurrentRound(undefined);
setLatestStats(null);
+ // Reset stats tracking
+ processedStatIdsRef.current.clear();
+ cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
setStatusChangedFromRunningAt(null);
statusChangedFromRunningAtRef.current = null;
lastRenderedTimestampRef.current = null;
@@ -525,8 +569,10 @@ export function useWorkflowLifecycle() {
const selectWorkflow = useCallback(async (workflowIdToSelect: string) => {
try {
setWorkflowId(workflowIdToSelect);
- // Reset lastRenderedTimestamp for new workflow selection
+ // Reset lastRenderedTimestamp and stats for new workflow selection
lastRenderedTimestampRef.current = null;
+ processedStatIdsRef.current.clear();
+ cumulativeStatsRef.current = { priceUsd: 0, processingTime: 0, bytesSent: 0, bytesReceived: 0 };
const workflowData = await fetchWorkflowApi(request, workflowIdToSelect).catch(() => null);
diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts
index 6f20195..ae44fda 100644
--- a/src/hooks/useAutomations.ts
+++ b/src/hooks/useAutomations.ts
@@ -56,6 +56,60 @@ export function useAutomations() {
const { request, isLoading: loading, error } = useApiRequest();
const { checkPermission } = usePermissions();
+ // Fallback attributes for automation form
+ const fallbackAttributes: AttributeDefinition[] = [
+ {
+ name: 'label',
+ type: 'text',
+ label: 'Name',
+ required: true,
+ editable: true,
+ visible: true,
+ sortable: true,
+ searchable: true,
+ width: 200,
+ },
+ {
+ name: 'schedule',
+ type: 'text',
+ label: 'Zeitplan (Cron)',
+ required: false,
+ editable: true,
+ visible: true,
+ description: 'z.B. "0 8 * * *" für täglich 8:00 Uhr',
+ width: 150,
+ },
+ {
+ name: 'template',
+ type: 'textarea',
+ label: 'Template',
+ required: false,
+ editable: true,
+ visible: true,
+ width: 200,
+ },
+ {
+ name: 'active',
+ type: 'checkbox',
+ label: 'Aktiv',
+ required: false,
+ editable: true,
+ visible: true,
+ default: true,
+ width: 80,
+ },
+ {
+ name: 'status',
+ type: 'text',
+ label: 'Status',
+ required: false,
+ editable: false,
+ visible: true,
+ readonly: true,
+ width: 100,
+ },
+ ];
+
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
@@ -76,12 +130,17 @@ export function useAutomations() {
}
}
+ // Use fallback if no attributes returned
+ if (attrs.length === 0) {
+ attrs = fallbackAttributes;
+ }
+
setAttributes(attrs);
return attrs;
} catch (error: any) {
- console.error('Error fetching automation attributes:', error);
- setAttributes([]);
- return [];
+ console.error('Error fetching automation attributes, using fallback:', error);
+ setAttributes(fallbackAttributes);
+ return fallbackAttributes;
}
}, []);
diff --git a/src/hooks/useFiles.ts b/src/hooks/useFiles.ts
index d961dbc..9812b26 100644
--- a/src/hooks/useFiles.ts
+++ b/src/hooks/useFiles.ts
@@ -289,6 +289,22 @@ export function useUserFiles() {
fetchPermissions();
}, [fetchAttributes, fetchPermissions]);
+ // Listen for file upload events and refresh the list
+ useEffect(() => {
+ const handleFileUploaded = (event: CustomEvent) => {
+ console.log('📁 File uploaded event received, refreshing list...', event.detail);
+ // Small delay to ensure backend has persisted the file
+ setTimeout(() => {
+ fetchFiles();
+ }, 100);
+ };
+
+ window.addEventListener('fileUploaded', handleFileUploaded as EventListener);
+ return () => {
+ window.removeEventListener('fileUploaded', handleFileUploaded as EventListener);
+ };
+ }, [fetchFiles]);
+
return {
data: files,
loading,
@@ -503,6 +519,9 @@ export function useFileOperations() {
showWarning(t('warning.duplicate_file.title'), message);
}
+ // Dispatch event to notify other components about the new file
+ window.dispatchEvent(new CustomEvent('fileUploaded', { detail: fileData }));
+
return { success: true, fileData };
} catch (error: any) {
console.error('Upload failed:', error);
diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css
index ec020b8..0cc9116 100644
--- a/src/pages/admin/Admin.module.css
+++ b/src/pages/admin/Admin.module.css
@@ -407,3 +407,233 @@
:global(.dark-theme) .modalOverlay {
background: rgba(0, 0, 0, 0.7);
}
+
+/* Danger button */
+.dangerButton {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ background: #dc3545;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.dangerButton:hover {
+ background: #c82333;
+}
+
+/* Template list */
+.templateList {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.templateItem {
+ border: 1px solid var(--border-color, #e0e0e0);
+ padding: 1rem;
+ border-radius: 8px;
+ background: var(--bg-secondary, #f9f9f9);
+}
+
+.templateHeader {
+ margin-bottom: 0.5rem;
+}
+
+.templateTitle {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.templateDescription {
+ margin: 0 0 1rem 0;
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+}
+
+/* Execution status */
+.executionStatus {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ padding: 0.75rem;
+ background: var(--bg-secondary, #f9f9f9);
+ border-radius: 6px;
+}
+
+.statusBadge {
+ padding: 0.25rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-transform: uppercase;
+}
+
+.statusBadge.starting,
+.statusBadge.running {
+ background: #e3f2fd;
+ color: #1976d2;
+}
+
+.statusBadge.completed {
+ background: #e8f5e9;
+ color: #388e3c;
+}
+
+.statusBadge.stopped {
+ background: #fff3e0;
+ color: #f57c00;
+}
+
+.statusBadge.error,
+.statusBadge.failed {
+ background: #ffebee;
+ color: #d32f2f;
+}
+
+.workflowId {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+}
+
+.workflowId code {
+ background: var(--bg-tertiary, #eee);
+ padding: 0.125rem 0.375rem;
+ border-radius: 4px;
+ font-family: monospace;
+}
+
+/* Execution logs */
+.executionLogs {
+ background: var(--bg-tertiary, #f5f5f5);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 6px;
+ padding: 1rem;
+}
+
+.logEntry {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+ border-bottom: 1px solid var(--border-color, #e0e0e0);
+}
+
+.logEntry:last-child {
+ border-bottom: none;
+}
+
+.logTime {
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+.logStatus {
+ color: var(--primary-color, #f25843);
+}
+
+.logMessage {
+ flex: 1;
+ word-break: break-word;
+}
+
+.logProgress {
+ color: var(--text-secondary);
+ flex-shrink: 0;
+}
+
+/* Logs history */
+.logsHistory {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.logHistoryItem {
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 6px;
+ padding: 0.75rem;
+ background: var(--bg-secondary, #f9f9f9);
+}
+
+.logHistoryItem.completed {
+ border-left: 3px solid #388e3c;
+}
+
+.logHistoryItem.error,
+.logHistoryItem.failed {
+ border-left: 3px solid #d32f2f;
+}
+
+.logHistoryItem.stopped {
+ border-left: 3px solid #f57c00;
+}
+
+.logHistoryHeader {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.5rem;
+ flex-wrap: wrap;
+}
+
+.logHistoryDate {
+ font-size: 0.875rem;
+ color: var(--text-primary);
+ font-weight: 500;
+}
+
+.logHistoryMessages {
+ font-size: 0.8125rem;
+ color: var(--text-secondary);
+}
+
+.logHistoryMessage {
+ padding: 0.125rem 0;
+}
+
+/* Status icons */
+.successIcon {
+ color: #388e3c;
+}
+
+.errorIcon {
+ color: #d32f2f;
+}
+
+.warningIcon {
+ color: #f57c00;
+}
+
+.spinningIcon {
+ animation: spin 1s linear infinite;
+}
+
+/* Empty actions */
+.emptyActions {
+ display: flex;
+ gap: 0.75rem;
+ margin-top: 1rem;
+}
+
+/* Spinning animation */
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+:global(.spinning) {
+ animation: spin 1s linear infinite;
+}
diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx
index f725e69..b42f2f0 100644
--- a/src/pages/basedata/FilesPage.tsx
+++ b/src/pages/basedata/FilesPage.tsx
@@ -10,6 +10,7 @@ import { useUserFiles, useFileOperations } from '../../hooks/useFiles';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaFolder, FaUpload, FaDownload, FaEye } from 'react-icons/fa';
+import { useToast } from '../../contexts/ToastContext';
import styles from '../admin/Admin.module.css';
interface UserFile {
@@ -22,6 +23,7 @@ interface UserFile {
export const FilesPage: React.FC = () => {
const fileInputRef = useRef(null);
+ const { showSuccess, showError } = useToast();
// Data hook
const {
@@ -141,15 +143,36 @@ export const FilesPage: React.FC = () => {
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent) => {
const selectedFiles = e.target.files;
- if (selectedFiles) {
+ if (selectedFiles && selectedFiles.length > 0) {
+ let successCount = 0;
+ let errorCount = 0;
+
for (const file of Array.from(selectedFiles)) {
- await handleFileUpload(file);
+ const result = await handleFileUpload(file);
+ if (result?.success) {
+ successCount++;
+ } else {
+ errorCount++;
+ }
}
- refetch();
- // Reset input
+
+ // Reset input first
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
+
+ // Refresh table to show new files
+ await refetch();
+
+ // Show feedback
+ if (successCount > 0) {
+ showSuccess(
+ 'Upload erfolgreich',
+ `${successCount} Datei(en) hochgeladen${errorCount > 0 ? `, ${errorCount} fehlgeschlagen` : ''}`
+ );
+ } else if (errorCount > 0) {
+ showError('Upload fehlgeschlagen', `${errorCount} Datei(en) konnten nicht hochgeladen werden`);
+ }
}
};
diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx
index 47849ff..7297b7b 100644
--- a/src/pages/basedata/PromptsPage.tsx
+++ b/src/pages/basedata/PromptsPage.tsx
@@ -51,19 +51,24 @@ export const PromptsPage: React.FC = () => {
refetch();
}, []);
- // Generate columns from attributes
+ // Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => {
- return (attributes || []).map(attr => ({
- key: attr.name,
- label: attr.label || attr.name,
- type: attr.type as any,
- sortable: attr.sortable !== false,
- filterable: attr.filterable !== false,
- searchable: attr.searchable !== false,
- width: attr.name === 'content' ? 300 : attr.width || 150,
- minWidth: attr.minWidth || 100,
- maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
- }));
+ // Fields to hide in table view
+ const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete'];
+
+ return (attributes || [])
+ .filter(attr => !hiddenColumns.includes(attr.name))
+ .map(attr => ({
+ key: attr.name,
+ label: attr.label || attr.name,
+ type: attr.type as any,
+ sortable: attr.sortable !== false,
+ filterable: attr.filterable !== false,
+ searchable: attr.searchable !== false,
+ width: attr.name === 'content' ? 300 : attr.width || 150,
+ minWidth: attr.minWidth || 100,
+ maxWidth: attr.name === 'content' ? 500 : attr.maxWidth || 400,
+ }));
}, [attributes]);
// Check permissions
diff --git a/src/pages/workflows/AutomationsPage.tsx b/src/pages/workflows/AutomationsPage.tsx
index b5353d5..ee83a73 100644
--- a/src/pages/workflows/AutomationsPage.tsx
+++ b/src/pages/workflows/AutomationsPage.tsx
@@ -2,25 +2,26 @@
* AutomationsPage
*
* Page for viewing and managing workflow automations using FormGeneratorTable.
- * Follows the pattern established in AdminUsersPage/WorkflowsPage.
+ * Includes template selection, execution modal with live logs, and execution history.
*/
-import React, { useState, useMemo, useEffect } from 'react';
-import { useAutomations, useAutomationOperations } from '../../hooks/useAutomations';
+import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
+import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
-import { FaSync, FaRobot, FaPlay, FaPlus, FaToggleOn, FaToggleOff } from 'react-icons/fa';
+import { FaSync, FaRobot, FaPlay, FaPlus, FaToggleOn, FaToggleOff, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
+import { useToast } from '../../contexts/ToastContext';
+import { useApiRequest } from '../../hooks/useApi';
import styles from '../admin/Admin.module.css';
-interface Automation {
+
+
+interface WorkflowLog {
id: string;
- label: string;
- schedule?: string;
- active: boolean;
+ timestamp: number;
+ message: string;
status?: string;
- template?: string;
- placeholders?: any;
- [key: string]: any;
+ progress?: number;
}
export const AutomationsPage: React.FC = () => {
@@ -45,32 +46,90 @@ export const AutomationsPage: React.FC = () => {
handleAutomationExecute,
handleAutomationToggleActive,
handleInlineUpdate,
+ fetchTemplates,
deletingAutomations,
executingAutomations,
- creatingAutomation,
} = useAutomationOperations();
+ const { showSuccess, showError, showInfo } = useToast();
+ const { request } = useApiRequest();
+
+ // Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingAutomation, setEditingAutomation] = useState(null);
+ const [showTemplateModal, setShowTemplateModal] = useState(false);
+ const [templates, setTemplates] = useState([]);
+ const [loadingTemplates, setLoadingTemplates] = useState(false);
+
+ // Execution modal state
+ const [executionModal, setExecutionModal] = useState<{
+ visible: boolean;
+ automationId: string | null;
+ automationLabel: string;
+ workflowId: string | null;
+ status: 'starting' | 'running' | 'completed' | 'stopped' | 'error';
+ logs: WorkflowLog[];
+ }>({
+ visible: false,
+ automationId: null,
+ automationLabel: '',
+ workflowId: null,
+ status: 'starting',
+ logs: [],
+ });
+
+ // Logs modal state
+ const [logsModal, setLogsModal] = useState<{
+ visible: boolean;
+ automation: Automation | null;
+ }>({
+ visible: false,
+ automation: null,
+ });
+
+ // Refs for polling
+ const pollIntervalRef = useRef(null);
+ const lastLogIdRef = useRef(null);
+ const logContainerRef = useRef(null);
// Initial fetch
useEffect(() => {
refetch();
}, []);
- // Generate columns from attributes
+ // Cleanup polling on unmount
+ useEffect(() => {
+ return () => {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ }
+ };
+ }, []);
+
+ // Auto-scroll logs
+ useEffect(() => {
+ if (logContainerRef.current) {
+ logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
+ }
+ }, [executionModal.logs]);
+
+ // Generate columns from attributes - exclude ID fields from display
const columns = useMemo(() => {
- return (attributes || []).map(attr => ({
- key: attr.name,
- label: attr.label || attr.name,
- type: attr.type as any,
- sortable: attr.sortable !== false,
- filterable: attr.filterable !== false,
- searchable: attr.searchable !== false,
- width: attr.width || 150,
- minWidth: attr.minWidth || 100,
- maxWidth: attr.maxWidth || 400,
- }));
+ const hiddenColumns = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'template', 'executionLogs'];
+
+ return (attributes || [])
+ .filter(attr => !hiddenColumns.includes(attr.name))
+ .map(attr => ({
+ key: attr.name,
+ label: attr.label || attr.name,
+ type: attr.type as any,
+ sortable: attr.sortable !== false,
+ filterable: attr.filterable !== false,
+ searchable: attr.searchable !== false,
+ width: attr.width || 150,
+ minWidth: attr.minWidth || 100,
+ maxWidth: attr.maxWidth || 400,
+ }));
}, [attributes]);
// Check permissions
@@ -91,6 +150,7 @@ export const AutomationsPage: React.FC = () => {
const result = await handleAutomationCreate(data as any);
if (result) {
setShowCreateModal(false);
+ showSuccess('Automatisierung erstellt');
refetch();
}
};
@@ -98,9 +158,10 @@ export const AutomationsPage: React.FC = () => {
// Handle edit submit
const handleEditSubmit = async (data: Partial) => {
if (!editingAutomation) return;
- const success = await handleAutomationUpdate(editingAutomation.id, data);
+ const success = await handleAutomationUpdate(editingAutomation.id, data as any);
if (success) {
setEditingAutomation(null);
+ showSuccess('Automatisierung aktualisiert');
refetch();
}
};
@@ -110,41 +171,260 @@ export const AutomationsPage: React.FC = () => {
if (window.confirm(`Möchten Sie die Automatisierung "${automation.label}" wirklich löschen?`)) {
const success = await handleAutomationDelete(automation.id);
if (success) {
+ showSuccess('Automatisierung gelöscht');
refetch();
}
}
};
- // Handle execute automation
- const handleExecute = async (automation: Automation) => {
+ // Load templates
+ const handleLoadTemplates = async () => {
+ setLoadingTemplates(true);
try {
- await handleAutomationExecute(automation.id);
- // Show success feedback (could use toast)
- console.log('Automation started:', automation.label);
- } catch (err: any) {
- console.error('Error executing automation:', err);
+ const loadedTemplates = await fetchTemplates();
+ setTemplates(loadedTemplates);
+ if (loadedTemplates.length === 0) {
+ showInfo('Keine Vorlagen verfügbar');
+ } else {
+ setShowTemplateModal(true);
+ }
+ } catch (err) {
+ showError('Fehler beim Laden der Vorlagen');
+ } finally {
+ setLoadingTemplates(false);
}
};
+ // Handle template selection
+ const handleTemplateSelect = async (template: AutomationTemplate) => {
+ setShowTemplateModal(false);
+
+ // Pre-fill form with template data
+ const prefillData: Partial = {
+ label: template.template?.overview || 'Neue Automatisierung',
+ template: JSON.stringify(template.template, null, 2),
+ placeholders: template.parameters || {},
+ active: false,
+ schedule: '0 */4 * * *',
+ };
+
+ // Create automation directly
+ const result = await handleAutomationCreate(prefillData as any);
+ if (result) {
+ showSuccess('Automatisierung aus Vorlage erstellt');
+ refetch();
+ }
+ };
+
+ // Poll workflow logs
+ const pollWorkflowLogs = useCallback(async (workflowId: string) => {
+ try {
+ const response = await request({
+ url: `/api/workflows/${workflowId}/logs`,
+ method: 'get',
+ params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {},
+ });
+
+ const logs: WorkflowLog[] = response?.items || response || [];
+
+ if (logs.length > 0) {
+ setExecutionModal(prev => ({
+ ...prev,
+ logs: [...prev.logs, ...logs],
+ }));
+ lastLogIdRef.current = logs[logs.length - 1].id;
+ }
+
+ // Check workflow status
+ const statusResponse = await request({
+ url: `/api/workflows/${workflowId}`,
+ method: 'get',
+ });
+
+ const workflowStatus = statusResponse?.status;
+
+ if (workflowStatus === 'completed' || workflowStatus === 'stopped' || workflowStatus === 'error' || workflowStatus === 'failed') {
+ // Stop polling
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ pollIntervalRef.current = null;
+ }
+
+ setExecutionModal(prev => ({
+ ...prev,
+ status: workflowStatus === 'completed' ? 'completed' :
+ workflowStatus === 'error' || workflowStatus === 'failed' ? 'error' : 'stopped',
+ }));
+
+ if (workflowStatus === 'completed') {
+ showSuccess('Automatisierung erfolgreich abgeschlossen');
+ } else if (workflowStatus === 'error' || workflowStatus === 'failed') {
+ showError('Automatisierung fehlgeschlagen');
+ } else {
+ showInfo('Automatisierung gestoppt');
+ }
+
+ refetch();
+ }
+ } catch (err) {
+ console.error('Error polling workflow logs:', err);
+ }
+ }, [request, refetch, showSuccess, showError, showInfo]);
+
+ // Handle execute automation with modal
+ const handleExecute = async (automation: Automation) => {
+ // Reset and show modal
+ lastLogIdRef.current = null;
+ setExecutionModal({
+ visible: true,
+ automationId: automation.id,
+ automationLabel: automation.label,
+ workflowId: null,
+ status: 'starting',
+ logs: [{
+ id: 'init',
+ timestamp: Date.now() / 1000,
+ message: 'Automatisierung wird gestartet...',
+ }],
+ });
+
+ try {
+ const result = await handleAutomationExecute(automation.id);
+ const workflowId = result?.id;
+
+ if (workflowId) {
+ setExecutionModal(prev => ({
+ ...prev,
+ workflowId,
+ status: 'running',
+ logs: [...prev.logs, {
+ id: 'started',
+ timestamp: Date.now() / 1000,
+ message: `Workflow ${workflowId} gestartet`,
+ status: 'running',
+ }],
+ }));
+
+ // Start polling
+ pollIntervalRef.current = setInterval(() => {
+ pollWorkflowLogs(workflowId);
+ }, 2000);
+ }
+ } catch (err: any) {
+ setExecutionModal(prev => ({
+ ...prev,
+ status: 'error',
+ logs: [...prev.logs, {
+ id: 'error',
+ timestamp: Date.now() / 1000,
+ message: `Fehler: ${err.message || 'Unbekannter Fehler'}`,
+ status: 'error',
+ }],
+ }));
+ showError(`Fehler beim Ausführen: ${err.message}`);
+ }
+ };
+
+ // Handle stop workflow
+ const handleStopWorkflow = async () => {
+ if (!executionModal.workflowId) return;
+
+ try {
+ await request({
+ url: `/api/workflows/${executionModal.workflowId}/stop`,
+ method: 'post',
+ });
+
+ setExecutionModal(prev => ({
+ ...prev,
+ logs: [...prev.logs, {
+ id: 'stopping',
+ timestamp: Date.now() / 1000,
+ message: 'Workflow wird gestoppt...',
+ }],
+ }));
+ } catch (err: any) {
+ showError(`Fehler beim Stoppen: ${err.message}`);
+ }
+ };
+
+ // Close execution modal
+ const closeExecutionModal = () => {
+ if (pollIntervalRef.current) {
+ clearInterval(pollIntervalRef.current);
+ pollIntervalRef.current = null;
+ }
+ setExecutionModal({
+ visible: false,
+ automationId: null,
+ automationLabel: '',
+ workflowId: null,
+ status: 'starting',
+ logs: [],
+ });
+ };
+
// Handle toggle active
const handleToggleActive = async (automation: Automation) => {
- // Optimistic update
updateOptimistically(automation.id, { active: !automation.active });
const success = await handleAutomationToggleActive(automation.id, automation.active);
- if (!success) {
- // Revert on failure
+ if (success) {
+ showSuccess(automation.active ? 'Automatisierung deaktiviert' : 'Automatisierung aktiviert');
+ } else {
updateOptimistically(automation.id, { active: automation.active });
+ showError('Fehler beim Ändern des Status');
}
};
+ // Show logs modal
+ const handleShowLogs = async (automation: Automation) => {
+ const fullAutomation = await fetchAutomationById(automation.id);
+ setLogsModal({
+ visible: true,
+ automation: fullAutomation as Automation || automation,
+ });
+ };
+
// Form attributes for create/edit modal
const formAttributes = useMemo(() => {
- const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status'];
+ const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status', 'executionLogs'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
+ // Format timestamp
+ const formatTimestamp = (timestamp: number) => {
+ if (!timestamp) return '';
+ const date = new Date(timestamp * 1000);
+ return date.toLocaleString('de-DE');
+ };
+
+ // Format time only
+ const formatTime = (timestamp: number) => {
+ if (!timestamp) return '';
+ const date = new Date(timestamp * 1000);
+ return date.toLocaleTimeString('de-DE');
+ };
+
+ // Get status icon
+ const getStatusIcon = (status: string) => {
+ switch (status) {
+ case 'completed':
+ return ;
+ case 'error':
+ case 'failed':
+ return ;
+ case 'running':
+ case 'starting':
+ return ;
+ case 'stopped':
+ return ;
+ default:
+ return null;
+ }
+ };
+
if (error) {
return (
@@ -175,12 +455,21 @@ export const AutomationsPage: React.FC = () => {
Aktualisieren
{canCreate && (
- setShowCreateModal(true)}
- >
- Neue Automatisierung
-
+ <>
+
+ {loadingTemplates ? 'Lädt...' : 'Aus Vorlage'}
+
+ setShowCreateModal(true)}
+ >
+ Neue Automatisierung
+
+ >
)}
@@ -199,17 +488,25 @@ export const AutomationsPage: React.FC = () => {
Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.
{canCreate && (
- setShowCreateModal(true)}
- >
- Erste Automatisierung erstellen
-
+
+
+ Aus Vorlage erstellen
+
+ setShowCreateModal(true)}
+ >
+ Manuell erstellen
+
+
)}
) : (
{
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
- loading: (row: Automation) => deletingAutomations.has(row.id),
+ loading: (row: any) => deletingAutomations.has(row.id),
}] : []),
]}
customActions={[
@@ -236,14 +533,20 @@ export const AutomationsPage: React.FC = () => {
icon: ,
onClick: handleExecute,
title: 'Ausführen',
- loading: (row: Automation) => executingAutomations.has(row.id),
+ loading: (row: any) => executingAutomations.has(row.id),
},
{
id: 'toggleActive',
- icon: (row: Automation) => row.active ? : ,
+ icon: (row: any) => row.active ? : ,
onClick: handleToggleActive,
- title: (row: Automation) => row.active ? 'Deaktivieren' : 'Aktivieren',
+ title: (row: any) => row.active ? 'Deaktivieren' : 'Aktivieren',
} as any,
+ {
+ id: 'logs',
+ icon: ,
+ onClick: handleShowLogs,
+ title: 'Ausführungsverlauf',
+ },
]}
onDelete={handleDelete}
hookData={{
@@ -265,11 +568,8 @@ export const AutomationsPage: React.FC = () => {
e.stopPropagation()}>
Neue Automatisierung
- setShowCreateModal(false)}
- >
- ✕
+ setShowCreateModal(false)}>
+
@@ -299,11 +599,8 @@ export const AutomationsPage: React.FC = () => {
e.stopPropagation()}>
Automatisierung bearbeiten
- setEditingAutomation(null)}
- >
- ✕
+ setEditingAutomation(null)}>
+
@@ -327,6 +624,166 @@ export const AutomationsPage: React.FC = () => {
)}
+
+ {/* Template Selection Modal */}
+ {showTemplateModal && (
+
setShowTemplateModal(false)}>
+
e.stopPropagation()}>
+
+
Vorlage auswählen
+ setShowTemplateModal(false)}>
+
+
+
+
+
+ {templates.map((template, index) => (
+
+
+
+ {template.template?.overview || `Vorlage ${index + 1}`}
+
+
+
+ {template.template?.tasks?.[0]?.description ||
+ template.template?.tasks?.[0]?.objective ||
+ 'Keine Beschreibung'}
+
+
handleTemplateSelect(template)}
+ >
+ Verwenden
+
+
+ ))}
+
+
+
+ setShowTemplateModal(false)}>
+ Abbrechen
+
+
+
+
+ )}
+
+ {/* Execution Modal */}
+ {executionModal.visible && (
+
+
e.stopPropagation()} style={{ maxWidth: '700px' }}>
+
+
+ {getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel}
+
+
+
+
+
+
+
+
+ {executionModal.status === 'starting' && 'Wird gestartet...'}
+ {executionModal.status === 'running' && 'Läuft...'}
+ {executionModal.status === 'completed' && 'Abgeschlossen'}
+ {executionModal.status === 'stopped' && 'Gestoppt'}
+ {executionModal.status === 'error' && 'Fehler'}
+
+ {executionModal.workflowId && (
+
+ Workflow: {executionModal.workflowId}
+
+ )}
+
+
+ {executionModal.logs.map((log, index) => (
+
+ [{formatTime(log.timestamp)}]
+ {log.status && {log.status}: }
+ {log.message}
+ {log.progress !== undefined && log.progress !== null && log.progress < 1 && (
+ ({Math.round(log.progress * 100)}%)
+ )}
+
+ ))}
+
+
+
+ {executionModal.status === 'running' && (
+
+ Stoppen
+
+ )}
+
+ Schliessen
+
+
+
+
+ )}
+
+ {/* Logs History Modal */}
+ {logsModal.visible && logsModal.automation && (
+
setLogsModal({ visible: false, automation: null })}>
+
e.stopPropagation()} style={{ maxWidth: '700px' }}>
+
+
+ Ausführungsverlauf: {logsModal.automation.label}
+
+ setLogsModal({ visible: false, automation: null })}
+ >
+
+
+
+
+ {(!logsModal.automation.executionLogs || logsModal.automation.executionLogs.length === 0) ? (
+
+
Keine Ausführungen vorhanden
+
+ ) : (
+
+ {[...logsModal.automation.executionLogs].reverse().map((log, index) => (
+
+
+ {formatTimestamp(log.timestamp)}
+
+ {log.status || 'Unbekannt'}
+
+ {log.workflowId && (
+
+ Workflow: {log.workflowId}
+
+ )}
+
+ {log.messages && log.messages.length > 0 && (
+
+ {log.messages.map((msg, msgIndex) => (
+
{msg}
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+
+ setLogsModal({ visible: false, automation: null })}
+ >
+ Schliessen
+
+
+
+
+ )}
);
};
diff --git a/src/pages/workflows/PlaygroundPage.module.css b/src/pages/workflows/PlaygroundPage.module.css
index 418c9ef..cbb271b 100644
--- a/src/pages/workflows/PlaygroundPage.module.css
+++ b/src/pages/workflows/PlaygroundPage.module.css
@@ -491,3 +491,96 @@
transform: rotate(360deg);
}
}
+
+/* Drag & Drop Styles */
+.dragOver {
+ position: relative;
+}
+
+.dragOverlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(var(--primary-rgb, 242, 88, 67), 0.1);
+ border: 2px dashed var(--primary-color, #f25843);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ pointer-events: none;
+}
+
+.dragOverlayContent {
+ text-align: center;
+ color: var(--primary-color, #f25843);
+ font-size: 1rem;
+ font-weight: 500;
+}
+
+.dragOverFooter {
+ border-color: var(--primary-color, #f25843);
+ background: rgba(var(--primary-rgb, 242, 88, 67), 0.05);
+}
+
+/* Prompts Row */
+.promptsRow {
+ display: flex;
+ align-items: center;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--border-color);
+ margin-bottom: 0.75rem;
+}
+
+.promptsSelect {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ max-width: 400px;
+}
+
+.promptDropdown {
+ flex: 1;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--surface-color);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ cursor: pointer;
+}
+
+.promptDropdown:focus {
+ outline: none;
+ border-color: var(--primary-color, #f25843);
+}
+
+.promptDropdown:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Voice Recording Button */
+.iconButton.recording {
+ background: var(--danger-color, #e53e3e);
+ border-color: var(--danger-color, #e53e3e);
+ color: white;
+ animation: pulse 1.5s infinite;
+}
+
+.iconButton.recording:hover {
+ background: #c53030;
+ border-color: #c53030;
+ color: white;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ box-shadow: 0 0 0 0 rgba(229, 62, 62, 0.4);
+ }
+ 50% {
+ box-shadow: 0 0 0 8px rgba(229, 62, 62, 0);
+ }
+}
diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx
index 3fbe661..5c45d3b 100644
--- a/src/pages/workflows/PlaygroundPage.tsx
+++ b/src/pages/workflows/PlaygroundPage.tsx
@@ -3,27 +3,41 @@
*
* Global page for workflow execution and chat interaction.
* Features a resizable two-column layout with chat on the left and dashboard on the right.
+ * Includes: Drag & Drop file upload, Prompts selection, Voice input
*/
-import React, { useRef } from 'react';
+import React, { useRef, useState, useEffect, useCallback } from 'react';
+import { useSearchParams } from 'react-router-dom';
import { useDashboardInputForm } from '../../hooks/usePlayground';
import { useUserWorkflows } from '../../hooks/useWorkflows';
import { useResizablePanels } from '../../hooks/useResizablePanels';
-import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus } from 'react-icons/fa';
+import { usePrompts } from '../../hooks/usePrompts';
+import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
+import { useToast } from '../../contexts/ToastContext';
+import api from '../../api';
import styles from './PlaygroundPage.module.css';
export const PlaygroundPage: React.FC = () => {
+ // Read workflowId from URL query parameters
+ const [searchParams] = useSearchParams();
+ const urlWorkflowId = searchParams.get('workflowId');
+
// Main hook for input form and data
const hookData = useDashboardInputForm();
const {
inputValue,
onInputChange,
isRunning,
+ isStopping,
handleSubmit,
+ handleStop,
isSubmitting,
+ workflowStatus,
messages,
dashboardTree,
onToggleOperationExpanded,
+ onToggleRoundExpanded,
+ currentRound,
workflowId,
onWorkflowSelect,
workflowItems,
@@ -34,6 +48,8 @@ export const PlaygroundPage: React.FC = () => {
} = hookData;
const { data: workflows } = useUserWorkflows();
+ const { prompts, refetch: refetchPrompts } = usePrompts();
+ const { showError, showSuccess } = useToast();
// Resizable panels hook
const {
@@ -51,6 +67,214 @@ export const PlaygroundPage: React.FC = () => {
// File input ref for hidden file input
const fileInputRef = useRef
(null);
+ // Drag & Drop state
+ const [isDragOver, setIsDragOver] = useState(false);
+ const dragCounterRef = useRef(0);
+
+ // Voice recording state
+ const [isRecording, setIsRecording] = useState(false);
+ const [mediaRecorder, setMediaRecorder] = useState(null);
+ const [audioChunks, setAudioChunks] = useState([]);
+
+ // Prompts dropdown state
+ const [selectedPromptId, setSelectedPromptId] = useState('');
+
+ // Load prompts on mount
+ useEffect(() => {
+ refetchPrompts();
+ }, []);
+
+ // Load workflow from URL parameter
+ const urlWorkflowLoadedRef = useRef(false);
+
+ // Debug: Log URL parameter status
+ useEffect(() => {
+ console.log('🔍 PlaygroundPage URL debug:', {
+ urlWorkflowId,
+ currentWorkflowId: workflowId,
+ hasOnWorkflowSelect: !!onWorkflowSelect,
+ alreadyLoaded: urlWorkflowLoadedRef.current,
+ fullUrl: window.location.href
+ });
+ }, [urlWorkflowId, workflowId, onWorkflowSelect]);
+
+ useEffect(() => {
+ // Only load once on mount, and only if we have a URL workflowId
+ if (urlWorkflowId && !urlWorkflowLoadedRef.current && onWorkflowSelect) {
+ urlWorkflowLoadedRef.current = true;
+ console.log('🔗 Loading workflow from URL:', urlWorkflowId);
+ // Small delay to ensure hooks are initialized
+ setTimeout(() => {
+ onWorkflowSelect({ id: urlWorkflowId, label: '', value: urlWorkflowId });
+ }, 100);
+ }
+ }, [urlWorkflowId, onWorkflowSelect]);
+
+ // Format bytes helper
+ const formatBytes = (bytes: number): string => {
+ if (!bytes || bytes < 0) return '0 B';
+ if (bytes < 1024) return `${bytes} B`;
+ const kbytes = bytes / 1024;
+ if (kbytes < 1000) return `${Math.round(kbytes)} kB`;
+ const mbytes = kbytes / 1024;
+ return `${Math.round(mbytes * 10) / 10} MB`;
+ };
+
+ // Format duration helper (for stats)
+ const formatDuration = (seconds: number): string => {
+ if (!seconds || seconds < 0) return '0s';
+ if (seconds < 60) return `${Math.round(seconds)}s`;
+ const minutes = Math.floor(seconds / 60);
+ const remainingSeconds = Math.round(seconds % 60);
+ return `${minutes}m ${remainingSeconds}s`;
+ };
+
+ // Handle prompt selection
+ const handlePromptSelect = (promptId: string) => {
+ setSelectedPromptId(promptId);
+ if (promptId) {
+ const prompt = prompts?.find((p: any) => p.id === promptId);
+ if (prompt && prompt.content) {
+ // Append prompt content to input
+ const currentText = inputValue || '';
+ const newText = currentText ? `${currentText}\n\n${prompt.content}` : prompt.content;
+ onInputChange(newText);
+ }
+ }
+ };
+
+ // Drag & Drop handlers
+ const handleDragEnter = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current++;
+ if (e.dataTransfer.types.includes('Files')) {
+ setIsDragOver(true);
+ }
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current--;
+ if (dragCounterRef.current === 0) {
+ setIsDragOver(false);
+ }
+ }, []);
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }, []);
+
+ const handleDrop = useCallback(async (e: React.DragEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ dragCounterRef.current = 0;
+ setIsDragOver(false);
+
+ const files = e.dataTransfer.files;
+ if (files.length > 0 && hookData.handleFileUpload) {
+ for (const file of Array.from(files)) {
+ await hookData.handleFileUpload(file);
+ }
+ }
+ }, [hookData.handleFileUpload]);
+
+ // Voice recording handlers
+ const startRecording = async () => {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+
+ // Find supported MIME type
+ const mimeTypes = [
+ 'audio/webm;codecs=opus',
+ 'audio/webm',
+ 'audio/ogg;codecs=opus',
+ 'audio/mp4',
+ ];
+ let mimeType = '';
+ for (const type of mimeTypes) {
+ if (MediaRecorder.isTypeSupported(type)) {
+ mimeType = type;
+ break;
+ }
+ }
+
+ const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
+ const chunks: Blob[] = [];
+
+ recorder.ondataavailable = (e) => {
+ if (e.data.size > 0) {
+ chunks.push(e.data);
+ }
+ };
+
+ recorder.onstop = async () => {
+ // Stop all tracks
+ stream.getTracks().forEach(track => track.stop());
+
+ // Process recording
+ if (chunks.length > 0) {
+ const audioBlob = new Blob(chunks, { type: mimeType || 'audio/webm' });
+ await processVoiceRecording(audioBlob);
+ }
+ };
+
+ recorder.start();
+ setMediaRecorder(recorder);
+ setAudioChunks([]);
+ setIsRecording(true);
+ } catch (error: any) {
+ console.error('Error starting recording:', error);
+ showError('Mikrofonzugriff verweigert', 'Bitte erlauben Sie den Mikrofonzugriff in Ihren Browser-Einstellungen.');
+ }
+ };
+
+ const stopRecording = () => {
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
+ mediaRecorder.stop();
+ setIsRecording(false);
+ setMediaRecorder(null);
+ }
+ };
+
+ const processVoiceRecording = async (audioBlob: Blob) => {
+ try {
+ // Create FormData for speech-to-text API
+ const formData = new FormData();
+ formData.append('file', audioBlob, 'voice_recording.webm');
+ formData.append('language', 'de-DE');
+
+ // Call speech-to-text API
+ const response = await api.post('/api/ai/speech-to-text', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+
+ if (response.data?.success && response.data?.text) {
+ const transcribedText = response.data.text.trim();
+ // Append transcribed text to input
+ const currentText = inputValue || '';
+ const newText = currentText ? `${currentText} ${transcribedText}` : transcribedText;
+ onInputChange(newText);
+ showSuccess('Transkription erfolgreich', 'Text wurde hinzugefügt.');
+ } else {
+ showError('Transkription fehlgeschlagen', response.data?.error || 'Unbekannter Fehler');
+ }
+ } catch (error: any) {
+ console.error('Error processing voice recording:', error);
+ showError('Transkription fehlgeschlagen', error.message || 'Fehler bei der Sprachverarbeitung');
+ }
+ };
+
+ const handleVoiceClick = () => {
+ if (isRecording) {
+ stopRecording();
+ } else {
+ startRecording();
+ }
+ };
+
// Simple wrapper for workflow selection
const handleWorkflowChange = (id: string | null) => {
if (!id) {
@@ -144,9 +368,13 @@ export const PlaygroundPage: React.FC = () => {
);
};
- // Render dashboard tree
+ // Render dashboard tree with rounds
const renderDashboard = () => {
- if (!dashboardTree || dashboardTree.rootOperations.length === 0) {
+ // Check if we have rounds data
+ const hasRounds = dashboardTree && dashboardTree.rounds && dashboardTree.rounds.size > 0;
+ const hasOperations = dashboardTree && dashboardTree.rootOperations.length > 0;
+
+ if (!hasRounds && !hasOperations) {
return (
@@ -157,8 +385,8 @@ export const PlaygroundPage: React.FC = () => {
);
}
- const renderOperation = (operationId: string, depth: number = 0) => {
- const operation = dashboardTree.operations.get(operationId);
+ const renderOperation = (operationId: string, depth: number = 0, roundOperations?: Map) => {
+ const operation = roundOperations?.get(operationId) || dashboardTree.operations.get(operationId);
if (!operation) return null;
const childOps = Array.from(dashboardTree.operations.entries())
@@ -202,23 +430,31 @@ export const PlaygroundPage: React.FC = () => {
}}>
{operation.operationName || operationId.slice(0, 20)}
+ {operation.latestProgress !== null && operation.latestProgress < 1 && (
+
+ {Math.round(operation.latestProgress * 100)}%
+
+ )}
{operation.latestStatus && (
@@ -228,13 +464,93 @@ export const PlaygroundPage: React.FC = () => {
{operation.expanded && childOps.length > 0 && (
- {childOps.map(childId => renderOperation(childId, depth + 1))}
+ {childOps.map(childId => renderOperation(childId, depth + 1, roundOperations))}
)}
);
};
+ // If we have rounds, render them
+ if (hasRounds) {
+ const sortedRounds = Array.from(dashboardTree.rounds.entries()).sort((a, b) => a[0] - b[0]);
+
+ return (
+
+ {sortedRounds.map(([roundNumber, round]) => (
+
+ {/* Round Header */}
+
onToggleRoundExpanded(roundNumber)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.5rem',
+ padding: '0.5rem 0.75rem',
+ background: roundNumber === currentRound
+ ? 'var(--primary-bg, #eff6ff)'
+ : 'var(--bg-secondary)',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ marginBottom: round.expanded ? '0.5rem' : '0',
+ }}
+ >
+
+ ▶
+
+
+ Runde {roundNumber}
+
+ {round.isCompleted && (
+
+ abgeschlossen
+
+ )}
+ {roundNumber === currentRound && !round.isCompleted && (
+
+ aktiv
+
+ )}
+
+ {/* Round Operations */}
+ {round.expanded && (
+
+ {round.rootOperations.map(opId => renderOperation(opId, 0, round.operations))}
+
+ )}
+
+ ))}
+
+ );
+ }
+
+ // Fallback: render without rounds (for backward compatibility)
return (
{dashboardTree.rootOperations.map(opId => renderOperation(opId))}
@@ -242,8 +558,11 @@ export const PlaygroundPage: React.FC = () => {
);
};
- // Permission check
- if (!playgroundUIPermission) {
+ // Debug: Log permission status
+ console.log('🔐 PlaygroundPage permission check:', { playgroundUIPermission });
+
+ // Permission check - also show while loading
+ if (playgroundUIPermission === false) {
return (
@@ -255,6 +574,17 @@ export const PlaygroundPage: React.FC = () => {
);
}
+
+ // Show loading state while permission is being checked (undefined)
+ if (playgroundUIPermission === undefined) {
+ return (
+
+ );
+ }
return (
@@ -290,11 +620,24 @@ export const PlaygroundPage: React.FC = () => {
- {/* Main Content - Resizable Two-Column Layout */}
+ {/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
+ {/* Drag overlay */}
+ {isDragOver && (
+
+
+
+
Dateien hier ablegen
+
+
+ )}
{/* Left Panel - Chat Messages */}
{
{/* Input Footer */}
-
+
+ {/* Prompts Selection Row */}
+
+
+
+ handlePromptSelect(e.target.value)}
+ disabled={isRunning}
+ >
+ Prompt-Vorlage wählen...
+ {prompts?.map((prompt: any) => (
+
+ {prompt.name || prompt.content?.substring(0, 50) + '...'}
+
+ ))}
+
+
+
+
{/* Pending files */}
{pendingFiles && pendingFiles.length > 0 && (
@@ -360,21 +729,29 @@ export const PlaygroundPage: React.FC = () => {
)}
{/* Stats bar */}
- {latestStats && (
+ {latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && (
- {latestStats.promptTokens !== undefined && (
+ {(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && (
- Tokens:
+ Daten:
- {latestStats.promptTokens + (latestStats.completionTokens || 0)}
+ {formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)}
)}
- {latestStats.totalCost !== undefined && (
+ {latestStats.processingTime !== undefined && latestStats.processingTime > 0 && (
+
+ Zeit:
+
+ {formatDuration(latestStats.processingTime)}
+
+
+ )}
+ {latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && (
Kosten:
- ${latestStats.totalCost.toFixed(4)}
+ ${latestStats.priceUsd.toFixed(4)}
)}
@@ -389,8 +766,20 @@ export const PlaygroundPage: React.FC = () => {
className={styles.inputTextarea}
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
- placeholder="Geben Sie Ihre Nachricht ein..."
- disabled={isRunning && !workflowId}
+ placeholder={
+ isRunning
+ ? "Workflow läuft. Neue Eingabe zum Unterbrechen und Neustarten..."
+ : workflowStatus === 'completed'
+ ? "Workflow abgeschlossen. Neue Eingabe zum Fortsetzen..."
+ : workflowStatus === 'failed'
+ ? "Workflow fehlgeschlagen. Neue Eingabe zum Wiederholen..."
+ : workflowStatus === 'stopped'
+ ? "Workflow gestoppt. Neue Eingabe zum Fortfahren..."
+ : !workflowId
+ ? "Geben Sie einen Prompt ein, um zu starten..."
+ : "Geben Sie Ihre Nachricht ein oder ziehen Sie Dateien hierher..."
+ }
+ disabled={false}
rows={3}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -405,34 +794,51 @@ export const PlaygroundPage: React.FC = () => {
+
+ {isRecording ? : }
+
- {isRunning ? (
+ {/* Stop button - only visible when running */}
+ {isRunning && (
- {isSubmitting ? 'Stoppt...' : 'Stoppen'}
-
- ) : (
-
-
- {isSubmitting ? 'Senden...' : 'Senden'}
+ {isStopping ? 'Stoppt...' : 'Stop'}
)}
+ {/* Send button - always visible with dynamic text */}
+
+
+ {isSubmitting
+ ? 'Senden...'
+ : isRunning
+ ? 'Neue Eingabe'
+ : !workflowId
+ ? 'Starten'
+ : 'Senden'
+ }
+
diff --git a/src/pages/workflows/WorkflowsPage.tsx b/src/pages/workflows/WorkflowsPage.tsx
index 66e4608..dff712b 100644
--- a/src/pages/workflows/WorkflowsPage.tsx
+++ b/src/pages/workflows/WorkflowsPage.tsx
@@ -5,7 +5,7 @@
* Follows the pattern established in AdminUsersPage.
*/
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
import { useUserWorkflows, useWorkflowOperations } from '../../hooks/useWorkflows';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
@@ -49,6 +49,11 @@ export const WorkflowsPage: React.FC = () => {
const [editingWorkflow, setEditingWorkflow] = useState
(null);
+ // Initial fetch on mount
+ useEffect(() => {
+ refetch();
+ }, []);
+
// Generate columns from attributes
const columns = useMemo(() => {
return (attributes || []).map(attr => ({
@@ -80,7 +85,7 @@ export const WorkflowsPage: React.FC = () => {
// Handle continue workflow - navigate to playground
const handleContinueWorkflow = (workflow: Workflow) => {
- navigate(`/playground?workflowId=${workflow.id}`);
+ navigate(`/workflows/playground?workflowId=${workflow.id}`);
};
// Handle edit submit