576 lines
20 KiB
TypeScript
576 lines
20 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 스크립트 실행 노드 속성 편집
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Card, CardContent } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Plus, Trash2, Terminal, FileCode, Settings, Play } from "lucide-react";
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import type { ScriptActionNodeData } from "@/types/node-editor";
|
|
|
|
interface ScriptActionPropertiesProps {
|
|
nodeId: string;
|
|
data: ScriptActionNodeData;
|
|
}
|
|
|
|
export function ScriptActionProperties({ nodeId, data }: ScriptActionPropertiesProps) {
|
|
const { updateNode } = useFlowEditorStore();
|
|
|
|
// 로컬 상태
|
|
const [displayName, setDisplayName] = useState(data.displayName || "스크립트 실행");
|
|
const [scriptType, setScriptType] = useState<ScriptActionNodeData["scriptType"]>(data.scriptType || "python");
|
|
const [executionMode, setExecutionMode] = useState<"inline" | "file">(data.executionMode || "inline");
|
|
const [inlineScript, setInlineScript] = useState(data.inlineScript || "");
|
|
const [scriptPath, setScriptPath] = useState(data.scriptPath || "");
|
|
const [executablePath, setExecutablePath] = useState(data.executablePath || "");
|
|
const [inputMethod, setInputMethod] = useState<ScriptActionNodeData["inputMethod"]>(data.inputMethod || "stdin");
|
|
const [inputFormat, setInputFormat] = useState<"json" | "csv" | "text">(data.inputFormat || "json");
|
|
const [workingDirectory, setWorkingDirectory] = useState(data.workingDirectory || "");
|
|
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "60000");
|
|
const [maxBuffer, setMaxBuffer] = useState(data.options?.maxBuffer?.toString() || "1048576");
|
|
const [shell, setShell] = useState(data.options?.shell || "");
|
|
const [captureStdout, setCaptureStdout] = useState(data.outputHandling?.captureStdout ?? true);
|
|
const [captureStderr, setCaptureStderr] = useState(data.outputHandling?.captureStderr ?? true);
|
|
const [parseOutput, setParseOutput] = useState<"json" | "lines" | "text">(data.outputHandling?.parseOutput || "text");
|
|
|
|
// 환경변수
|
|
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>(
|
|
Object.entries(data.environmentVariables || {}).map(([key, value]) => ({ key, value }))
|
|
);
|
|
|
|
// 명령줄 인자
|
|
const [args, setArgs] = useState<string[]>(data.arguments || []);
|
|
|
|
// 데이터 변경 시 로컬 상태 동기화
|
|
useEffect(() => {
|
|
setDisplayName(data.displayName || "스크립트 실행");
|
|
setScriptType(data.scriptType || "python");
|
|
setExecutionMode(data.executionMode || "inline");
|
|
setInlineScript(data.inlineScript || "");
|
|
setScriptPath(data.scriptPath || "");
|
|
setExecutablePath(data.executablePath || "");
|
|
setInputMethod(data.inputMethod || "stdin");
|
|
setInputFormat(data.inputFormat || "json");
|
|
setWorkingDirectory(data.workingDirectory || "");
|
|
setTimeout(data.options?.timeout?.toString() || "60000");
|
|
setMaxBuffer(data.options?.maxBuffer?.toString() || "1048576");
|
|
setShell(data.options?.shell || "");
|
|
setCaptureStdout(data.outputHandling?.captureStdout ?? true);
|
|
setCaptureStderr(data.outputHandling?.captureStderr ?? true);
|
|
setParseOutput(data.outputHandling?.parseOutput || "text");
|
|
setEnvVars(Object.entries(data.environmentVariables || {}).map(([key, value]) => ({ key, value })));
|
|
setArgs(data.arguments || []);
|
|
}, [data]);
|
|
|
|
// 노드 업데이트 함수
|
|
const updateNodeData = useCallback(
|
|
(updates: Partial<ScriptActionNodeData>) => {
|
|
updateNode(nodeId, {
|
|
...data,
|
|
...updates,
|
|
});
|
|
},
|
|
[nodeId, data, updateNode]
|
|
);
|
|
|
|
// 표시명 변경
|
|
const handleDisplayNameChange = (value: string) => {
|
|
setDisplayName(value);
|
|
updateNodeData({ displayName: value });
|
|
};
|
|
|
|
// 스크립트 타입 변경
|
|
const handleScriptTypeChange = (value: ScriptActionNodeData["scriptType"]) => {
|
|
setScriptType(value);
|
|
updateNodeData({ scriptType: value });
|
|
};
|
|
|
|
// 실행 모드 변경
|
|
const handleExecutionModeChange = (value: "inline" | "file") => {
|
|
setExecutionMode(value);
|
|
updateNodeData({ executionMode: value });
|
|
};
|
|
|
|
// 스크립트 내용 업데이트
|
|
const updateScriptContent = useCallback(() => {
|
|
updateNodeData({
|
|
inlineScript,
|
|
scriptPath,
|
|
executablePath,
|
|
});
|
|
}, [inlineScript, scriptPath, executablePath, updateNodeData]);
|
|
|
|
// 입력 설정 업데이트
|
|
const updateInputSettings = useCallback(() => {
|
|
updateNodeData({
|
|
inputMethod,
|
|
inputFormat,
|
|
workingDirectory: workingDirectory || undefined,
|
|
});
|
|
}, [inputMethod, inputFormat, workingDirectory, updateNodeData]);
|
|
|
|
// 옵션 업데이트
|
|
const updateOptions = useCallback(() => {
|
|
updateNodeData({
|
|
options: {
|
|
timeout: parseInt(timeout) || 60000,
|
|
maxBuffer: parseInt(maxBuffer) || 1048576,
|
|
shell: shell || undefined,
|
|
},
|
|
});
|
|
}, [timeout, maxBuffer, shell, updateNodeData]);
|
|
|
|
// 출력 처리 업데이트
|
|
const updateOutputHandling = useCallback(() => {
|
|
updateNodeData({
|
|
outputHandling: {
|
|
captureStdout,
|
|
captureStderr,
|
|
parseOutput,
|
|
},
|
|
});
|
|
}, [captureStdout, captureStderr, parseOutput, updateNodeData]);
|
|
|
|
// 환경변수 추가
|
|
const addEnvVar = () => {
|
|
const newEnvVars = [...envVars, { key: "", value: "" }];
|
|
setEnvVars(newEnvVars);
|
|
};
|
|
|
|
// 환경변수 삭제
|
|
const removeEnvVar = (index: number) => {
|
|
const newEnvVars = envVars.filter((_, i) => i !== index);
|
|
setEnvVars(newEnvVars);
|
|
const envObj = Object.fromEntries(newEnvVars.filter(e => e.key).map(e => [e.key, e.value]));
|
|
updateNodeData({ environmentVariables: envObj });
|
|
};
|
|
|
|
// 환경변수 업데이트
|
|
const updateEnvVar = (index: number, field: "key" | "value", value: string) => {
|
|
const newEnvVars = [...envVars];
|
|
newEnvVars[index][field] = value;
|
|
setEnvVars(newEnvVars);
|
|
};
|
|
|
|
// 환경변수 저장
|
|
const saveEnvVars = () => {
|
|
const envObj = Object.fromEntries(envVars.filter(e => e.key).map(e => [e.key, e.value]));
|
|
updateNodeData({ environmentVariables: envObj });
|
|
};
|
|
|
|
// 인자 추가
|
|
const addArg = () => {
|
|
const newArgs = [...args, ""];
|
|
setArgs(newArgs);
|
|
};
|
|
|
|
// 인자 삭제
|
|
const removeArg = (index: number) => {
|
|
const newArgs = args.filter((_, i) => i !== index);
|
|
setArgs(newArgs);
|
|
updateNodeData({ arguments: newArgs });
|
|
};
|
|
|
|
// 인자 업데이트
|
|
const updateArg = (index: number, value: string) => {
|
|
const newArgs = [...args];
|
|
newArgs[index] = value;
|
|
setArgs(newArgs);
|
|
};
|
|
|
|
// 인자 저장
|
|
const saveArgs = () => {
|
|
updateNodeData({ arguments: args.filter(a => a) });
|
|
};
|
|
|
|
// 스크립트 타입별 기본 스크립트 템플릿
|
|
const getScriptTemplate = (type: string) => {
|
|
switch (type) {
|
|
case "python":
|
|
return `import sys
|
|
import json
|
|
|
|
# 입력 데이터 읽기 (stdin)
|
|
input_data = json.loads(sys.stdin.read())
|
|
|
|
# 처리 로직
|
|
result = {
|
|
"status": "success",
|
|
"data": input_data
|
|
}
|
|
|
|
# 결과 출력
|
|
print(json.dumps(result))`;
|
|
case "shell":
|
|
return `#!/bin/bash
|
|
|
|
# 입력 데이터 읽기
|
|
INPUT=$(cat)
|
|
|
|
# 처리 로직
|
|
echo "입력 데이터: $INPUT"
|
|
|
|
# 결과 출력
|
|
echo '{"status": "success"}'`;
|
|
case "powershell":
|
|
return `# 입력 데이터 읽기
|
|
$input = $input | ConvertFrom-Json
|
|
|
|
# 처리 로직
|
|
$result = @{
|
|
status = "success"
|
|
data = $input
|
|
}
|
|
|
|
# 결과 출력
|
|
$result | ConvertTo-Json`;
|
|
case "node":
|
|
return `const readline = require('readline');
|
|
|
|
let input = '';
|
|
|
|
process.stdin.on('data', (chunk) => {
|
|
input += chunk;
|
|
});
|
|
|
|
process.stdin.on('end', () => {
|
|
const data = JSON.parse(input);
|
|
|
|
// 처리 로직
|
|
const result = {
|
|
status: 'success',
|
|
data: data
|
|
};
|
|
|
|
console.log(JSON.stringify(result));
|
|
});`;
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 p-4">
|
|
{/* 표시명 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">표시명</Label>
|
|
<Input
|
|
value={displayName}
|
|
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
|
placeholder="스크립트 실행"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* 스크립트 타입 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">스크립트 타입</Label>
|
|
<Select value={scriptType} onValueChange={handleScriptTypeChange}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="python">Python</SelectItem>
|
|
<SelectItem value="shell">Shell (Bash)</SelectItem>
|
|
<SelectItem value="powershell">PowerShell</SelectItem>
|
|
<SelectItem value="node">Node.js</SelectItem>
|
|
<SelectItem value="executable">실행 파일</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 실행 모드 */}
|
|
<div className="space-y-2">
|
|
<Label className="text-xs font-medium">실행 방식</Label>
|
|
<Select value={executionMode} onValueChange={handleExecutionModeChange}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="inline">인라인 스크립트</SelectItem>
|
|
<SelectItem value="file">파일 실행</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Tabs defaultValue="script" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="script" className="text-xs">
|
|
<FileCode className="mr-1 h-3 w-3" />
|
|
스크립트
|
|
</TabsTrigger>
|
|
<TabsTrigger value="input" className="text-xs">
|
|
<Play className="mr-1 h-3 w-3" />
|
|
입력
|
|
</TabsTrigger>
|
|
<TabsTrigger value="env" className="text-xs">
|
|
<Terminal className="mr-1 h-3 w-3" />
|
|
환경
|
|
</TabsTrigger>
|
|
<TabsTrigger value="output" className="text-xs">
|
|
<Settings className="mr-1 h-3 w-3" />
|
|
출력
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 스크립트 탭 */}
|
|
<TabsContent value="script" className="space-y-3 pt-3">
|
|
{executionMode === "inline" ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">스크립트 코드</Label>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-6 text-xs"
|
|
onClick={() => setInlineScript(getScriptTemplate(scriptType))}
|
|
>
|
|
템플릿 삽입
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={inlineScript}
|
|
onChange={(e) => setInlineScript(e.target.value)}
|
|
onBlur={updateScriptContent}
|
|
placeholder="스크립트 코드를 입력하세요..."
|
|
className="min-h-[250px] font-mono text-xs"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{scriptType === "executable" ? (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">실행 파일 경로</Label>
|
|
<Input
|
|
value={executablePath}
|
|
onChange={(e) => setExecutablePath(e.target.value)}
|
|
onBlur={updateScriptContent}
|
|
placeholder="/path/to/executable"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">스크립트 파일 경로</Label>
|
|
<Input
|
|
value={scriptPath}
|
|
onChange={(e) => setScriptPath(e.target.value)}
|
|
onBlur={updateScriptContent}
|
|
placeholder={`/path/to/script.${scriptType === "python" ? "py" : scriptType === "shell" ? "sh" : scriptType === "powershell" ? "ps1" : "js"}`}
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 명령줄 인자 */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">명령줄 인자</Label>
|
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addArg}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
{args.map((arg, index) => (
|
|
<div key={index} className="flex gap-2">
|
|
<Input
|
|
value={arg}
|
|
onChange={(e) => updateArg(index, e.target.value)}
|
|
onBlur={saveArgs}
|
|
placeholder={`인자 ${index + 1}`}
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeArg(index)}>
|
|
<Trash2 className="h-3 w-3 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 입력 탭 */}
|
|
<TabsContent value="input" className="space-y-3 pt-3">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">입력 전달 방식</Label>
|
|
<Select value={inputMethod} onValueChange={(v: ScriptActionNodeData["inputMethod"]) => {
|
|
setInputMethod(v);
|
|
updateNodeData({ inputMethod: v });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="stdin">표준입력 (stdin)</SelectItem>
|
|
<SelectItem value="args">명령줄 인자</SelectItem>
|
|
<SelectItem value="env">환경변수</SelectItem>
|
|
<SelectItem value="file">임시 파일</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{(inputMethod === "stdin" || inputMethod === "file") && (
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">입력 형식</Label>
|
|
<Select value={inputFormat} onValueChange={(v: "json" | "csv" | "text") => {
|
|
setInputFormat(v);
|
|
updateNodeData({ inputFormat: v });
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="json">JSON</SelectItem>
|
|
<SelectItem value="csv">CSV</SelectItem>
|
|
<SelectItem value="text">텍스트</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">작업 디렉토리</Label>
|
|
<Input
|
|
value={workingDirectory}
|
|
onChange={(e) => setWorkingDirectory(e.target.value)}
|
|
onBlur={updateInputSettings}
|
|
placeholder="/path/to/working/directory"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">타임아웃 (ms)</Label>
|
|
<Input
|
|
type="number"
|
|
value={timeout}
|
|
onChange={(e) => setTimeout(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="60000"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 환경변수 탭 */}
|
|
<TabsContent value="env" className="space-y-3 pt-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs">환경변수</Label>
|
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={addEnvVar}>
|
|
<Plus className="mr-1 h-3 w-3" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
|
|
{envVars.map((env, index) => (
|
|
<div key={index} className="flex gap-2">
|
|
<Input
|
|
value={env.key}
|
|
onChange={(e) => updateEnvVar(index, "key", e.target.value)}
|
|
onBlur={saveEnvVars}
|
|
placeholder="변수명"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Input
|
|
value={env.value}
|
|
onChange={(e) => updateEnvVar(index, "value", e.target.value)}
|
|
onBlur={saveEnvVars}
|
|
placeholder="값"
|
|
className="h-8 text-sm"
|
|
/>
|
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeEnvVar(index)}>
|
|
<Trash2 className="h-3 w-3 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
|
|
{envVars.length === 0 && (
|
|
<Card className="bg-gray-50">
|
|
<CardContent className="p-3 text-xs text-gray-500">
|
|
환경변수가 없습니다. 추가 버튼을 클릭하여 환경변수를 설정하세요.
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">사용할 쉘</Label>
|
|
<Input
|
|
value={shell}
|
|
onChange={(e) => setShell(e.target.value)}
|
|
onBlur={updateOptions}
|
|
placeholder="/bin/bash (기본값 사용 시 비워두기)"
|
|
className="h-8 text-sm"
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* 출력 탭 */}
|
|
<TabsContent value="output" className="space-y-3 pt-3">
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
checked={captureStdout}
|
|
onCheckedChange={(checked) => {
|
|
setCaptureStdout(checked);
|
|
updateNodeData({
|
|
outputHandling: { ...data.outputHandling, captureStdout: checked, captureStderr, parseOutput },
|
|
});
|
|
}}
|
|
/>
|
|
<Label className="text-xs">표준출력 (stdout) 캡처</Label>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<Switch
|
|
checked={captureStderr}
|
|
onCheckedChange={(checked) => {
|
|
setCaptureStderr(checked);
|
|
updateNodeData({
|
|
outputHandling: { ...data.outputHandling, captureStdout, captureStderr: checked, parseOutput },
|
|
});
|
|
}}
|
|
/>
|
|
<Label className="text-xs">표준에러 (stderr) 캡처</Label>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label className="text-xs">출력 파싱</Label>
|
|
<Select value={parseOutput} onValueChange={(v: "json" | "lines" | "text") => {
|
|
setParseOutput(v);
|
|
updateNodeData({
|
|
outputHandling: { ...data.outputHandling, captureStdout, captureStderr, parseOutput: v },
|
|
});
|
|
}}>
|
|
<SelectTrigger className="h-8 text-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="json">JSON 파싱</SelectItem>
|
|
<SelectItem value="lines">줄 단위 배열</SelectItem>
|
|
<SelectItem value="text">텍스트 그대로</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Card className="bg-gray-50">
|
|
<CardContent className="p-3 text-xs text-gray-600">
|
|
<div className="font-medium mb-1">출력 데이터 사용:</div>
|
|
스크립트의 출력은 다음 노드에서 <code>{"{{scriptOutput}}"}</code>로 참조할 수 있습니다.
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|
|
|