외부호출 노드들
This commit is contained in:
parent
cf73ce6ebb
commit
bb98e9319f
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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: "로그",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
관리자 > 메일관리 > 계정관리에서 추가하세요.
|
||||
</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>메일관리 > 계정관리 선택</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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: "",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 요청 액션
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue