시나리오

This commit is contained in:
현성필
2025-01-25 09:51:11 +09:00
parent 097b2c79c2
commit bca5840712
17 changed files with 989 additions and 515 deletions
+21
View File
@@ -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) => {