Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
baa656dee5
|
|
@ -30,6 +30,7 @@ import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||||
import layoutRoutes from "./routes/layoutRoutes";
|
import layoutRoutes from "./routes/layoutRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
|
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||||
// import userRoutes from './routes/userRoutes';
|
// import userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -123,6 +124,7 @@ app.use("/api/layouts", layoutRoutes);
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||||
|
app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -173,7 +173,7 @@ router.put(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/external-db-connections/:id
|
* DELETE /api/external-db-connections/:id
|
||||||
* 외부 DB 연결 삭제 (논리 삭제)
|
* 외부 DB 연결 삭제 (물리 삭제)
|
||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/: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
|
* GET /api/external-db-connections/types/supported
|
||||||
* 지원하는 DB 타입 목록 조회
|
* 지원하는 DB 타입 목록 조회
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 DB 연결 삭제 (논리 삭제)
|
* 외부 DB 연결 삭제 (물리 삭제)
|
||||||
*/
|
*/
|
||||||
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
|
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
|
||||||
try {
|
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 },
|
where: { id },
|
||||||
data: {
|
|
||||||
is_active: "N",
|
|
||||||
updated_date: new Date(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -318,6 +314,185 @@ export class ExternalDbConnectionService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 연결 테스트
|
||||||
|
*/
|
||||||
|
static async testConnection(
|
||||||
|
testData: import("../types/externalDbTypes").ConnectionTestRequest
|
||||||
|
): Promise<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
|
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<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
|
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<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
|
// 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<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
|
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<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
|
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<import("../types/externalDbTypes").ConnectionTestResult> {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 연결 데이터 검증
|
* 연결 데이터 검증
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,32 @@ export const ACTIVE_STATUS_OPTIONS = [
|
||||||
{ value: "", label: "전체" },
|
{ 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 타입별 추가 옵션)
|
// 연결 옵션 스키마 (각 DB 타입별 추가 옵션)
|
||||||
export interface MySQLConnectionOptions {
|
export interface MySQLConnectionOptions {
|
||||||
charset?: string;
|
charset?: string;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ services:
|
||||||
container_name: pms-backend-mac
|
container_name: pms-backend-mac
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
|
|
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
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<ExternalDbConnection[]>([]);
|
||||||
|
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<ExternalDbConnection | undefined>();
|
||||||
|
const [supportedDbTypes, setSupportedDbTypes] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [connectionToDelete, setConnectionToDelete] = useState<ExternalDbConnection | null>(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 (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="mb-2 text-2xl font-bold text-gray-900">외부 커넥션 관리</h1>
|
||||||
|
<p className="text-gray-600">외부 데이터베이스 연결 정보를 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="연결명 또는 설명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="w-64 pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DB 타입 필터 */}
|
||||||
|
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="DB 타입" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{supportedDbTypes.map((type) => (
|
||||||
|
<SelectItem key={type.value} value={type.value}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 활성 상태 필터 */}
|
||||||
|
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="상태" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가 버튼 */}
|
||||||
|
<Button onClick={handleAddConnection} className="shrink-0">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />새 연결 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 연결 목록 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<div className="text-gray-500">로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
) : connections.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="py-8 text-center text-gray-500">
|
||||||
|
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
|
||||||
|
<p className="mb-2 text-lg font-medium">등록된 연결이 없습니다</p>
|
||||||
|
<p className="mb-4 text-sm text-gray-400">새 외부 데이터베이스 연결을 추가해보세요.</p>
|
||||||
|
<Button onClick={handleAddConnection}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />첫 번째 연결 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[200px]">연결명</TableHead>
|
||||||
|
<TableHead className="w-[120px]">DB 타입</TableHead>
|
||||||
|
<TableHead className="w-[200px]">호스트:포트</TableHead>
|
||||||
|
<TableHead className="w-[150px]">데이터베이스</TableHead>
|
||||||
|
<TableHead className="w-[120px]">사용자</TableHead>
|
||||||
|
<TableHead className="w-[80px]">상태</TableHead>
|
||||||
|
<TableHead className="w-[100px]">생성일</TableHead>
|
||||||
|
<TableHead className="w-[120px] text-right">작업</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{connections.map((connection) => (
|
||||||
|
<TableRow key={connection.id} className="hover:bg-gray-50">
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{connection.connection_name}</div>
|
||||||
|
{connection.description && (
|
||||||
|
<div className="max-w-[180px] truncate text-sm text-gray-500" title={connection.description}>
|
||||||
|
{connection.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">
|
||||||
|
{connection.host}:{connection.port}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{connection.database_name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{connection.username}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-xs">
|
||||||
|
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditConnection(connection)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteConnection(connection)}
|
||||||
|
className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 연결 설정 모달 */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<ExternalDbConnectionModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={handleModalCancel}
|
||||||
|
onSave={handleModalSave}
|
||||||
|
connection={editingConnection}
|
||||||
|
supportedDbTypes={supportedDbTypes.filter((type) => type.value !== "ALL")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>연결 삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
"{connectionToDelete?.connection_name}" 연결을 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="font-medium text-red-600">이 작업은 되돌릴 수 없습니다.</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={cancelDeleteConnection}>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDeleteConnection}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,43 +1,61 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Database, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
import {
|
||||||
ExternalDbConnectionAPI,
|
ExternalDbConnectionAPI,
|
||||||
ExternalDbConnection,
|
ExternalDbConnection,
|
||||||
DB_TYPE_OPTIONS,
|
ConnectionTestRequest,
|
||||||
DB_TYPE_DEFAULTS,
|
ConnectionTestResult,
|
||||||
} from "@/lib/api/externalDbConnection";
|
} from "@/lib/api/externalDbConnection";
|
||||||
|
|
||||||
interface ExternalDbConnectionModalProps {
|
interface ExternalDbConnectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
editingConnection?: ExternalDbConnection | null;
|
connection?: ExternalDbConnection;
|
||||||
|
supportedDbTypes: Array<{ value: string; label: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExternalDbConnectionModal({
|
// 기본 포트 설정
|
||||||
|
const DEFAULT_PORTS: Record<string, number> = {
|
||||||
|
mysql: 3306,
|
||||||
|
postgresql: 5432,
|
||||||
|
oracle: 1521,
|
||||||
|
mssql: 1433,
|
||||||
|
sqlite: 0, // SQLite는 파일 기반이므로 포트 없음
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
editingConnection,
|
connection,
|
||||||
}: ExternalDbConnectionModalProps) {
|
supportedDbTypes,
|
||||||
const [formData, setFormData] = useState<Partial<ExternalDbConnection>>({
|
}) => {
|
||||||
|
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<ConnectionTestResult | null>(null);
|
||||||
|
|
||||||
|
// 폼 데이터
|
||||||
|
const [formData, setFormData] = useState<ExternalDbConnection>({
|
||||||
connection_name: "",
|
connection_name: "",
|
||||||
description: "",
|
description: "",
|
||||||
db_type: "mysql",
|
db_type: "postgresql",
|
||||||
host: "",
|
host: "localhost",
|
||||||
port: 3306,
|
port: DEFAULT_PORTS.postgresql,
|
||||||
database_name: "",
|
database_name: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
|
@ -50,143 +68,244 @@ export function ExternalDbConnectionModal({
|
||||||
is_active: "Y",
|
is_active: "Y",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
// 편집 모드인지 확인
|
||||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
const isEditMode = !!connection;
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
|
|
||||||
// 편집 모드일 때 기존 데이터 로드
|
// 연결 정보가 변경될 때 폼 데이터 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (connection) {
|
||||||
if (editingConnection) {
|
setFormData({
|
||||||
setFormData({
|
...connection,
|
||||||
...editingConnection,
|
// 편집 시에는 비밀번호를 빈 문자열로 설정 (보안상 기존 비밀번호는 보여주지 않음)
|
||||||
password: "", // 보안상 비밀번호는 빈 값으로 시작
|
password: "",
|
||||||
});
|
});
|
||||||
setShowAdvancedSettings(true); // 편집 시 고급 설정 펼치기
|
} else {
|
||||||
} else {
|
// 새 연결 생성 시 기본값 설정
|
||||||
// 새 연결 생성 시 기본값 설정
|
setFormData({
|
||||||
setFormData({
|
connection_name: "",
|
||||||
connection_name: "",
|
description: "",
|
||||||
description: "",
|
db_type: "postgresql",
|
||||||
db_type: "mysql",
|
host: "localhost",
|
||||||
host: "",
|
port: DEFAULT_PORTS.postgresql,
|
||||||
port: 3306,
|
database_name: "",
|
||||||
database_name: "",
|
username: "",
|
||||||
username: "",
|
password: "",
|
||||||
password: "",
|
connection_timeout: 30,
|
||||||
connection_timeout: 30,
|
query_timeout: 60,
|
||||||
query_timeout: 60,
|
max_connections: 10,
|
||||||
max_connections: 10,
|
ssl_enabled: "N",
|
||||||
ssl_enabled: "N",
|
ssl_cert_path: "",
|
||||||
ssl_cert_path: "",
|
company_code: "*",
|
||||||
company_code: "*",
|
is_active: "Y",
|
||||||
is_active: "Y",
|
});
|
||||||
});
|
|
||||||
setShowAdvancedSettings(false);
|
|
||||||
}
|
|
||||||
setShowPassword(false);
|
|
||||||
}
|
}
|
||||||
}, [isOpen, editingConnection]);
|
}, [connection]);
|
||||||
|
|
||||||
// DB 타입 변경 시 기본 포트 설정
|
// DB 타입 변경 시 기본 포트 설정
|
||||||
const handleDbTypeChange = (dbType: string) => {
|
const handleDbTypeChange = (dbType: string) => {
|
||||||
const defaultPort = DB_TYPE_DEFAULTS[dbType as keyof typeof DB_TYPE_DEFAULTS]?.port || 3306;
|
setFormData({
|
||||||
setFormData((prev) => ({
|
...formData,
|
||||||
...prev,
|
|
||||||
db_type: dbType as ExternalDbConnection["db_type"],
|
db_type: dbType as ExternalDbConnection["db_type"],
|
||||||
port: defaultPort,
|
port: DEFAULT_PORTS[dbType] || 5432,
|
||||||
}));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 폼 데이터 변경 핸들러
|
// 입력값 변경 처리
|
||||||
const handleInputChange = (field: keyof ExternalDbConnection, value: string | number) => {
|
const handleInputChange = (field: keyof ExternalDbConnection, value: string | number) => {
|
||||||
setFormData((prev) => ({
|
setFormData({
|
||||||
...prev,
|
...formData,
|
||||||
[field]: value,
|
[field]: value,
|
||||||
}));
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 저장
|
// 폼 검증
|
||||||
const handleSave = async () => {
|
const validateForm = (): boolean => {
|
||||||
// 필수 필드 검증
|
if (!formData.connection_name.trim()) {
|
||||||
if (
|
toast({
|
||||||
!formData.connection_name ||
|
title: "검증 오류",
|
||||||
!formData.db_type ||
|
description: "연결명을 입력해주세요.",
|
||||||
!formData.host ||
|
variant: "destructive",
|
||||||
!formData.port ||
|
});
|
||||||
!formData.database_name ||
|
return false;
|
||||||
!formData.username
|
}
|
||||||
) {
|
|
||||||
toast.error("필수 필드를 모두 입력해주세요.");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지
|
if (!formData.database_name.trim()) {
|
||||||
if (editingConnection && !formData.password) {
|
toast({
|
||||||
formData.password = "***ENCRYPTED***"; // 서버에서 기존 비밀번호 유지하도록 표시
|
title: "검증 오류",
|
||||||
} else if (!editingConnection && !formData.password) {
|
description: "데이터베이스명을 입력해주세요.",
|
||||||
toast.error("새 연결 생성 시 비밀번호는 필수입니다.");
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.username.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "검증 오류",
|
||||||
|
description: "사용자명을 입력해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "검증 오류",
|
||||||
|
description: "비밀번호를 입력해주세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setTestingConnection(true);
|
||||||
|
setTestResult(null);
|
||||||
|
|
||||||
const connectionData = {
|
const testData: ConnectionTestRequest = {
|
||||||
...formData,
|
db_type: formData.db_type,
|
||||||
port: Number(formData.port),
|
host: formData.host,
|
||||||
connection_timeout: Number(formData.connection_timeout),
|
port: formData.port,
|
||||||
query_timeout: Number(formData.query_timeout),
|
database_name: formData.database_name,
|
||||||
max_connections: Number(formData.max_connections),
|
username: formData.username,
|
||||||
} as ExternalDbConnection;
|
password: formData.password,
|
||||||
|
connection_timeout: formData.connection_timeout,
|
||||||
|
ssl_enabled: formData.ssl_enabled,
|
||||||
|
};
|
||||||
|
|
||||||
let response;
|
const result = await ExternalDbConnectionAPI.testConnection(testData);
|
||||||
if (editingConnection?.id) {
|
setTestResult(result);
|
||||||
response = await ExternalDbConnectionAPI.updateConnection(editingConnection.id, connectionData);
|
|
||||||
|
if (result.success) {
|
||||||
|
toast({
|
||||||
|
title: "연결 테스트 성공",
|
||||||
|
description: result.message,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
response = await ExternalDbConnectionAPI.createConnection(connectionData);
|
toast({
|
||||||
}
|
title: "연결 테스트 실패",
|
||||||
|
description: result.message,
|
||||||
if (response.success) {
|
variant: "destructive",
|
||||||
toast.success(editingConnection ? "연결이 수정되었습니다." : "연결이 생성되었습니다.");
|
});
|
||||||
onSave();
|
|
||||||
} else {
|
|
||||||
toast.error(response.message || "저장 중 오류가 발생했습니다.");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장 오류:", error);
|
console.error("연결 테스트 오류:", error);
|
||||||
toast.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 {
|
} finally {
|
||||||
setLoading(false);
|
setTestingConnection(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 취소
|
// 저장 처리
|
||||||
const handleCancel = () => {
|
const handleSave = async () => {
|
||||||
onClose();
|
if (!validateForm()) return;
|
||||||
};
|
|
||||||
|
|
||||||
// 저장 버튼 비활성화 조건
|
try {
|
||||||
const isSaveDisabled = () => {
|
setSaving(true);
|
||||||
return (
|
|
||||||
loading ||
|
// 편집 모드에서 비밀번호가 비어있으면 기존 비밀번호 유지
|
||||||
!formData.connection_name ||
|
let dataToSave = { ...formData };
|
||||||
!formData.host ||
|
if (isEditMode && !dataToSave.password.trim()) {
|
||||||
!formData.port ||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
!formData.database_name ||
|
const { password, ...dataWithoutPassword } = dataToSave;
|
||||||
!formData.username ||
|
dataToSave = dataWithoutPassword as ExternalDbConnection;
|
||||||
(!editingConnection && !formData.password)
|
}
|
||||||
);
|
|
||||||
|
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 (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleCancel}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle>{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
|
||||||
<Database className="h-5 w-5" />
|
|
||||||
{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -199,21 +318,22 @@ export function ExternalDbConnectionModal({
|
||||||
<Label htmlFor="connection_name">연결명 *</Label>
|
<Label htmlFor="connection_name">연결명 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="connection_name"
|
id="connection_name"
|
||||||
value={formData.connection_name || ""}
|
value={formData.connection_name}
|
||||||
onChange={(e) => handleInputChange("connection_name", e.target.value)}
|
onChange={(e) => handleInputChange("connection_name", e.target.value)}
|
||||||
placeholder="예: 영업팀 MySQL"
|
placeholder="예: 운영 DB"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="db_type">DB 타입 *</Label>
|
<Label htmlFor="db_type">DB 타입 *</Label>
|
||||||
<Select value={formData.db_type || "mysql"} onValueChange={handleDbTypeChange}>
|
<Select value={formData.db_type} onValueChange={handleDbTypeChange}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{DB_TYPE_OPTIONS.map((option) => (
|
{supportedDbTypes.map((type) => (
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<SelectItem key={type.value} value={type.value}>
|
||||||
{option.label}
|
{type.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -237,25 +357,25 @@ export function ExternalDbConnectionModal({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-medium">연결 정보</h3>
|
<h3 className="text-lg font-medium">연결 정보</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="col-span-2">
|
<div>
|
||||||
<Label htmlFor="host">호스트 주소 *</Label>
|
<Label htmlFor="host">호스트 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="host"
|
id="host"
|
||||||
value={formData.host || ""}
|
value={formData.host}
|
||||||
onChange={(e) => handleInputChange("host", e.target.value)}
|
onChange={(e) => handleInputChange("host", e.target.value)}
|
||||||
placeholder="예: localhost, db.company.com"
|
placeholder="localhost"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="port">포트 *</Label>
|
<Label htmlFor="port">포트 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="port"
|
id="port"
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.port || ""}
|
value={formData.port}
|
||||||
onChange={(e) => handleInputChange("port", parseInt(e.target.value) || 0)}
|
onChange={(e) => handleInputChange("port", parseInt(e.target.value) || 0)}
|
||||||
min={1}
|
placeholder="5432"
|
||||||
max={65535}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -264,9 +384,9 @@ export function ExternalDbConnectionModal({
|
||||||
<Label htmlFor="database_name">데이터베이스명 *</Label>
|
<Label htmlFor="database_name">데이터베이스명 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="database_name"
|
id="database_name"
|
||||||
value={formData.database_name || ""}
|
value={formData.database_name}
|
||||||
onChange={(e) => handleInputChange("database_name", e.target.value)}
|
onChange={(e) => handleInputChange("database_name", e.target.value)}
|
||||||
placeholder="예: sales_db, production"
|
placeholder="database_name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -275,88 +395,164 @@ export function ExternalDbConnectionModal({
|
||||||
<Label htmlFor="username">사용자명 *</Label>
|
<Label htmlFor="username">사용자명 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="username"
|
id="username"
|
||||||
value={formData.username || ""}
|
value={formData.username}
|
||||||
onChange={(e) => handleInputChange("username", e.target.value)}
|
onChange={(e) => handleInputChange("username", e.target.value)}
|
||||||
placeholder="DB 사용자명"
|
placeholder="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="password">비밀번호 {!editingConnection && "*"}</Label>
|
<Label htmlFor="password">비밀번호 {isEditMode ? "(변경 시에만 입력)" : "*"}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
value={formData.password || ""}
|
value={formData.password}
|
||||||
onChange={(e) => handleInputChange("password", e.target.value)}
|
onChange={(e) => handleInputChange("password", e.target.value)}
|
||||||
placeholder={editingConnection ? "변경하려면 입력하세요" : "DB 비밀번호"}
|
placeholder={isEditMode ? "변경하지 않으려면 비워두세요" : "password"}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="absolute top-1/2 right-2 h-8 w-8 -translate-y-1/2 p-0"
|
className="absolute top-0 right-0 h-full px-3 py-2 hover:bg-transparent"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 고급 설정 (접기/펼치기) */}
|
{/* 연결 테스트 */}
|
||||||
<Collapsible open={showAdvancedSettings} onOpenChange={setShowAdvancedSettings}>
|
<div className="space-y-3">
|
||||||
<CollapsibleTrigger asChild>
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" className="flex w-full justify-between p-0">
|
<Button
|
||||||
<h3 className="text-lg font-medium">고급 설정</h3>
|
type="button"
|
||||||
{showAdvancedSettings ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
variant="outline"
|
||||||
</Button>
|
onClick={handleTestConnection}
|
||||||
</CollapsibleTrigger>
|
disabled={
|
||||||
<CollapsibleContent className="space-y-4 pt-4">
|
testingConnection ||
|
||||||
<div className="grid grid-cols-3 gap-4">
|
!formData.host ||
|
||||||
<div>
|
!formData.database_name ||
|
||||||
<Label htmlFor="connection_timeout">연결 타임아웃 (초)</Label>
|
!formData.username ||
|
||||||
<Input
|
!formData.password
|
||||||
id="connection_timeout"
|
}
|
||||||
type="number"
|
className="w-32"
|
||||||
value={formData.connection_timeout || 30}
|
>
|
||||||
onChange={(e) => handleInputChange("connection_timeout", parseInt(e.target.value) || 30)}
|
{testingConnection ? "테스트 중..." : "연결 테스트"}
|
||||||
min={1}
|
</Button>
|
||||||
max={300}
|
{testingConnection && <div className="text-sm text-gray-500">연결을 확인하고 있습니다...</div>}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="query_timeout">쿼리 타임아웃 (초)</Label>
|
|
||||||
<Input
|
|
||||||
id="query_timeout"
|
|
||||||
type="number"
|
|
||||||
value={formData.query_timeout || 60}
|
|
||||||
onChange={(e) => handleInputChange("query_timeout", parseInt(e.target.value) || 60)}
|
|
||||||
min={1}
|
|
||||||
max={3600}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="max_connections">최대 연결 수</Label>
|
|
||||||
<Input
|
|
||||||
id="max_connections"
|
|
||||||
type="number"
|
|
||||||
value={formData.max_connections || 10}
|
|
||||||
onChange={(e) => handleInputChange("max_connections", parseInt(e.target.value) || 10)}
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* 테스트 결과 표시 */}
|
||||||
<div className="flex items-center space-x-2">
|
{testResult && (
|
||||||
<Switch
|
<div
|
||||||
id="ssl_enabled"
|
className={`rounded-md border p-3 text-sm ${
|
||||||
checked={formData.ssl_enabled === "Y"}
|
testResult.success
|
||||||
onCheckedChange={(checked) => handleInputChange("ssl_enabled", checked ? "Y" : "N")}
|
? "border-green-200 bg-green-50 text-green-800"
|
||||||
/>
|
: "border-red-200 bg-red-50 text-red-800"
|
||||||
<Label htmlFor="ssl_enabled">SSL 사용</Label>
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">{testResult.success ? "✅ 연결 성공" : "❌ 연결 실패"}</div>
|
||||||
|
<div className="mt-1">{testResult.message}</div>
|
||||||
|
|
||||||
|
{testResult.success && testResult.details && (
|
||||||
|
<div className="mt-2 space-y-1 text-xs">
|
||||||
|
{testResult.details.response_time && <div>응답 시간: {testResult.details.response_time}ms</div>}
|
||||||
|
{testResult.details.server_version && <div>서버 버전: {testResult.details.server_version}</div>}
|
||||||
|
{testResult.details.database_size && (
|
||||||
|
<div>데이터베이스 크기: {testResult.details.database_size}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!testResult.success && testResult.error && (
|
||||||
|
<div className="mt-2 text-xs">
|
||||||
|
<div>오류 코드: {testResult.error.code}</div>
|
||||||
|
{testResult.error.details && <div className="mt-1 text-red-600">{testResult.error.details}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 고급 설정 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
className="flex h-auto items-center gap-2 p-0 font-medium"
|
||||||
|
>
|
||||||
|
{showAdvanced ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
고급 설정
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showAdvanced && (
|
||||||
|
<div className="space-y-4 border-l-2 border-gray-200 pl-6">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="connection_timeout">연결 타임아웃 (초)</Label>
|
||||||
|
<Input
|
||||||
|
id="connection_timeout"
|
||||||
|
type="number"
|
||||||
|
value={formData.connection_timeout || 30}
|
||||||
|
onChange={(e) => handleInputChange("connection_timeout", parseInt(e.target.value) || 30)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="query_timeout">쿼리 타임아웃 (초)</Label>
|
||||||
|
<Input
|
||||||
|
id="query_timeout"
|
||||||
|
type="number"
|
||||||
|
value={formData.query_timeout || 60}
|
||||||
|
onChange={(e) => handleInputChange("query_timeout", parseInt(e.target.value) || 60)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="max_connections">최대 연결 수</Label>
|
||||||
|
<Input
|
||||||
|
id="max_connections"
|
||||||
|
type="number"
|
||||||
|
value={formData.max_connections || 10}
|
||||||
|
onChange={(e) => handleInputChange("max_connections", parseInt(e.target.value) || 10)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="ssl_enabled">SSL 사용</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.ssl_enabled || "N"}
|
||||||
|
onValueChange={(value) => handleInputChange("ssl_enabled", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y">사용</SelectItem>
|
||||||
|
<SelectItem value="N">사용 안함</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="is_active">활성 상태</Label>
|
||||||
|
<Select value={formData.is_active} onValueChange={(value) => handleInputChange("is_active", value)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Y">활성</SelectItem>
|
||||||
|
<SelectItem value="N">비활성</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.ssl_enabled === "Y" && (
|
{formData.ssl_enabled === "Y" && (
|
||||||
|
|
@ -366,67 +562,24 @@ export function ExternalDbConnectionModal({
|
||||||
id="ssl_cert_path"
|
id="ssl_cert_path"
|
||||||
value={formData.ssl_cert_path || ""}
|
value={formData.ssl_cert_path || ""}
|
||||||
onChange={(e) => handleInputChange("ssl_cert_path", e.target.value)}
|
onChange={(e) => handleInputChange("ssl_cert_path", e.target.value)}
|
||||||
placeholder="/path/to/certificate.pem"
|
placeholder="/path/to/ssl/cert.pem"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
</div>
|
||||||
<div>
|
|
||||||
<Label htmlFor="company_code">회사 코드</Label>
|
|
||||||
<Input
|
|
||||||
id="company_code"
|
|
||||||
value={formData.company_code || "*"}
|
|
||||||
onChange={(e) => handleInputChange("company_code", e.target.value)}
|
|
||||||
placeholder="회사 코드 (기본: *)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="is_active">상태</Label>
|
|
||||||
<Select
|
|
||||||
value={formData.is_active || "Y"}
|
|
||||||
onValueChange={(value) => handleInputChange("is_active", value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Y">활성</SelectItem>
|
|
||||||
<SelectItem value="N">비활성</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* 편집 모드일 때 정보 표시 */}
|
|
||||||
{editingConnection && (
|
|
||||||
<div className="bg-muted rounded-lg p-4 text-sm">
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<Badge variant="secondary">편집 모드</Badge>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
생성일:{" "}
|
|
||||||
{editingConnection.created_date ? new Date(editingConnection.created_date).toLocaleString() : "-"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
비밀번호를 변경하지 않으려면 비워두세요. 기존 비밀번호가 유지됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleCancel} disabled={loading}>
|
<Button variant="outline" onClick={onClose} disabled={loading}>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={isSaveDisabled()}>
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
{loading ? "저장 중..." : editingConnection ? "수정" : "생성"}
|
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -1,15 +1,8 @@
|
||||||
// 외부 DB 연결 API 클라이언트
|
// 외부 DB 연결 API 클라이언트
|
||||||
// 작성일: 2024-12-17
|
// 작성일: 2024-12-19
|
||||||
|
|
||||||
// API 기본 설정
|
import { apiClient } from "./client";
|
||||||
const getApiBaseUrl = () => {
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
return "http://localhost:8080/api";
|
|
||||||
}
|
|
||||||
return "/api";
|
|
||||||
};
|
|
||||||
|
|
||||||
// 타입 정의
|
|
||||||
export interface ExternalDbConnection {
|
export interface ExternalDbConnection {
|
||||||
id?: number;
|
id?: number;
|
||||||
connection_name: string;
|
connection_name: string;
|
||||||
|
|
@ -45,214 +38,205 @@ export interface ApiResponse<T> {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: T;
|
data?: T;
|
||||||
message?: string;
|
message?: string;
|
||||||
error?: string;
|
error?: {
|
||||||
|
code?: string;
|
||||||
|
details?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB 타입 옵션
|
// 연결 테스트 관련 타입
|
||||||
export const DB_TYPE_OPTIONS = [
|
export interface ConnectionTestRequest {
|
||||||
{ value: "mysql", label: "MySQL" },
|
db_type: string;
|
||||||
{ value: "postgresql", label: "PostgreSQL" },
|
host: string;
|
||||||
{ value: "oracle", label: "Oracle" },
|
port: number;
|
||||||
{ value: "mssql", label: "SQL Server" },
|
database_name: string;
|
||||||
{ value: "sqlite", label: "SQLite" },
|
username: string;
|
||||||
];
|
password: string;
|
||||||
|
connection_timeout?: number;
|
||||||
|
ssl_enabled?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// DB 타입별 기본 설정
|
export interface ConnectionTestResult {
|
||||||
export const DB_TYPE_DEFAULTS = {
|
success: boolean;
|
||||||
mysql: { port: 3306, driver: "mysql2" },
|
message: string;
|
||||||
postgresql: { port: 5432, driver: "pg" },
|
details?: {
|
||||||
oracle: { port: 1521, driver: "oracledb" },
|
response_time?: number;
|
||||||
mssql: { port: 1433, driver: "mssql" },
|
server_version?: string;
|
||||||
sqlite: { port: 0, driver: "sqlite3" },
|
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 {
|
export class ExternalDbConnectionAPI {
|
||||||
private static getAuthHeaders() {
|
private static readonly BASE_PATH = "/external-db-connections";
|
||||||
const token = localStorage.getItem("authToken");
|
|
||||||
return {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token && { Authorization: `Bearer ${token}` }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
|
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 DB 연결 목록 조회
|
* 외부 DB 연결 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getConnections(filter?: ExternalDbConnectionFilter): Promise<ApiResponse<ExternalDbConnection[]>> {
|
static async getConnections(filter: ExternalDbConnectionFilter = {}): Promise<ExternalDbConnection[]> {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
if (filter) {
|
if (filter.db_type) params.append("db_type", filter.db_type);
|
||||||
Object.entries(filter).forEach(([key, value]) => {
|
if (filter.is_active) params.append("is_active", filter.is_active);
|
||||||
if (value && value.trim()) {
|
if (filter.company_code) params.append("company_code", filter.company_code);
|
||||||
params.append(key, value.trim());
|
if (filter.search) params.append("search", filter.search);
|
||||||
}
|
|
||||||
});
|
const response = await apiClient.get<ApiResponse<ExternalDbConnection[]>>(
|
||||||
|
`${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()}` : ""}`;
|
return response.data.data || [];
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: "GET",
|
|
||||||
headers: this.getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.handleResponse<ExternalDbConnection[]>(response);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 연결 목록 조회 오류:", error);
|
console.error("외부 DB 연결 목록 조회 오류:", error);
|
||||||
return {
|
throw error;
|
||||||
success: false,
|
|
||||||
message: "네트워크 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "Network error",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 외부 DB 연결 조회
|
* 특정 외부 DB 연결 조회
|
||||||
*/
|
*/
|
||||||
static async getConnectionById(id: number): Promise<ApiResponse<ExternalDbConnection>> {
|
static async getConnectionById(id: number): Promise<ExternalDbConnection> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
|
const response = await apiClient.get<ApiResponse<ExternalDbConnection>>(`${this.BASE_PATH}/${id}`);
|
||||||
method: "GET",
|
|
||||||
headers: this.getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.handleResponse<ExternalDbConnection>(response);
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "연결 정보 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error("연결 정보를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 연결 조회 오류:", error);
|
console.error("외부 DB 연결 조회 오류:", error);
|
||||||
return {
|
throw error;
|
||||||
success: false,
|
|
||||||
message: "네트워크 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "Network error",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 새 외부 DB 연결 생성
|
* 새 외부 DB 연결 생성
|
||||||
*/
|
*/
|
||||||
static async createConnection(data: ExternalDbConnection): Promise<ApiResponse<ExternalDbConnection>> {
|
static async createConnection(data: ExternalDbConnection): Promise<ExternalDbConnection> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiBaseUrl()}/external-db-connections`, {
|
const response = await apiClient.post<ApiResponse<ExternalDbConnection>>(this.BASE_PATH, data);
|
||||||
method: "POST",
|
|
||||||
headers: this.getAuthHeaders(),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.handleResponse<ExternalDbConnection>(response);
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "연결 생성에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error("생성된 연결 정보를 받을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 연결 생성 오류:", error);
|
console.error("외부 DB 연결 생성 오류:", error);
|
||||||
return {
|
throw error;
|
||||||
success: false,
|
|
||||||
message: "네트워크 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "Network error",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 DB 연결 수정
|
* 외부 DB 연결 수정
|
||||||
*/
|
*/
|
||||||
static async updateConnection(
|
static async updateConnection(id: number, data: ExternalDbConnection): Promise<ExternalDbConnection> {
|
||||||
id: number,
|
|
||||||
data: Partial<ExternalDbConnection>,
|
|
||||||
): Promise<ApiResponse<ExternalDbConnection>> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
|
const response = await apiClient.put<ApiResponse<ExternalDbConnection>>(`${this.BASE_PATH}/${id}`, data);
|
||||||
method: "PUT",
|
|
||||||
headers: this.getAuthHeaders(),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.handleResponse<ExternalDbConnection>(response);
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "연결 수정에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data.data) {
|
||||||
|
throw new Error("수정된 연결 정보를 받을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 연결 수정 오류:", error);
|
console.error("외부 DB 연결 수정 오류:", error);
|
||||||
return {
|
throw error;
|
||||||
success: false,
|
|
||||||
message: "네트워크 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "Network error",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 외부 DB 연결 삭제
|
* 외부 DB 연결 삭제
|
||||||
*/
|
*/
|
||||||
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
|
static async deleteConnection(id: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
|
const response = await apiClient.delete<ApiResponse<null>>(`${this.BASE_PATH}/${id}`);
|
||||||
method: "DELETE",
|
|
||||||
headers: this.getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.handleResponse<void>(response);
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "연결 삭제에 실패했습니다.");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("외부 DB 연결 삭제 오류:", error);
|
console.error("외부 DB 연결 삭제 오류:", error);
|
||||||
return {
|
throw error;
|
||||||
success: false,
|
|
||||||
message: "네트워크 오류가 발생했습니다.",
|
|
||||||
error: error instanceof Error ? error.message : "Network error",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지원하는 DB 타입 목록 조회
|
* 지원되는 DB 타입 목록 조회
|
||||||
*/
|
*/
|
||||||
static async getSupportedTypes(): Promise<
|
static async getSupportedTypes(): Promise<Array<{ value: string; label: string }>> {
|
||||||
ApiResponse<{ types: typeof DB_TYPE_OPTIONS; defaults: typeof DB_TYPE_DEFAULTS }>
|
|
||||||
> {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/types/supported`, {
|
const response = await apiClient.get<ApiResponse<{ types: Array<{ value: string; label: string }> }>>(
|
||||||
method: "GET",
|
`${this.BASE_PATH}/types/supported`,
|
||||||
headers: this.getAuthHeaders(),
|
);
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
} catch (error) {
|
||||||
console.error("지원 DB 타입 조회 오류:", error);
|
console.error("지원 DB 타입 조회 오류:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 연결 테스트
|
||||||
|
*/
|
||||||
|
static async testConnection(testData: ConnectionTestRequest): Promise<ConnectionTestResult> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(`${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 {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "네트워크 오류가 발생했습니다.",
|
message: "연결 테스트 중 오류가 발생했습니다.",
|
||||||
error: error instanceof Error ? error.message : "Network error",
|
error: {
|
||||||
|
code: "NETWORK_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue