441 lines
17 KiB
TypeScript
441 lines
17 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* 외부 DB 소스 노드 속성 편집
|
||
*/
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Database, RefreshCw, Table, FileText } 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 {
|
||
nodeId: string;
|
||
data: ExternalDBSourceNodeData;
|
||
}
|
||
|
||
const DB_TYPE_INFO: Record<string, { label: string; color: string; icon: string }> = {
|
||
postgresql: { label: "PostgreSQL", color: "#336791", icon: "🐘" },
|
||
mysql: { label: "MySQL", color: "#4479A1", icon: "🐬" },
|
||
oracle: { label: "Oracle", color: "#F80000", icon: "🔴" },
|
||
mssql: { label: "MS SQL Server", color: "#CC2927", icon: "🏢" },
|
||
mariadb: { label: "MariaDB", color: "#003545", icon: "🌊" },
|
||
};
|
||
|
||
export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePropertiesProps) {
|
||
const { updateNode, getExternalConnectionsCache, setExternalConnectionsCache } = useFlowEditorStore();
|
||
|
||
const [displayName, setDisplayName] = useState(data.displayName || data.connectionName);
|
||
const [selectedConnectionId, setSelectedConnectionId] = useState<number | undefined>(data.connectionId);
|
||
const [tableName, setTableName] = useState(data.tableName);
|
||
const [schema, setSchema] = useState(data.schema || "");
|
||
|
||
// 🆕 데이터 소스 타입 (기본값: context-data)
|
||
const [dataSourceType, setDataSourceType] = useState<"context-data" | "table-all">(
|
||
(data as any).dataSourceType || "context-data"
|
||
);
|
||
|
||
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 =
|
||
selectedConnection && DB_TYPE_INFO[selectedConnection.db_type]
|
||
? DB_TYPE_INFO[selectedConnection.db_type]
|
||
: {
|
||
label: selectedConnection ? selectedConnection.db_type.toUpperCase() : "알 수 없음",
|
||
color: "#666",
|
||
icon: "💾",
|
||
};
|
||
|
||
// 🔥 첫 로드 시에만 커넥션 목록 로드 (전역 캐싱)
|
||
useEffect(() => {
|
||
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 handleDisplayNameChange = (newDisplayName: string) => {
|
||
setDisplayName(newDisplayName);
|
||
updateNode(nodeId, {
|
||
displayName: newDisplayName,
|
||
});
|
||
};
|
||
|
||
/**
|
||
* 🆕 데이터 소스 타입 변경 핸들러
|
||
*/
|
||
const handleDataSourceTypeChange = (newType: "context-data" | "table-all") => {
|
||
setDataSourceType(newType);
|
||
updateNode(nodeId, {
|
||
dataSourceType: newType,
|
||
});
|
||
console.log(`✅ 데이터 소스 타입 변경: ${newType}`);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4 p-4 pb-8">
|
||
{/* DB 타입 정보 */}
|
||
<div
|
||
className="rounded-lg border-2 p-4"
|
||
style={{
|
||
borderColor: dbInfo.color,
|
||
backgroundColor: `${dbInfo.color}10`,
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<div
|
||
className="flex h-12 w-12 items-center justify-center rounded-lg"
|
||
style={{ backgroundColor: dbInfo.color }}
|
||
>
|
||
<span className="text-2xl">{dbInfo.icon}</span>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm font-semibold" style={{ color: dbInfo.color }}>
|
||
{dbInfo.label}
|
||
</p>
|
||
<p className="text-xs text-gray-600">외부 데이터베이스</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 연결 선택 */}
|
||
<div>
|
||
<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">
|
||
표시 이름
|
||
</Label>
|
||
<Input
|
||
id="displayName"
|
||
value={displayName}
|
||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||
className="mt-1"
|
||
placeholder="노드 표시 이름"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 테이블 선택 */}
|
||
{selectedConnectionId && (
|
||
<div>
|
||
<h3 className="mb-3 text-sm font-semibold">테이블 선택</h3>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<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>
|
||
)}
|
||
|
||
{/* 🆕 데이터 소스 설정 */}
|
||
{tableName && (
|
||
<div>
|
||
<h3 className="mb-3 text-sm font-semibold">데이터 소스 설정</h3>
|
||
|
||
<div className="space-y-3">
|
||
<div>
|
||
<Label className="text-xs">데이터 소스 타입</Label>
|
||
<Select value={dataSourceType} onValueChange={handleDataSourceTypeChange}>
|
||
<SelectTrigger className="mt-1">
|
||
<SelectValue placeholder="데이터 소스 선택" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="context-data">
|
||
<div className="flex items-center gap-2">
|
||
<FileText className="h-4 w-4" />
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">컨텍스트 데이터</span>
|
||
<span className="text-muted-foreground text-xs">
|
||
버튼에서 전달된 데이터 사용
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</SelectItem>
|
||
<SelectItem value="table-all">
|
||
<div className="flex items-center gap-2">
|
||
<Table className="h-4 w-4" />
|
||
<div className="flex flex-col">
|
||
<span className="font-medium">테이블 전체 데이터</span>
|
||
<span className="text-muted-foreground text-xs">
|
||
외부 DB의 모든 행 조회
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
|
||
{/* 설명 텍스트 */}
|
||
<div className="mt-2 rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||
{dataSourceType === "context-data" ? (
|
||
<>
|
||
<p className="font-medium mb-1">💡 컨텍스트 데이터 모드</p>
|
||
<p>버튼 실행 시 전달된 데이터를 사용합니다.</p>
|
||
</>
|
||
) : (
|
||
<>
|
||
<p className="font-medium mb-1">📊 테이블 전체 데이터 모드</p>
|
||
<p>외부 DB의 **모든 행**을 직접 조회합니다.</p>
|
||
<p className="mt-1 text-orange-600 font-medium">⚠️ 대량 데이터 시 성능 주의</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 컬럼 정보 */}
|
||
{columns.length > 0 && (
|
||
<div>
|
||
<h3 className="mb-3 text-sm font-semibold">출력 필드 ({columns.length}개)</h3>
|
||
{loadingColumns ? (
|
||
<p className="text-xs text-gray-500">컬럼 목록 로딩 중... ⏳</p>
|
||
) : (
|
||
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||
{columns.map((col, index) => (
|
||
<div
|
||
key={index}
|
||
className="flex items-center justify-between rounded bg-white px-2 py-1.5 text-xs"
|
||
>
|
||
<span className="truncate font-medium" title={col.column_name}>
|
||
{col.column_name}
|
||
</span>
|
||
<span className="ml-2 shrink-0 font-mono text-gray-500">{col.data_type}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
|
||
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|