448 lines
18 KiB
TypeScript
448 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
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";
|
|
import { SqlQueryModal } from "@/components/admin/SqlQueryModal";
|
|
import { RestApiConnectionList } from "@/components/admin/RestApiConnectionList";
|
|
import { ResponsiveDataView, RDVColumn, RDVCardField } from "@/components/common/ResponsiveDataView";
|
|
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
|
|
type ConnectionTabType = "database" | "rest-api";
|
|
|
|
// 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 [activeTab, setActiveTab] = useState<ConnectionTabType>("database");
|
|
|
|
// 상태 관리
|
|
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 [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 () => {
|
|
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 handleTestConnection = async (connection: ExternalDbConnection) => {
|
|
if (!connection.id) return;
|
|
|
|
setTestingConnections((prev) => new Set(prev).add(connection.id!));
|
|
|
|
try {
|
|
const result = await ExternalDbConnectionAPI.testConnection(connection.id);
|
|
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
|
|
|
if (result.success) {
|
|
toast({ title: "연결 성공", description: `${connection.connection_name} 연결이 성공했습니다.` });
|
|
} else {
|
|
toast({
|
|
title: "연결 실패",
|
|
description: result.message || `${connection.connection_name} 연결에 실패했습니다.`,
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error("연결 테스트 오류:", error);
|
|
setTestResults((prev) => new Map(prev).set(connection.id!, false));
|
|
toast({ title: "연결 테스트 오류", description: "연결 테스트 중 오류가 발생했습니다.", variant: "destructive" });
|
|
} finally {
|
|
setTestingConnections((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(connection.id!);
|
|
return newSet;
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleModalSave = () => {
|
|
setIsModalOpen(false);
|
|
setEditingConnection(undefined);
|
|
loadConnections();
|
|
};
|
|
|
|
const handleModalCancel = () => {
|
|
setIsModalOpen(false);
|
|
setEditingConnection(undefined);
|
|
};
|
|
|
|
// 테이블 컬럼 정의
|
|
const columns: RDVColumn<ExternalDbConnection>[] = [
|
|
{ key: "connection_name", label: "연결명",
|
|
render: (v) => <span className="font-medium">{v}</span> },
|
|
{ key: "company_code", label: "회사", width: "100px",
|
|
render: (_v, row) => (row as any).company_name || row.company_code },
|
|
{ key: "db_type", label: "DB 타입", width: "120px",
|
|
render: (v) => <Badge variant="outline">{DB_TYPE_LABELS[v] || v}</Badge> },
|
|
{ key: "host", label: "호스트:포트", width: "180px", hideOnMobile: true,
|
|
render: (_v, row) => <span className="font-mono">{row.host}:{row.port}</span> },
|
|
{ key: "database_name", label: "데이터베이스", width: "140px", hideOnMobile: true,
|
|
render: (v) => <span className="font-mono">{v}</span> },
|
|
{ key: "username", label: "사용자", width: "100px", hideOnMobile: true,
|
|
render: (v) => <span className="font-mono">{v}</span> },
|
|
{ key: "is_active", label: "상태", width: "80px",
|
|
render: (v) => (
|
|
<Badge variant={v === "Y" ? "default" : "secondary"}>
|
|
{v === "Y" ? "활성" : "비활성"}
|
|
</Badge>
|
|
) },
|
|
{ key: "created_date", label: "생성일", width: "100px", hideOnMobile: true,
|
|
render: (v) => (
|
|
<span className="text-muted-foreground">
|
|
{v ? new Date(v).toLocaleDateString() : "N/A"}
|
|
</span>
|
|
) },
|
|
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
|
|
render: (_v, row) => (
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
|
|
disabled={testingConnections.has(row.id!)}
|
|
className="h-9 text-sm">
|
|
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
|
|
</Button>
|
|
{testResults.has(row.id!) && (
|
|
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
|
|
{testResults.get(row.id!) ? "성공" : "실패"}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
) },
|
|
];
|
|
|
|
// 모바일 카드 필드 정의
|
|
const cardFields: RDVCardField<ExternalDbConnection>[] = [
|
|
{ label: "DB 타입",
|
|
render: (c) => <Badge variant="outline">{DB_TYPE_LABELS[c.db_type] || c.db_type}</Badge> },
|
|
{ label: "호스트",
|
|
render: (c) => <span className="font-mono text-xs">{c.host}:{c.port}</span> },
|
|
{ label: "데이터베이스",
|
|
render: (c) => <span className="font-mono text-xs">{c.database_name}</span> },
|
|
{ label: "상태",
|
|
render: (c) => (
|
|
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
|
{c.is_active === "Y" ? "활성" : "비활성"}
|
|
</Badge>
|
|
) },
|
|
];
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col bg-background">
|
|
<div className="space-y-6 p-6">
|
|
{/* 페이지 헤더 */}
|
|
<div className="space-y-2 border-b pb-4">
|
|
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
|
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
|
</div>
|
|
|
|
{/* 탭 */}
|
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
|
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
|
|
<TabsTrigger value="database" className="flex items-center gap-2">
|
|
<Database className="h-4 w-4" />
|
|
데이터베이스 연결
|
|
</TabsTrigger>
|
|
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
|
<Globe className="h-4 w-4" />
|
|
REST API 연결
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 데이터베이스 연결 탭 */}
|
|
<TabsContent value="database" className="space-y-6">
|
|
{/* 검색 및 필터 */}
|
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<div className="relative w-full sm:w-[300px]">
|
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="연결명 또는 설명으로 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="h-10 pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
|
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
|
<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="h-10 w-full sm:w-[120px]">
|
|
<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="h-10 gap-2 text-sm font-medium">
|
|
<Plus className="h-4 w-4" />
|
|
새 연결 추가
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 연결 목록 - ResponsiveDataView */}
|
|
<ResponsiveDataView
|
|
data={connections}
|
|
columns={columns}
|
|
keyExtractor={(c) => String(c.id || c.connection_name)}
|
|
isLoading={loading}
|
|
emptyMessage="등록된 연결이 없습니다"
|
|
skeletonCount={5}
|
|
cardTitle={(c) => c.connection_name}
|
|
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
|
|
cardHeaderRight={(c) => (
|
|
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
|
{c.is_active === "Y" ? "활성" : "비활성"}
|
|
</Badge>
|
|
)}
|
|
cardFields={cardFields}
|
|
renderActions={(c) => (
|
|
<>
|
|
<Button variant="outline" size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
|
|
disabled={testingConnections.has(c.id!)}
|
|
className="h-9 flex-1 gap-2 text-sm">
|
|
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
|
|
</Button>
|
|
<Button variant="outline" size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setSelectedConnection(c);
|
|
setSqlModalOpen(true);
|
|
}}
|
|
className="h-9 flex-1 gap-2 text-sm">
|
|
<Terminal className="h-4 w-4" />
|
|
SQL
|
|
</Button>
|
|
<Button variant="outline" size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
|
|
className="h-9 flex-1 gap-2 text-sm">
|
|
<Pencil className="h-4 w-4" />
|
|
편집
|
|
</Button>
|
|
<Button variant="outline" size="sm"
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
|
|
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
|
|
<Trash2 className="h-4 w-4" />
|
|
삭제
|
|
</Button>
|
|
</>
|
|
)}
|
|
actionsLabel="작업"
|
|
actionsWidth="180px"
|
|
/>
|
|
|
|
{/* 연결 설정 모달 */}
|
|
{isModalOpen && (
|
|
<ExternalDbConnectionModal
|
|
isOpen={isModalOpen}
|
|
onClose={handleModalCancel}
|
|
onSave={handleModalSave}
|
|
connection={editingConnection}
|
|
supportedDbTypes={supportedDbTypes.filter((type) => type.value !== "ALL")}
|
|
/>
|
|
)}
|
|
|
|
{/* 삭제 확인 다이얼로그 */}
|
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
<AlertDialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-base sm:text-lg">연결 삭제 확인</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-xs sm:text-sm">
|
|
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까?
|
|
<br />
|
|
이 작업은 되돌릴 수 없습니다.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
|
<AlertDialogCancel
|
|
onClick={cancelDeleteConnection}
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
취소
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDeleteConnection}
|
|
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm"
|
|
>
|
|
삭제
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* SQL 쿼리 모달 */}
|
|
{selectedConnection && (
|
|
<SqlQueryModal
|
|
isOpen={sqlModalOpen}
|
|
onClose={() => {
|
|
setSqlModalOpen(false);
|
|
setSelectedConnection(null);
|
|
}}
|
|
connectionId={selectedConnection.id!}
|
|
connectionName={selectedConnection.connection_name}
|
|
/>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* REST API 연결 탭 */}
|
|
<TabsContent value="rest-api" className="space-y-6">
|
|
<RestApiConnectionList />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
<ScrollToTop />
|
|
</div>
|
|
);
|
|
}
|