feature connection

This commit is contained in:
leeheejin
2025-09-22 17:28:31 +09:00
parent 3a96f9dc81
commit 1ae16bb690
16 changed files with 1332 additions and 100 deletions

View File

@@ -1,7 +1,7 @@
"use client";
import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database } from "lucide-react";
import { Plus, Search, Pencil, Trash2, Database, Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
@@ -26,6 +26,7 @@ import {
ConnectionTestRequest,
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
// DB 타입 매핑
const DB_TYPE_LABELS: Record<string, string> = {
@@ -59,6 +60,8 @@ export default function ExternalConnectionsPage() {
const [connectionToDelete, setConnectionToDelete] = useState<ExternalDbConnection | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map());
const [sqlModalOpen, setSqlModalOpen] = useState(false);
const [selectedConnection, setSelectedConnection] = useState<ExternalDbConnection | null>(null);
// 데이터 로딩
const loadConnections = async () => {
@@ -170,18 +173,7 @@ export default function ExternalConnectionsPage() {
setTestingConnections((prev) => new Set(prev).add(connection.id!));
try {
const testData: ConnectionTestRequest = {
db_type: connection.db_type,
host: connection.host,
port: connection.port,
database_name: connection.database_name,
username: connection.username,
password: connection.password,
connection_timeout: connection.connection_timeout,
ssl_enabled: connection.ssl_enabled,
};
const result = await ExternalDbConnectionAPI.testConnection(testData);
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
@@ -193,7 +185,7 @@ export default function ExternalConnectionsPage() {
} else {
toast({
title: "연결 실패",
description: `${connection.connection_name} 연결에 실패했습니다.`,
description: result.message || `${connection.connection_name} 연결에 실패했습니다.`,
variant: "destructive",
});
}
@@ -369,6 +361,19 @@ export default function ExternalConnectionsPage() {
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
console.log("SQL 쿼리 실행 버튼 클릭 - connection:", connection);
setSelectedConnection(connection);
setSqlModalOpen(true);
}}
className="h-8 w-8 p-0"
title="SQL 쿼리 실행"
>
<Terminal className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -428,6 +433,19 @@ export default function ExternalConnectionsPage() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* SQL 쿼리 모달 */}
{selectedConnection && (
<SqlQueryModal
isOpen={sqlModalOpen}
onClose={() => {
setSqlModalOpen(false);
setSelectedConnection(null);
}}
connectionId={selectedConnection.id!}
connectionName={selectedConnection.connection_name}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,275 @@
"use client";
import { useState, useEffect, ChangeEvent } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
interface TableColumn {
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}
interface TableInfo {
table_name: string;
columns: TableColumn[];
description: string | null;
}
interface QueryResult {
[key: string]: string | number | boolean | null | undefined;
}
interface TableColumn {
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
}
interface TableInfo {
table_name: string;
columns: TableColumn[];
description: string | null;
}
interface SqlQueryModalProps {
isOpen: boolean;
onClose: () => void;
connectionId: number;
connectionName: string;
}
export const SqlQueryModal: React.FC<SqlQueryModalProps> = ({ isOpen, onClose, connectionId, connectionName }) => {
const { toast } = useToast();
const [query, setQuery] = useState("SELECT * FROM ");
const [results, setResults] = useState<QueryResult[]>([]);
const [loading, setLoading] = useState(false);
const [tables, setTables] = useState<TableInfo[]>([]);
const [selectedTable, setSelectedTable] = useState("");
const [loadingTables, setLoadingTables] = useState(false);
// 테이블 목록 로딩
useEffect(() => {
console.log("SqlQueryModal - connectionId:", connectionId);
const loadTables = async () => {
setLoadingTables(true);
try {
const result = await ExternalDbConnectionAPI.getTables(connectionId);
if (result.success && result.data) {
setTables(result.data as unknown as TableInfo[]);
}
} catch (error) {
console.error("테이블 목록 로딩 오류:", error);
toast({
title: "오류",
description: "테이블 목록을 불러오는데 실패했습니다.",
variant: "destructive",
});
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
const handleExecute = async () => {
console.log("실행 버튼 클릭");
if (!query.trim()) {
toast({
title: "오류",
description: "실행할 쿼리를 입력해주세요.",
variant: "destructive",
});
return;
}
console.log("쿼리 실행 시작:", { connectionId, query });
setLoading(true);
try {
const result = await ExternalDbConnectionAPI.executeQuery(connectionId, query);
console.log("쿼리 실행 결과:", result);
if (result.success && result.data) {
setResults(result.data);
toast({
title: "성공",
description: "쿼리가 성공적으로 실행되었습니다.",
});
} else {
toast({
title: "오류",
description: result.message || "쿼리 실행 중 오류가 발생했습니다.",
variant: "destructive",
});
}
} catch (error) {
console.error("쿼리 실행 오류:", error);
toast({
title: "오류",
description: error instanceof Error ? error.message : "쿼리 실행 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-h-[90vh] max-w-5xl" aria-describedby="modal-description">
<DialogHeader>
<DialogTitle>{connectionName} - SQL </DialogTitle>
<DialogDescription>
SQL SELECT .
</DialogDescription>
</DialogHeader>
{/* 쿼리 입력 영역 */}
<div className="mb-4 space-y-4">
<div className="space-y-2">
{/* 테이블 선택 */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Select
value={selectedTable}
onValueChange={(value) => {
setSelectedTable(value);
// 현재 커서 위치에 테이블 이름 삽입
setQuery((prev) => {
const fromIndex = prev.toUpperCase().lastIndexOf("FROM");
if (fromIndex === -1) {
return `SELECT * FROM ${value}`;
}
const beforeFrom = prev.substring(0, fromIndex + 4);
return `${beforeFrom} ${value}`;
});
}}
disabled={loadingTables}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder={loadingTables ? "테이블 로딩 중..." : "테이블 선택"} />
</SelectTrigger>
<SelectContent>
{tables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
{table.table_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 테이블 정보 */}
<div className="bg-muted/50 rounded-md border p-4">
<h3 className="mb-2 font-medium"> </h3>
<div className="space-y-4">
{tables.map((table) => (
<div key={table.table_name} className="border-b pb-2 last:border-b-0 last:pb-0">
<div className="flex items-center justify-between">
<h4 className="font-mono font-bold">{table.table_name}</h4>
<Button variant="ghost" size="sm" onClick={() => setQuery(`SELECT * FROM ${table.table_name}`)}>
</Button>
</div>
{table.description && (
<p className="text-muted-foreground mt-1 text-sm">{table.description}</p>
)}
<div className="mt-2 grid grid-cols-3 gap-2">
{table.columns.map((column: TableColumn) => (
<div key={column.column_name} className="text-sm">
<span className="font-mono">{column.column_name}</span>
<span className="text-muted-foreground ml-1">({column.data_type})</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
</div>
{/* 쿼리 입력 */}
<div className="relative">
<Textarea
value={query}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setQuery(e.target.value)}
placeholder="SELECT * FROM table_name"
className="min-h-[120px] pr-24 font-mono"
/>
<Button
onClick={() => {
console.log("버튼 클릭됨");
handleExecute();
}}
disabled={loading}
className="absolute top-2 right-2 w-20"
size="sm"
>
{loading ? "실행 중..." : "실행"}
</Button>
</div>
</div>
</div>
{/* 결과 섹션 */}
<div className="mt-4 space-y-4">
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{loading ? "쿼리 실행 중..." : results.length > 0 ? `${results.length}개의 결과가 있습니다.` : "실행된 쿼리가 없습니다."}
</div>
</div>
{/* 결과 그리드 */}
<div className="rounded-md border">
<div className="max-h-[400px] overflow-auto">
<Table>
{results.length > 0 ? (
<>
<TableHeader className="sticky top-0 bg-white">
<TableRow>
{Object.keys(results[0]).map((key) => (
<TableHead key={key} className="font-mono font-bold">
{key}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{results.map((row: QueryResult, i: number) => (
<TableRow key={i} className="hover:bg-muted/50">
{Object.values(row).map((value, j) => (
<TableCell key={j} className="font-mono">
{value === null ? (
<span className="text-muted-foreground italic">NULL</span>
) : (
String(value)
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</>
) : (
<TableBody>
<TableRow>
<TableCell className="text-muted-foreground h-32 text-center">
{loading ? "쿼리 실행 중..." : "쿼리를 실행하면 결과가 여기에 표시됩니다."}
</TableCell>
</TableRow>
</TableBody>
)}
</Table>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -203,33 +203,29 @@ export class ExternalDbConnectionAPI {
}
/**
* 데이터베이스 연결 테스트
* 데이터베이스 연결 테스트 (ID 기반)
*/
static async testConnection(testData: ConnectionTestRequest): Promise<ConnectionTestResult> {
static async testConnection(connectionId: number): Promise<ConnectionTestResult> {
try {
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(`${this.BASE_PATH}/test`, testData);
const response = await apiClient.post<ApiResponse<ConnectionTestResult>>(
`${this.BASE_PATH}/${connectionId}/test`
);
if (!response.data.success) {
// 백엔드에서 테스트 실패 시에도 200으로 응답하지만 data.success가 false
return (
response.data.data || {
success: false,
message: response.data.message || "연결 테스트에 실패했습니다.",
error: response.data.error,
}
);
return {
success: false,
message: response.data.message || "연결 테스트에 실패했습니다.",
error: response.data.error,
};
}
return (
response.data.data || {
success: true,
message: response.data.message || "연결 테스트가 완료되었습니다.",
}
);
return response.data.data || {
success: true,
message: response.data.message || "연결 테스트가 완료되었습니다.",
};
} catch (error) {
console.error("연결 테스트 오류:", error);
// 네트워크 오류 등의 경우
return {
success: false,
message: "연결 테스트 중 오류가 발생했습니다.",
@@ -240,4 +236,37 @@ export class ExternalDbConnectionAPI {
};
}
}
/**
* SQL 쿼리 실행
*/
/**
* 데이터베이스 테이블 목록 조회
*/
static async getTables(connectionId: number): Promise<ApiResponse<string[]>> {
try {
const response = await apiClient.get<ApiResponse<string[]>>(
`${this.BASE_PATH}/${connectionId}/tables`
);
return response.data;
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
throw error;
}
}
static async executeQuery(connectionId: number, query: string): Promise<ApiResponse<any[]>> {
try {
console.log("API 요청:", `${this.BASE_PATH}/${connectionId}/execute`, { query });
const response = await apiClient.post<ApiResponse<any[]>>(
`${this.BASE_PATH}/${connectionId}/execute`,
{ query }
);
console.log("API 응답:", response.data);
return response.data;
} catch (error) {
console.error("SQL 쿼리 실행 오류:", error);
throw error;
}
}
}

View File

@@ -56,8 +56,8 @@
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.86.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8",
@@ -2757,9 +2757,9 @@
}
},
"node_modules/@types/react": {
"version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"version": "19.1.13",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"

View File

@@ -64,8 +64,8 @@
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.86.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"eslint": "^9",
"eslint-config-next": "15.4.4",
"eslint-config-prettier": "^10.1.8",