외부 db노드 설정
This commit is contained in:
parent
0743786f9b
commit
37e018b33c
|
|
@ -0,0 +1,231 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import {
|
||||
authenticateToken,
|
||||
AuthenticatedRequest,
|
||||
} from "../../middleware/authMiddleware";
|
||||
import { ExternalDbConnectionService } from "../../services/externalDbConnectionService";
|
||||
import { ExternalDbConnectionFilter } from "../../types/externalDbTypes";
|
||||
import logger from "../../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/tested
|
||||
* 노드 플로우용: 테스트에 성공한 외부 DB 커넥션 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/tested",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
logger.info("🔍 노드 플로우용 테스트 완료된 커넥션 조회 요청");
|
||||
|
||||
// 활성 상태의 외부 커넥션 조회
|
||||
const filter: ExternalDbConnectionFilter = {
|
||||
is_active: "Y",
|
||||
};
|
||||
|
||||
const externalConnections =
|
||||
await ExternalDbConnectionService.getConnections(filter);
|
||||
|
||||
if (!externalConnections.success) {
|
||||
return res.status(400).json(externalConnections);
|
||||
}
|
||||
|
||||
// 외부 커넥션들에 대해 연결 테스트 수행 (제한된 병렬 처리 + 타임아웃 관리)
|
||||
const validExternalConnections: any[] = [];
|
||||
const connections = externalConnections.data || [];
|
||||
const MAX_CONCURRENT = 3; // 최대 동시 연결 수
|
||||
const TIMEOUT_MS = 3000; // 타임아웃 3초
|
||||
|
||||
// 청크 단위로 처리 (최대 3개씩)
|
||||
for (let i = 0; i < connections.length; i += MAX_CONCURRENT) {
|
||||
const chunk = connections.slice(i, i + MAX_CONCURRENT);
|
||||
|
||||
const chunkResults = await Promise.allSettled(
|
||||
chunk.map(async (connection) => {
|
||||
let testPromise: Promise<any> | null = null;
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
try {
|
||||
// 타임아웃과 함께 테스트 실행
|
||||
testPromise = ExternalDbConnectionService.testConnectionById(
|
||||
connection.id!
|
||||
);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error("연결 테스트 타임아웃"));
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const testResult = await Promise.race([
|
||||
testPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
// 타임아웃 정리
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (testResult.success) {
|
||||
return {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
description: connection.description,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
// 타임아웃 정리
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// 🔥 타임아웃 시 연결 강제 해제
|
||||
try {
|
||||
const { DatabaseConnectorFactory } = await import(
|
||||
"../../database/DatabaseConnectorFactory"
|
||||
);
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connection.id!,
|
||||
connection.db_type
|
||||
);
|
||||
logger.info(
|
||||
`🧹 타임아웃/실패로 인한 커넥션 정리 완료: ${connection.connection_name}`
|
||||
);
|
||||
} catch (cleanupError) {
|
||||
logger.warn(
|
||||
`커넥션 정리 실패 (ID: ${connection.id}):`,
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: cleanupError
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`커넥션 테스트 실패 (ID: ${connection.id}):`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// fulfilled 결과만 수집
|
||||
chunkResults.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
validExternalConnections.push(result.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 다음 청크 처리 전 짧은 대기 (연결 풀 안정화)
|
||||
if (i + MAX_CONCURRENT < connections.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 테스트 성공한 커넥션: ${validExternalConnections.length}/${externalConnections.data?.length || 0}개`
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: validExternalConnections,
|
||||
message: `테스트에 성공한 ${validExternalConnections.length}개의 커넥션을 조회했습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("노드 플로우용 커넥션 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/:id/tables
|
||||
* 특정 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`🔍 외부 DB 테이블 목록 조회: connectionId=${id}`);
|
||||
|
||||
const result =
|
||||
await ExternalDbConnectionService.getTablesFromConnection(id);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/:id/tables/:tableName/columns
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables/:tableName/columns",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { tableName } = req.params;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔍 외부 DB 컬럼 목록 조회: connectionId=${id}, table=${tableName}`
|
||||
);
|
||||
|
||||
const result = await ExternalDbConnectionService.getColumnsFromConnection(
|
||||
id,
|
||||
tableName
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
executeConditionalActions,
|
||||
} from "../controllers/conditionalConnectionController";
|
||||
import nodeFlowsRouter from "./dataflow/node-flows";
|
||||
import nodeExternalConnectionsRouter from "./dataflow/node-external-connections";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -153,4 +154,10 @@ router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
|||
*/
|
||||
router.use("/node-flows", nodeFlowsRouter);
|
||||
|
||||
/**
|
||||
* 노드 플로우용 외부 DB 커넥션 관리
|
||||
* /api/dataflow/node-external-connections/*
|
||||
*/
|
||||
router.use("/node-external-connections", nodeExternalConnectionsRouter);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -1181,4 +1181,157 @@ export class ExternalDbConnectionService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 연결의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionId: number
|
||||
): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const password = connection.password
|
||||
? PasswordEncryption.decrypt(connection.password)
|
||||
: "";
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: password,
|
||||
connectionTimeoutMillis:
|
||||
connection.connection_timeout != null
|
||||
? connection.connection_timeout * 1000
|
||||
: undefined,
|
||||
queryTimeoutMillis:
|
||||
connection.query_timeout != null
|
||||
? connection.query_timeout * 1000
|
||||
: undefined,
|
||||
ssl:
|
||||
connection.ssl_enabled === "Y"
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
};
|
||||
|
||||
// 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type,
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
try {
|
||||
const tables = await connector.getTables();
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`,
|
||||
};
|
||||
} finally {
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connection.db_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
static async getColumnsFromConnection(
|
||||
connectionId: number,
|
||||
tableName: string
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const password = connection.password
|
||||
? PasswordEncryption.decrypt(connection.password)
|
||||
: "";
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: password,
|
||||
connectionTimeoutMillis:
|
||||
connection.connection_timeout != null
|
||||
? connection.connection_timeout * 1000
|
||||
: undefined,
|
||||
queryTimeoutMillis:
|
||||
connection.query_timeout != null
|
||||
? connection.query_timeout * 1000
|
||||
: undefined,
|
||||
ssl:
|
||||
connection.ssl_enabled === "Y"
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
};
|
||||
|
||||
// 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type,
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
try {
|
||||
const columns = await connector.getColumns(tableName);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`,
|
||||
};
|
||||
} finally {
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connection.db_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("컬럼 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,12 +5,22 @@
|
|||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database } from "lucide-react";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import {
|
||||
getTestedExternalConnections,
|
||||
getExternalTables,
|
||||
getExternalColumns,
|
||||
type ExternalConnection,
|
||||
type ExternalTable,
|
||||
type ExternalColumn,
|
||||
} from "@/lib/api/nodeExternalConnections";
|
||||
import { toast } from "sonner";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface ExternalDBSourcePropertiesProps {
|
||||
|
|
@ -27,36 +37,179 @@ const DB_TYPE_INFO: Record<string, { label: string; color: string; icon: string
|
|||
};
|
||||
|
||||
export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
const { updateNode, getExternalConnectionsCache, setExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.connectionName);
|
||||
const [connectionName, setConnectionName] = useState(data.connectionName);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<number | undefined>(data.connectionId);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
const [schema, setSchema] = useState(data.schema || "");
|
||||
|
||||
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||
const [tables, setTables] = useState<ExternalTable[]>([]);
|
||||
const [columns, setColumns] = useState<ExternalColumn[]>([]);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0); // 🔥 마지막 새로고침 시간
|
||||
const [remainingCooldown, setRemainingCooldown] = useState<number>(0); // 🔥 남은 쿨다운 시간
|
||||
|
||||
const selectedConnection = connections.find((conn) => conn.id === selectedConnectionId);
|
||||
const dbInfo =
|
||||
data.dbType && DB_TYPE_INFO[data.dbType]
|
||||
? DB_TYPE_INFO[data.dbType]
|
||||
selectedConnection && DB_TYPE_INFO[selectedConnection.db_type]
|
||||
? DB_TYPE_INFO[selectedConnection.db_type]
|
||||
: {
|
||||
label: data.dbType ? data.dbType.toUpperCase() : "알 수 없음",
|
||||
label: selectedConnection ? selectedConnection.db_type.toUpperCase() : "알 수 없음",
|
||||
color: "#666",
|
||||
icon: "💾",
|
||||
};
|
||||
|
||||
// 🔥 첫 로드 시에만 커넥션 목록 로드 (전역 캐싱)
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.connectionName);
|
||||
setConnectionName(data.connectionName);
|
||||
setTableName(data.tableName);
|
||||
setSchema(data.schema || "");
|
||||
}, [data]);
|
||||
const cachedData = getExternalConnectionsCache();
|
||||
if (cachedData) {
|
||||
console.log("✅ 캐시된 커넥션 사용:", cachedData.length);
|
||||
setConnections(cachedData);
|
||||
} else {
|
||||
console.log("🔄 API 호출하여 커넥션 로드");
|
||||
loadConnections();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 커넥션 변경 시 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedConnectionId) {
|
||||
loadTables();
|
||||
}
|
||||
}, [selectedConnectionId]);
|
||||
|
||||
// 테이블 변경 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedConnectionId && tableName) {
|
||||
loadColumns();
|
||||
}
|
||||
}, [selectedConnectionId, tableName]);
|
||||
|
||||
// 🔥 쿨다운 타이머 (1초마다 업데이트)
|
||||
useEffect(() => {
|
||||
const THROTTLE_DURATION = 10000; // 10초
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (lastRefreshTime > 0) {
|
||||
const elapsed = Date.now() - lastRefreshTime;
|
||||
const remaining = Math.max(0, THROTTLE_DURATION - elapsed);
|
||||
setRemainingCooldown(Math.ceil(remaining / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [lastRefreshTime]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
// 🔥 쓰로틀링: 10초 이내 재요청 차단
|
||||
const THROTTLE_DURATION = 10000; // 10초
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastRefreshTime < THROTTLE_DURATION) {
|
||||
const remainingSeconds = Math.ceil((THROTTLE_DURATION - (now - lastRefreshTime)) / 1000);
|
||||
toast.warning(`잠시 후 다시 시도해주세요 (${remainingSeconds}초 후)`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingConnections(true);
|
||||
setLastRefreshTime(now); // 🔥 마지막 실행 시간 기록
|
||||
|
||||
try {
|
||||
const data = await getTestedExternalConnections();
|
||||
setConnections(data);
|
||||
setExternalConnectionsCache(data); // 🔥 전역 캐시에 저장
|
||||
console.log("✅ 테스트 성공한 커넥션 로드 및 캐싱:", data.length);
|
||||
toast.success(`${data.length}개의 커넥션을 불러왔습니다.`);
|
||||
} catch (error) {
|
||||
console.error("❌ 커넥션 로드 실패:", error);
|
||||
toast.error("외부 DB 연결 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async () => {
|
||||
if (!selectedConnectionId) return;
|
||||
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await getExternalTables(selectedConnectionId);
|
||||
setTables(data);
|
||||
console.log("✅ 테이블 목록 로드:", data.length);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async () => {
|
||||
if (!selectedConnectionId || !tableName) return;
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const data = await getExternalColumns(selectedConnectionId, tableName);
|
||||
setColumns(data);
|
||||
console.log("✅ 컬럼 목록 로드:", data.length);
|
||||
|
||||
// 노드에 outputFields 업데이트
|
||||
updateNode(nodeId, {
|
||||
outputFields: data.map((col) => ({
|
||||
name: col.column_name,
|
||||
type: col.data_type,
|
||||
label: col.column_name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionChange = (connectionId: string) => {
|
||||
const id = parseInt(connectionId);
|
||||
setSelectedConnectionId(id);
|
||||
setTableName("");
|
||||
setTables([]);
|
||||
setColumns([]);
|
||||
|
||||
const connection = connections.find((conn) => conn.id === id);
|
||||
if (connection) {
|
||||
updateNode(nodeId, {
|
||||
connectionId: id,
|
||||
connectionName: connection.connection_name,
|
||||
dbType: connection.db_type,
|
||||
displayName: connection.connection_name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (newTableName: string) => {
|
||||
setTableName(newTableName);
|
||||
setColumns([]);
|
||||
|
||||
updateNode(nodeId, {
|
||||
tableName: newTableName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
connectionName,
|
||||
connectionId: selectedConnectionId,
|
||||
connectionName: selectedConnection?.connection_name || "",
|
||||
tableName,
|
||||
schema,
|
||||
dbType: selectedConnection?.db_type,
|
||||
});
|
||||
toast.success("설정이 저장되었습니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -86,11 +239,62 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
{/* 연결 선택 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">연결 정보</h3>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">외부 DB 연결</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={loadConnections}
|
||||
disabled={loadingConnections || remainingCooldown > 0}
|
||||
className="relative h-7 px-2"
|
||||
title={
|
||||
loadingConnections
|
||||
? "테스트 진행 중..."
|
||||
: remainingCooldown > 0
|
||||
? `${remainingCooldown}초 후 재시도 가능`
|
||||
: "연결 테스트 재실행 (10초 간격 제한)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loadingConnections ? "animate-spin" : ""}`} />
|
||||
{remainingCooldown > 0 && !loadingConnections && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[9px] text-white">
|
||||
{remainingCooldown}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">연결 선택 (테스트 성공한 커넥션만 표시)</Label>
|
||||
<Select
|
||||
value={selectedConnectionId?.toString()}
|
||||
onValueChange={handleConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="외부 DB 연결 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{DB_TYPE_INFO[conn.db_type]?.icon || "💾"}</span>
|
||||
<span>{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingConnections && <p className="mt-1 text-xs text-gray-500">테스트 중... ⏳</p>}
|
||||
{connections.length === 0 && !loadingConnections && (
|
||||
<p className="mt-1 text-xs text-orange-600">⚠️ 테스트에 성공한 커넥션이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
|
|
@ -103,71 +307,58 @@ export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePro
|
|||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="connectionName" className="text-xs">
|
||||
연결명
|
||||
</Label>
|
||||
<Input
|
||||
id="connectionName"
|
||||
value={connectionName}
|
||||
onChange={(e) => setConnectionName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="외부 DB 연결명"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">외부 DB 연결 관리에서 설정한 연결명입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 정보 */}
|
||||
{/* 테이블 선택 */}
|
||||
{selectedConnectionId && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">테이블 정보</h3>
|
||||
<h3 className="mb-3 text-sm font-semibold">테이블 선택</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="tableName" className="text-xs">
|
||||
테이블명
|
||||
</Label>
|
||||
<Input
|
||||
id="tableName"
|
||||
value={tableName}
|
||||
onChange={(e) => setTableName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="데이터를 가져올 테이블"
|
||||
/>
|
||||
<Label className="text-xs">테이블명</Label>
|
||||
<Select value={tableName} onValueChange={handleTableChange} disabled={loadingTables}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="테이블 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>📋</span>
|
||||
<span>{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingTables && <p className="mt-1 text-xs text-gray-500">테이블 목록 로딩 중... ⏳</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
{columns.length > 0 && (
|
||||
<div>
|
||||
<Label htmlFor="schema" className="text-xs">
|
||||
스키마 (선택)
|
||||
</Label>
|
||||
<Input
|
||||
id="schema"
|
||||
value={schema}
|
||||
onChange={(e) => setSchema(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="스키마명"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 */}
|
||||
{data.outputFields && data.outputFields.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
<div className="space-y-1">
|
||||
{data.outputFields.map((field, index) => (
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드 ({columns.length}개)</h3>
|
||||
{loadingColumns ? (
|
||||
<p className="text-xs text-gray-500">컬럼 목록 로딩 중... ⏳</p>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
<span className="font-mono text-gray-500">{field.type}</span>
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="font-mono text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { apiClient } from "./client";
|
||||
|
||||
export interface ExternalConnection {
|
||||
id: number;
|
||||
connection_name: string;
|
||||
description?: string;
|
||||
db_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
}
|
||||
|
||||
export interface ExternalTable {
|
||||
table_name: string;
|
||||
table_type?: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
export interface ExternalColumn {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
column_default?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트에 성공한 외부 DB 커넥션 목록 조회
|
||||
*/
|
||||
export async function getTestedExternalConnections(): Promise<ExternalConnection[]> {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ExternalConnection[];
|
||||
message?: string;
|
||||
}>("/dataflow/node-external-connections/tested");
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || "커넥션 목록을 조회할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
export async function getExternalTables(connectionId: number): Promise<ExternalTable[]> {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ExternalTable[];
|
||||
message?: string;
|
||||
}>(`/dataflow/node-external-connections/${connectionId}/tables`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || "테이블 목록을 조회할 수 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
export async function getExternalColumns(connectionId: number, tableName: string): Promise<ExternalColumn[]> {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ExternalColumn[];
|
||||
message?: string;
|
||||
}>(`/dataflow/node-external-connections/${connectionId}/tables/${tableName}/columns`);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
throw new Error(response.data.message || "컬럼 목록을 조회할 수 없습니다.");
|
||||
}
|
||||
|
|
@ -7,6 +7,12 @@ import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChang
|
|||
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
|
||||
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
|
||||
|
||||
// 🔥 외부 커넥션 캐시 타입
|
||||
interface ExternalConnectionCache {
|
||||
data: any[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FlowEditorState {
|
||||
// 노드 및 엣지
|
||||
nodes: FlowNode[];
|
||||
|
|
@ -30,6 +36,9 @@ interface FlowEditorState {
|
|||
// 검증 결과
|
||||
validationResult: ValidationResult | null;
|
||||
|
||||
// 🔥 외부 커넥션 캐시 (전역 캐싱)
|
||||
externalConnectionsCache: ExternalConnectionCache | null;
|
||||
|
||||
// ========================================================================
|
||||
// 노드 관리
|
||||
// ========================================================================
|
||||
|
|
@ -41,6 +50,14 @@ interface FlowEditorState {
|
|||
removeNode: (id: string) => void;
|
||||
removeNodes: (ids: string[]) => void;
|
||||
|
||||
// ========================================================================
|
||||
// 🔥 외부 커넥션 캐시 관리
|
||||
// ========================================================================
|
||||
|
||||
setExternalConnectionsCache: (data: any[]) => void;
|
||||
clearExternalConnectionsCache: () => void;
|
||||
getExternalConnectionsCache: () => any[] | null;
|
||||
|
||||
// ========================================================================
|
||||
// 엣지 관리
|
||||
// ========================================================================
|
||||
|
|
@ -110,6 +127,7 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
|||
showValidationPanel: false,
|
||||
showPropertiesPanel: true,
|
||||
validationResult: null,
|
||||
externalConnectionsCache: null, // 🔥 캐시 초기화
|
||||
|
||||
// ========================================================================
|
||||
// 노드 관리
|
||||
|
|
@ -380,6 +398,39 @@ export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
|||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 🔥 외부 커넥션 캐시 관리
|
||||
// ========================================================================
|
||||
|
||||
setExternalConnectionsCache: (data) => {
|
||||
set({
|
||||
externalConnectionsCache: {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
clearExternalConnectionsCache: () => {
|
||||
set({ externalConnectionsCache: null });
|
||||
},
|
||||
|
||||
getExternalConnectionsCache: () => {
|
||||
const cache = get().externalConnectionsCache;
|
||||
if (!cache) return null;
|
||||
|
||||
// 🔥 5분 후 캐시 만료
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5분
|
||||
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
|
||||
|
||||
if (isExpired) {
|
||||
set({ externalConnectionsCache: null });
|
||||
return null;
|
||||
}
|
||||
|
||||
return cache.data;
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue