외부호출 노드들

This commit is contained in:
kjs 2025-12-09 12:13:30 +09:00
parent cf73ce6ebb
commit bb98e9319f
12 changed files with 2671 additions and 5 deletions

View File

@ -32,6 +32,9 @@ export type NodeType =
| "updateAction"
| "deleteAction"
| "upsertAction"
| "emailAction" // 이메일 발송 액션
| "scriptAction" // 스크립트 실행 액션
| "httpRequestAction" // HTTP 요청 액션
| "comment"
| "log";
@ -547,6 +550,15 @@ export class NodeFlowExecutionService {
case "condition":
return this.executeCondition(node, inputData, context);
case "emailAction":
return this.executeEmailAction(node, inputData, context);
case "scriptAction":
return this.executeScriptAction(node, inputData, context);
case "httpRequestAction":
return this.executeHttpRequestAction(node, inputData, context);
case "comment":
case "log":
// 로그/코멘트는 실행 없이 통과
@ -3379,4 +3391,463 @@ export class NodeFlowExecutionService {
return filteredResults;
}
// ===================================================================
// 외부 연동 액션 노드들
// ===================================================================
/**
*
*/
private static async executeEmailAction(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any> {
const {
from,
to,
cc,
bcc,
subject,
body,
bodyType,
isHtml, // 레거시 지원
accountId: nodeAccountId, // 프론트엔드에서 선택한 계정 ID
smtpConfigId, // 레거시 지원
attachments,
templateVariables,
} = node.data;
logger.info(`📧 이메일 발송 노드 실행: ${node.data.displayName || node.id}`);
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const results: any[] = [];
// 동적 임포트로 순환 참조 방지
const { mailSendSimpleService } = await import("./mailSendSimpleService");
const { mailAccountFileService } = await import("./mailAccountFileService");
// 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정
let accountId = nodeAccountId || smtpConfigId;
if (!accountId) {
const accounts = await mailAccountFileService.getAccounts();
const activeAccount = accounts.find((acc: any) => acc.status === "active");
if (activeAccount) {
accountId = activeAccount.id;
logger.info(`📧 자동 선택된 메일 계정: ${activeAccount.name} (${activeAccount.email})`);
} else {
throw new Error("활성화된 메일 계정이 없습니다. 메일 계정을 먼저 설정해주세요.");
}
}
// HTML 여부 판단 (bodyType 우선, isHtml 레거시 지원)
const useHtml = bodyType === "html" || isHtml === true;
for (const data of dataArray) {
try {
// 템플릿 변수 치환
const processedSubject = this.replaceTemplateVariables(subject || "", data);
const processedBody = this.replaceTemplateVariables(body || "", data);
const processedTo = this.replaceTemplateVariables(to || "", data);
const processedCc = cc ? this.replaceTemplateVariables(cc, data) : undefined;
const processedBcc = bcc ? this.replaceTemplateVariables(bcc, data) : undefined;
// 수신자 파싱 (쉼표로 구분)
const toList = processedTo.split(",").map((email: string) => email.trim()).filter((email: string) => email);
const ccList = processedCc ? processedCc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined;
const bccList = processedBcc ? processedBcc.split(",").map((email: string) => email.trim()).filter((email: string) => email) : undefined;
if (toList.length === 0) {
throw new Error("수신자 이메일 주소가 지정되지 않았습니다.");
}
// 메일 발송 요청
const sendResult = await mailSendSimpleService.sendMail({
accountId,
to: toList,
cc: ccList,
bcc: bccList,
subject: processedSubject,
customHtml: useHtml ? processedBody : `<pre>${processedBody}</pre>`,
attachments: attachments?.map((att: any) => ({
filename: att.type === "dataField" ? data[att.value] : att.value,
path: att.type === "dataField" ? data[att.value] : att.value,
})),
});
if (sendResult.success) {
logger.info(`✅ 이메일 발송 성공: ${toList.join(", ")}`);
results.push({
success: true,
to: toList,
messageId: sendResult.messageId,
});
} else {
logger.error(`❌ 이메일 발송 실패: ${sendResult.error}`);
results.push({
success: false,
to: toList,
error: sendResult.error,
});
}
} catch (error: any) {
logger.error(`❌ 이메일 발송 오류:`, error);
results.push({
success: false,
error: error.message,
});
}
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`📧 이메일 발송 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
return {
action: "emailAction",
totalCount: results.length,
successCount,
failedCount,
results,
};
}
/**
*
*/
private static async executeScriptAction(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any> {
const {
scriptType,
scriptPath,
arguments: scriptArgs,
workingDirectory,
environmentVariables,
timeout,
captureOutput,
} = node.data;
logger.info(`🖥️ 스크립트 실행 노드 실행: ${node.data.displayName || node.id}`);
logger.info(` 스크립트 타입: ${scriptType}, 경로: ${scriptPath}`);
if (!scriptPath) {
throw new Error("스크립트 경로가 지정되지 않았습니다.");
}
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const results: any[] = [];
// child_process 모듈 동적 임포트
const { spawn } = await import("child_process");
const path = await import("path");
for (const data of dataArray) {
try {
// 인자 처리
const processedArgs: string[] = [];
if (scriptArgs && Array.isArray(scriptArgs)) {
for (const arg of scriptArgs) {
if (arg.type === "dataField") {
// 데이터 필드 참조
const value = this.replaceTemplateVariables(arg.value, data);
processedArgs.push(value);
} else {
processedArgs.push(arg.value);
}
}
}
// 환경 변수 처리
const env = {
...process.env,
...(environmentVariables || {}),
};
// 스크립트 타입에 따른 명령어 결정
let command: string;
let args: string[];
switch (scriptType) {
case "python":
command = "python3";
args = [scriptPath, ...processedArgs];
break;
case "shell":
command = "bash";
args = [scriptPath, ...processedArgs];
break;
case "executable":
command = scriptPath;
args = processedArgs;
break;
default:
throw new Error(`지원하지 않는 스크립트 타입: ${scriptType}`);
}
logger.info(` 실행 명령: ${command} ${args.join(" ")}`);
// 스크립트 실행 (Promise로 래핑)
const result = await new Promise<{
exitCode: number | null;
stdout: string;
stderr: string;
}>((resolve, reject) => {
const childProcess = spawn(command, args, {
cwd: workingDirectory || process.cwd(),
env,
timeout: timeout || 60000, // 기본 60초
});
let stdout = "";
let stderr = "";
if (captureOutput !== false) {
childProcess.stdout?.on("data", (data) => {
stdout += data.toString();
});
childProcess.stderr?.on("data", (data) => {
stderr += data.toString();
});
}
childProcess.on("close", (code) => {
resolve({ exitCode: code, stdout, stderr });
});
childProcess.on("error", (error) => {
reject(error);
});
});
if (result.exitCode === 0) {
logger.info(`✅ 스크립트 실행 성공 (종료 코드: ${result.exitCode})`);
results.push({
success: true,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
data,
});
} else {
logger.warn(`⚠️ 스크립트 실행 완료 (종료 코드: ${result.exitCode})`);
results.push({
success: false,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
data,
});
}
} catch (error: any) {
logger.error(`❌ 스크립트 실행 오류:`, error);
results.push({
success: false,
error: error.message,
data,
});
}
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`🖥️ 스크립트 실행 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
return {
action: "scriptAction",
scriptType,
scriptPath,
totalCount: results.length,
successCount,
failedCount,
results,
};
}
/**
* HTTP
*/
private static async executeHttpRequestAction(
node: FlowNode,
inputData: any,
context: ExecutionContext
): Promise<any> {
const {
url,
method,
headers,
bodyTemplate,
bodyType,
authentication,
timeout,
retryCount,
responseMapping,
} = node.data;
logger.info(`🌐 HTTP 요청 노드 실행: ${node.data.displayName || node.id}`);
logger.info(` 메서드: ${method}, URL: ${url}`);
if (!url) {
throw new Error("HTTP 요청 URL이 지정되지 않았습니다.");
}
// 입력 데이터를 배열로 정규화
const dataArray = Array.isArray(inputData) ? inputData : inputData ? [inputData] : [{}];
const results: any[] = [];
for (const data of dataArray) {
let currentRetry = 0;
const maxRetries = retryCount || 0;
while (currentRetry <= maxRetries) {
try {
// URL 템플릿 변수 치환
const processedUrl = this.replaceTemplateVariables(url, data);
// 헤더 처리
const processedHeaders: Record<string, string> = {};
if (headers && Array.isArray(headers)) {
for (const header of headers) {
const headerValue =
header.valueType === "dataField"
? this.replaceTemplateVariables(header.value, data)
: header.value;
processedHeaders[header.name] = headerValue;
}
}
// 인증 헤더 추가
if (authentication) {
switch (authentication.type) {
case "basic":
if (authentication.username && authentication.password) {
const credentials = Buffer.from(
`${authentication.username}:${authentication.password}`
).toString("base64");
processedHeaders["Authorization"] = `Basic ${credentials}`;
}
break;
case "bearer":
if (authentication.token) {
processedHeaders["Authorization"] = `Bearer ${authentication.token}`;
}
break;
case "apikey":
if (authentication.apiKey) {
if (authentication.apiKeyLocation === "query") {
// 쿼리 파라미터로 추가 (URL에 추가)
const paramName = authentication.apiKeyQueryParam || "api_key";
const separator = processedUrl.includes("?") ? "&" : "?";
// URL은 이미 처리되었으므로 여기서는 결과에 포함
} else {
// 헤더로 추가
const headerName = authentication.apiKeyHeader || "X-API-Key";
processedHeaders[headerName] = authentication.apiKey;
}
}
break;
}
}
// Content-Type 기본값
if (!processedHeaders["Content-Type"] && ["POST", "PUT", "PATCH"].includes(method)) {
processedHeaders["Content-Type"] =
bodyType === "json" ? "application/json" : "text/plain";
}
// 바디 처리
let processedBody: string | undefined;
if (["POST", "PUT", "PATCH"].includes(method) && bodyTemplate) {
processedBody = this.replaceTemplateVariables(bodyTemplate, data);
}
logger.info(` 요청 URL: ${processedUrl}`);
logger.info(` 요청 헤더: ${JSON.stringify(processedHeaders)}`);
if (processedBody) {
logger.info(` 요청 바디: ${processedBody.substring(0, 200)}...`);
}
// HTTP 요청 실행
const response = await axios({
method: method.toLowerCase() as any,
url: processedUrl,
headers: processedHeaders,
data: processedBody,
timeout: timeout || 30000,
validateStatus: () => true, // 모든 상태 코드 허용
});
logger.info(` 응답 상태: ${response.status} ${response.statusText}`);
// 응답 데이터 처리
let responseData = response.data;
// 응답 매핑 적용
if (responseMapping && responseData) {
const paths = responseMapping.split(".");
for (const path of paths) {
if (responseData && typeof responseData === "object" && path in responseData) {
responseData = responseData[path];
} else {
logger.warn(`⚠️ 응답 매핑 경로를 찾을 수 없습니다: ${responseMapping}`);
break;
}
}
}
const isSuccess = response.status >= 200 && response.status < 300;
if (isSuccess) {
logger.info(`✅ HTTP 요청 성공`);
results.push({
success: true,
statusCode: response.status,
data: responseData,
inputData: data,
});
break; // 성공 시 재시도 루프 종료
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error: any) {
currentRetry++;
if (currentRetry > maxRetries) {
logger.error(`❌ HTTP 요청 실패 (재시도 ${currentRetry - 1}/${maxRetries}):`, error.message);
results.push({
success: false,
error: error.message,
inputData: data,
});
} else {
logger.warn(`⚠️ HTTP 요청 재시도 (${currentRetry}/${maxRetries}): ${error.message}`);
// 재시도 전 잠시 대기
await new Promise((resolve) => setTimeout(resolve, 1000 * currentRetry));
}
}
}
}
const successCount = results.filter((r) => r.success).length;
const failedCount = results.filter((r) => !r.success).length;
logger.info(`🌐 HTTP 요청 완료: 성공 ${successCount}건, 실패 ${failedCount}`);
return {
action: "httpRequestAction",
method,
url,
totalCount: results.length,
successCount,
failedCount,
results,
};
}
}

View File

@ -28,6 +28,9 @@ import { AggregateNode } from "./nodes/AggregateNode";
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
import { CommentNode } from "./nodes/CommentNode";
import { LogNode } from "./nodes/LogNode";
import { EmailActionNode } from "./nodes/EmailActionNode";
import { ScriptActionNode } from "./nodes/ScriptActionNode";
import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode";
import { validateFlow } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
@ -41,11 +44,15 @@ const nodeTypes = {
condition: ConditionNode,
dataTransform: DataTransformNode,
aggregate: AggregateNode,
// 액션
// 데이터 액션
insertAction: InsertActionNode,
updateAction: UpdateActionNode,
deleteAction: DeleteActionNode,
upsertAction: UpsertActionNode,
// 외부 연동 액션
emailAction: EmailActionNode,
scriptAction: ScriptActionNode,
httpRequestAction: HttpRequestActionNode,
// 유틸리티
comment: CommentNode,
log: LogNode,
@ -246,7 +253,7 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
defaultData.responseMapping = "";
}
// 액션 노드의 경우 targetType 기본값 설정
// 데이터 액션 노드의 경우 targetType 기본값 설정
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
defaultData.targetType = "internal"; // 기본값: 내부 DB
defaultData.fieldMappings = [];
@ -261,6 +268,49 @@ function FlowEditorInner({ initialFlowId }: FlowEditorInnerProps) {
}
}
// 메일 발송 노드
if (type === "emailAction") {
defaultData.displayName = "메일 발송";
defaultData.smtpConfig = {
host: "",
port: 587,
secure: false,
};
defaultData.from = "";
defaultData.to = "";
defaultData.subject = "";
defaultData.body = "";
defaultData.bodyType = "text";
}
// 스크립트 실행 노드
if (type === "scriptAction") {
defaultData.displayName = "스크립트 실행";
defaultData.scriptType = "python";
defaultData.executionMode = "inline";
defaultData.inlineScript = "";
defaultData.inputMethod = "stdin";
defaultData.inputFormat = "json";
defaultData.outputHandling = {
captureStdout: true,
captureStderr: true,
parseOutput: "text",
};
}
// HTTP 요청 노드
if (type === "httpRequestAction") {
defaultData.displayName = "HTTP 요청";
defaultData.url = "";
defaultData.method = "GET";
defaultData.bodyType = "none";
defaultData.authentication = { type: "none" };
defaultData.options = {
timeout: 30000,
followRedirects: true,
};
}
const newNode: any = {
id: `node_${Date.now()}`,
type,

View File

@ -0,0 +1,103 @@
"use client";
/**
*
* SMTP를
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Mail, Server } from "lucide-react";
import type { EmailActionNodeData } from "@/types/node-editor";
export const EmailActionNode = memo(({ data, selected }: NodeProps<EmailActionNodeData>) => {
const hasSmtpConfig = data.smtpConfig?.host && data.smtpConfig?.port;
const hasRecipient = data.to && data.to.trim().length > 0;
const hasSubject = data.subject && data.subject.trim().length > 0;
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-pink-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-pink-500 px-3 py-2 text-white">
<Mail className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "메일 발송"}</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* SMTP 설정 상태 */}
<div className="flex items-center gap-2 text-xs">
<Server className="h-3 w-3 text-gray-400" />
<span className="text-gray-600">
{hasSmtpConfig ? (
<span className="text-green-600">
{data.smtpConfig.host}:{data.smtpConfig.port}
</span>
) : (
<span className="text-orange-500">SMTP </span>
)}
</span>
</div>
{/* 수신자 */}
<div className="text-xs">
<span className="text-gray-500">: </span>
{hasRecipient ? (
<span className="text-gray-700">{data.to}</span>
) : (
<span className="text-orange-500"></span>
)}
</div>
{/* 제목 */}
<div className="text-xs">
<span className="text-gray-500">: </span>
{hasSubject ? (
<span className="truncate text-gray-700">{data.subject}</span>
) : (
<span className="text-orange-500"></span>
)}
</div>
{/* 본문 형식 */}
<div className="flex items-center gap-2">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
data.bodyType === "html" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"
}`}
>
{data.bodyType === "html" ? "HTML" : "TEXT"}
</span>
{data.attachments && data.attachments.length > 0 && (
<span className="rounded bg-purple-100 px-1.5 py-0.5 text-xs text-purple-700">
{data.attachments.length}
</span>
)}
</div>
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-pink-500"
/>
</div>
);
});
EmailActionNode.displayName = "EmailActionNode";

View File

@ -0,0 +1,124 @@
"use client";
/**
* HTTP
* REST API를
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Globe, Lock, Unlock } from "lucide-react";
import type { HttpRequestActionNodeData } from "@/types/node-editor";
// HTTP 메서드별 색상
const METHOD_COLORS: Record<string, { bg: string; text: string }> = {
GET: { bg: "bg-green-100", text: "text-green-700" },
POST: { bg: "bg-blue-100", text: "text-blue-700" },
PUT: { bg: "bg-orange-100", text: "text-orange-700" },
PATCH: { bg: "bg-yellow-100", text: "text-yellow-700" },
DELETE: { bg: "bg-red-100", text: "text-red-700" },
HEAD: { bg: "bg-gray-100", text: "text-gray-700" },
OPTIONS: { bg: "bg-purple-100", text: "text-purple-700" },
};
export const HttpRequestActionNode = memo(({ data, selected }: NodeProps<HttpRequestActionNodeData>) => {
const methodColor = METHOD_COLORS[data.method] || METHOD_COLORS.GET;
const hasUrl = data.url && data.url.trim().length > 0;
const hasAuth = data.authentication?.type && data.authentication.type !== "none";
// URL에서 도메인 추출
const getDomain = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return url;
}
};
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-cyan-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-cyan-500 px-3 py-2 text-white">
<Globe className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "HTTP 요청"}</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* 메서드 & 인증 */}
<div className="flex items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-bold ${methodColor.bg} ${methodColor.text}`}>
{data.method}
</span>
{hasAuth ? (
<span className="flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">
<Lock className="h-3 w-3" />
{data.authentication?.type}
</span>
) : (
<span className="flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-500">
<Unlock className="h-3 w-3" />
</span>
)}
</div>
{/* URL */}
<div className="text-xs">
<span className="text-gray-500">URL: </span>
{hasUrl ? (
<span className="truncate text-gray-700" title={data.url}>
{getDomain(data.url)}
</span>
) : (
<span className="text-orange-500">URL </span>
)}
</div>
{/* 바디 타입 */}
{data.bodyType && data.bodyType !== "none" && (
<div className="text-xs">
<span className="text-gray-500">Body: </span>
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600">
{data.bodyType.toUpperCase()}
</span>
</div>
)}
{/* 타임아웃 & 재시도 */}
<div className="flex gap-2 text-xs text-gray-500">
{data.options?.timeout && (
<span>: {Math.round(data.options.timeout / 1000)}</span>
)}
{data.options?.retryCount && data.options.retryCount > 0 && (
<span>: {data.options.retryCount}</span>
)}
</div>
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-cyan-500"
/>
</div>
);
});
HttpRequestActionNode.displayName = "HttpRequestActionNode";

View File

@ -0,0 +1,118 @@
"use client";
/**
*
* Python, Shell, PowerShell
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { Terminal, FileCode, Play } from "lucide-react";
import type { ScriptActionNodeData } from "@/types/node-editor";
// 스크립트 타입별 아이콘 색상
const SCRIPT_TYPE_COLORS: Record<string, { bg: string; text: string; label: string }> = {
python: { bg: "bg-yellow-100", text: "text-yellow-700", label: "Python" },
shell: { bg: "bg-green-100", text: "text-green-700", label: "Shell" },
powershell: { bg: "bg-blue-100", text: "text-blue-700", label: "PowerShell" },
node: { bg: "bg-emerald-100", text: "text-emerald-700", label: "Node.js" },
executable: { bg: "bg-gray-100", text: "text-gray-700", label: "실행파일" },
};
export const ScriptActionNode = memo(({ data, selected }: NodeProps<ScriptActionNodeData>) => {
const scriptTypeInfo = SCRIPT_TYPE_COLORS[data.scriptType] || SCRIPT_TYPE_COLORS.executable;
const hasScript = data.executionMode === "inline" ? !!data.inlineScript : !!data.scriptPath;
return (
<div
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
selected ? "border-emerald-500 shadow-lg" : "border-gray-200"
}`}
>
{/* 입력 핸들 */}
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
/>
{/* 헤더 */}
<div className="flex items-center gap-2 rounded-t-lg bg-emerald-500 px-3 py-2 text-white">
<Terminal className="h-4 w-4" />
<div className="flex-1">
<div className="text-sm font-semibold">{data.displayName || "스크립트 실행"}</div>
</div>
</div>
{/* 본문 */}
<div className="space-y-2 p-3">
{/* 스크립트 타입 */}
<div className="flex items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-medium ${scriptTypeInfo.bg} ${scriptTypeInfo.text}`}>
{scriptTypeInfo.label}
</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{data.executionMode === "inline" ? "인라인" : "파일"}
</span>
</div>
{/* 스크립트 정보 */}
<div className="flex items-center gap-2 text-xs">
{data.executionMode === "inline" ? (
<>
<FileCode className="h-3 w-3 text-gray-400" />
<span className="text-gray-600">
{hasScript ? (
<span className="text-green-600">
{data.inlineScript!.split("\n").length}
</span>
) : (
<span className="text-orange-500"> </span>
)}
</span>
</>
) : (
<>
<Play className="h-3 w-3 text-gray-400" />
<span className="text-gray-600">
{hasScript ? (
<span className="truncate text-green-600">{data.scriptPath}</span>
) : (
<span className="text-orange-500"> </span>
)}
</span>
</>
)}
</div>
{/* 입력 방식 */}
<div className="text-xs">
<span className="text-gray-500">: </span>
<span className="text-gray-700">
{data.inputMethod === "stdin" && "표준입력 (stdin)"}
{data.inputMethod === "args" && "명령줄 인자"}
{data.inputMethod === "env" && "환경변수"}
{data.inputMethod === "file" && "파일"}
</span>
</div>
{/* 타임아웃 */}
{data.options?.timeout && (
<div className="text-xs text-gray-500">
: {Math.round(data.options.timeout / 1000)}
</div>
)}
</div>
{/* 출력 핸들 */}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !border-2 !border-white !bg-emerald-500"
/>
</div>
);
});
ScriptActionNode.displayName = "ScriptActionNode";

