reference fixes

This commit is contained in:
ValueOn AG 2026-01-24 00:42:13 +01:00
parent dc4b475728
commit cc8770dec3
12 changed files with 1645 additions and 155 deletions

View file

@ -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,

View file

@ -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<string, OperationData>;
rootOperations: string[];
expanded: boolean;
isCompleted: boolean;
}
interface DashboardLogTree {
@ -16,6 +24,7 @@ interface DashboardLogTree {
rootOperations: string[];
logExpandedStates: Map<string, boolean>;
currentRound: number | null;
rounds: Map<number, RoundData>;
}
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<DashboardLogTree>(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<string>();
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<string>();
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
};

View file

@ -32,6 +32,10 @@ export function useWorkflowLifecycle() {
const statusRef = useRef<string>('idle');
const statusChangedFromRunningAtRef = useRef<number | null>(null);
const lastRenderedTimestampRef = useRef<number | null>(null);
// Track processed stat IDs to avoid double-counting
const processedStatIdsRef = useRef<Set<string>>(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);

View file

@ -56,6 +56,60 @@ export function useAutomations() {
const { request, isLoading: loading, error } = useApiRequest<null, Automation[]>();
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;
}
}, []);

View file

@ -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);

View file

@ -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;
}

View file

@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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`);
}
}
};

View file

@ -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

View file

@ -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<Automation | null>(null);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
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<NodeJS.Timeout | null>(null);
const lastLogIdRef = useRef<string | null>(null);
const logContainerRef = useRef<HTMLDivElement>(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<Automation>) => {
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<Automation> = {
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 <FaCheck className={styles.successIcon} />;
case 'error':
case 'failed':
return <FaExclamationCircle className={styles.errorIcon} />;
case 'running':
case 'starting':
return <FaSpinner className={`${styles.spinningIcon} spinning`} />;
case 'stopped':
return <FaStop className={styles.warningIcon} />;
default:
return null;
}
};
if (error) {
return (
<div className={styles.adminPage}>
@ -175,12 +455,21 @@ export const AutomationsPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Automatisierung
</button>
<>
<button
className={styles.secondaryButton}
onClick={handleLoadTemplates}
disabled={loadingTemplates}
>
<FaFileAlt /> {loadingTemplates ? 'Lädt...' : 'Aus Vorlage'}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Automatisierung
</button>
</>
)}
</div>
</div>
@ -199,17 +488,25 @@ export const AutomationsPage: React.FC = () => {
Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Automatisierung erstellen
</button>
<div className={styles.emptyActions}>
<button
className={styles.secondaryButton}
onClick={handleLoadTemplates}
>
<FaFileAlt /> Aus Vorlage erstellen
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Manuell erstellen
</button>
</div>
)}
</div>
) : (
<FormGeneratorTable
data={automations}
data={automations as any[]}
columns={columns}
loading={loading}
pagination={true}
@ -227,7 +524,7 @@ export const AutomationsPage: React.FC = () => {
...(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: <FaPlay />,
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 ? <FaToggleOn /> : <FaToggleOff />,
icon: (row: any) => row.active ? <FaToggleOn /> : <FaToggleOff />,
onClick: handleToggleActive,
title: (row: Automation) => row.active ? 'Deaktivieren' : 'Aktivieren',
title: (row: any) => row.active ? 'Deaktivieren' : 'Aktivieren',
} as any,
{
id: 'logs',
icon: <FaList />,
onClick: handleShowLogs,
title: 'Ausführungsverlauf',
},
]}
onDelete={handleDelete}
hookData={{
@ -265,11 +568,8 @@ export const AutomationsPage: React.FC = () => {
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Automatisierung</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
<button className={styles.modalClose} onClick={() => setShowCreateModal(false)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
@ -299,11 +599,8 @@ export const AutomationsPage: React.FC = () => {
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Automatisierung bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => setEditingAutomation(null)}
>
<button className={styles.modalClose} onClick={() => setEditingAutomation(null)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
@ -327,6 +624,166 @@ export const AutomationsPage: React.FC = () => {
</div>
</div>
)}
{/* Template Selection Modal */}
{showTemplateModal && (
<div className={styles.modalOverlay} onClick={() => setShowTemplateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Vorlage auswählen</h2>
<button className={styles.modalClose} onClick={() => setShowTemplateModal(false)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.templateList}>
{templates.map((template, index) => (
<div key={index} className={styles.templateItem}>
<div className={styles.templateHeader}>
<h4 className={styles.templateTitle}>
{template.template?.overview || `Vorlage ${index + 1}`}
</h4>
</div>
<p className={styles.templateDescription}>
{template.template?.tasks?.[0]?.description ||
template.template?.tasks?.[0]?.objective ||
'Keine Beschreibung'}
</p>
<button
className={styles.primaryButton}
onClick={() => handleTemplateSelect(template)}
>
<FaCheck /> Verwenden
</button>
</div>
))}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={() => setShowTemplateModal(false)}>
Abbrechen
</button>
</div>
</div>
</div>
)}
{/* Execution Modal */}
{executionModal.visible && (
<div className={styles.modalOverlay} onClick={closeExecutionModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel}
</h2>
<button className={styles.modalClose} onClick={closeExecutionModal}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.executionStatus}>
<span className={`${styles.statusBadge} ${styles[executionModal.status]}`}>
{executionModal.status === 'starting' && 'Wird gestartet...'}
{executionModal.status === 'running' && 'Läuft...'}
{executionModal.status === 'completed' && 'Abgeschlossen'}
{executionModal.status === 'stopped' && 'Gestoppt'}
{executionModal.status === 'error' && 'Fehler'}
</span>
{executionModal.workflowId && (
<span className={styles.workflowId}>
Workflow: <code>{executionModal.workflowId}</code>
</span>
)}
</div>
<div
ref={logContainerRef}
className={styles.executionLogs}
style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }}
>
{executionModal.logs.map((log, index) => (
<div key={log.id || index} className={styles.logEntry}>
<span className={styles.logTime}>[{formatTime(log.timestamp)}]</span>
{log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>}
<span className={styles.logMessage}>{log.message}</span>
{log.progress !== undefined && log.progress !== null && log.progress < 1 && (
<span className={styles.logProgress}>({Math.round(log.progress * 100)}%)</span>
)}
</div>
))}
</div>
</div>
<div className={styles.modalFooter}>
{executionModal.status === 'running' && (
<button className={styles.dangerButton} onClick={handleStopWorkflow}>
<FaStop /> Stoppen
</button>
)}
<button className={styles.secondaryButton} onClick={closeExecutionModal}>
Schliessen
</button>
</div>
</div>
</div>
)}
{/* Logs History Modal */}
{logsModal.visible && logsModal.automation && (
<div className={styles.modalOverlay} onClick={() => setLogsModal({ visible: false, automation: null })}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
Ausführungsverlauf: {logsModal.automation.label}
</h2>
<button
className={styles.modalClose}
onClick={() => setLogsModal({ visible: false, automation: null })}
>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{(!logsModal.automation.executionLogs || logsModal.automation.executionLogs.length === 0) ? (
<div className={styles.emptyState}>
<p>Keine Ausführungen vorhanden</p>
</div>
) : (
<div className={styles.logsHistory}>
{[...logsModal.automation.executionLogs].reverse().map((log, index) => (
<div key={index} className={`${styles.logHistoryItem} ${styles[log.status || 'unknown']}`}>
<div className={styles.logHistoryHeader}>
<span className={styles.logHistoryDate}>{formatTimestamp(log.timestamp)}</span>
<span className={`${styles.statusBadge} ${styles[log.status || 'unknown']}`}>
{log.status || 'Unbekannt'}
</span>
{log.workflowId && (
<span className={styles.workflowId}>
Workflow: <code>{log.workflowId}</code>
</span>
)}
</div>
{log.messages && log.messages.length > 0 && (
<div className={styles.logHistoryMessages}>
{log.messages.map((msg, msgIndex) => (
<div key={msgIndex} className={styles.logHistoryMessage}>{msg}</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
<div className={styles.modalFooter}>
<button
className={styles.secondaryButton}
onClick={() => setLogsModal({ visible: false, automation: null })}
>
Schliessen
</button>
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -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);
}
}

View file

@ -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<HTMLInputElement>(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<MediaRecorder | null>(null);
const [audioChunks, setAudioChunks] = useState<Blob[]>([]);
// Prompts dropdown state
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
// 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 (
<div className={styles.emptyState} style={{ padding: '2rem' }}>
<FaTasks className={styles.emptyIcon} style={{ fontSize: '2rem' }} />
@ -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<string, any>) => {
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)}
</span>
{operation.latestProgress !== null && operation.latestProgress < 1 && (
<span style={{
fontSize: '0.6875rem',
color: 'var(--text-secondary)',
}}>
{Math.round(operation.latestProgress * 100)}%
</span>
)}
{operation.latestStatus && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: operation.latestStatus === 'completed'
background: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
? 'var(--success-bg, #dcfce7)'
: operation.latestStatus === 'running'
? 'var(--info-bg, #dbeafe)'
: operation.latestStatus === 'error'
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
? 'var(--danger-bg, #fee2e2)'
: 'var(--bg-secondary)',
color: operation.latestStatus === 'completed'
color: operation.latestStatus === 'completed' || operation.latestStatus === 'success'
? 'var(--success-color, #16a34a)'
: operation.latestStatus === 'running'
? 'var(--info-color, #2563eb)'
: operation.latestStatus === 'error'
: operation.latestStatus === 'error' || operation.latestStatus === 'failed'
? 'var(--danger-color, #dc2626)'
: 'var(--text-secondary)',
}}>
@ -228,13 +464,93 @@ export const PlaygroundPage: React.FC = () => {
</div>
{operation.expanded && childOps.length > 0 && (
<div style={{ marginTop: '0.25rem' }}>
{childOps.map(childId => renderOperation(childId, depth + 1))}
{childOps.map(childId => renderOperation(childId, depth + 1, roundOperations))}
</div>
)}
</div>
);
};
// If we have rounds, render them
if (hasRounds) {
const sortedRounds = Array.from(dashboardTree.rounds.entries()).sort((a, b) => a[0] - b[0]);
return (
<div>
{sortedRounds.map(([roundNumber, round]) => (
<div key={`round-${roundNumber}`} style={{ marginBottom: '0.5rem' }}>
{/* Round Header */}
<div
onClick={() => 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',
}}
>
<span style={{
fontSize: '0.75rem',
color: 'var(--text-secondary)',
transform: round.expanded ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
}}>
</span>
<span style={{
flex: 1,
fontSize: '0.875rem',
fontWeight: 600,
color: 'var(--text-primary)',
}}>
Runde {roundNumber}
</span>
{round.isCompleted && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: 'var(--success-bg, #dcfce7)',
color: 'var(--success-color, #16a34a)',
}}>
abgeschlossen
</span>
)}
{roundNumber === currentRound && !round.isCompleted && (
<span style={{
fontSize: '0.6875rem',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
background: 'var(--info-bg, #dbeafe)',
color: 'var(--info-color, #2563eb)',
}}>
aktiv
</span>
)}
</div>
{/* Round Operations */}
{round.expanded && (
<div style={{
paddingLeft: '0.5rem',
borderLeft: '2px solid var(--border-color)',
marginLeft: '0.5rem',
}}>
{round.rootOperations.map(opId => renderOperation(opId, 0, round.operations))}
</div>
)}
</div>
))}
</div>
);
}
// Fallback: render without rounds (for backward compatibility)
return (
<div>
{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 (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
@ -255,6 +574,17 @@ export const PlaygroundPage: React.FC = () => {
</div>
);
}
// Show loading state while permission is being checked (undefined)
if (playgroundUIPermission === undefined) {
return (
<div className={styles.playgroundContainer}>
<div className={styles.emptyState}>
<p>Lade...</p>
</div>
</div>
);
}
return (
<div className={styles.playgroundContainer}>
@ -290,11 +620,24 @@ export const PlaygroundPage: React.FC = () => {
</div>
</header>
{/* Main Content - Resizable Two-Column Layout */}
{/* Main Content - Resizable Two-Column Layout with Drag & Drop */}
<div
ref={containerRef}
className={`${styles.mainContent} ${isDragging ? styles.dragging : ''}`}
className={`${styles.mainContent} ${isDragging ? styles.dragging : ''} ${isDragOver ? styles.dragOver : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Drag overlay */}
{isDragOver && (
<div className={styles.dragOverlay}>
<div className={styles.dragOverlayContent}>
<FaFile style={{ fontSize: '3rem', marginBottom: '1rem' }} />
<p>Dateien hier ablegen</p>
</div>
</div>
)}
{/* Left Panel - Chat Messages */}
<div
className={styles.leftPanel}
@ -339,7 +682,33 @@ export const PlaygroundPage: React.FC = () => {
</div>
{/* Input Footer */}
<div className={styles.inputFooter}>
<div
className={`${styles.inputFooter} ${isDragOver ? styles.dragOverFooter : ''}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{/* Prompts Selection Row */}
<div className={styles.promptsRow}>
<div className={styles.promptsSelect}>
<FaFileAlt style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', marginRight: '0.5rem' }} />
<select
className={styles.promptDropdown}
value={selectedPromptId}
onChange={(e) => handlePromptSelect(e.target.value)}
disabled={isRunning}
>
<option value="">Prompt-Vorlage wählen...</option>
{prompts?.map((prompt: any) => (
<option key={prompt.id} value={prompt.id}>
{prompt.name || prompt.content?.substring(0, 50) + '...'}
</option>
))}
</select>
</div>
</div>
{/* Pending files */}
{pendingFiles && pendingFiles.length > 0 && (
<div className={styles.pendingFiles}>
@ -360,21 +729,29 @@ export const PlaygroundPage: React.FC = () => {
)}
{/* Stats bar */}
{latestStats && (
{latestStats && (latestStats.bytesSent || latestStats.bytesReceived || latestStats.processingTime || latestStats.priceUsd) && (
<div className={styles.statsBar}>
{latestStats.promptTokens !== undefined && (
{(latestStats.bytesSent !== undefined || latestStats.bytesReceived !== undefined) && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Tokens:</span>
<span className={styles.statLabel}>Daten:</span>
<span className={styles.statValue}>
{latestStats.promptTokens + (latestStats.completionTokens || 0)}
{formatBytes(latestStats.bytesSent || 0)} / {formatBytes(latestStats.bytesReceived || 0)}
</span>
</div>
)}
{latestStats.totalCost !== undefined && (
{latestStats.processingTime !== undefined && latestStats.processingTime > 0 && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Zeit:</span>
<span className={styles.statValue}>
{formatDuration(latestStats.processingTime)}
</span>
</div>
)}
{latestStats.priceUsd !== undefined && latestStats.priceUsd > 0 && (
<div className={styles.statItem}>
<span className={styles.statLabel}>Kosten:</span>
<span className={styles.statValue}>
${latestStats.totalCost.toFixed(4)}
${latestStats.priceUsd.toFixed(4)}
</span>
</div>
)}
@ -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 = () => {
<button
className={styles.iconButton}
onClick={handleFileClick}
disabled={isRunning}
disabled={false}
title="Datei anhängen"
>
<FaPlus />
</button>
<button
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
onClick={handleVoiceClick}
disabled={false}
title={isRecording ? 'Aufnahme stoppen' : 'Sprachaufnahme starten'}
>
{isRecording ? <FaSquare /> : <FaMicrophone />}
</button>
</div>
<div className={styles.actionButtons}>
{isRunning ? (
{/* Stop button - only visible when running */}
{isRunning && (
<button
type="button"
className={styles.stopButton}
onClick={handleSubmit}
disabled={isSubmitting}
onClick={handleStop}
disabled={isStopping}
title="Workflow stoppen"
>
<FaStop />
{isSubmitting ? 'Stoppt...' : 'Stoppen'}
</button>
) : (
<button
type="button"
className={styles.primaryButton}
onClick={handleSubmit}
disabled={!inputValue.trim() || isSubmitting}
>
<FaPaperPlane />
{isSubmitting ? 'Senden...' : 'Senden'}
{isStopping ? 'Stoppt...' : 'Stop'}
</button>
)}
{/* Send button - always visible with dynamic text */}
<button
type="button"
className={styles.primaryButton}
onClick={handleSubmit}
disabled={!inputValue.trim() || isSubmitting}
>
<FaPaperPlane />
{isSubmitting
? 'Senden...'
: isRunning
? 'Neue Eingabe'
: !workflowId
? 'Starten'
: 'Senden'
}
</button>
</div>
</div>
</div>

View file

@ -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<Workflow | null>(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