시나리오
This commit is contained in:
@@ -4,6 +4,8 @@ import MainContentArea from '@/components/MainContentArea.jsx';
|
|||||||
import {useAPI} from '@/providers/APIProvider.jsx';
|
import {useAPI} from '@/providers/APIProvider.jsx';
|
||||||
import AddCollectionDialog from '@/components/api-tester/AddCollectionDialog.jsx';
|
import AddCollectionDialog from '@/components/api-tester/AddCollectionDialog.jsx';
|
||||||
import AddAPIDialog from '@/components/api-tester/AddAPIDialog.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 = [
|
const menuConfig = [
|
||||||
{
|
{
|
||||||
@@ -78,7 +80,9 @@ const APITester = () => {
|
|||||||
const [isApiMode, setIsApiMode] = useState(true);
|
const [isApiMode, setIsApiMode] = useState(true);
|
||||||
const [showAddCollection, setShowAddCollection] = useState(false);
|
const [showAddCollection, setShowAddCollection] = useState(false);
|
||||||
const [showAddApi, setShowAddApi] = useState(false);
|
const [showAddApi, setShowAddApi] = useState(false);
|
||||||
|
const [showAddScenario, setShowAddScenario] = useState(false);
|
||||||
const { handleLogout, createCollection, createAPIRequest } = useAPI();
|
const { handleLogout, createCollection, createAPIRequest } = useAPI();
|
||||||
|
const {createScenario} = useLoadTest();
|
||||||
|
|
||||||
// 모드 전환 핸들러
|
// 모드 전환 핸들러
|
||||||
const toggleMode = () => {
|
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) => {
|
const handleMenuClick = async (action) => {
|
||||||
// Handle menu item clicks
|
// Handle menu item clicks
|
||||||
@@ -115,6 +128,9 @@ const APITester = () => {
|
|||||||
case 'collection-add':
|
case 'collection-add':
|
||||||
setShowAddCollection(true);
|
setShowAddCollection(true);
|
||||||
break;
|
break;
|
||||||
|
case 'scenario-add':
|
||||||
|
setShowAddScenario(true);
|
||||||
|
break;
|
||||||
case 'logout':
|
case 'logout':
|
||||||
const success = await handleLogout();
|
const success = await handleLogout();
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -176,6 +192,11 @@ const APITester = () => {
|
|||||||
onOpenChange={setShowAddApi}
|
onOpenChange={setShowAddApi}
|
||||||
onSubmit={handleAddApi}
|
onSubmit={handleAddApi}
|
||||||
/>
|
/>
|
||||||
|
<AddScenarioDialog
|
||||||
|
open={showAddScenario}
|
||||||
|
onOpenChange={setShowAddScenario}
|
||||||
|
onSubmit={handleAddScenario}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ScenarioEditor from '@/components/api-tester/ScenarioEditor.jsx';
|
|||||||
|
|
||||||
const MainContentArea = ({ isApiMode, toggleMode }) => {
|
const MainContentArea = ({ isApiMode, toggleMode }) => {
|
||||||
return (
|
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}/>}
|
{isApiMode ? <APIMode isApiMode={isApiMode} onToggleMode={toggleMode}/> : <ScenarioMode isApiMode={isApiMode} onToggleMode={toggleMode}/>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -128,22 +128,19 @@ const APIMode = ({ isApiMode, onToggleMode }) => {
|
|||||||
|
|
||||||
// 시나리오 모드
|
// 시나리오 모드
|
||||||
const ScenarioMode = ({ 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 */}
|
{/* 왼쪽 고정: ScenarioNavigation */}
|
||||||
<div className="navigation-sidebar min-w-[200px] border-r bg-gray-100">
|
<div className="navigation-sidebar min-w-[200px] border-r bg-gray-100">
|
||||||
<ScenarioNavigation
|
<ScenarioNavigation
|
||||||
contextPath="/"
|
contextPath="/"
|
||||||
side="left"
|
side="left"
|
||||||
onScenarioSelect={(scenarioId) => {
|
|
||||||
// Handle scenario selection
|
|
||||||
}}
|
|
||||||
isApiMode={isApiMode}
|
isApiMode={isApiMode}
|
||||||
onToggleMode={onToggleMode}
|
onToggleMode={onToggleMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: ScenarioEditor */}
|
{/* 중앙: ScenarioEditor */}
|
||||||
<div className="editor flex-1">
|
<div className="editor flex-1 h-[calc(100vh-57px)] overflow-hidden">
|
||||||
<ScenarioEditor/>
|
<ScenarioEditor/>
|
||||||
</div>
|
</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 {Background, Controls, MiniMap, ReactFlow, useEdgesState, useNodesState, Position, Handle, ReactFlowProvider} from '@xyflow/react';
|
||||||
import React, { useCallback, memo, useMemo, useRef, useState, useEffect } from 'react';
|
import React, {useCallback, memo, useMemo, useRef, useState, useEffect, forwardRef, useImperativeHandle} from 'react';
|
||||||
import { Play } from 'lucide-react';
|
import {Play} from 'lucide-react';
|
||||||
|
import {useLoadTest} from '@/providers/LoadTestProvider.jsx';
|
||||||
|
|
||||||
// Optimized node components with proper prop types and memoization
|
// Optimized node components with proper prop types and memoization
|
||||||
const StartNode = memo(({ data, isConnectable }) => {
|
const StartNode = memo(({data, isConnectable}) => {
|
||||||
// Memoize handles configuration
|
// Memoize handles configuration
|
||||||
const handles = useMemo(() => ([
|
const handles = useMemo(() => ([
|
||||||
{ id: 'start-right', type: 'source', position: Position.Right }
|
{id: 'start-right', type: 'source', position: Position.Right}
|
||||||
]), []);
|
]), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,18 +22,18 @@ const StartNode = memo(({ data, isConnectable }) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="flex items-center justify-center gap-2">
|
<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>
|
<span className="font-semibold text-sm text-green-700">Start</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ApiNode = memo(({ data, isConnectable }) => {
|
const ApiNode = memo(({data, isConnectable}) => {
|
||||||
// Memoize handles and colors
|
// Memoize handles and colors
|
||||||
const handles = useMemo(() => ([
|
const handles = useMemo(() => ([
|
||||||
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
{id: `${data.id}-left`, type: 'target', position: Position.Left},
|
||||||
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
{id: `${data.id}-right`, type: 'source', position: Position.Right}
|
||||||
]), [data.id]);
|
]), [data.id]);
|
||||||
|
|
||||||
const methodColor = useMemo(() => {
|
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 justify-between mb-2">
|
||||||
<div className="flex items-center gap-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>
|
<span className="font-bold text-sm">{data.method}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +72,7 @@ const ApiNode = memo(({ data, isConnectable }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ConditionNode = memo(({ data, isConnectable }) => {
|
const ConditionNode = memo(({data, isConnectable}) => {
|
||||||
// Memoize handle styles
|
// Memoize handle styles
|
||||||
const handleStyles = useMemo(() => ({
|
const handleStyles = useMemo(() => ({
|
||||||
true: {
|
true: {
|
||||||
@@ -113,7 +114,7 @@ const ConditionNode = memo(({ data, isConnectable }) => {
|
|||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-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>
|
<span className="font-bold text-sm">Condition</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,10 +125,10 @@ const ConditionNode = memo(({ data, isConnectable }) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const DelayNode = memo(({ data, isConnectable }) => {
|
const DelayNode = memo(({data, isConnectable}) => {
|
||||||
const handles = useMemo(() => ([
|
const handles = useMemo(() => ([
|
||||||
{ id: `${data.id}-left`, type: 'target', position: Position.Left },
|
{id: `${data.id}-left`, type: 'target', position: Position.Left},
|
||||||
{ id: `${data.id}-right`, type: 'source', position: Position.Right }
|
{id: `${data.id}-right`, type: 'source', position: Position.Right}
|
||||||
]), [data.id]);
|
]), [data.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,7 +144,7 @@ const DelayNode = memo(({ data, isConnectable }) => {
|
|||||||
))}
|
))}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-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>
|
<span className="font-bold text-sm">Delay</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -160,18 +161,6 @@ ApiNode.displayName = 'ApiNode';
|
|||||||
ConditionNode.displayName = 'ConditionNode';
|
ConditionNode.displayName = 'ConditionNode';
|
||||||
DelayNode.displayName = 'DelayNode';
|
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
|
// Memoize node types
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
startNode: StartNode,
|
startNode: StartNode,
|
||||||
@@ -180,11 +169,40 @@ const nodeTypes = {
|
|||||||
delayNode: DelayNode
|
delayNode: DelayNode
|
||||||
};
|
};
|
||||||
|
|
||||||
const DiagramArea = () => {
|
const DiagramArea = forwardRef(({onSave}, ref) => {
|
||||||
|
const {currentFlowData} = useLoadTest();
|
||||||
const reactFlowWrapper = useRef(null);
|
const reactFlowWrapper = useRef(null);
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState(null);
|
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
|
// Click handlers for node selection
|
||||||
const onNodeClick = useCallback((event, node) => {
|
const onNodeClick = useCallback((event, node) => {
|
||||||
@@ -206,9 +224,11 @@ const DiagramArea = () => {
|
|||||||
);
|
);
|
||||||
}, [selectedNodeId, setNodes]);
|
}, [selectedNodeId, setNodes]);
|
||||||
|
|
||||||
// Memoize callback functions
|
|
||||||
const onConnect = useCallback((params) => {
|
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) => {
|
const onInit = useCallback((instance) => {
|
||||||
@@ -223,25 +243,24 @@ const DiagramArea = () => {
|
|||||||
const onDrop = useCallback((event) => {
|
const onDrop = useCallback((event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (!reactFlowWrapper.current) return;
|
if (!reactFlowWrapper.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiData = JSON.parse(event.dataTransfer.getData('application/json'));
|
const apiData = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||||
const position = reactFlowWrapper.current.screenToFlowPosition({
|
const position = reactFlowWrapper.current.screenToFlowPosition({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY
|
||||||
});
|
});
|
||||||
|
|
||||||
const newNode = {
|
const newNode = {
|
||||||
id: `api-${Date.now()}`,
|
id: `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
type: 'apiNode',
|
type: 'apiNode',
|
||||||
position,
|
position,
|
||||||
data: {
|
data: {
|
||||||
id: apiData.id,
|
...apiData,
|
||||||
name: apiData.name,
|
id: `api-${Date.now()}` // Ensure unique API ID
|
||||||
method: apiData.method,
|
|
||||||
path: apiData.path,
|
|
||||||
collectionId: apiData.collectionId
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -252,34 +271,35 @@ const DiagramArea = () => {
|
|||||||
}, [setNodes]);
|
}, [setNodes]);
|
||||||
|
|
||||||
// Default edge options memoized
|
// Default edge options memoized
|
||||||
const defaultEdgeOptions = useMemo(() => ({ type: 'smoothstep' }), []);
|
const defaultEdgeOptions = useMemo(() => ({type: 'smoothstep'}), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[50vh] border-b">
|
|
||||||
<ReactFlowProvider>
|
<ReactFlowProvider>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
onInit={onInit}
|
onInit={onInit}
|
||||||
defaultEdgeOptions={defaultEdgeOptions}
|
defaultEdgeOptions={defaultEdgeOptions}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onNodeClick={onNodeClick}
|
onNodeClick={onNodeClick}
|
||||||
onPaneClick={onPaneClick}
|
onPaneClick={onPaneClick}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
defaultZoom={1.0} // 초기 배율 설정 (1이 기본값)
|
proOptions={{hideAttribution: true}}
|
||||||
proOptions={{ hideAttribution: true }}
|
>
|
||||||
>
|
<Background/>
|
||||||
<Background />
|
<Controls/>
|
||||||
<Controls />
|
<MiniMap/>
|
||||||
<MiniMap />
|
</ReactFlow>
|
||||||
</ReactFlow>
|
</ReactFlowProvider>
|
||||||
</ReactFlowProvider>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
DiagramArea.displayName = 'DiagramArea';
|
||||||
|
|
||||||
export default memo(DiagramArea);
|
export default memo(DiagramArea);
|
||||||
|
|||||||
@@ -37,18 +37,7 @@ const DraggableApiItem = ({ api, collectionId, onApiAction }) => {
|
|||||||
|
|
||||||
const handleDragStart = (event) => {
|
const handleDragStart = (event) => {
|
||||||
// Prepare API data for drag operation
|
// Prepare API data for drag operation
|
||||||
const apiData = {
|
const apiData = {...api};
|
||||||
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'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create drag image for better visual feedback
|
// Create drag image for better visual feedback
|
||||||
const dragImage = document.createElement('div');
|
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 '@xyflow/react/dist/style.css';
|
||||||
import MonacoEditor from '@/components/shared/MonacoEditor';
|
import MonacoEditor from '@/components/shared/MonacoEditor';
|
||||||
import ScenarioMenuBar from '@/components/api-tester/ScenarioMenuBar.jsx';
|
import ScenarioMenuBar from '@/components/api-tester/ScenarioMenuBar.jsx';
|
||||||
import DiagramArea from '@/components/api-tester/DiagramArea.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
|
// ConsoleArea Component
|
||||||
const ConsoleArea = () => {
|
const ConsoleArea = ({value}) => (
|
||||||
const [consoleOutput, setConsoleOutput] = useState('');
|
|
||||||
|
|
||||||
return (
|
<MonacoEditor
|
||||||
<div className="h-[50vh]">
|
id="scenario-console"
|
||||||
<MonacoEditor
|
value={value}
|
||||||
id="scenario-console"
|
language="plaintext"
|
||||||
value={consoleOutput}
|
readOnly={true}
|
||||||
language="plaintext"
|
height="full"
|
||||||
readOnly={true}
|
/>
|
||||||
height="full"
|
|
||||||
/>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Main ScenarioEditor Component
|
// Main ScenarioEditor Component
|
||||||
const ScenarioEditor = () => {
|
const ScenarioEditor = () => {
|
||||||
const handleSave = () => {
|
const diagramRef = useRef(null);
|
||||||
console.log('Saving scenario...');
|
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 = () => {
|
const handleStepRun = useCallback(async () => {
|
||||||
console.log('Running step...');
|
setIsExecuting(true);
|
||||||
};
|
try {
|
||||||
|
await executeStep();
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
}
|
||||||
|
}, [executeStep]);
|
||||||
|
|
||||||
const handleFullRun = () => {
|
const handleFullRun = useCallback(async () => {
|
||||||
console.log('Running all steps...');
|
setIsExecuting(true);
|
||||||
};
|
try {
|
||||||
|
await executeScenario();
|
||||||
|
} finally {
|
||||||
|
setIsExecuting(false);
|
||||||
|
}
|
||||||
|
}, [executeScenario]);
|
||||||
|
|
||||||
const handlePause = () => {
|
const handlePause = useCallback(() => {
|
||||||
console.log('Pausing execution...');
|
pauseExecution();
|
||||||
};
|
}, [pauseExecution]);
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = useCallback(() => {
|
||||||
console.log('Resetting scenario...');
|
resetExecutor();
|
||||||
};
|
clearLogs();
|
||||||
|
}, [resetExecutor, clearLogs]);
|
||||||
|
|
||||||
|
if (!currentFlowData) {
|
||||||
|
return <div className="flex items-center justify-center h-full">Select a scenario to edit</div>;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -52,9 +101,35 @@ const ScenarioEditor = () => {
|
|||||||
onFullRun={handleFullRun}
|
onFullRun={handleFullRun}
|
||||||
onPause={handlePause}
|
onPause={handlePause}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
|
disabled={isExecuting}
|
||||||
|
isRunning={isRunning}
|
||||||
/>
|
/>
|
||||||
<DiagramArea/>
|
<div className="flex-1 min-h-0 grid grid-rows-2">
|
||||||
<ConsoleArea/>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import { FileText, MoreHorizontal } from 'lucide-react';
|
import { FileText, MoreHorizontal } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -23,6 +23,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useLoadTest } from '@/providers/LoadTestProvider';
|
import { useLoadTest } from '@/providers/LoadTestProvider';
|
||||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||||
|
import DeleteScenarioDialog from '@/components/api-tester/DeleteScenarioDialog.jsx';
|
||||||
|
|
||||||
const ScenarioDropdownMenu = ({ onOpen, onRename, onDelete }) => (
|
const ScenarioDropdownMenu = ({ onOpen, onRename, onDelete }) => (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -64,16 +65,21 @@ const ErrorMessage = ({ message }) => (
|
|||||||
|
|
||||||
const ScenarioNavigation = ({
|
const ScenarioNavigation = ({
|
||||||
contextPath = '/',
|
contextPath = '/',
|
||||||
onScenarioSelect,
|
|
||||||
side,
|
side,
|
||||||
isApiMode,
|
isApiMode,
|
||||||
onToggleMode
|
onToggleMode
|
||||||
}) => {
|
}) => {
|
||||||
|
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [scenarioToDelete, setScenarioToDelete] = useState(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scenarios,
|
scenarios,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
loadScenarioList
|
deleteScenario,
|
||||||
|
loadScenarioList,
|
||||||
|
selectScenario
|
||||||
} = useLoadTest();
|
} = useLoadTest();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -81,14 +87,18 @@ const ScenarioNavigation = ({
|
|||||||
}, [loadScenarioList]);
|
}, [loadScenarioList]);
|
||||||
|
|
||||||
const handleScenarioAction = {
|
const handleScenarioAction = {
|
||||||
open: (scenarioId) => {
|
open: async (scenarioId) => {
|
||||||
if (onScenarioSelect) {
|
try {
|
||||||
onScenarioSelect(scenarioId);
|
console.log({scenarioId});
|
||||||
|
selectScenario(scenarioId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to select scenario:', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
delete: async (scenarioId) => {
|
delete: async (scenario) => {
|
||||||
// TODO: Implement delete functionality
|
console.log('delete', scenario);
|
||||||
console.log('Delete scenario:', scenarioId);
|
setScenarioToDelete(scenario);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
},
|
},
|
||||||
rename: (scenarioId) => {
|
rename: (scenarioId) => {
|
||||||
// TODO: Implement rename functionality
|
// 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) => (
|
const renderScenarioItem = (scenario) => (
|
||||||
<SidebarMenuItem key={scenario.id}>
|
<SidebarMenuItem key={scenario.id}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
@@ -117,7 +135,7 @@ const ScenarioNavigation = ({
|
|||||||
<ScenarioDropdownMenu
|
<ScenarioDropdownMenu
|
||||||
onOpen={() => handleScenarioAction.open(scenario.id)}
|
onOpen={() => handleScenarioAction.open(scenario.id)}
|
||||||
onRename={() => handleScenarioAction.rename(scenario.id)}
|
onRename={() => handleScenarioAction.rename(scenario.id)}
|
||||||
onDelete={() => handleScenarioAction.delete(scenario.id)}
|
onDelete={() => handleScenarioAction.delete(scenario)}
|
||||||
/>
|
/>
|
||||||
</SidebarMenuAction>
|
</SidebarMenuAction>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,6 +179,12 @@ const ScenarioNavigation = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
<DeleteScenarioDialog
|
||||||
|
open={deleteDialogOpen}
|
||||||
|
onOpenChange={setDeleteDialogOpen}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
scenarioName={scenarioToDelete?.name || ''}
|
||||||
|
/>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</SidebarProvider>
|
</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 * as monaco from 'monaco-editor';
|
||||||
import APIClient from '../../lib/api-client';
|
import {Button} from '@/components/ui/button';
|
||||||
import { Button } from "@/components/ui/button";
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import {Card, CardContent} from '@/components/ui/card';
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import LayoutGrid from '../layout/LayoutGrid';
|
import LayoutGrid from '../layout/LayoutGrid';
|
||||||
import LayoutTreeGrid from '../layout/LayoutTreeGrid';
|
|
||||||
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
|
import LayoutTreeDataGrid from '../layout/LayoutTreeDataGrid';
|
||||||
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
|
import ApiLayoutItemTreeBuilder from '../../lib/ApiLayoutItemTreeBuilder';
|
||||||
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
|
import ApiLayoutItemTreeDataBuilder from '../../lib/ApiLayoutItemTreeDataBuilder';
|
||||||
import VariableSnippet from '../shared/VariableSnippet';
|
import VariableSnippet from '../shared/VariableSnippet';
|
||||||
|
import APIClient from '@/services/APIClient.js';
|
||||||
|
|
||||||
export const TCPClientView = ({ parent, serverManager, model, index }) => {
|
export const TCPClientView = ({ parent, serverManager, model, index }) => {
|
||||||
const [apiClient] = useState(() => new APIClient());
|
const [apiClient] = useState(() => new APIClient());
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import EditableInput from '../shared/EditableInput';
|
import EditableInput from '../shared/EditableInput';
|
||||||
import APIClient from '@/lib/api-client';
|
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import APIClient from '@/services/APIClient.js';
|
||||||
|
|
||||||
// Helper function to get indentation for tree visualization
|
// Helper function to get indentation for tree visualization
|
||||||
const getIndent = (depth) => {
|
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 React, {createContext, useState, useContext, useCallback, useEffect} from 'react';
|
||||||
import APIClient from '../lib/api-client';
|
|
||||||
import {collectionService} from '@/services/collectionService';
|
import {collectionService} from '@/services/collectionService';
|
||||||
import {apiRequestService} from '@/services/apiRequestService';
|
import {apiRequestService} from '@/services/apiRequestService';
|
||||||
import {serverService} from '@/services/serverService';
|
import {serverService} from '@/services/serverService';
|
||||||
import {authService} from '@/services/authService';
|
import {authService} from '@/services/authService';
|
||||||
|
import APIClient from '@/services/APIClient.js';
|
||||||
|
|
||||||
const APIContext = createContext(null);
|
const APIContext = createContext(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +1,110 @@
|
|||||||
|
// providers/LoadTestProvider.jsx
|
||||||
import React, { createContext, useState, useContext, useCallback } from 'react';
|
import React, { createContext, useState, useContext, useCallback } from 'react';
|
||||||
|
import { loadTestService } from '@/services/loadTestService';
|
||||||
|
import { scenarioService } from '@/services/scenarioService';
|
||||||
|
|
||||||
// Create the context
|
|
||||||
const LoadTestContext = createContext();
|
const LoadTestContext = createContext();
|
||||||
|
|
||||||
// Provider component
|
|
||||||
export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
||||||
const [tests, setTests] = useState([]);
|
const [tests, setTests] = useState([]);
|
||||||
const [scenarios, setScenarios] = useState([]);
|
const [scenarios, setScenarios] = useState([]);
|
||||||
const [currentTest, setCurrentTest] = useState(null);
|
const [currentTest, setCurrentTest] = useState(null);
|
||||||
|
const [currentScenario, setCurrentScenario] = useState(null);
|
||||||
|
const [currentFlowData, setCurrentFlowData] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
// Load scenario list
|
// Initialize services
|
||||||
|
const loadTests$ = loadTestService(contextPath);
|
||||||
|
const scenarios$ = scenarioService(contextPath);
|
||||||
|
|
||||||
const loadScenarioList = useCallback(async () => {
|
const loadScenarioList = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}mgmt/api_scenario/list.do`, {
|
const data = await scenarios$.loadScenarios();
|
||||||
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();
|
|
||||||
setScenarios(data);
|
setScenarios(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
return [];
|
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 () => {
|
const loadTestList = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}mgmt/load_test/list.do`, {
|
const data = await loadTests$.loadTests();
|
||||||
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();
|
|
||||||
setTests(data);
|
setTests(data);
|
||||||
return data;
|
return data;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -54,24 +113,13 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [contextPath]);
|
}, []);
|
||||||
|
|
||||||
// Create a new test
|
|
||||||
const createTest = useCallback(async (testName) => {
|
const createTest = useCallback(async (testName) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}mgmt/load_test/create.do`, {
|
const newTest = await loadTests$.createTest(testName);
|
||||||
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();
|
|
||||||
await loadTestList();
|
await loadTestList();
|
||||||
return newTest;
|
return newTest;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -80,60 +128,32 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [contextPath, loadTestList]);
|
}, [loadTestList]);
|
||||||
|
|
||||||
// Update a test
|
|
||||||
const updateTest = useCallback(async (test) => {
|
const updateTest = useCallback(async (test) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}mgmt/load_test/update.do`, {
|
const updatedTest = await loadTests$.updateTest(test);
|
||||||
method: 'POST',
|
setTests(prev => prev.map(t => t.id === test.id ? updatedTest : t));
|
||||||
headers: {
|
return updatedTest;
|
||||||
'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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [contextPath]);
|
}, []);
|
||||||
|
|
||||||
// Delete a test
|
|
||||||
const deleteTest = useCallback(async (testId) => {
|
const deleteTest = useCallback(async (testId) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}mgmt/load_test/delete.do`, {
|
await loadTests$.deleteTest(testId);
|
||||||
method: 'POST',
|
setTests(prev => prev.filter(t => t.id !== testId));
|
||||||
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
|
|
||||||
if (currentTest?.id === testId) {
|
if (currentTest?.id === testId) {
|
||||||
setCurrentTest(null);
|
setCurrentTest(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -141,60 +161,43 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [contextPath, currentTest]);
|
}, [currentTest?.id]);
|
||||||
|
|
||||||
// Start a load test
|
|
||||||
const startTest = useCallback(async (test) => {
|
const startTest = useCallback(async (test) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}startLoadTest`, {
|
return await loadTests$.startTest(test);
|
||||||
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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [contextPath]);
|
}, []);
|
||||||
|
|
||||||
// Stop a load test
|
|
||||||
const stopTest = useCallback(async (test) => {
|
const stopTest = useCallback(async (test) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${contextPath}stopLoadTest`, {
|
return await loadTests$.stopTest(test);
|
||||||
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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [contextPath]);
|
}, []);
|
||||||
|
|
||||||
// Prepare context value
|
|
||||||
const contextValue = {
|
const contextValue = {
|
||||||
tests,
|
tests,
|
||||||
scenarios,
|
scenarios,
|
||||||
|
currentScenario,
|
||||||
|
currentFlowData,
|
||||||
|
createScenario,
|
||||||
|
updateScenario,
|
||||||
|
deleteScenario,
|
||||||
|
selectScenario,
|
||||||
currentTest,
|
currentTest,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
@@ -216,14 +219,11 @@ export const LoadTestProvider = ({ children, contextPath = '/' }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom hook to use the LoadTestContext
|
|
||||||
export const useLoadTest = () => {
|
export const useLoadTest = () => {
|
||||||
const context = useContext(LoadTestContext);
|
const context = useContext(LoadTestContext);
|
||||||
|
|
||||||
if (!context) {
|
if (!context) {
|
||||||
throw new Error('useLoadTest must be used within a LoadTestProvider');
|
throw new Error('useLoadTest must be used within a LoadTestProvider');
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
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