View File

@ -19,6 +19,9 @@ import { AggregateProperties } from "./properties/AggregateProperties";
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
import { CommentProperties } from "./properties/CommentProperties";
import { LogProperties } from "./properties/LogProperties";
import { EmailActionProperties } from "./properties/EmailActionProperties";
import { ScriptActionProperties } from "./properties/ScriptActionProperties";
import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties";
import type { NodeType } from "@/types/node-editor";
export function PropertiesPanel() {
@ -131,6 +134,15 @@ function NodePropertiesRenderer({ node }: { node: any }) {
case "log":
return <LogProperties nodeId={node.id} data={node.data} />;
case "emailAction":
return <EmailActionProperties nodeId={node.id} data={node.data} />;
case "scriptAction":
return <ScriptActionProperties nodeId={node.id} data={node.data} />;
case "httpRequestAction":
return <HttpRequestActionProperties nodeId={node.id} data={node.data} />;
default:
return (
<div className="p-4">
@ -165,6 +177,9 @@ function getNodeTypeLabel(type: NodeType): string {
updateAction: "UPDATE 액션",
deleteAction: "DELETE 액션",
upsertAction: "UPSERT 액션",
emailAction: "메일 발송",
scriptAction: "스크립트 실행",
httpRequestAction: "HTTP 요청",
comment: "주석",
log: "로그",
};

View File

@ -0,0 +1,431 @@
"use client";
/**
*
* -
*/
import { useEffect, useState, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
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, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, Trash2, Mail, Server, FileText, Settings, RefreshCw, CheckCircle, AlertCircle, User } from "lucide-react";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { getMailAccounts, type MailAccount } from "@/lib/api/mail";
import type { EmailActionNodeData } from "@/types/node-editor";
interface EmailActionPropertiesProps {
nodeId: string;
data: EmailActionNodeData;
}
export function EmailActionProperties({ nodeId, data }: EmailActionPropertiesProps) {
const { updateNode } = useFlowEditorStore();
// 메일 계정 목록
const [mailAccounts, setMailAccounts] = useState<MailAccount[]>([]);
const [isLoadingAccounts, setIsLoadingAccounts] = useState(false);
const [accountError, setAccountError] = useState<string | null>(null);
// 로컬 상태
const [displayName, setDisplayName] = useState(data.displayName || "메일 발송");
// 계정 선택
const [selectedAccountId, setSelectedAccountId] = useState(data.accountId || "");
// 메일 내용
const [to, setTo] = useState(data.to || "");
const [cc, setCc] = useState(data.cc || "");
const [bcc, setBcc] = useState(data.bcc || "");
const [subject, setSubject] = useState(data.subject || "");
const [body, setBody] = useState(data.body || "");
const [bodyType, setBodyType] = useState<"text" | "html">(data.bodyType || "text");
// 고급 설정
const [replyTo, setReplyTo] = useState(data.replyTo || "");
const [priority, setPriority] = useState<"high" | "normal" | "low">(data.priority || "normal");
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000");
const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "3");
// 메일 계정 목록 로드
const loadMailAccounts = useCallback(async () => {
setIsLoadingAccounts(true);
setAccountError(null);
try {
const accounts = await getMailAccounts();
setMailAccounts(accounts.filter(acc => acc.status === 'active'));
} catch (error) {
console.error("메일 계정 로드 실패:", error);
setAccountError("메일 계정을 불러오는데 실패했습니다");
} finally {
setIsLoadingAccounts(false);
}
}, []);
// 컴포넌트 마운트 시 메일 계정 로드
useEffect(() => {
loadMailAccounts();
}, [loadMailAccounts]);
// 데이터 변경 시 로컬 상태 동기화
useEffect(() => {
setDisplayName(data.displayName || "메일 발송");
setSelectedAccountId(data.accountId || "");
setTo(data.to || "");
setCc(data.cc || "");
setBcc(data.bcc || "");
setSubject(data.subject || "");
setBody(data.body || "");
setBodyType(data.bodyType || "text");
setReplyTo(data.replyTo || "");
setPriority(data.priority || "normal");
setTimeout(data.options?.timeout?.toString() || "30000");
setRetryCount(data.options?.retryCount?.toString() || "3");
}, [data]);
// 선택된 계정 정보
const selectedAccount = mailAccounts.find(acc => acc.id === selectedAccountId);
// 노드 업데이트 함수
const updateNodeData = useCallback(
(updates: Partial<EmailActionNodeData>) => {
updateNode(nodeId, {
...data,
...updates,
});
},
[nodeId, data, updateNode]
);
// 표시명 변경
const handleDisplayNameChange = (value: string) => {
setDisplayName(value);
updateNodeData({ displayName: value });
};
// 계정 선택 변경
const handleAccountChange = (accountId: string) => {
setSelectedAccountId(accountId);
const account = mailAccounts.find(acc => acc.id === accountId);
updateNodeData({
accountId,
// 계정의 이메일을 발신자로 자동 설정
from: account?.email || ""
});
};
// 메일 내용 업데이트
const updateMailContent = useCallback(() => {
updateNodeData({
to,
cc: cc || undefined,
bcc: bcc || undefined,
subject,
body,
bodyType,
replyTo: replyTo || undefined,
priority,
});
}, [to, cc, bcc, subject, body, bodyType, replyTo, priority, updateNodeData]);
// 옵션 업데이트
const updateOptions = useCallback(() => {
updateNodeData({
options: {
timeout: parseInt(timeout) || 30000,
retryCount: parseInt(retryCount) || 3,
},
});
}, [timeout, retryCount, updateNodeData]);
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>
<Tabs defaultValue="account" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="account" className="text-xs">
<User className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="mail" className="text-xs">
<Mail className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="content" className="text-xs">
<FileText className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="options" className="text-xs">
<Settings className="mr-1 h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 계정 선택 탭 */}
<TabsContent value="account" className="space-y-3 pt-3">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> *</Label>
<Button
variant="ghost"
size="sm"
onClick={loadMailAccounts}
disabled={isLoadingAccounts}
className="h-6 px-2"
>
<RefreshCw className={`h-3 w-3 ${isLoadingAccounts ? 'animate-spin' : ''}`} />
</Button>
</div>
{accountError && (
<div className="flex items-center gap-2 text-xs text-red-500">
<AlertCircle className="h-3 w-3" />
{accountError}
</div>
)}
<Select
value={selectedAccountId}
onValueChange={handleAccountChange}
disabled={isLoadingAccounts}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder={isLoadingAccounts ? "로딩 중..." : "메일 계정을 선택하세요"} />
</SelectTrigger>
<SelectContent>
{mailAccounts.length === 0 ? (
<div className="p-2 text-xs text-gray-500">
.
<br />
&gt; &gt; .
</div>
) : (
mailAccounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
<div className="flex items-center gap-2">
<span className="font-medium">{account.name}</span>
<span className="text-xs text-gray-500">({account.email})</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 선택된 계정 정보 표시 */}
{selectedAccount && (
<Card className="bg-green-50 border-green-200">
<CardContent className="p-3 space-y-2">
<div className="flex items-center gap-2 text-green-700">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium"> </span>
</div>
<div className="text-xs space-y-1 text-green-800">
<div><strong>:</strong> {selectedAccount.name}</div>
<div><strong>:</strong> {selectedAccount.email}</div>
<div><strong>SMTP:</strong> {selectedAccount.smtpHost}:{selectedAccount.smtpPort}</div>
</div>
</CardContent>
</Card>
)}
{!selectedAccount && mailAccounts.length > 0 && (
<Card className="bg-yellow-50 border-yellow-200">
<CardContent className="p-3 text-xs text-yellow-700">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span> .</span>
</div>
</CardContent>
</Card>
)}
{mailAccounts.length === 0 && !isLoadingAccounts && (
<Card className="bg-gray-50">
<CardContent className="p-3 text-xs text-gray-600">
<div className="space-y-2">
<div className="font-medium"> :</div>
<ol className="list-decimal list-inside space-y-1">
<li> </li>
<li> &gt; </li>
<li> </li>
<li>SMTP </li>
</ol>
</div>
</CardContent>
</Card>
)}
</TabsContent>
{/* 메일 설정 탭 */}
<TabsContent value="mail" className="space-y-3 pt-3">
{/* 발신자는 선택된 계정에서 자동으로 설정됨 */}
{selectedAccount && (
<div className="space-y-2">
<Label className="text-xs"> (From)</Label>
<div className="h-8 px-3 py-2 text-sm bg-gray-100 rounded-md border flex items-center">
{selectedAccount.email}
</div>
<p className="text-xs text-gray-500"> .</p>
</div>
)}
<div className="space-y-2">
<Label className="text-xs"> (To) *</Label>
<Input
value={to}
onChange={(e) => setTo(e.target.value)}
onBlur={updateMailContent}
placeholder="recipient@example.com (쉼표로 구분)"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> (CC)</Label>
<Input
value={cc}
onChange={(e) => setCc(e.target.value)}
onBlur={updateMailContent}
placeholder="cc@example.com"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> (BCC)</Label>
<Input
value={bcc}
onChange={(e) => setBcc(e.target.value)}
onBlur={updateMailContent}
placeholder="bcc@example.com"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> (Reply-To)</Label>
<Input
value={replyTo}
onChange={(e) => setReplyTo(e.target.value)}
onBlur={updateMailContent}
placeholder="reply@example.com"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select value={priority} onValueChange={(v: "high" | "normal" | "low") => {
setPriority(v);
updateNodeData({ priority: v });
}}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="high"></SelectItem>
<SelectItem value="normal"></SelectItem>
<SelectItem value="low"></SelectItem>
</SelectContent>
</Select>
</div>
</TabsContent>
{/* 본문 탭 */}
<TabsContent value="content" className="space-y-3 pt-3">
<div className="space-y-2">
<Label className="text-xs"> *</Label>
<Input
value={subject}
onChange={(e) => setSubject(e.target.value)}
onBlur={updateMailContent}
placeholder="메일 제목 ({{변수}} 사용 가능)"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select value={bodyType} onValueChange={(v: "text" | "html") => {
setBodyType(v);
updateNodeData({ bodyType: v });
}}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="text"></SelectItem>
<SelectItem value="html">HTML</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={updateMailContent}
placeholder={bodyType === "html" ? "<html><body>...</body></html>" : "메일 본문 내용"}
className="min-h-[200px] text-sm font-mono"
/>
</div>
<Card className="bg-gray-50">
<CardContent className="p-3 text-xs text-gray-600">
<div className="font-medium mb-1"> 릿 :</div>
<code className="block">{"{{sourceData}}"}</code>
<code className="block">{"{{timestamp}}"}</code>
<code className="block">{"{{필드명}}"}</code>
</CardContent>
</Card>
</TabsContent>
{/* 옵션 탭 */}
<TabsContent value="options" className="space-y-3 pt-3">
<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="30000"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="number"
value={retryCount}
onChange={(e) => setRetryCount(e.target.value)}
onBlur={updateOptions}
placeholder="3"
className="h-8 text-sm"
/>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,568 @@
"use client";
/**
* HTTP
*/
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, Globe, Key, FileJson, Settings } from "lucide-react";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import type { HttpRequestActionNodeData } from "@/types/node-editor";
interface HttpRequestActionPropertiesProps {
nodeId: string;
data: HttpRequestActionNodeData;
}
export function HttpRequestActionProperties({ nodeId, data }: HttpRequestActionPropertiesProps) {
const { updateNode } = useFlowEditorStore();
// 로컬 상태
const [displayName, setDisplayName] = useState(data.displayName || "HTTP 요청");
const [url, setUrl] = useState(data.url || "");
const [method, setMethod] = useState<HttpRequestActionNodeData["method"]>(data.method || "GET");
const [bodyType, setBodyType] = useState<HttpRequestActionNodeData["bodyType"]>(data.bodyType || "none");
const [body, setBody] = useState(data.body || "");
// 인증
const [authType, setAuthType] = useState<NonNullable<HttpRequestActionNodeData["authentication"]>["type"]>(
data.authentication?.type || "none"
);
const [authUsername, setAuthUsername] = useState(data.authentication?.username || "");
const [authPassword, setAuthPassword] = useState(data.authentication?.password || "");
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
const [authApiKey, setAuthApiKey] = useState(data.authentication?.apiKey || "");
const [authApiKeyName, setAuthApiKeyName] = useState(data.authentication?.apiKeyName || "X-API-Key");
const [authApiKeyLocation, setAuthApiKeyLocation] = useState<"header" | "query">(
data.authentication?.apiKeyLocation || "header"
);
// 옵션
const [timeout, setTimeout] = useState(data.options?.timeout?.toString() || "30000");
const [followRedirects, setFollowRedirects] = useState(data.options?.followRedirects ?? true);
const [retryCount, setRetryCount] = useState(data.options?.retryCount?.toString() || "0");
const [retryDelay, setRetryDelay] = useState(data.options?.retryDelay?.toString() || "1000");
// 헤더
const [headers, setHeaders] = useState<Array<{ key: string; value: string }>>(
Object.entries(data.headers || {}).map(([key, value]) => ({ key, value }))
);
// 쿼리 파라미터
const [queryParams, setQueryParams] = useState<Array<{ key: string; value: string }>>(
Object.entries(data.queryParams || {}).map(([key, value]) => ({ key, value }))
);
// 응답 처리
const [extractPath, setExtractPath] = useState(data.responseHandling?.extractPath || "");
const [saveToVariable, setSaveToVariable] = useState(data.responseHandling?.saveToVariable || "");
// 데이터 변경 시 로컬 상태 동기화
useEffect(() => {
setDisplayName(data.displayName || "HTTP 요청");
setUrl(data.url || "");
setMethod(data.method || "GET");
setBodyType(data.bodyType || "none");
setBody(data.body || "");
setAuthType(data.authentication?.type || "none");
setAuthUsername(data.authentication?.username || "");
setAuthPassword(data.authentication?.password || "");
setAuthToken(data.authentication?.token || "");
setAuthApiKey(data.authentication?.apiKey || "");
setAuthApiKeyName(data.authentication?.apiKeyName || "X-API-Key");
setAuthApiKeyLocation(data.authentication?.apiKeyLocation || "header");
setTimeout(data.options?.timeout?.toString() || "30000");
setFollowRedirects(data.options?.followRedirects ?? true);
setRetryCount(data.options?.retryCount?.toString() || "0");
setRetryDelay(data.options?.retryDelay?.toString() || "1000");
setHeaders(Object.entries(data.headers || {}).map(([key, value]) => ({ key, value })));
setQueryParams(Object.entries(data.queryParams || {}).map(([key, value]) => ({ key, value })));
setExtractPath(data.responseHandling?.extractPath || "");
setSaveToVariable(data.responseHandling?.saveToVariable || "");
}, [data]);
// 노드 업데이트 함수
const updateNodeData = useCallback(
(updates: Partial<HttpRequestActionNodeData>) => {
updateNode(nodeId, {
...data,
...updates,
});
},
[nodeId, data, updateNode]
);
// 표시명 변경
const handleDisplayNameChange = (value: string) => {
setDisplayName(value);
updateNodeData({ displayName: value });
};
// URL 업데이트
const updateUrl = () => {
updateNodeData({ url });
};
// 메서드 변경
const handleMethodChange = (value: HttpRequestActionNodeData["method"]) => {
setMethod(value);
updateNodeData({ method: value });
};
// 바디 타입 변경
const handleBodyTypeChange = (value: HttpRequestActionNodeData["bodyType"]) => {
setBodyType(value);
updateNodeData({ bodyType: value });
};
// 바디 업데이트
const updateBody = () => {
updateNodeData({ body });
};
// 인증 업데이트
const updateAuthentication = useCallback(() => {
updateNodeData({
authentication: {
type: authType,
username: authUsername || undefined,
password: authPassword || undefined,
token: authToken || undefined,
apiKey: authApiKey || undefined,
apiKeyName: authApiKeyName || undefined,
apiKeyLocation: authApiKeyLocation,
},
});
}, [authType, authUsername, authPassword, authToken, authApiKey, authApiKeyName, authApiKeyLocation, updateNodeData]);
// 옵션 업데이트
const updateOptions = useCallback(() => {
updateNodeData({
options: {
timeout: parseInt(timeout) || 30000,
followRedirects,
retryCount: parseInt(retryCount) || 0,
retryDelay: parseInt(retryDelay) || 1000,
},
});
}, [timeout, followRedirects, retryCount, retryDelay, updateNodeData]);
// 응답 처리 업데이트
const updateResponseHandling = useCallback(() => {
updateNodeData({
responseHandling: {
extractPath: extractPath || undefined,
saveToVariable: saveToVariable || undefined,
},
});
}, [extractPath, saveToVariable, updateNodeData]);
// 헤더 추가
const addHeader = () => {
setHeaders([...headers, { key: "", value: "" }]);
};
// 헤더 삭제
const removeHeader = (index: number) => {
const newHeaders = headers.filter((_, i) => i !== index);
setHeaders(newHeaders);
const headersObj = Object.fromEntries(newHeaders.filter(h => h.key).map(h => [h.key, h.value]));
updateNodeData({ headers: headersObj });
};
// 헤더 업데이트
const updateHeader = (index: number, field: "key" | "value", value: string) => {
const newHeaders = [...headers];
newHeaders[index][field] = value;
setHeaders(newHeaders);
};
// 헤더 저장
const saveHeaders = () => {
const headersObj = Object.fromEntries(headers.filter(h => h.key).map(h => [h.key, h.value]));
updateNodeData({ headers: headersObj });
};
// 쿼리 파라미터 추가
const addQueryParam = () => {
setQueryParams([...queryParams, { key: "", value: "" }]);
};
// 쿼리 파라미터 삭제
const removeQueryParam = (index: number) => {
const newParams = queryParams.filter((_, i) => i !== index);
setQueryParams(newParams);
const paramsObj = Object.fromEntries(newParams.filter(p => p.key).map(p => [p.key, p.value]));
updateNodeData({ queryParams: paramsObj });
};
// 쿼리 파라미터 업데이트
const updateQueryParam = (index: number, field: "key" | "value", value: string) => {
const newParams = [...queryParams];
newParams[index][field] = value;
setQueryParams(newParams);
};
// 쿼리 파라미터 저장
const saveQueryParams = () => {
const paramsObj = Object.fromEntries(queryParams.filter(p => p.key).map(p => [p.key, p.value]));
updateNodeData({ queryParams: paramsObj });
};
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="HTTP 요청"
className="h-8 text-sm"
/>
</div>
{/* URL */}
<div className="space-y-2">
<Label className="text-xs font-medium">URL *</Label>
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={updateUrl}
placeholder="https://api.example.com/endpoint"
className="h-8 text-sm"
/>
</div>
{/* 메서드 */}
<div className="space-y-2">
<Label className="text-xs font-medium">HTTP </Label>
<Select value={method} onValueChange={handleMethodChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
<SelectItem value="OPTIONS">OPTIONS</SelectItem>
</SelectContent>
</Select>
</div>
<Tabs defaultValue="headers" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="headers" className="text-xs">
<Globe className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="body" className="text-xs">
<FileJson className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="auth" className="text-xs">
<Key className="mr-1 h-3 w-3" />
</TabsTrigger>
<TabsTrigger value="options" className="text-xs">
<Settings className="mr-1 h-3 w-3" />
</TabsTrigger>
</TabsList>
{/* 헤더 탭 */}
<TabsContent value="headers" className="space-y-3 pt-3">
{/* 쿼리 파라미터 */}
<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={addQueryParam}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{queryParams.map((param, index) => (
<div key={index} className="flex gap-2">
<Input
value={param.key}
onChange={(e) => updateQueryParam(index, "key", e.target.value)}
onBlur={saveQueryParams}
placeholder="파라미터명"
className="h-8 text-sm"
/>
<Input
value={param.value}
onChange={(e) => updateQueryParam(index, "value", e.target.value)}
onBlur={saveQueryParams}
placeholder="값"
className="h-8 text-sm"
/>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeQueryParam(index)}>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
</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={addHeader}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{headers.map((header, index) => (
<div key={index} className="flex gap-2">
<Input
value={header.key}
onChange={(e) => updateHeader(index, "key", e.target.value)}
onBlur={saveHeaders}
placeholder="헤더명"
className="h-8 text-sm"
/>
<Input
value={header.value}
onChange={(e) => updateHeader(index, "value", e.target.value)}
onBlur={saveHeaders}
placeholder="값"
className="h-8 text-sm"
/>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => removeHeader(index)}>
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
</div>
))}
</div>
</TabsContent>
{/* 바디 탭 */}
<TabsContent value="body" className="space-y-3 pt-3">
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select value={bodyType} onValueChange={handleBodyTypeChange}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="form">Form Data</SelectItem>
<SelectItem value="text"></SelectItem>
</SelectContent>
</Select>
</div>
{bodyType !== "none" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onBlur={updateBody}
placeholder={bodyType === "json" ? '{\n "key": "value"\n}' : "바디 내용"}
className="min-h-[200px] font-mono text-xs"
/>
</div>
)}
<Card className="bg-gray-50">
<CardContent className="p-3 text-xs text-gray-600">
<div className="font-medium mb-1">릿 :</div>
<code className="block">{"{{sourceData}}"}</code>
<code className="block">{"{{필드명}}"}</code>
</CardContent>
</Card>
</TabsContent>
{/* 인증 탭 */}
<TabsContent value="auth" className="space-y-3 pt-3">
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select value={authType} onValueChange={(v: any) => {
setAuthType(v);
updateNodeData({ authentication: { ...data.authentication, type: v } });
}}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="apikey">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{authType === "basic" && (
<>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={authUsername}
onChange={(e) => setAuthUsername(e.target.value)}
onBlur={updateAuthentication}
placeholder="username"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
type="password"
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
onBlur={updateAuthentication}
placeholder="password"
className="h-8 text-sm"
/>
</div>
</>
)}
{authType === "bearer" && (
<div className="space-y-2">
<Label className="text-xs">Bearer Token</Label>
<Input
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
onBlur={updateAuthentication}
placeholder="your-token-here"
className="h-8 text-sm"
/>
</div>
)}
{authType === "apikey" && (
<>
<div className="space-y-2">
<Label className="text-xs">API Key</Label>
<Input
value={authApiKey}
onChange={(e) => setAuthApiKey(e.target.value)}
onBlur={updateAuthentication}
placeholder="your-api-key"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">/ </Label>
<Input
value={authApiKeyName}
onChange={(e) => setAuthApiKeyName(e.target.value)}
onBlur={updateAuthentication}
placeholder="X-API-Key"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select value={authApiKeyLocation} onValueChange={(v: "header" | "query") => {
setAuthApiKeyLocation(v);
updateNodeData({
authentication: { ...data.authentication, type: authType, apiKeyLocation: v },
});
}}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header"></SelectItem>
<SelectItem value="query"> </SelectItem>
</SelectContent>
</Select>
</div>
</>
)}
</TabsContent>
{/* 옵션 탭 */}
<TabsContent value="options" className="space-y-3 pt-3">
<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="30000"
className="h-8 text-sm"
/>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={followRedirects}
onCheckedChange={(checked) => {
setFollowRedirects(checked);
updateNodeData({ options: { ...data.options, followRedirects: checked } });
}}
/>
<Label className="text-xs"> </Label>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="number"
value={retryCount}
onChange={(e) => setRetryCount(e.target.value)}
onBlur={updateOptions}
placeholder="0"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> (ms)</Label>
<Input
type="number"
value={retryDelay}
onChange={(e) => setRetryDelay(e.target.value)}
onBlur={updateOptions}
placeholder="1000"
className="h-8 text-sm"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-xs"> (JSONPath)</Label>
<Input
value={extractPath}
onChange={(e) => setExtractPath(e.target.value)}
onBlur={updateResponseHandling}
placeholder="data.items"
className="h-8 text-sm"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={saveToVariable}
onChange={(e) => setSaveToVariable(e.target.value)}
onBlur={updateResponseHandling}
placeholder="apiResponse"
className="h-8 text-sm"
/>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,575 @@
"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>
);
}

View File

@ -97,6 +97,34 @@ export const NODE_PALETTE: NodePaletteItem[] = [
color: "#8B5CF6", // 보라색
},
// ========================================================================
// 외부 연동
// ========================================================================
{
type: "emailAction",
label: "메일 발송",
icon: "",
description: "SMTP를 통해 이메일을 발송합니다",
category: "external",
color: "#EC4899", // 핑크색
},
{
type: "scriptAction",
label: "스크립트 실행",
icon: "",
description: "Python, Shell 등 외부 스크립트를 실행합니다",
category: "external",
color: "#10B981", // 에메랄드
},
{
type: "httpRequestAction",
label: "HTTP 요청",
icon: "",
description: "REST API를 호출합니다",
category: "external",
color: "#06B6D4", // 시안
},
// ========================================================================
// 유틸리티
// ========================================================================
@ -123,7 +151,12 @@ export const NODE_CATEGORIES = [
},
{
id: "action",
label: "액션",
label: "데이터 액션",
icon: "",
},
{
id: "external",
label: "외부 연동",
icon: "",
},
{

View File

@ -797,7 +797,15 @@ function isSourceOnlyNode(type: NodeType): boolean {
*
*/
function isActionNode(type: NodeType): boolean {
return type === "insertAction" || type === "updateAction" || type === "deleteAction" || type === "upsertAction";
return (
type === "insertAction" ||
type === "updateAction" ||
type === "deleteAction" ||
type === "upsertAction" ||
type === "emailAction" || // 이메일 발송 액션
type === "scriptAction" || // 스크립트 실행 액션
type === "httpRequestAction" // HTTP 요청 액션
);
}
/**

View File

@ -19,6 +19,9 @@ export type NodeType =
| "updateAction" // UPDATE 액션
| "deleteAction" // DELETE 액션
| "upsertAction" // UPSERT 액션
| "emailAction" // 메일 발송 액션
| "scriptAction" // 스크립트 실행 액션
| "httpRequestAction" // HTTP 요청 액션
| "comment" // 주석
| "log"; // 로그
@ -393,6 +396,170 @@ export interface LogNodeData {
includeData?: boolean;
}
// ============================================================================
// 외부 연동 액션 노드 (메일, 스크립트, HTTP 요청)
// ============================================================================
// 메일 발송 액션 노드
export interface EmailActionNodeData {
displayName?: string;
// 메일 계정 선택 (메일관리에서 등록한 계정)
accountId?: string; // 메일 계정 ID (우선 사용)
// SMTP 서버 설정 (직접 설정 시 사용, accountId가 있으면 무시됨)
smtpConfig?: {
host: string;
port: number;
secure: boolean; // true = SSL/TLS
auth?: {
user: string;
pass: string;
};
};
// 메일 내용
from?: string; // 발신자 이메일 (계정 선택 시 자동 설정)
to: string; // 수신자 이메일 (쉼표로 구분하여 여러 명)
cc?: string; // 참조
bcc?: string; // 숨은 참조
subject: string; // 제목 (템플릿 변수 지원)
body: string; // 본문 (템플릿 변수 지원)
bodyType: "text" | "html"; // 본문 형식
// 첨부파일 (선택)
attachments?: Array<{
filename: string;
path?: string; // 파일 경로
content?: string; // Base64 인코딩된 내용
}>;
// 고급 설정
replyTo?: string;
priority?: "high" | "normal" | "low";
// 실행 옵션
options?: {
retryCount?: number;
retryDelay?: number; // ms
timeout?: number; // ms
};
}
// 스크립트 실행 액션 노드
export interface ScriptActionNodeData {
displayName?: string;
// 스크립트 타입
scriptType: "python" | "shell" | "powershell" | "node" | "executable";
// 실행 방식
executionMode: "inline" | "file";
// 인라인 스크립트 (executionMode === "inline")
inlineScript?: string;
// 파일 경로 (executionMode === "file")
scriptPath?: string;
// 실행 파일 경로 (scriptType === "executable")
executablePath?: string;
// 명령줄 인자
arguments?: string[];
// 환경 변수
environmentVariables?: Record<string, string>;
// 입력 데이터 전달 방식
inputMethod: "stdin" | "args" | "env" | "file";
inputFormat?: "json" | "csv" | "text"; // stdin/file 사용 시
// 작업 디렉토리
workingDirectory?: string;
// 실행 옵션
options?: {
timeout?: number; // ms (기본: 60000)
maxBuffer?: number; // bytes (기본: 1MB)
shell?: string; // 사용할 쉘 (예: /bin/bash)
encoding?: string; // 출력 인코딩 (기본: utf8)
};
// 출력 처리
outputHandling?: {
captureStdout: boolean;
captureStderr: boolean;
parseOutput?: "json" | "lines" | "text";
successExitCodes?: number[]; // 성공으로 간주할 종료 코드 (기본: [0])
};
}
// HTTP 요청 액션 노드
export interface HttpRequestActionNodeData {
displayName?: string;
// 기본 설정
url: string; // URL (템플릿 변수 지원)
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
// 헤더
headers?: Record<string, string>;
// 쿼리 파라미터
queryParams?: Record<string, string>;
// 요청 본문
bodyType?: "none" | "json" | "form" | "text" | "binary";
body?: string; // JSON 문자열 또는 텍스트 (템플릿 변수 지원)
formData?: Array<{
key: string;
value: string;
type: "text" | "file";
}>;
// 인증
authentication?: {
type: "none" | "basic" | "bearer" | "apikey" | "oauth2";
// Basic Auth
username?: string;
password?: string;
// Bearer Token
token?: string;
// API Key
apiKey?: string;
apiKeyName?: string;
apiKeyLocation?: "header" | "query";
// OAuth2 (향후 확장)
oauth2Config?: {
grantType: "client_credentials" | "password" | "authorization_code";
tokenUrl: string;
clientId: string;
clientSecret: string;
scope?: string;
};
};
// 고급 설정
options?: {
timeout?: number; // ms (기본: 30000)
followRedirects?: boolean; // 리다이렉트 따라가기 (기본: true)
maxRedirects?: number; // 최대 리다이렉트 횟수 (기본: 5)
validateStatus?: string; // 성공 상태 코드 범위 (예: "2xx", "200-299")
retryCount?: number;
retryDelay?: number; // ms
retryOn?: ("timeout" | "5xx" | "network")[]; // 재시도 조건
};
// 응답 처리
responseHandling?: {
extractPath?: string; // JSON 경로 (예: "data.items")
saveToVariable?: string; // 결과를 저장할 변수명
validateSchema?: boolean; // JSON 스키마 검증
expectedSchema?: object; // 예상 스키마
};
}
// ============================================================================
// 통합 노드 데이터 타입
// ============================================================================
@ -409,6 +576,9 @@ export type NodeData =
| UpdateActionNodeData
| DeleteActionNodeData
| UpsertActionNodeData
| EmailActionNodeData
| ScriptActionNodeData
| HttpRequestActionNodeData
| CommentNodeData
| LogNodeData;
@ -526,7 +696,7 @@ export interface NodePaletteItem {
label: string;
icon: string;
description: string;
category: "source" | "transform" | "action" | "utility";
category: "source" | "transform" | "action" | "external" | "utility";
color: string;
}