reference fixes
This commit is contained in:
parent
dc4b475728
commit
cc8770dec3
12 changed files with 1645 additions and 155 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue