From efb08b01039f18c69881a178c97e225fd9f4b628 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Fri, 19 Sep 2025 12:15:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=20=EA=B4=80=EB=A6=AC=20~=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../src/routes/externalDbConnectionRoutes.ts | 59 +- .../services/externalDbConnectionService.ts | 189 +++++- backend-node/src/types/externalDbTypes.ts | 26 + docker/dev/docker-compose.backend.mac.yml | 2 + .../admin/external-connections/page.tsx | 364 ++++++++++ .../admin/ExternalDbConnectionModal.tsx | 639 +++++++++++------- frontend/hooks/use-toast.ts | 26 + frontend/lib/api/externalDbConnection.ts | 284 ++++---- 9 files changed, 1190 insertions(+), 401 deletions(-) create mode 100644 frontend/app/(main)/admin/external-connections/page.tsx create mode 100644 frontend/hooks/use-toast.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1a0d193e..8a01bdaf 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -30,6 +30,7 @@ import componentStandardRoutes from "./routes/componentStandardRoutes"; import layoutRoutes from "./routes/layoutRoutes"; import dataRoutes from "./routes/dataRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; +import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; // import userRoutes from './routes/userRoutes'; // import menuRoutes from './routes/menuRoutes'; @@ -123,6 +124,7 @@ app.use("/api/layouts", layoutRoutes); app.use("/api/screen", screenStandardRoutes); app.use("/api/data", dataRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes); +app.use("/api/external-db-connections", externalDbConnectionRoutes); // app.use('/api/users', userRoutes); // app.use('/api/menus', menuRoutes); diff --git a/backend-node/src/routes/externalDbConnectionRoutes.ts b/backend-node/src/routes/externalDbConnectionRoutes.ts index 9c6e86e1..534aac5d 100644 --- a/backend-node/src/routes/externalDbConnectionRoutes.ts +++ b/backend-node/src/routes/externalDbConnectionRoutes.ts @@ -173,7 +173,7 @@ router.put( /** * DELETE /api/external-db-connections/:id - * 외부 DB 연결 삭제 (논리 삭제) + * 외부 DB 연결 삭제 (물리 삭제) */ router.delete( "/:id", @@ -207,6 +207,63 @@ router.delete( } ); +/** + * POST /api/external-db-connections/test + * 데이터베이스 연결 테스트 + */ +router.post( + "/test", + authenticateToken, + async (req: AuthenticatedRequest, res: Response) => { + try { + const testData = + req.body as import("../types/externalDbTypes").ConnectionTestRequest; + + // 필수 필드 검증 + const requiredFields = [ + "db_type", + "host", + "port", + "database_name", + "username", + "password", + ]; + const missingFields = requiredFields.filter( + (field) => !testData[field as keyof typeof testData] + ); + + if (missingFields.length > 0) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + error: { + code: "MISSING_FIELDS", + details: `다음 필드가 필요합니다: ${missingFields.join(", ")}`, + }, + }); + } + + const result = await ExternalDbConnectionService.testConnection(testData); + + return res.status(200).json({ + success: result.success, + data: result, + message: result.message, + }); + } catch (error) { + console.error("연결 테스트 오류:", error); + return res.status(500).json({ + success: false, + message: "연결 테스트 중 서버 오류가 발생했습니다.", + error: { + code: "SERVER_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }); + } + } +); + /** * GET /api/external-db-connections/types/supported * 지원하는 DB 타입 목록 조회 diff --git a/backend-node/src/services/externalDbConnectionService.ts b/backend-node/src/services/externalDbConnectionService.ts index f614253f..2ed1e3b7 100644 --- a/backend-node/src/services/externalDbConnectionService.ts +++ b/backend-node/src/services/externalDbConnectionService.ts @@ -279,7 +279,7 @@ export class ExternalDbConnectionService { } /** - * 외부 DB 연결 삭제 (논리 삭제) + * 외부 DB 연결 삭제 (물리 삭제) */ static async deleteConnection(id: number): Promise> { try { @@ -295,13 +295,9 @@ export class ExternalDbConnectionService { }; } - // 논리 삭제 (is_active를 'N'으로 변경) - await prisma.external_db_connections.update({ + // 물리 삭제 (실제 데이터 삭제) + await prisma.external_db_connections.delete({ where: { id }, - data: { - is_active: "N", - updated_date: new Date(), - }, }); return { @@ -318,6 +314,185 @@ export class ExternalDbConnectionService { } } + /** + * 데이터베이스 연결 테스트 + */ + static async testConnection( + testData: import("../types/externalDbTypes").ConnectionTestRequest + ): Promise { + const startTime = Date.now(); + + try { + switch (testData.db_type.toLowerCase()) { + case "postgresql": + return await this.testPostgreSQLConnection(testData, startTime); + case "mysql": + return await this.testMySQLConnection(testData, startTime); + case "oracle": + return await this.testOracleConnection(testData, startTime); + case "mssql": + return await this.testMSSQLConnection(testData, startTime); + case "sqlite": + return await this.testSQLiteConnection(testData, startTime); + default: + return { + success: false, + message: `지원하지 않는 데이터베이스 타입입니다: ${testData.db_type}`, + error: { + code: "UNSUPPORTED_DB_TYPE", + details: `${testData.db_type} 타입은 현재 지원하지 않습니다.`, + }, + }; + } + } catch (error) { + return { + success: false, + message: "연결 테스트 중 오류가 발생했습니다.", + error: { + code: "TEST_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * PostgreSQL 연결 테스트 + */ + private static async testPostgreSQLConnection( + testData: import("../types/externalDbTypes").ConnectionTestRequest, + startTime: number + ): Promise { + const { Client } = await import("pg"); + const client = new Client({ + host: testData.host, + port: testData.port, + database: testData.database_name, + user: testData.username, + password: testData.password, + connectionTimeoutMillis: (testData.connection_timeout || 30) * 1000, + ssl: testData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false, + }); + + try { + await client.connect(); + const result = await client.query( + "SELECT version(), pg_database_size(current_database()) as size" + ); + const responseTime = Date.now() - startTime; + + await client.end(); + + return { + success: true, + message: "PostgreSQL 연결이 성공했습니다.", + details: { + response_time: responseTime, + server_version: result.rows[0]?.version || "알 수 없음", + database_size: this.formatBytes( + parseInt(result.rows[0]?.size || "0") + ), + }, + }; + } catch (error) { + try { + await client.end(); + } catch (endError) { + // 연결 종료 오류는 무시 + } + + return { + success: false, + message: "PostgreSQL 연결에 실패했습니다.", + error: { + code: "CONNECTION_FAILED", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + } + } + + /** + * MySQL 연결 테스트 (모의 구현) + */ + private static async testMySQLConnection( + testData: import("../types/externalDbTypes").ConnectionTestRequest, + startTime: number + ): Promise { + // MySQL 라이브러리가 없으므로 모의 구현 + return { + success: false, + message: "MySQL 연결 테스트는 현재 지원하지 않습니다.", + error: { + code: "NOT_IMPLEMENTED", + details: "MySQL 라이브러리가 설치되지 않았습니다.", + }, + }; + } + + /** + * Oracle 연결 테스트 (모의 구현) + */ + private static async testOracleConnection( + testData: import("../types/externalDbTypes").ConnectionTestRequest, + startTime: number + ): Promise { + return { + success: false, + message: "Oracle 연결 테스트는 현재 지원하지 않습니다.", + error: { + code: "NOT_IMPLEMENTED", + details: "Oracle 라이브러리가 설치되지 않았습니다.", + }, + }; + } + + /** + * SQL Server 연결 테스트 (모의 구현) + */ + private static async testMSSQLConnection( + testData: import("../types/externalDbTypes").ConnectionTestRequest, + startTime: number + ): Promise { + return { + success: false, + message: "SQL Server 연결 테스트는 현재 지원하지 않습니다.", + error: { + code: "NOT_IMPLEMENTED", + details: "SQL Server 라이브러리가 설치되지 않았습니다.", + }, + }; + } + + /** + * SQLite 연결 테스트 (모의 구현) + */ + private static async testSQLiteConnection( + testData: import("../types/externalDbTypes").ConnectionTestRequest, + startTime: number + ): Promise { + return { + success: false, + message: "SQLite 연결 테스트는 현재 지원하지 않습니다.", + error: { + code: "NOT_IMPLEMENTED", + details: + "SQLite는 파일 기반이므로 네트워크 연결 테스트가 불가능합니다.", + }, + }; + } + + /** + * 바이트 크기를 읽기 쉬운 형태로 변환 + */ + private static formatBytes(bytes: number): string { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + } + /** * 연결 데이터 검증 */ diff --git a/backend-node/src/types/externalDbTypes.ts b/backend-node/src/types/externalDbTypes.ts index 3053cfbd..b5d65959 100644 --- a/backend-node/src/types/externalDbTypes.ts +++ b/backend-node/src/types/externalDbTypes.ts @@ -64,6 +64,32 @@ export const ACTIVE_STATUS_OPTIONS = [ { value: "", label: "전체" }, ]; +// 연결 테스트 관련 타입 +export interface ConnectionTestRequest { + db_type: string; + host: string; + port: number; + database_name: string; + username: string; + password: string; + connection_timeout?: number; + ssl_enabled?: string; +} + +export interface ConnectionTestResult { + success: boolean; + message: string; + details?: { + response_time?: number; + server_version?: string; + database_size?: string; + }; + error?: { + code?: string; + details?: string; + }; +} + // 연결 옵션 스키마 (각 DB 타입별 추가 옵션) export interface MySQLConnectionOptions { charset?: string; diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml index 06618628..3862a74f 100644 --- a/docker/dev/docker-compose.backend.mac.yml +++ b/docker/dev/docker-compose.backend.mac.yml @@ -7,6 +7,8 @@ services: container_name: pms-backend-mac ports: - "8080:8080" + extra_hosts: + - "host.docker.internal:host-gateway" environment: - NODE_ENV=development - PORT=8080 diff --git a/frontend/app/(main)/admin/external-connections/page.tsx b/frontend/app/(main)/admin/external-connections/page.tsx new file mode 100644 index 00000000..2b6b27a5 --- /dev/null +++ b/frontend/app/(main)/admin/external-connections/page.tsx @@ -0,0 +1,364 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Plus, Search, Pencil, Trash2, Database } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useToast } from "@/hooks/use-toast"; +import { + ExternalDbConnectionAPI, + ExternalDbConnection, + ExternalDbConnectionFilter, +} from "@/lib/api/externalDbConnection"; +import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal"; + +// DB 타입 매핑 +const DB_TYPE_LABELS: Record = { + mysql: "MySQL", + postgresql: "PostgreSQL", + oracle: "Oracle", + mssql: "SQL Server", + sqlite: "SQLite", +}; + +// 활성 상태 옵션 +const ACTIVE_STATUS_OPTIONS = [ + { value: "ALL", label: "전체" }, + { value: "Y", label: "활성" }, + { value: "N", label: "비활성" }, +]; + +export default function ExternalConnectionsPage() { + const { toast } = useToast(); + + // 상태 관리 + const [connections, setConnections] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(""); + const [dbTypeFilter, setDbTypeFilter] = useState("ALL"); + const [activeStatusFilter, setActiveStatusFilter] = useState("ALL"); + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingConnection, setEditingConnection] = useState(); + const [supportedDbTypes, setSupportedDbTypes] = useState>([]); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [connectionToDelete, setConnectionToDelete] = useState(null); + + // 데이터 로딩 + const loadConnections = async () => { + try { + setLoading(true); + + const filter: ExternalDbConnectionFilter = { + search: searchTerm.trim() || undefined, + db_type: dbTypeFilter === "ALL" ? undefined : dbTypeFilter, + is_active: activeStatusFilter === "ALL" ? undefined : activeStatusFilter, + }; + + const data = await ExternalDbConnectionAPI.getConnections(filter); + setConnections(data); + } catch (error) { + console.error("연결 목록 로딩 오류:", error); + toast({ + title: "오류", + description: "연결 목록을 불러오는데 실패했습니다.", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }; + + // 지원되는 DB 타입 로딩 + const loadSupportedDbTypes = async () => { + try { + const types = await ExternalDbConnectionAPI.getSupportedTypes(); + setSupportedDbTypes([{ value: "ALL", label: "전체" }, ...types]); + } catch (error) { + console.error("지원 DB 타입 로딩 오류:", error); + // 실패 시 기본값 사용 + setSupportedDbTypes([ + { value: "ALL", label: "전체" }, + { value: "mysql", label: "MySQL" }, + { value: "postgresql", label: "PostgreSQL" }, + { value: "oracle", label: "Oracle" }, + { value: "mssql", label: "SQL Server" }, + { value: "sqlite", label: "SQLite" }, + ]); + } + }; + + // 초기 데이터 로딩 + useEffect(() => { + loadConnections(); + loadSupportedDbTypes(); + }, []); + + // 필터 변경 시 데이터 재로딩 + useEffect(() => { + loadConnections(); + }, [searchTerm, dbTypeFilter, activeStatusFilter]); + + // 새 연결 추가 + const handleAddConnection = () => { + setEditingConnection(undefined); + setIsModalOpen(true); + }; + + // 연결 편집 + const handleEditConnection = (connection: ExternalDbConnection) => { + setEditingConnection(connection); + setIsModalOpen(true); + }; + + // 연결 삭제 확인 다이얼로그 열기 + const handleDeleteConnection = (connection: ExternalDbConnection) => { + setConnectionToDelete(connection); + setDeleteDialogOpen(true); + }; + + // 연결 삭제 실행 + const confirmDeleteConnection = async () => { + if (!connectionToDelete?.id) return; + + try { + await ExternalDbConnectionAPI.deleteConnection(connectionToDelete.id); + toast({ + title: "성공", + description: "연결이 삭제되었습니다.", + }); + loadConnections(); + } catch (error) { + console.error("연결 삭제 오류:", error); + toast({ + title: "오류", + description: error instanceof Error ? error.message : "연결 삭제에 실패했습니다.", + variant: "destructive", + }); + } finally { + setDeleteDialogOpen(false); + setConnectionToDelete(null); + } + }; + + // 연결 삭제 취소 + const cancelDeleteConnection = () => { + setDeleteDialogOpen(false); + setConnectionToDelete(null); + }; + + // 모달 저장 처리 + const handleModalSave = () => { + setIsModalOpen(false); + setEditingConnection(undefined); + loadConnections(); + }; + + // 모달 취소 처리 + const handleModalCancel = () => { + setIsModalOpen(false); + setEditingConnection(undefined); + }; + + return ( +
+
+

외부 커넥션 관리

+

외부 데이터베이스 연결 정보를 관리합니다.

+
+ + {/* 검색 및 필터 */} + + +
+
+ {/* 검색 */} +
+ + setSearchTerm(e.target.value)} + className="w-64 pl-10" + /> +
+ + {/* DB 타입 필터 */} + + + {/* 활성 상태 필터 */} + +
+ + {/* 추가 버튼 */} + +
+
+
+ + {/* 연결 목록 */} + {loading ? ( +
+
로딩 중...
+
+ ) : connections.length === 0 ? ( + + +
+ +

등록된 연결이 없습니다

+

새 외부 데이터베이스 연결을 추가해보세요.

+ +
+
+
+ ) : ( + + + + + + 연결명 + DB 타입 + 호스트:포트 + 데이터베이스 + 사용자 + 상태 + 생성일 + 작업 + + + + {connections.map((connection) => ( + + +
+
{connection.connection_name}
+ {connection.description && ( +
+ {connection.description} +
+ )} +
+
+ + + {DB_TYPE_LABELS[connection.db_type] || connection.db_type} + + + + {connection.host}:{connection.port} + + {connection.database_name} + {connection.username} + + + {connection.is_active === "Y" ? "활성" : "비활성"} + + + + {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"} + + +
+ + +
+
+
+ ))} +
+
+
+
+ )} + + {/* 연결 설정 모달 */} + {isModalOpen && ( + type.value !== "ALL")} + /> + )} + + {/* 삭제 확인 다이얼로그 */} + + + + 연결 삭제 확인 + + "{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+
+ ); +} diff --git a/frontend/components/admin/ExternalDbConnectionModal.tsx b/frontend/components/admin/ExternalDbConnectionModal.tsx index db6c582a..b86050f8 100644 --- a/frontend/components/admin/ExternalDbConnectionModal.tsx +++ b/frontend/components/admin/ExternalDbConnectionModal.tsx @@ -1,43 +1,61 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Eye, EyeOff, ChevronDown, ChevronRight } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import { Database, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react"; -import { toast } from "sonner"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { useToast } from "@/hooks/use-toast"; import { ExternalDbConnectionAPI, ExternalDbConnection, - DB_TYPE_OPTIONS, - DB_TYPE_DEFAULTS, + ConnectionTestRequest, + ConnectionTestResult, } from "@/lib/api/externalDbConnection"; interface ExternalDbConnectionModalProps { isOpen: boolean; onClose: () => void; onSave: () => void; - editingConnection?: ExternalDbConnection | null; + connection?: ExternalDbConnection; + supportedDbTypes: Array<{ value: string; label: string }>; } -export function ExternalDbConnectionModal({ +// 기본 포트 설정 +const DEFAULT_PORTS: Record = { + mysql: 3306, + postgresql: 5432, + oracle: 1521, + mssql: 1433, + sqlite: 0, // SQLite는 파일 기반이므로 포트 없음 +}; + +export const ExternalDbConnectionModal: React.FC = ({ isOpen, onClose, onSave, - editingConnection, -}: ExternalDbConnectionModalProps) { - const [formData, setFormData] = useState>({ + connection, + supportedDbTypes, +}) => { + const { toast } = useToast(); + + // 상태 관리 + const [loading, setSaving] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [testingConnection, setTestingConnection] = useState(false); + const [testResult, setTestResult] = useState(null); + + // 폼 데이터 + const [formData, setFormData] = useState({ connection_name: "", description: "", - db_type: "mysql", - host: "", - port: 3306, + db_type: "postgresql", + host: "localhost", + port: DEFAULT_PORTS.postgresql, database_name: "", username: "", password: "", @@ -50,143 +68,244 @@ export function ExternalDbConnectionModal({ is_active: "Y", }); - const [loading, setLoading] = useState(false); - const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); - const [showPassword, setShowPassword] = useState(false); + // 편집 모드인지 확인 + const isEditMode = !!connection; - // 편집 모드일 때 기존 데이터 로드 + // 연결 정보가 변경될 때 폼 데이터 업데이트 useEffect(() => { - if (isOpen) { - if (editingConnection) { - setFormData({ - ...editingConnection, - password: "", // 보안상 비밀번호는 빈 값으로 시작 - }); - setShowAdvancedSettings(true); // 편집 시 고급 설정 펼치기 - } else { - // 새 연결 생성 시 기본값 설정 - setFormData({ - connection_name: "", - description: "", - db_type: "mysql", - host: "", - port: 3306, - database_name: "", - username: "", - password: "", - connection_timeout: 30, - query_timeout: 60, - max_connections: 10, - ssl_enabled: "N", - ssl_cert_path: "", - company_code: "*", - is_active: "Y", - }); - setShowAdvancedSettings(false); - } - setShowPassword(false); + if (connection) { + setFormData({ + ...connection, + // 편집 시에는 비밀번호를 빈 문자열로 설정 (보안상 기존 비밀번호는 보여주지 않음) + password: "", + }); + } else { + // 새 연결 생성 시 기본값 설정 + setFormData({ + connection_name: "", + description: "", + db_type: "postgresql", + host: "localhost", + port: DEFAULT_PORTS.postgresql, + database_name: "", + username: "", + password: "", + connection_timeout: 30, + query_timeout: 60, + max_connections: 10, + ssl_enabled: "N", + ssl_cert_path: "", + company_code: "*", + is_active: "Y", + }); } - }, [isOpen, editingConnection]); + }, [connection]); // DB 타입 변경 시 기본 포트 설정 const handleDbTypeChange = (dbType: string) => { - const defaultPort = DB_TYPE_DEFAULTS[dbType as keyof typeof DB_TYPE_DEFAULTS]?.port || 3306; - setFormData((prev) => ({ - ...prev, + setFormData({ + ...formData, db_type: dbType as ExternalDbConnection["db_type"], - port: defaultPort, - })); + port: DEFAULT_PORTS[dbType] || 5432, + }); }; - // 폼 데이터 변경 핸들러 + // 입력값 변경 처리 const handleInputChange = (field: keyof ExternalDbConnection, value: string | number) => { - setFormData((prev) => ({ - ...prev, + setFormData({ + ...formData, [field]: value, - })); + }); }; - // 저장 - const handleSave = async () => { - // 필수 필드 검증 - if ( - !formData.connection_name || - !formData.db_type || - !formData.host || - !formData.port || - !formData.database_name || - !formData.username - ) { - toast.error("필수 필드를 모두 입력해주세요."); + // 폼 검증 + const validateForm = (): boolean => { + if (!formData.connection_name.trim()) { + toast({ + title: "검증 오류", + description: "연결명을 입력해주세요.", + variant: "destructive", + }); + return false; + } + + if (!formData.host.trim()) { + toast({ + title: "검증 오류", + description: "호스트를 입력해주세요.", + variant: "destructive", + }); + return false; + } + + if (!formData.database_name.trim()) { + toast({ + title: "검증 오류", + description: "데이터베이스명을 입력해주세요.", + variant: "destructive", + }); + return false; + } + + if (!formData.username.trim()) { + toast({ + title: "검증 오류", + description: "사용자명을 입력해주세요.", + variant: "destructive", + }); + return false; + } + + if (!isEditMode && !formData.password.trim()) { + toast({ + title: "검증 오류", + description: "비밀번호를 입력해주세요.", + variant: "destructive", + }); + return false; + } + + return true; + }; + + // 연결 테스트 처리 + const handleTestConnection = async () => { + // 기본 필수 필드 검증 + if (!formData.host.trim()) { + toast({ + title: "검증 오류", + description: "호스트를 입력해주세요.", + variant: "destructive", + }); return; } - // 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지 - if (editingConnection && !formData.password) { - formData.password = "***ENCRYPTED***"; // 서버에서 기존 비밀번호 유지하도록 표시 - } else if (!editingConnection && !formData.password) { - toast.error("새 연결 생성 시 비밀번호는 필수입니다."); + if (!formData.database_name.trim()) { + toast({ + title: "검증 오류", + description: "데이터베이스명을 입력해주세요.", + variant: "destructive", + }); + return; + } + + if (!formData.username.trim()) { + toast({ + title: "검증 오류", + description: "사용자명을 입력해주세요.", + variant: "destructive", + }); + return; + } + + if (!formData.password.trim()) { + toast({ + title: "검증 오류", + description: "비밀번호를 입력해주세요.", + variant: "destructive", + }); return; } try { - setLoading(true); + setTestingConnection(true); + setTestResult(null); - const connectionData = { - ...formData, - port: Number(formData.port), - connection_timeout: Number(formData.connection_timeout), - query_timeout: Number(formData.query_timeout), - max_connections: Number(formData.max_connections), - } as ExternalDbConnection; + const testData: ConnectionTestRequest = { + db_type: formData.db_type, + host: formData.host, + port: formData.port, + database_name: formData.database_name, + username: formData.username, + password: formData.password, + connection_timeout: formData.connection_timeout, + ssl_enabled: formData.ssl_enabled, + }; - let response; - if (editingConnection?.id) { - response = await ExternalDbConnectionAPI.updateConnection(editingConnection.id, connectionData); + const result = await ExternalDbConnectionAPI.testConnection(testData); + setTestResult(result); + + if (result.success) { + toast({ + title: "연결 테스트 성공", + description: result.message, + }); } else { - response = await ExternalDbConnectionAPI.createConnection(connectionData); - } - - if (response.success) { - toast.success(editingConnection ? "연결이 수정되었습니다." : "연결이 생성되었습니다."); - onSave(); - } else { - toast.error(response.message || "저장 중 오류가 발생했습니다."); + toast({ + title: "연결 테스트 실패", + description: result.message, + variant: "destructive", + }); } } catch (error) { - console.error("저장 오류:", error); - toast.error("저장 중 오류가 발생했습니다."); + console.error("연결 테스트 오류:", error); + const errorResult: ConnectionTestResult = { + success: false, + message: "연결 테스트 중 오류가 발생했습니다.", + error: { + code: "TEST_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, + }; + setTestResult(errorResult); + + toast({ + title: "연결 테스트 오류", + description: errorResult.message, + variant: "destructive", + }); } finally { - setLoading(false); + setTestingConnection(false); } }; - // 취소 - const handleCancel = () => { - onClose(); - }; + // 저장 처리 + const handleSave = async () => { + if (!validateForm()) return; - // 저장 버튼 비활성화 조건 - const isSaveDisabled = () => { - return ( - loading || - !formData.connection_name || - !formData.host || - !formData.port || - !formData.database_name || - !formData.username || - (!editingConnection && !formData.password) - ); + try { + setSaving(true); + + // 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지 + let dataToSave = { ...formData }; + if (isEditMode && !dataToSave.password.trim()) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password, ...dataWithoutPassword } = dataToSave; + dataToSave = dataWithoutPassword as ExternalDbConnection; + } + + if (isEditMode && connection?.id) { + await ExternalDbConnectionAPI.updateConnection(connection.id, dataToSave); + toast({ + title: "성공", + description: "연결 정보가 수정되었습니다.", + }); + } else { + await ExternalDbConnectionAPI.createConnection(dataToSave); + toast({ + title: "성공", + description: "새 연결이 생성되었습니다.", + }); + } + + onSave(); + } catch (error) { + console.error("연결 저장 오류:", error); + toast({ + title: "오류", + description: error instanceof Error ? error.message : "연결 저장에 실패했습니다.", + variant: "destructive", + }); + } finally { + setSaving(false); + } }; return ( - + - - - {editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"} - + {isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
@@ -199,21 +318,22 @@ export function ExternalDbConnectionModal({ handleInputChange("connection_name", e.target.value)} - placeholder="예: 영업팀 MySQL" + placeholder="예: 운영 DB" />
+
- - {DB_TYPE_OPTIONS.map((option) => ( - - {option.label} + {supportedDbTypes.map((type) => ( + + {type.label} ))} @@ -237,25 +357,25 @@ export function ExternalDbConnectionModal({

연결 정보

-
-
- +
+
+ handleInputChange("host", e.target.value)} - placeholder="예: localhost, db.company.com" + placeholder="localhost" />
+
handleInputChange("port", parseInt(e.target.value) || 0)} - min={1} - max={65535} + placeholder="5432" />
@@ -264,9 +384,9 @@ export function ExternalDbConnectionModal({ handleInputChange("database_name", e.target.value)} - placeholder="예: sales_db, production" + placeholder="database_name" />
@@ -275,88 +395,164 @@ export function ExternalDbConnectionModal({ handleInputChange("username", e.target.value)} - placeholder="DB 사용자명" + placeholder="username" />
+
- +
handleInputChange("password", e.target.value)} - placeholder={editingConnection ? "변경하려면 입력하세요" : "DB 비밀번호"} + placeholder={isEditMode ? "변경하지 않으려면 비워두세요" : "password"} />
-
- {/* 고급 설정 (접기/펼치기) */} - - - - - -
-
- - handleInputChange("connection_timeout", parseInt(e.target.value) || 30)} - min={1} - max={300} - /> -
-
- - handleInputChange("query_timeout", parseInt(e.target.value) || 60)} - min={1} - max={3600} - /> -
-
- - handleInputChange("max_connections", parseInt(e.target.value) || 10)} - min={1} - max={100} - /> -
+ {/* 연결 테스트 */} +
+
+ + {testingConnection &&
연결을 확인하고 있습니다...
}
-
-
- handleInputChange("ssl_enabled", checked ? "Y" : "N")} - /> - + {/* 테스트 결과 표시 */} + {testResult && ( +
+
{testResult.success ? "✅ 연결 성공" : "❌ 연결 실패"}
+
{testResult.message}
+ + {testResult.success && testResult.details && ( +
+ {testResult.details.response_time &&
응답 시간: {testResult.details.response_time}ms
} + {testResult.details.server_version &&
서버 버전: {testResult.details.server_version}
} + {testResult.details.database_size && ( +
데이터베이스 크기: {testResult.details.database_size}
+ )} +
+ )} + + {!testResult.success && testResult.error && ( +
+
오류 코드: {testResult.error.code}
+ {testResult.error.details &&
{testResult.error.details}
} +
+ )} +
+ )} +
+
+ + {/* 고급 설정 */} +
+ + + {showAdvanced && ( +
+
+
+ + handleInputChange("connection_timeout", parseInt(e.target.value) || 30)} + /> +
+ +
+ + handleInputChange("query_timeout", parseInt(e.target.value) || 60)} + /> +
+ +
+ + handleInputChange("max_connections", parseInt(e.target.value) || 10)} + /> +
+
+ +
+
+ + +
+ +
+ + +
{formData.ssl_enabled === "Y" && ( @@ -366,67 +562,24 @@ export function ExternalDbConnectionModal({ id="ssl_cert_path" value={formData.ssl_cert_path || ""} onChange={(e) => handleInputChange("ssl_cert_path", e.target.value)} - placeholder="/path/to/certificate.pem" + placeholder="/path/to/ssl/cert.pem" />
)}
- -
-
- - handleInputChange("company_code", e.target.value)} - placeholder="회사 코드 (기본: *)" - /> -
-
- - -
-
- - - - {/* 편집 모드일 때 정보 표시 */} - {editingConnection && ( -
-
- 편집 모드 - - 생성일:{" "} - {editingConnection.created_date ? new Date(editingConnection.created_date).toLocaleString() : "-"} - -
-

- 비밀번호를 변경하지 않으려면 비워두세요. 기존 비밀번호가 유지됩니다. -

-
- )} + )} +
- -
); -} +}; diff --git a/frontend/hooks/use-toast.ts b/frontend/hooks/use-toast.ts new file mode 100644 index 00000000..d81f669f --- /dev/null +++ b/frontend/hooks/use-toast.ts @@ -0,0 +1,26 @@ +import { toast as sonnerToast } from "sonner"; + +interface ToastOptions { + title?: string; + description?: string; + variant?: "default" | "destructive"; + duration?: number; +} + +export const useToast = () => { + const toast = ({ title, description, variant = "default", duration }: ToastOptions) => { + if (variant === "destructive") { + sonnerToast.error(title || "오류", { + description, + duration: duration || 4000, + }); + } else { + sonnerToast.success(title || "성공", { + description, + duration: duration || 4000, + }); + } + }; + + return { toast }; +}; diff --git a/frontend/lib/api/externalDbConnection.ts b/frontend/lib/api/externalDbConnection.ts index 37f24a7a..5d7d1111 100644 --- a/frontend/lib/api/externalDbConnection.ts +++ b/frontend/lib/api/externalDbConnection.ts @@ -1,15 +1,8 @@ // 외부 DB 연결 API 클라이언트 -// 작성일: 2024-12-17 +// 작성일: 2024-12-19 -// API 기본 설정 -const getApiBaseUrl = () => { - if (process.env.NODE_ENV === "development") { - return "http://localhost:8080/api"; - } - return "/api"; -}; +import { apiClient } from "./client"; -// 타입 정의 export interface ExternalDbConnection { id?: number; connection_name: string; @@ -45,214 +38,205 @@ export interface ApiResponse { success: boolean; data?: T; message?: string; - error?: string; + error?: { + code?: string; + details?: string; + }; } -// DB 타입 옵션 -export const DB_TYPE_OPTIONS = [ - { value: "mysql", label: "MySQL" }, - { value: "postgresql", label: "PostgreSQL" }, - { value: "oracle", label: "Oracle" }, - { value: "mssql", label: "SQL Server" }, - { value: "sqlite", label: "SQLite" }, -]; +// 연결 테스트 관련 타입 +export interface ConnectionTestRequest { + db_type: string; + host: string; + port: number; + database_name: string; + username: string; + password: string; + connection_timeout?: number; + ssl_enabled?: string; +} -// DB 타입별 기본 설정 -export const DB_TYPE_DEFAULTS = { - mysql: { port: 3306, driver: "mysql2" }, - postgresql: { port: 5432, driver: "pg" }, - oracle: { port: 1521, driver: "oracledb" }, - mssql: { port: 1433, driver: "mssql" }, - sqlite: { port: 0, driver: "sqlite3" }, -}; +export interface ConnectionTestResult { + success: boolean; + message: string; + details?: { + response_time?: number; + server_version?: string; + database_size?: string; + }; + error?: { + code?: string; + details?: string; + }; +} -// 활성 상태 옵션 -export const ACTIVE_STATUS_OPTIONS = [ - { value: "Y", label: "활성" }, - { value: "N", label: "비활성" }, - { value: "ALL", label: "전체" }, -]; - -// API 클라이언트 클래스 export class ExternalDbConnectionAPI { - private static getAuthHeaders() { - const token = localStorage.getItem("authToken"); - return { - "Content-Type": "application/json", - ...(token && { Authorization: `Bearer ${token}` }), - }; - } - - private static async handleResponse(response: Response): Promise> { - try { - // 응답이 JSON인지 확인 - const contentType = response.headers.get("content-type"); - if (!contentType || !contentType.includes("application/json")) { - throw new Error(`서버에서 JSON이 아닌 응답을 받았습니다: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - - if (!response.ok) { - return { - success: false, - message: data.message || `HTTP ${response.status}: ${response.statusText}`, - error: data.error, - }; - } - - return data; - } catch (error) { - console.error("API 응답 처리 오류:", error); - return { - success: false, - message: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Unknown error", - }; - } - } + private static readonly BASE_PATH = "/external-db-connections"; /** * 외부 DB 연결 목록 조회 */ - static async getConnections(filter?: ExternalDbConnectionFilter): Promise> { + static async getConnections(filter: ExternalDbConnectionFilter = {}): Promise { try { const params = new URLSearchParams(); - if (filter) { - Object.entries(filter).forEach(([key, value]) => { - if (value && value.trim()) { - params.append(key, value.trim()); - } - }); + if (filter.db_type) params.append("db_type", filter.db_type); + if (filter.is_active) params.append("is_active", filter.is_active); + if (filter.company_code) params.append("company_code", filter.company_code); + if (filter.search) params.append("search", filter.search); + + const response = await apiClient.get>( + `${this.BASE_PATH}?${params.toString()}`, + ); + + if (!response.data.success) { + throw new Error(response.data.message || "연결 목록 조회에 실패했습니다."); } - const url = `${getApiBaseUrl()}/external-db-connections${params.toString() ? `?${params.toString()}` : ""}`; - - const response = await fetch(url, { - method: "GET", - headers: this.getAuthHeaders(), - }); - - return this.handleResponse(response); + return response.data.data || []; } catch (error) { console.error("외부 DB 연결 목록 조회 오류:", error); - return { - success: false, - message: "네트워크 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Network error", - }; + throw error; } } /** * 특정 외부 DB 연결 조회 */ - static async getConnectionById(id: number): Promise> { + static async getConnectionById(id: number): Promise { try { - const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, { - method: "GET", - headers: this.getAuthHeaders(), - }); + const response = await apiClient.get>(`${this.BASE_PATH}/${id}`); - return this.handleResponse(response); + if (!response.data.success) { + throw new Error(response.data.message || "연결 정보 조회에 실패했습니다."); + } + + if (!response.data.data) { + throw new Error("연결 정보를 찾을 수 없습니다."); + } + + return response.data.data; } catch (error) { console.error("외부 DB 연결 조회 오류:", error); - return { - success: false, - message: "네트워크 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Network error", - }; + throw error; } } /** * 새 외부 DB 연결 생성 */ - static async createConnection(data: ExternalDbConnection): Promise> { + static async createConnection(data: ExternalDbConnection): Promise { try { - const response = await fetch(`${getApiBaseUrl()}/external-db-connections`, { - method: "POST", - headers: this.getAuthHeaders(), - body: JSON.stringify(data), - }); + const response = await apiClient.post>(this.BASE_PATH, data); - return this.handleResponse(response); + if (!response.data.success) { + throw new Error(response.data.message || "연결 생성에 실패했습니다."); + } + + if (!response.data.data) { + throw new Error("생성된 연결 정보를 받을 수 없습니다."); + } + + return response.data.data; } catch (error) { console.error("외부 DB 연결 생성 오류:", error); - return { - success: false, - message: "네트워크 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Network error", - }; + throw error; } } /** * 외부 DB 연결 수정 */ - static async updateConnection( - id: number, - data: Partial, - ): Promise> { + static async updateConnection(id: number, data: ExternalDbConnection): Promise { try { - const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, { - method: "PUT", - headers: this.getAuthHeaders(), - body: JSON.stringify(data), - }); + const response = await apiClient.put>(`${this.BASE_PATH}/${id}`, data); - return this.handleResponse(response); + if (!response.data.success) { + throw new Error(response.data.message || "연결 수정에 실패했습니다."); + } + + if (!response.data.data) { + throw new Error("수정된 연결 정보를 받을 수 없습니다."); + } + + return response.data.data; } catch (error) { console.error("외부 DB 연결 수정 오류:", error); - return { - success: false, - message: "네트워크 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Network error", - }; + throw error; } } /** * 외부 DB 연결 삭제 */ - static async deleteConnection(id: number): Promise> { + static async deleteConnection(id: number): Promise { try { - const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, { - method: "DELETE", - headers: this.getAuthHeaders(), - }); + const response = await apiClient.delete>(`${this.BASE_PATH}/${id}`); - return this.handleResponse(response); + if (!response.data.success) { + throw new Error(response.data.message || "연결 삭제에 실패했습니다."); + } } catch (error) { console.error("외부 DB 연결 삭제 오류:", error); - return { - success: false, - message: "네트워크 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Network error", - }; + throw error; } } /** - * 지원하는 DB 타입 목록 조회 + * 지원되는 DB 타입 목록 조회 */ - static async getSupportedTypes(): Promise< - ApiResponse<{ types: typeof DB_TYPE_OPTIONS; defaults: typeof DB_TYPE_DEFAULTS }> - > { + static async getSupportedTypes(): Promise> { try { - const response = await fetch(`${getApiBaseUrl()}/external-db-connections/types/supported`, { - method: "GET", - headers: this.getAuthHeaders(), - }); + const response = await apiClient.get }>>( + `${this.BASE_PATH}/types/supported`, + ); - return this.handleResponse<{ types: typeof DB_TYPE_OPTIONS; defaults: typeof DB_TYPE_DEFAULTS }>(response); + if (!response.data.success) { + throw new Error(response.data.message || "지원 DB 타입 조회에 실패했습니다."); + } + + return response.data.data?.types || []; } catch (error) { console.error("지원 DB 타입 조회 오류:", error); + throw error; + } + } + + /** + * 데이터베이스 연결 테스트 + */ + static async testConnection(testData: ConnectionTestRequest): Promise { + try { + const response = await apiClient.post>(`${this.BASE_PATH}/test`, testData); + + if (!response.data.success) { + // 백엔드에서 테스트 실패 시에도 200으로 응답하지만 data.success가 false + return ( + response.data.data || { + success: false, + message: response.data.message || "연결 테스트에 실패했습니다.", + error: response.data.error, + } + ); + } + + return ( + response.data.data || { + success: true, + message: response.data.message || "연결 테스트가 완료되었습니다.", + } + ); + } catch (error) { + console.error("연결 테스트 오류:", error); + + // 네트워크 오류 등의 경우 return { success: false, - message: "네트워크 오류가 발생했습니다.", - error: error instanceof Error ? error.message : "Network error", + message: "연결 테스트 중 오류가 발생했습니다.", + error: { + code: "NETWORK_ERROR", + details: error instanceof Error ? error.message : "알 수 없는 오류", + }, }; } }