외부 커넥션 테스트까지 #42

Merged
hyeonsu merged 2 commits from external-connections into dev 2025-09-19 12:17:26 +09:00
9 changed files with 1190 additions and 401 deletions

View File

@ -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);

View File

@ -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

View File

@ -279,7 +279,7 @@ export class ExternalDbConnectionService {
}
/**
* DB ( )
* DB ( )
*/
static async deleteConnection(id: number): Promise<ApiResponse<void>> {
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<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];
}
/**
*
*/

View File

@ -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;

View File

@ -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

View File

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

View File

@ -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<string, number> = {
mysql: 3306,
postgresql: 5432,
oracle: 1521,
mssql: 1433,
sqlite: 0, // SQLite는 파일 기반이므로 포트 없음
};
export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps> = ({
isOpen,
onClose,
onSave,
editingConnection,
}: ExternalDbConnectionModalProps) {
const [formData, setFormData] = useState<Partial<ExternalDbConnection>>({
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<ConnectionTestResult | null>(null);
// 폼 데이터
const [formData, setFormData] = useState<ExternalDbConnection>({
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 (
<Dialog open={isOpen} onOpenChange={handleCancel}>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}
</DialogTitle>
<DialogTitle>{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
</DialogHeader>
<div className="space-y-6">
@ -199,21 +318,22 @@ export function ExternalDbConnectionModal({
<Label htmlFor="connection_name"> *</Label>
<Input
id="connection_name"
value={formData.connection_name || ""}
value={formData.connection_name}
onChange={(e) => handleInputChange("connection_name", e.target.value)}
placeholder="예: 영업팀 MySQL"
placeholder="예: 운영 DB"
/>
</div>
<div>
<Label htmlFor="db_type">DB *</Label>
<Select value={formData.db_type || "mysql"} onValueChange={handleDbTypeChange}>
<Select value={formData.db_type} onValueChange={handleDbTypeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DB_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{supportedDbTypes.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
@ -237,25 +357,25 @@ export function ExternalDbConnectionModal({
<div className="space-y-4">
<h3 className="text-lg font-medium"> </h3>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">
<Label htmlFor="host"> *</Label>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="host"> *</Label>
<Input
id="host"
value={formData.host || ""}
value={formData.host}
onChange={(e) => handleInputChange("host", e.target.value)}
placeholder="예: localhost, db.company.com"
placeholder="localhost"
/>
</div>
<div>
<Label htmlFor="port"> *</Label>
<Input
id="port"
type="number"
value={formData.port || ""}
value={formData.port}
onChange={(e) => handleInputChange("port", parseInt(e.target.value) || 0)}
min={1}
max={65535}
placeholder="5432"
/>
</div>
</div>
@ -264,9 +384,9 @@ export function ExternalDbConnectionModal({
<Label htmlFor="database_name"> *</Label>
<Input
id="database_name"
value={formData.database_name || ""}
value={formData.database_name}
onChange={(e) => handleInputChange("database_name", e.target.value)}
placeholder="예: sales_db, production"
placeholder="database_name"
/>
</div>
@ -275,88 +395,164 @@ export function ExternalDbConnectionModal({
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username || ""}
value={formData.username}
onChange={(e) => handleInputChange("username", e.target.value)}
placeholder="DB 사용자명"
placeholder="username"
/>
</div>
<div>
<Label htmlFor="password"> {!editingConnection && "*"}</Label>
<Label htmlFor="password"> {isEditMode ? "(변경 시에만 입력)" : "*"}</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
value={formData.password || ""}
value={formData.password}
onChange={(e) => handleInputChange("password", e.target.value)}
placeholder={editingConnection ? "변경하려면 입력하세요" : "DB 비밀번호"}
placeholder={isEditMode ? "변경하지 않으려면 비워두세요" : "password"}
/>
<Button
type="button"
variant="ghost"
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)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
</div>
{/* 고급 설정 (접기/펼치기) */}
<Collapsible open={showAdvancedSettings} onOpenChange={setShowAdvancedSettings}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="flex w-full justify-between p-0">
<h3 className="text-lg font-medium"> </h3>
{showAdvancedSettings ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<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)}
min={1}
max={300}
/>
</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 className="space-y-3">
<div className="flex items-center gap-3">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={
testingConnection ||
!formData.host ||
!formData.database_name ||
!formData.username ||
!formData.password
}
className="w-32"
>
{testingConnection ? "테스트 중..." : "연결 테스트"}
</Button>
{testingConnection && <div className="text-sm text-gray-500"> ...</div>}
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Switch
id="ssl_enabled"
checked={formData.ssl_enabled === "Y"}
onCheckedChange={(checked) => handleInputChange("ssl_enabled", checked ? "Y" : "N")}
/>
<Label htmlFor="ssl_enabled">SSL </Label>
{/* 테스트 결과 표시 */}
{testResult && (
<div
className={`rounded-md border p-3 text-sm ${
testResult.success
? "border-green-200 bg-green-50 text-green-800"
: "border-red-200 bg-red-50 text-red-800"
}`}
>
<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>
{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"
/>
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<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>
<Button variant="outline" onClick={handleCancel} disabled={loading}>
<Button variant="outline" onClick={onClose} disabled={loading}>
</Button>
<Button onClick={handleSave} disabled={isSaveDisabled()}>
{loading ? "저장 중..." : editingConnection ? "수정" : "생성"}
<Button onClick={handleSave} disabled={loading}>
{loading ? "저장 중..." : isEditMode ? "수정" : "생성"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
};

View File

@ -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 };
};

View File

@ -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<T> {
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<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",
};
}
}
private static readonly BASE_PATH = "/external-db-connections";
/**
* DB
*/
static async getConnections(filter?: ExternalDbConnectionFilter): Promise<ApiResponse<ExternalDbConnection[]>> {
static async getConnections(filter: ExternalDbConnectionFilter = {}): Promise<ExternalDbConnection[]> {
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<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()}` : ""}`;
const response = await fetch(url, {
method: "GET",
headers: this.getAuthHeaders(),
});
return this.handleResponse<ExternalDbConnection[]>(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<ApiResponse<ExternalDbConnection>> {
static async getConnectionById(id: number): Promise<ExternalDbConnection> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
method: "GET",
headers: this.getAuthHeaders(),
});
const response = await apiClient.get<ApiResponse<ExternalDbConnection>>(`${this.BASE_PATH}/${id}`);
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) {
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<ApiResponse<ExternalDbConnection>> {
static async createConnection(data: ExternalDbConnection): Promise<ExternalDbConnection> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections`, {
method: "POST",
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
const response = await apiClient.post<ApiResponse<ExternalDbConnection>>(this.BASE_PATH, 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) {
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<ExternalDbConnection>,
): Promise<ApiResponse<ExternalDbConnection>> {
static async updateConnection(id: number, data: ExternalDbConnection): Promise<ExternalDbConnection> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
method: "PUT",
headers: this.getAuthHeaders(),
body: JSON.stringify(data),
});
const response = await apiClient.put<ApiResponse<ExternalDbConnection>>(`${this.BASE_PATH}/${id}`, 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) {
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<ApiResponse<void>> {
static async deleteConnection(id: number): Promise<void> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/${id}`, {
method: "DELETE",
headers: this.getAuthHeaders(),
});
const response = await apiClient.delete<ApiResponse<null>>(`${this.BASE_PATH}/${id}`);
return this.handleResponse<void>(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<Array<{ value: string; label: string }>> {
try {
const response = await fetch(`${getApiBaseUrl()}/external-db-connections/types/supported`, {
method: "GET",
headers: this.getAuthHeaders(),
});
const response = await apiClient.get<ApiResponse<{ types: Array<{ value: string; label: string }> }>>(
`${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<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 {
success: false,
message: "네트워크 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Network error",
message: "연결 테스트 중 오류가 발생했습니다.",
error: {
code: "NETWORK_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}