외부 db노드 설정

This commit is contained in:
kjs
2025-10-02 16:43:40 +09:00
parent 0743786f9b
commit 37e018b33c
6 changed files with 782 additions and 74 deletions

View File

@@ -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,74 +307,61 @@ 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>
{/* 테이블 정보 */}
<div>
<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="데이터를 가져올 테이블"
/>
</div>
<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 && (
{/* 테이블 선택 */}
{selectedConnectionId && (
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-1">
{data.outputFields.map((field, 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>
</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>
)}
{/* 컬럼 정보 */}
{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-[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">{col.column_name}</span>
<span className="font-mono text-gray-500">{col.data_type}</span>
</div>
))}
</div>
)}
</div>
)}
<Button onClick={handleSave} className="w-full" size="sm">
</Button>