시나리오
This commit is contained in:
@@ -4,6 +4,8 @@ import MainContentArea from '@/components/MainContentArea.jsx';
|
||||
import {useAPI} from '@/providers/APIProvider.jsx';
|
||||
import AddCollectionDialog from '@/components/api-tester/AddCollectionDialog.jsx';
|
||||
import AddAPIDialog from '@/components/api-tester/AddAPIDialog.jsx';
|
||||
import AddScenarioDialog from '@/components/api-tester/AddScenarioDialog.jsx';
|
||||
import {useLoadTest} from '@/providers/LoadTestProvider.jsx';
|
||||
|
||||
const menuConfig = [
|
||||
{
|
||||
@@ -78,7 +80,9 @@ const APITester = () => {
|
||||
const [isApiMode, setIsApiMode] = useState(true);
|
||||
const [showAddCollection, setShowAddCollection] = useState(false);
|
||||
const [showAddApi, setShowAddApi] = useState(false);
|
||||
const [showAddScenario, setShowAddScenario] = useState(false);
|
||||
const { handleLogout, createCollection, createAPIRequest } = useAPI();
|
||||
const {createScenario} = useLoadTest();
|
||||
|
||||
// 모드 전환 핸들러
|
||||
const toggleMode = () => {
|
||||
@@ -104,6 +108,15 @@ const APITester = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddScenario = async (name) => {
|
||||
try {
|
||||
await createScenario(name);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('시나리오 생성에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleMenuClick = async (action) => {
|
||||
// Handle menu item clicks
|
||||
@@ -115,6 +128,9 @@ const APITester = () => {
|
||||
case 'collection-add':
|
||||
setShowAddCollection(true);
|
||||
break;
|
||||
case 'scenario-add':
|
||||
setShowAddScenario(true);
|
||||
break;
|
||||
case 'logout':
|
||||
const success = await handleLogout();
|
||||
if (success) {
|
||||
@@ -176,6 +192,11 @@ const APITester = () => {
|
||||
onOpenChange={setShowAddApi}
|
||||
onSubmit={handleAddApi}
|
||||
/>
|
||||
<AddScenarioDialog
|
||||
open={showAddScenario}
|
||||
onOpenChange={setShowAddScenario}
|
||||
onSubmit={handleAddScenario}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import ScenarioEditor from '@/components/api-tester/ScenarioEditor.jsx';
|
||||
|
||||
const MainContentArea = ({ isApiMode, toggleMode }) => {
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="h-[calc(100vh-57px)] w-full overflow-hidden">
|
||||
{isApiMode ? <APIMode isApiMode={isApiMode} onToggleMode={toggleMode}/> : <ScenarioMode isApiMode={isApiMode} onToggleMode={toggleMode}/>}
|
||||
</div>
|
||||
);
|
||||
@@ -128,22 +128,19 @@ const APIMode = ({ isApiMode, onToggleMode }) => {
|
||||
|
||||
// 시나리오 모드
|
||||
const ScenarioMode = ({ isApiMode, onToggleMode }) => (
|
||||
<div className="scenario-mode flex flex-1">
|
||||
<div className="scenario-mode flex flex-1 h-[calc(100vh-57px)]">
|
||||
{/* 왼쪽 고정: ScenarioNavigation */}
|
||||
<div className="navigation-sidebar min-w-[200px] border-r bg-gray-100">
|
||||
<ScenarioNavigation
|
||||
contextPath="/"
|
||||
side="left"
|
||||
onScenarioSelect={(scenarioId) => {
|
||||
// Handle scenario selection
|
||||
}}
|
||||
isApiMode={isApiMode}
|
||||
onToggleMode={onToggleMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 중앙: ScenarioEditor */}
|
||||
<div className="editor flex-1">
|
||||
<div className="editor flex-1 h-[calc(100vh-57px)] overflow-hidden">
|
||||
<ScenarioEditor/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import ErrorAlertDialog from '@/components/shared/ErrorAlertDialog.jsx';
|
||||
|
||||
const AddScenarioDialog = ({ open, onOpenChange, onSubmit }) => {
|
||||
const [scenarioName, setScenarioName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!scenarioName.trim()) {
|
||||
setError('시나리오 이름을 입력해주세요');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await onSubmit(scenarioName.trim());
|
||||
if (success) {
|
||||
setScenarioName('');
|
||||
setError('');
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError('시나리오 생성에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || '시나리오 생성에 실패했습니다');
|
||||
setShowErrorDialog(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (open) => {
|
||||
if (!open) {
|
||||
setScenarioName('');
|
||||
setError('');
|
||||
}
|
||||
onOpenChange(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 시나리오 추가</DialogTitle>
|
||||
<DialogDescription>
|
||||
새로운 시나리오의 이름을 입력해주세요
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">시나리오 이름</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={scenarioName}
|
||||
onChange={(e) => {
|
||||
setScenarioName(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="시나리오 이름을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '생성 중...' : '생성'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ErrorAlertDialog
|
||||
open={showErrorDialog}
|
||||
onOpenChange={setShowErrorDialog}
|
||||
title="시나리오 생성 오류"
|
||||
description={error}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddScenarioDialog;
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
const DeleteScenarioDialog = ({ open, onOpenChange, onConfirm, scenarioName }) => {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>시나리오 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{scenarioName}' 시나리오를 삭제하시겠습니까?
|
||||
<br />
|
||||
이 작업은 되돌릴 수 없으며, 시나리오의 모든 구성요소가 함께 삭제됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirm}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteScenarioDialog;
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, Position, Handle, ReactFlowProvider } from '@xyflow/react';
|
||||
import React, { useCallback, memo, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import {Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, Position, Handle, ReactFlowProvider} from '@xyflow/react';
|
||||
import React, {useCallback, memo, useMemo, useRef, useState, useEffect, forwardRef, useImperativeHandle} from 'react';
|
||||
import {Play} from 'lucide-react';
|
||||
import {useLoadTest} from '@/providers/LoadTestProvider.jsx';
|
||||
|
||||
// Optimized node components with proper prop types and memoization
|
||||
const StartNode = memo(({ data, isConnectable }) => {
|
||||
const StartNode = memo(({data, isConnectable}) => {
|
||||
// Memoize handles configuration
|
||||
const handles = useMemo(() => ([
|
||||
{ id: 'start-right', type: 'source', position: Position.Right }
|
||||
{id: 'start-right', type: 'source', position: Position.Right}
|
||||
]), []);
|
||||
|
||||
return (
|
||||
@@ -21,18 +22,18 @@ const StartNode = memo(({ data, isConnectable }) => {
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Play className="h-4 w-4 text-green-600" />
|
||||
<Play className="h-4 w-4 text-green-600"/>
|
||||
<span className="font-semibold text-sm text-green-700">Start</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ApiNode = memo(({ data, isConnectable }) => {
|
||||
const ApiNode = memo(({data, isConnectable}) => {
|
||||
// Memoize handles and colors
|
||||
const handles = useMemo(() => ([
|
||||
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
||||
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
||||
{id: `${data.id}-left`, type: 'target', position: Position.Left},
|
||||
{id: `${data.id}-right`, type: 'source', position: Position.Right}
|
||||
]), [data.id]);
|
||||
|
||||
const methodColor = useMemo(() => {
|
||||
@@ -59,7 +60,7 @@ const ApiNode = memo(({ data, isConnectable }) => {
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${methodColor}`} />
|
||||
<div className={`w-2 h-2 rounded-full ${methodColor}`}/>
|
||||
<span className="font-bold text-sm">{data.method}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +72,7 @@ const ApiNode = memo(({ data, isConnectable }) => {
|
||||
);
|
||||
});
|
||||
|
||||
const ConditionNode = memo(({ data, isConnectable }) => {
|
||||
const ConditionNode = memo(({data, isConnectable}) => {
|
||||
// Memoize handle styles
|
||||
const handleStyles = useMemo(() => ({
|
||||
true: {
|
||||
@@ -113,7 +114,7 @@ const ConditionNode = memo(({ data, isConnectable }) => {
|
||||
/>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500" />
|
||||
<div className="w-2 h-2 rounded-full bg-purple-500"/>
|
||||
<span className="font-bold text-sm">Condition</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,10 +125,10 @@ const ConditionNode = memo(({ data, isConnectable }) => {
|
||||
);
|
||||
});
|
||||
|
||||
const DelayNode = memo(({ data, isConnectable }) => {
|
||||
const DelayNode = memo(({data, isConnectable}) => {
|
||||
const handles = useMemo(() => ([
|
||||
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
||||
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
||||
{id: `${data.id}-left`, type: 'target', position: Position.Left},
|
||||
{id: `${data.id}-right`, type: 'source', position: Position.Right}
|
||||
]), [data.id]);
|
||||
|
||||
return (
|
||||
@@ -143,7 +144,7 @@ const DelayNode = memo(({ data, isConnectable }) => {
|
||||
))}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
<div className="w-2 h-2 rounded-full bg-orange-500"/>
|
||||
<span className="font-bold text-sm">Delay</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,18 +161,6 @@ ApiNode.displayName = 'ApiNode';
|
||||
ConditionNode.displayName = 'ConditionNode';
|
||||
DelayNode.displayName = 'DelayNode';
|
||||
|
||||
// Memoize initial nodes and edges
|
||||
const initialNodes = [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'startNode',
|
||||
position: { x: 100, y: 50 },
|
||||
data: { id: 'start' }
|
||||
}
|
||||
];
|
||||
|
||||
const initialEdges = [];
|
||||
|
||||
// Memoize node types
|
||||
const nodeTypes = {
|
||||
startNode: StartNode,
|
||||
@@ -180,11 +169,40 @@ const nodeTypes = {
|
||||
delayNode: DelayNode
|
||||
};
|
||||
|
||||
const DiagramArea = () => {
|
||||
const DiagramArea = forwardRef(({onSave}, ref) => {
|
||||
const {currentFlowData} = useLoadTest();
|
||||
const reactFlowWrapper = useRef(null);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(currentFlowData?.edges || []);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(currentFlowData?.nodes || [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'startNode',
|
||||
position: {x: 100, y: 50},
|
||||
data: {id: 'start'}
|
||||
}]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentFlowData) {
|
||||
const {nodes: scenarioNodes, edges: scenarioEdges} = currentFlowData;
|
||||
setNodes(scenarioNodes?.length ? scenarioNodes : [
|
||||
{
|
||||
id: 'start',
|
||||
type: 'startNode',
|
||||
position: {x: 100, y: 50},
|
||||
data: {id: 'start'}
|
||||
}]);
|
||||
setEdges(scenarioEdges || []);
|
||||
}
|
||||
}, [currentFlowData, setNodes, setEdges]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFlowData: () => ({
|
||||
nodes,
|
||||
edges,
|
||||
viewport: reactFlowWrapper.current?.getViewport()
|
||||
})
|
||||
}));
|
||||
|
||||
// Click handlers for node selection
|
||||
const onNodeClick = useCallback((event, node) => {
|
||||
@@ -206,9 +224,11 @@ const DiagramArea = () => {
|
||||
);
|
||||
}, [selectedNodeId, setNodes]);
|
||||
|
||||
// Memoize callback functions
|
||||
const onConnect = useCallback((params) => {
|
||||
setEdges(prev => [...prev, { ...params, id: `e${prev.length + 1}` }]);
|
||||
setEdges(prev => {
|
||||
const newId = `edge-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
return [...prev, {...params, id: newId}];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onInit = useCallback((instance) => {
|
||||
@@ -223,25 +243,24 @@ const DiagramArea = () => {
|
||||
const onDrop = useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!reactFlowWrapper.current) return;
|
||||
if (!reactFlowWrapper.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiData = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||
const position = reactFlowWrapper.current.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
y: event.clientY
|
||||
});
|
||||
|
||||
const newNode = {
|
||||
id: `api-${Date.now()}`,
|
||||
id: `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'apiNode',
|
||||
position,
|
||||
data: {
|
||||
id: apiData.id,
|
||||
name: apiData.name,
|
||||
method: apiData.method,
|
||||
path: apiData.path,
|
||||
collectionId: apiData.collectionId
|
||||
...apiData,
|
||||
id: `api-${Date.now()}` // Ensure unique API ID
|
||||
}
|
||||
};
|
||||
|
||||
@@ -252,34 +271,35 @@ const DiagramArea = () => {
|
||||
}, [setNodes]);
|
||||
|
||||
// Default edge options memoized
|
||||
const defaultEdgeOptions = useMemo(() => ({ type: 'smoothstep' }), []);
|
||||
const defaultEdgeOptions = useMemo(() => ({type: 'smoothstep'}), []);
|
||||
|
||||
return (
|
||||
<div className="h-[50vh] border-b">
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onInit={onInit}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultZoom={1.0} // 초기 배율 설정 (1이 기본값)
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap />
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onInit={onInit}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
proOptions={{hideAttribution: true}}
|
||||
>
|
||||
<Background/>
|
||||
<Controls/>
|
||||
<MiniMap/>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
DiagramArea.displayName = 'DiagramArea';
|
||||
|
||||
export default memo(DiagramArea);
|
||||
|
||||
@@ -37,18 +37,7 @@ const DraggableApiItem = ({ api, collectionId, onApiAction }) => {
|
||||
|
||||
const handleDragStart = (event) => {
|
||||
// Prepare API data for drag operation
|
||||
const apiData = {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
method: api.method || 'GET',
|
||||
path: api.path || '/',
|
||||
collectionId: collectionId,
|
||||
headers: api.headers || [],
|
||||
parameters: api.parameters || [],
|
||||
body: api.body || '',
|
||||
description: api.description || '',
|
||||
type: api.type || 'HTTP'
|
||||
};
|
||||
const apiData = {...api};
|
||||
|
||||
// Create drag image for better visual feedback
|
||||
const dragImage = document.createElement('div');
|
||||
|
||||
@@ -1,48 +1,97 @@
|
||||
import React, {useState} from 'react';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import MonacoEditor from '@/components/shared/MonacoEditor';
|
||||
import ScenarioMenuBar from '@/components/api-tester/ScenarioMenuBar.jsx';
|
||||
import DiagramArea from '@/components/api-tester/DiagramArea.jsx';
|
||||
import {useLoadTest} from '@/providers/LoadTestProvider.jsx';
|
||||
import {useScenarioExecutor} from '@/services/ScenarioExecutor';
|
||||
import APIClient from '@/services/APIClient.js';
|
||||
import {Button} from '@/components/ui/button.jsx';
|
||||
|
||||
const apiClient = new APIClient();
|
||||
|
||||
// ConsoleArea Component
|
||||
const ConsoleArea = () => {
|
||||
const [consoleOutput, setConsoleOutput] = useState('');
|
||||
const ConsoleArea = ({value}) => (
|
||||
|
||||
return (
|
||||
<div className="h-[50vh]">
|
||||
<MonacoEditor
|
||||
id="scenario-console"
|
||||
value={consoleOutput}
|
||||
language="plaintext"
|
||||
readOnly={true}
|
||||
height="full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
<MonacoEditor
|
||||
id="scenario-console"
|
||||
value={value}
|
||||
language="plaintext"
|
||||
readOnly={true}
|
||||
height="full"
|
||||
/>
|
||||
|
||||
);
|
||||
|
||||
// Main ScenarioEditor Component
|
||||
const ScenarioEditor = () => {
|
||||
const handleSave = () => {
|
||||
console.log('Saving scenario...');
|
||||
const diagramRef = useRef(null);
|
||||
const {currentScenario, currentFlowData, updateScenario} = useLoadTest();
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
|
||||
const {
|
||||
logs,
|
||||
clearLogs,
|
||||
executeScenario,
|
||||
executeStep,
|
||||
resetExecutor,
|
||||
pauseExecution,
|
||||
isRunning
|
||||
} = useScenarioExecutor(
|
||||
currentFlowData?.nodes || [],
|
||||
currentFlowData?.edges || [],
|
||||
apiClient
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
resetExecutor();
|
||||
clearLogs();
|
||||
}, [currentFlowData]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
console.log(currentScenario);
|
||||
const flowData = diagramRef.current?.getFlowData();
|
||||
await updateScenario({
|
||||
...currentScenario,
|
||||
scenario: JSON.stringify(flowData)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to save scenario:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepRun = () => {
|
||||
console.log('Running step...');
|
||||
};
|
||||
const handleStepRun = useCallback(async () => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await executeStep();
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}, [executeStep]);
|
||||
|
||||
const handleFullRun = () => {
|
||||
console.log('Running all steps...');
|
||||
};
|
||||
const handleFullRun = useCallback(async () => {
|
||||
setIsExecuting(true);
|
||||
try {
|
||||
await executeScenario();
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
}, [executeScenario]);
|
||||
|
||||
const handlePause = () => {
|
||||
console.log('Pausing execution...');
|
||||
};
|
||||
const handlePause = useCallback(() => {
|
||||
pauseExecution();
|
||||
}, [pauseExecution]);
|
||||
|
||||
const handleReset = () => {
|
||||
console.log('Resetting scenario...');
|
||||
};
|
||||
const handleReset = useCallback(() => {
|
||||
resetExecutor();
|
||||
clearLogs();
|
||||
}, [resetExecutor, clearLogs]);
|
||||
|
||||
if (!currentFlowData) {
|
||||
return <div className="flex items-center justify-center h-full">Select a scenario to edit</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -52,9 +101,35 @@ const ScenarioEditor = () => {
|
||||
onFullRun={handleFullRun}
|
||||
onPause={handlePause}
|
||||
onReset={handleReset}
|
||||
disabled={isExecuting}
|
||||
isRunning={isRunning}
|
||||
/>
|
||||
<DiagramArea/>
|
||||
<ConsoleArea/>
|
||||
<div className="flex-1 min-h-0 grid grid-rows-2">
|
||||
<div className="min-h-0 border-b">
|
||||
<DiagramArea
|
||||
ref={diagramRef}
|
||||
initialNodes={currentFlowData?.nodes || []}
|
||||
initialEdges={currentFlowData?.edges || []}
|
||||
isExecuting={isExecuting}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 p-1 border-b">
|
||||
<div>Output: </div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm">
|
||||
숨기기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm">
|
||||
보이기
|
||||
</Button>
|
||||
</div>
|
||||
<ConsoleArea value={logs}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import { FileText, MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -23,6 +23,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useLoadTest } from '@/providers/LoadTestProvider';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import DeleteScenarioDialog from '@/components/api-tester/DeleteScenarioDialog.jsx';
|
||||
|
||||
const ScenarioDropdownMenu = ({ onOpen, onRename, onDelete }) => (
|
||||
<DropdownMenu>
|
||||
@@ -64,16 +65,21 @@ const ErrorMessage = ({ message }) => (
|
||||
|
||||
const ScenarioNavigation = ({
|
||||
contextPath = '/',
|
||||
onScenarioSelect,
|
||||
side,
|
||||
isApiMode,
|
||||
onToggleMode
|
||||
}) => {
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [scenarioToDelete, setScenarioToDelete] = useState(null);
|
||||
|
||||
const {
|
||||
scenarios,
|
||||
loading,
|
||||
error,
|
||||
loadScenarioList
|
||||
deleteScenario,
|
||||
loadScenarioList,
|
||||
selectScenario
|
||||
} = useLoadTest();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -81,14 +87,18 @@ const ScenarioNavigation = ({
|
||||
}, [loadScenarioList]);
|
||||
|
||||
const handleScenarioAction = {
|
||||
open: (scenarioId) => {
|
||||
if (onScenarioSelect) {
|
||||
onScenarioSelect(scenarioId);
|
||||
open: async (scenarioId) => {
|
||||
try {
|
||||
console.log({scenarioId});
|
||||
selectScenario(scenarioId);
|
||||
} catch (err) {
|
||||
console.error('Failed to select scenario:', err);
|
||||
}
|
||||
},
|
||||
delete: async (scenarioId) => {
|
||||
// TODO: Implement delete functionality
|
||||
console.log('Delete scenario:', scenarioId);
|
||||
delete: async (scenario) => {
|
||||
console.log('delete', scenario);
|
||||
setScenarioToDelete(scenario);
|
||||
setDeleteDialogOpen(true);
|
||||
},
|
||||
rename: (scenarioId) => {
|
||||
// TODO: Implement rename functionality
|
||||
@@ -96,6 +106,14 @@ const ScenarioNavigation = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (scenarioToDelete) {
|
||||
await deleteScenario(scenarioToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setScenarioToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const renderScenarioItem = (scenario) => (
|
||||
<SidebarMenuItem key={scenario.id}>
|
||||
<SidebarMenuButton
|
||||
@@ -117,7 +135,7 @@ const ScenarioNavigation = ({
|
||||
<ScenarioDropdownMenu
|
||||
onOpen={() => handleScenarioAction.open(scenario.id)}
|
||||
onRename={() => handleScenarioAction.rename(scenario.id)}
|
||||
onDelete={() => handleScenarioAction.delete(scenario.id)}
|
||||
onDelete={() => handleScenarioAction.delete(scenario)}
|
||||
/>
|
||||
</SidebarMenuAction>
|
||||
</div>
|
||||
@@ -161,6 +179,12 @@ const ScenarioNavigation = ({
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
{renderContent()}
|
||||
<DeleteScenarioDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
scenarioName={scenarioToDelete?.name || ''}
|
||||
/>
|
||||
</Sidebar>
|
||||
</SidebarProvider>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import APIClient from '../../lib/api-client';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {Card, CardContent} from '@/components/ui/card';
|
||||
import LayoutGrid from '../layout/LayoutGrid';
|
||||
import LayoutTreeGrid from '../layout/LayoutTreeGrid';
|
||||
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
|
||||
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
|
||||
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
|
||||
import VariableSnippet from '../shared/VariableSnippet';
|
||||
import APIClient from '@/services/APIClient.js';
|
||||
|
||||
export const TCPClientView = ({ parent, serverManager, model, index }) => {
|
||||
const [apiClient] = useState(() => new APIClient());
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { cn } from "@/lib/utils";
|
||||
import EditableInput from '../shared/EditableInput';
|
||||
import APIClient from '@/lib/api-client';
|
||||
import _ from 'lodash';
|
||||
import APIClient from '@/services/APIClient.js';
|
||||
|
||||
// Helper function to get indentation for tree visualization
|
||||
const getIndent = (depth) => {
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
export const fillPadding = async (apiRequest, value, dataLength) => {
|
||||
let paddedValue = value; // In real app, this would be processed by APIClient
|
||||
while (paddedValue.length < dataLength) {
|
||||
paddedValue = ' ' + paddedValue;
|
||||
}
|
||||
return paddedValue;
|
||||
};
|
||||
|
||||
class APIClient {
|
||||
constructor() {
|
||||
// Initialize global variables store
|
||||
window.globals = window.globals || {};
|
||||
this.abortController = null;
|
||||
this.contextPath = document.querySelector('meta[name="context-path"]')?.getAttribute('content') || '/';
|
||||
|
||||
if (!this.contextPath.endsWith('/')) {
|
||||
this.contextPath += '/';
|
||||
}
|
||||
}
|
||||
|
||||
getClientUrl() {
|
||||
return `${this.contextPath}mgmt/api/test.do`;
|
||||
}
|
||||
|
||||
// Format response for consistency
|
||||
formatResponse(data) {
|
||||
return {
|
||||
body: data.body,
|
||||
status: data.status,
|
||||
headers: data.headers.reduce((acc, header) => {
|
||||
acc[header.key] = header.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
size: data.size
|
||||
};
|
||||
}
|
||||
|
||||
// Abort ongoing requests
|
||||
abort() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the actual API request
|
||||
async makeAjaxRequest(processedRequest) {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch(this.getClientUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(processedRequest),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network request failed');
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
// Handle abort silently
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process pre-script and variables
|
||||
async processPreScriptAndVariables(model, value, consoleOutput) {
|
||||
const variables = {};
|
||||
const updatedModel = await this.executePreRequestScript(model, variables, consoleOutput);
|
||||
return this.replacePlaceholderValueWithVariables(updatedModel, value, variables);
|
||||
}
|
||||
|
||||
// Replace placeholder values with actual variables
|
||||
replacePlaceholderValueWithVariables(model, originalValue, variables = {}) {
|
||||
const mergedVariables = {
|
||||
...window.globals,
|
||||
...variables
|
||||
};
|
||||
|
||||
return Object.entries(mergedVariables).reduce((value, [key, replacement]) => {
|
||||
const placeholder = `{{${key}}}`;
|
||||
return value.replace(new RegExp(placeholder, 'g'), replacement);
|
||||
}, originalValue);
|
||||
}
|
||||
|
||||
// Main method to send API requests
|
||||
async sendAPIRequest(model, preProcessCallback, consoleOutput) {
|
||||
try {
|
||||
// Execute pre-request script and get variables
|
||||
const variables = {};
|
||||
const updatedModel = await this.executePreRequestScript(model, variables, consoleOutput);
|
||||
|
||||
// Process request with variables
|
||||
const processedRequest = this.replacePlaceholdersWithVariables(updatedModel, variables);
|
||||
|
||||
// Allow pre-processing if callback provided
|
||||
if (preProcessCallback) {
|
||||
preProcessCallback(processedRequest);
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const response = await this.makeAjaxRequest(processedRequest, consoleOutput);
|
||||
if (!response) return null;
|
||||
|
||||
const responseData = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.error || 'Unknown error');
|
||||
}
|
||||
|
||||
// Format response
|
||||
const finalResponse = this.formatResponse(responseData);
|
||||
|
||||
// Execute post-request script
|
||||
return this.executePostRequestScript(
|
||||
model.postRequestScript,
|
||||
variables,
|
||||
processedRequest,
|
||||
finalResponse,
|
||||
consoleOutput
|
||||
).catch(error => {
|
||||
console.error('Post-request script error:', error);
|
||||
return finalResponse;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`API 호출 중 오류가 발생했습니다. 사유[${error.message}]`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace placeholders in request with variables
|
||||
replacePlaceholdersWithVariables(original, variables = {}) {
|
||||
const request = JSON.parse(JSON.stringify(original));
|
||||
const mergedVariables = {
|
||||
...window.globals,
|
||||
...variables
|
||||
};
|
||||
|
||||
Object.entries(mergedVariables).forEach(([key, value]) => {
|
||||
const placeholder = new RegExp(`{{${key}}}`, 'g');
|
||||
|
||||
// Replace in path
|
||||
request.path = request.path.replace(placeholder, value);
|
||||
|
||||
// Replace in request body
|
||||
if (request.requestBody) {
|
||||
request.requestBody = request.requestBody.replace(placeholder, value);
|
||||
}
|
||||
|
||||
// Replace in headers
|
||||
if (request.headers?.length) {
|
||||
request.headers = JSON.parse(
|
||||
JSON.stringify(request.headers).replace(placeholder, value)
|
||||
);
|
||||
}
|
||||
|
||||
// Replace in query params
|
||||
if (request.queryParams?.length) {
|
||||
request.queryParams = JSON.parse(
|
||||
JSON.stringify(request.queryParams).replace(placeholder, value)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
// Execute pre-request script in an iframe
|
||||
executePreRequestScript(model, variables, consoleOutput) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const resultFrame = document.getElementById('preRequest');
|
||||
if (!resultFrame) {
|
||||
console.warn('preRequest iframe not found');
|
||||
resolve(model);
|
||||
return;
|
||||
}
|
||||
|
||||
window.preRequestComplete = (updatedModel, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(updatedModel);
|
||||
}
|
||||
};
|
||||
|
||||
window.temp_variable = variables;
|
||||
window.currentRequest = model;
|
||||
window.consoleOutput = consoleOutput;
|
||||
|
||||
resultFrame.srcdoc = `
|
||||
<script>
|
||||
var oldLog = console.log;
|
||||
console.log = function(message) {
|
||||
if(window.parent.consoleOutput)
|
||||
window.parent.consoleOutput(message);
|
||||
oldLog.apply(console, arguments);
|
||||
};
|
||||
|
||||
window.globals = window.parent.globals;
|
||||
window.variables = window.parent.temp_variable;
|
||||
window.request = window.parent.currentRequest;
|
||||
|
||||
try {
|
||||
${model.preRequestScript || ''}
|
||||
window.parent.preRequestComplete(window.request, null);
|
||||
} catch (error) {
|
||||
window.parent.preRequestComplete(null, error);
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// Execute post-request script in an iframe
|
||||
executePostRequestScript(script, variables, request, response, consoleOutput) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const resultFrame = document.getElementById('postRequest');
|
||||
if (!resultFrame) {
|
||||
console.warn('postRequest iframe not found');
|
||||
resolve(response);
|
||||
return;
|
||||
}
|
||||
|
||||
window.request = request;
|
||||
window.response = response;
|
||||
window.temp_variable = variables;
|
||||
window.postRequestComplete = (response, error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
};
|
||||
window.consoleOutput = consoleOutput;
|
||||
|
||||
resultFrame.srcdoc = `
|
||||
<script>
|
||||
var oldLog = console.log;
|
||||
console.log = function(message) {
|
||||
if(window.parent.consoleOutput)
|
||||
window.parent.consoleOutput(message);
|
||||
oldLog.apply(console, arguments);
|
||||
};
|
||||
|
||||
window.globals = window.parent.globals;
|
||||
window.variables = window.parent.temp_variable;
|
||||
window.request = window.parent.request;
|
||||
window.response = window.parent.response;
|
||||
|
||||
try {
|
||||
${script || ''}
|
||||
window.parent.postRequestComplete(window.response, null);
|
||||
} catch (error) {
|
||||
window.parent.postRequestComplete(window.response, error.toString());
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default APIClient;
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, {createContext, useState, useContext, useCallback, useEffect} from 'react';
|
||||
import APIClient from '../lib/api-client';
|
||||
import {collectionService} from '@/services/collectionService';
|
||||
import {apiRequestService} from '@/services/apiRequestService';
|
||||
import {serverService} from '@/services/serverService';
|
||||
import {authService} from '@/services/authService';
|
||||
import APIClient from '@/services/APIClient.js';
|
||||
|
||||
const APIContext = createContext(null);
|
||||
|
||||
|
||||
@@ -1,51 +1,110 @@
|
||||
// providers/LoadTestProvider.jsx
|
||||
import React, { createContext, useState, useContext, useCallback } from 'react';
|
||||
import { loadTestService } from '@/services/loadTestService';
|
||||
import { scenarioService } from '@/services/scenarioService';
|
||||
|
||||
// Create the context
|
||||
const LoadTestContext = createContext();
|
||||
|
||||
// Provider component
|
||||
export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||
const [tests, setTests] = useState([]);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [currentTest, setCurrentTest] = useState(null);
|
||||
const [currentScenario, setCurrentScenario] = useState(null);
|
||||
const [currentFlowData, setCurrentFlowData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Load scenario list
|
||||
// Initialize services
|
||||
const loadTests$ = loadTestService(contextPath);
|
||||
const scenarios$ = scenarioService(contextPath);
|
||||
|
||||
const loadScenarioList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load scenarios');
|
||||
|
||||
const data = await response.json();
|
||||
const data = await scenarios$.loadScenarios();
|
||||
setScenarios(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
}, []);
|
||||
|
||||
const selectScenario = useCallback((scenarioId) => {
|
||||
const scenario = scenarios.find(s => s.id === scenarioId);
|
||||
if (!scenario) {
|
||||
setError('Scenario not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
let flowData = { nodes: [], edges: [] };
|
||||
if (scenario.scenario) {
|
||||
try {
|
||||
flowData = JSON.parse(scenario.scenario);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse scenario data:', e);
|
||||
}
|
||||
}
|
||||
console.log(scenario);
|
||||
console.log(flowData);
|
||||
setCurrentScenario(scenario);
|
||||
setCurrentFlowData(flowData);
|
||||
return flowData;
|
||||
}, [scenarios]);
|
||||
|
||||
const createScenario = useCallback(async (name) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newScenario = await scenarios$.createScenario(name);
|
||||
await loadScenarioList();
|
||||
return newScenario;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadScenarioList]);
|
||||
|
||||
const updateScenario = useCallback(async (scenario) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await scenarios$.updateScenario(scenario);
|
||||
await loadScenarioList();
|
||||
return updated;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadScenarioList]);
|
||||
|
||||
const deleteScenario = useCallback(async (scenario) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newScenario = await scenarios$.deleteScenario(scenario);
|
||||
await loadScenarioList();
|
||||
return newScenario;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loadScenarioList]);
|
||||
|
||||
// Load test list
|
||||
const loadTestList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/list.do`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to load tests');
|
||||
|
||||
const data = await response.json();
|
||||
const data = await loadTests$.loadTests();
|
||||
setTests(data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
@@ -54,24 +113,13 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
}, []);
|
||||
|
||||
// Create a new test
|
||||
const createTest = useCallback(async (testName) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ name: testName })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create test');
|
||||
|
||||
const newTest = await response.json();
|
||||
const newTest = await loadTests$.createTest(testName);
|
||||
await loadTestList();
|
||||
return newTest;
|
||||
} catch (err) {
|
||||
@@ -80,60 +128,32 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath, loadTestList]);
|
||||
}, [loadTestList]);
|
||||
|
||||
// Update a test
|
||||
const updateTest = useCallback(async (test) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update test');
|
||||
|
||||
// Update local state
|
||||
setTests(prevTests =>
|
||||
prevTests.map(t => t.id === test.id ? { ...t, ...test } : t)
|
||||
);
|
||||
|
||||
return test;
|
||||
const updatedTest = await loadTests$.updateTest(test);
|
||||
setTests(prev => prev.map(t => t.id === test.id ? updatedTest : t));
|
||||
return updatedTest;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
}, []);
|
||||
|
||||
// Delete a test
|
||||
const deleteTest = useCallback(async (testId) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify({ id: testId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete test');
|
||||
|
||||
// Remove test from local state
|
||||
setTests(prevTests => prevTests.filter(t => t.id !== testId));
|
||||
|
||||
// Clear current test if deleted
|
||||
await loadTests$.deleteTest(testId);
|
||||
setTests(prev => prev.filter(t => t.id !== testId));
|
||||
if (currentTest?.id === testId) {
|
||||
setCurrentTest(null);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -141,60 +161,43 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath, currentTest]);
|
||||
}, [currentTest?.id]);
|
||||
|
||||
// Start a load test
|
||||
const startTest = useCallback(async (test) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}startLoadTest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to start test');
|
||||
|
||||
return true;
|
||||
return await loadTests$.startTest(test);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
}, []);
|
||||
|
||||
// Stop a load test
|
||||
const stopTest = useCallback(async (test) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`${contextPath}stopLoadTest`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': document.querySelector('meta[name="_csrf"]')?.getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to stop test');
|
||||
|
||||
return true;
|
||||
return await loadTests$.stopTest(test);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [contextPath]);
|
||||
}, []);
|
||||
|
||||
// Prepare context value
|
||||
const contextValue = {
|
||||
tests,
|
||||
scenarios,
|
||||
currentScenario,
|
||||
currentFlowData,
|
||||
createScenario,
|
||||
updateScenario,
|
||||
deleteScenario,
|
||||
selectScenario,
|
||||
currentTest,
|
||||
loading,
|
||||
error,
|
||||
@@ -216,14 +219,11 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// Custom hook to use the LoadTestContext
|
||||
export const useLoadTest = () => {
|
||||
const context = useContext(LoadTestContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useLoadTest must be used within a LoadTestProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getCSRFToken } from '@/services/token.js';
|
||||
|
||||
class APIClient {
|
||||
constructor() {
|
||||
this.globals = {};
|
||||
this.abortController = null;
|
||||
this.contextPath = document.querySelector('meta[name="context-path"]')?.getAttribute('content') || '/';
|
||||
this.contextPath = this.contextPath.endsWith('/') ? this.contextPath : this.contextPath + '/';
|
||||
this.CLIENT_URL = 'mgmt/api/test.do';
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async sendAPIRequest(model, onLog = () => {}) {
|
||||
try {
|
||||
onLog(`Request: ${model.method} ${model.path}`);
|
||||
|
||||
const response = await this.makeRequest(model);
|
||||
const formattedResponse = this.formatResponse(response);
|
||||
return formattedResponse;
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error('Request was cancelled');
|
||||
}
|
||||
throw new Error(`API Error: ${error.message}`);
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async makeRequest(request) {
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const response = await fetch(this.getClientUrl(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCSRFToken()
|
||||
},
|
||||
body: JSON.stringify(request),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
formatResponse(data) {
|
||||
return {
|
||||
body: data.body,
|
||||
status: data.status,
|
||||
headers: Object.fromEntries(data.headers.map(h => [h.key, h.value])),
|
||||
size: data.size
|
||||
};
|
||||
}
|
||||
|
||||
getClientUrl() {
|
||||
return this.contextPath + this.CLIENT_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export default APIClient;
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
export class ScenarioExecutor {
|
||||
constructor(nodes, edges, apiClient, onLogAppend, onNodeStatusChange) {
|
||||
this.nodes = nodes;
|
||||
this.edges = edges;
|
||||
this.apiClient = apiClient;
|
||||
this.onLogAppend = onLogAppend;
|
||||
this.onNodeStatusChange = onNodeStatusChange;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.currentNodeId = this.findStartNode()?.id;
|
||||
this.visitedNodes = new Set();
|
||||
this.isRunning = false;
|
||||
this.isPaused = false;
|
||||
this.isDone = false;
|
||||
this.nodes.forEach(node => {
|
||||
this.onNodeStatusChange?.(node.id, 'default');
|
||||
});
|
||||
}
|
||||
|
||||
pause() {
|
||||
this.isPaused = true;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
resume() {
|
||||
this.isPaused = false;
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
findStartNode() {
|
||||
return this.nodes.find(node => node.type === 'startNode');
|
||||
}
|
||||
|
||||
findNextNodes(nodeId) {
|
||||
return this.edges
|
||||
.filter(edge => edge.source === nodeId)
|
||||
.map(edge => ({
|
||||
nodeId: edge.target,
|
||||
condition: edge.sourceHandle
|
||||
}));
|
||||
}
|
||||
|
||||
async executeCurrentNode() {
|
||||
if (!this.currentNodeId || this.isPaused) return;
|
||||
console.log(this.currentNodeId);
|
||||
const currentNode = this.nodes.find(n => n.id === this.currentNodeId);
|
||||
console.log({currentNode});
|
||||
if (!currentNode) return;
|
||||
|
||||
this.isRunning = true;
|
||||
this.visitedNodes.add(this.currentNodeId);
|
||||
this.onNodeStatusChange?.(this.currentNodeId, 'running');
|
||||
|
||||
try {
|
||||
let result;
|
||||
switch (currentNode.type) {
|
||||
case 'startNode':
|
||||
this.appendLog('Starting API scenario\n');
|
||||
break;
|
||||
|
||||
case 'apiNode':
|
||||
result = await this.executeApiNode(currentNode);
|
||||
break;
|
||||
|
||||
case 'delayNode':
|
||||
await this.executeDelayNode(currentNode);
|
||||
break;
|
||||
|
||||
case 'conditionNode':
|
||||
result = await this.executeConditionNode(currentNode);
|
||||
break;
|
||||
}
|
||||
|
||||
this.onNodeStatusChange?.(this.currentNodeId, 'success');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.onNodeStatusChange?.(this.currentNodeId, 'error');
|
||||
this.appendLog(`Error executing node ${currentNode.id}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async executeApiNode(node) {
|
||||
const { data } = node;
|
||||
this.appendLog(`Executing API [${data.name}]`);
|
||||
|
||||
try {
|
||||
const response = await this.apiClient.sendAPIRequest(data, this.appendLog.bind(this));
|
||||
console.log({response});
|
||||
this.logApiResponse(response, data.type);
|
||||
return response;
|
||||
} catch (error) {
|
||||
throw new Error(`API Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async executeDelayNode(node) {
|
||||
const duration = node.data.duration || 1000;
|
||||
this.appendLog(`Delay: ${duration}ms`);
|
||||
await new Promise(resolve => setTimeout(resolve, duration));
|
||||
}
|
||||
|
||||
async executeConditionNode(node) {
|
||||
const { condition, script } = node.data;
|
||||
this.appendLog(`Evaluating condition: ${condition}`);
|
||||
|
||||
try {
|
||||
const result = await this.evaluateCondition(script || condition);
|
||||
this.appendLog(`Condition result: ${result}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(`Condition evaluation failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async evaluateCondition(script) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const scriptExecutor = document.createElement('iframe');
|
||||
scriptExecutor.style.display = 'none';
|
||||
document.body.appendChild(scriptExecutor);
|
||||
|
||||
window.conditionComplete = (result, error) => {
|
||||
document.body.removeChild(scriptExecutor);
|
||||
if (error) reject(error);
|
||||
else resolve(!!result);
|
||||
};
|
||||
|
||||
const scriptContent = `
|
||||
<script>
|
||||
try {
|
||||
const result = (function() { ${script} })();
|
||||
window.parent.conditionComplete(result);
|
||||
} catch (error) {
|
||||
window.parent.conditionComplete(null, error);
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
scriptExecutor.srcdoc = scriptContent;
|
||||
});
|
||||
}
|
||||
|
||||
appendLog(text) {
|
||||
this.onLogAppend(text + '\n\n');
|
||||
}
|
||||
|
||||
logApiResponse(response, type) {
|
||||
const responseLog = type === 'HTTP'
|
||||
? `Response:\nStatus: ${response.status}\nHeaders:\n${this.formatHeaders(response.headers)}\nBody:\n${response.body}`
|
||||
: `Response:\n${response.body}`;
|
||||
|
||||
this.appendLog(responseLog);
|
||||
}
|
||||
|
||||
formatHeaders(headers) {
|
||||
return Object.entries(headers)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
async executeAll() {
|
||||
while (this.currentNodeId && !this.isDone && !this.isPaused) {
|
||||
try {
|
||||
await this.executeCurrentNode();
|
||||
await this.moveToNextNode();
|
||||
} catch (error) {
|
||||
this.appendLog(`Execution stopped: ${error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isDone) {
|
||||
this.appendLog('Scenario execution completed');
|
||||
} else if (this.isPaused) {
|
||||
this.appendLog('Scenario execution paused');
|
||||
}
|
||||
}
|
||||
|
||||
async moveToNextNode() {
|
||||
const nextNodes = this.findNextNodes(this.currentNodeId);
|
||||
|
||||
if (nextNodes.length === 0) {
|
||||
this.isDone = true;
|
||||
this.currentNodeId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentNode = this.nodes.find(n => n.id === this.currentNodeId);
|
||||
|
||||
if (currentNode.type === 'conditionNode') {
|
||||
const result = await this.evaluateCondition(currentNode.data.script || currentNode.data.condition);
|
||||
const nextNode = nextNodes.find(n => n.condition === (result ? 'true' : 'false'));
|
||||
this.currentNodeId = nextNode?.nodeId;
|
||||
} else {
|
||||
const nextNode = nextNodes.find(n => !this.visitedNodes.has(n.nodeId));
|
||||
this.currentNodeId = nextNode?.nodeId;
|
||||
}
|
||||
|
||||
if (!this.currentNodeId) {
|
||||
this.isDone = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useScenarioExecutor = (nodes, edges, apiClient) => {
|
||||
const [logs, setLogs] = useState('');
|
||||
const [nodeStatuses, setNodeStatuses] = useState({});
|
||||
const executorRef = useRef(null);
|
||||
|
||||
const appendLog = useCallback((text) => {
|
||||
setLogs(prev => prev + text);
|
||||
}, []);
|
||||
|
||||
const updateNodeStatus = useCallback((nodeId, status) => {
|
||||
setNodeStatuses(prev => ({ ...prev, [nodeId]: status }));
|
||||
}, []);
|
||||
|
||||
const initExecutor = useCallback(() => {
|
||||
executorRef.current = new ScenarioExecutor(
|
||||
nodes,
|
||||
edges,
|
||||
apiClient,
|
||||
appendLog,
|
||||
updateNodeStatus
|
||||
);
|
||||
}, [nodes, edges, apiClient]);
|
||||
|
||||
const executeStep = useCallback(async () => {
|
||||
if (!executorRef.current) initExecutor();
|
||||
if (!executorRef.current.isDone) {
|
||||
await executorRef.current.executeCurrentNode();
|
||||
await executorRef.current.moveToNextNode();
|
||||
}
|
||||
}, [initExecutor]);
|
||||
|
||||
const executeScenario = useCallback(async () => {
|
||||
if (!executorRef.current) initExecutor();
|
||||
await executorRef.current.executeAll();
|
||||
}, [initExecutor]);
|
||||
|
||||
const pauseExecution = useCallback(() => {
|
||||
if (executorRef.current) {
|
||||
executorRef.current.pause();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resumeExecution = useCallback(async () => {
|
||||
if (executorRef.current) {
|
||||
executorRef.current.resume();
|
||||
await executorRef.current.executeAll();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
logs,
|
||||
nodeStatuses,
|
||||
clearLogs: () => setLogs(''),
|
||||
executeScenario,
|
||||
executeStep,
|
||||
pauseExecution,
|
||||
resumeExecution,
|
||||
resetExecutor: initExecutor,
|
||||
isRunning: executorRef.current?.isRunning ?? false,
|
||||
isPaused: executorRef.current?.isPaused ?? false,
|
||||
isDone: executorRef.current?.isDone ?? false
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
// services/loadTestService.js
|
||||
import { getCSRFToken } from '@/services/token.js';
|
||||
|
||||
export const loadTestService = (contextPath = '/') => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCSRFToken()
|
||||
};
|
||||
|
||||
return {
|
||||
async loadTests() {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/list.do`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch load tests');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async createTest(testName) {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: testName })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create test');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async updateTest(test) {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update test');
|
||||
return test;
|
||||
},
|
||||
|
||||
async deleteTest(testId) {
|
||||
const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ id: testId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete test');
|
||||
return true;
|
||||
},
|
||||
|
||||
async startTest(test) {
|
||||
const response = await fetch(`${contextPath}startLoadTest`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to start test');
|
||||
return true;
|
||||
},
|
||||
|
||||
async stopTest(test) {
|
||||
const response = await fetch(`${contextPath}stopLoadTest`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(test)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to stop test');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
// services/scenarioService.js
|
||||
import { getCSRFToken } from '@/services/token.js';
|
||||
|
||||
export const scenarioService = (contextPath = '/') => {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-XSRF-TOKEN': getCSRFToken()
|
||||
};
|
||||
|
||||
return {
|
||||
async loadScenarios() {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, { headers });
|
||||
if (!response.ok) throw new Error('Failed to fetch scenarios');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async createScenario(name) {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/create.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create scenario');
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async updateScenario(scenario) {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/update.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(scenario)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update scenario');
|
||||
return scenario;
|
||||
},
|
||||
|
||||
async deleteScenario(scenario) {
|
||||
const response = await fetch(`${contextPath}mgmt/api_scenario/delete.do`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(scenario)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete scenario');
|
||||
return true;
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user