ERP-node/frontend/components/dataflow/node-editor/panels/properties/ExternalDBSourceProperties.tsx

441 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}