4c1dc4082e
Build and Push Images / build-and-push (push) Has been cancelled
이전 세션들에서 작업된 아래 범위를 모두 포함: Fleet 서브시스템 (src/fleet/) - fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService - fleetMetricsService, fleetScriptService, fleetEdgeConfigService - Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화 Collector 확장 - centralMqttForwarder / centralForwarderConfigService - equipmentStateService, pythonHookRunner, scriptCache - Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트 - targetDbIntrospection (저장 DB 조회) Routes / API - automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes DB - importEdgeConfig (Python cached config → Pipeline DB) - seedDataSources (external_db_connections 초기 시드) 엣지 배포 리소스 - docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod - docker/edge/docker-compose.edge.yml 프론트엔드 - admin/automaticMng (centralForwarder, dashboard, equipmentState) - admin/fleet (commands, devices, deployments, releases, scripts, alerts) - admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등) - ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트 - lib/api: automationDashboard, centralForwarder, equipmentState, fleet docs/ - EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
401 lines
18 KiB
TypeScript
401 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Plus, Search, Pencil, Trash2, Database, Terminal, Globe, Cpu, FileText } 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 { ScrollToTop } from "@/components/common/ScrollToTop";
|
|
import dynamic from "next/dynamic";
|
|
|
|
const PipelineDevicePage = dynamic(() => import("@/app/(main)/admin/pipeline-device/page"), { ssr: false });
|
|
|
|
type ConnectionTabType = "database" | "rest-api" | "device" | "file-reader";
|
|
|
|
// DB 타입 매핑
|
|
const DB_TYPE_LABELS: Record<string, string> = {
|
|
mysql: "MySQL",
|
|
postgresql: "PostgreSQL",
|
|
oracle: "Oracle",
|
|
mssql: "SQL Server",
|
|
sqlite: "SQLite",
|
|
mariadb: "MariaDB",
|
|
};
|
|
|
|
// DB 타입 색상
|
|
const DB_TYPE_COLORS: Record<string, string> = {
|
|
mysql: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
|
postgresql: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
|
oracle: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
|
mssql: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400",
|
|
sqlite: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400",
|
|
mariadb: "bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400",
|
|
};
|
|
|
|
// 활성 상태 옵션
|
|
const ACTIVE_STATUS_OPTIONS = [
|
|
{ value: "ALL", label: "전체" },
|
|
{ value: "Y", label: "활성" },
|
|
{ value: "N", label: "비활성" },
|
|
];
|
|
|
|
export default function DataSourcePage() {
|
|
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);
|
|
}
|
|
};
|
|
|
|
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 s = new Set(prev); s.delete(connection.id!); return s; });
|
|
}
|
|
};
|
|
|
|
const handleModalSave = () => { setIsModalOpen(false); setEditingConnection(undefined); loadConnections(); };
|
|
const handleModalCancel = () => { setIsModalOpen(false); setEditingConnection(undefined); };
|
|
|
|
// 준비 중 안내 컴포넌트
|
|
const ComingSoon = ({ icon: Icon, title, desc }: { icon: React.ElementType; title: string; desc: string }) => (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-muted">
|
|
<Icon className="h-5 w-5 text-muted-foreground" />
|
|
</div>
|
|
<p className="text-sm font-medium">{title}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{desc}</p>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto bg-background">
|
|
<div className="space-y-4 p-4 sm:p-5">
|
|
{/* 페이지 헤더 */}
|
|
<div className="flex items-center justify-between border-b pb-3">
|
|
<div>
|
|
<h1 className="text-lg font-bold tracking-tight">데이터 소스</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">외부 데이터 연결을 통합 관리합니다</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 탭 */}
|
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
|
<TabsList className="h-8">
|
|
<TabsTrigger value="database" className="gap-1.5 px-3 text-xs">
|
|
<Database className="h-3 w-3" />
|
|
데이터베이스
|
|
</TabsTrigger>
|
|
<TabsTrigger value="rest-api" className="gap-1.5 px-3 text-xs">
|
|
<Globe className="h-3 w-3" />
|
|
REST API
|
|
</TabsTrigger>
|
|
<TabsTrigger value="device" className="gap-1.5 px-3 text-xs">
|
|
<Cpu className="h-3 w-3" />
|
|
장비 통신
|
|
</TabsTrigger>
|
|
<TabsTrigger value="file-reader" className="gap-1.5 px-3 text-xs">
|
|
<FileText className="h-3 w-3" />
|
|
파일 리더
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* 데이터베이스 탭 */}
|
|
<TabsContent value="database" className="mt-4 space-y-3">
|
|
{/* 검색 및 필터 */}
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
|
<div className="relative w-full sm:w-56">
|
|
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
<Input
|
|
placeholder="연결명으로 검색..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="h-8 pl-8 text-xs"
|
|
/>
|
|
</div>
|
|
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
|
<SelectTrigger className="h-8 w-full text-xs sm:w-32">
|
|
<SelectValue placeholder="DB 타입" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{supportedDbTypes.map((type) => (
|
|
<SelectItem key={type.value} value={type.value} className="text-xs">{type.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
|
<SelectTrigger className="h-8 w-full text-xs sm:w-24">
|
|
<SelectValue placeholder="상태" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ACTIVE_STATUS_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">{opt.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs">
|
|
<Plus className="h-3.5 w-3.5" />
|
|
새 연결
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 그리드 카드 목록 */}
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<div key={i} className="h-36 animate-pulse rounded-lg border bg-muted/30" />
|
|
))}
|
|
</div>
|
|
) : connections.length === 0 ? (
|
|
<div className="flex h-48 flex-col items-center justify-center rounded-lg border border-dashed">
|
|
<Database className="mb-2 h-5 w-5 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">등록된 연결이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{connections.map((conn) => {
|
|
const isTesting = testingConnections.has(conn.id!);
|
|
const testResult = testResults.get(conn.id!);
|
|
const typeColor = DB_TYPE_COLORS[conn.db_type] || "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300";
|
|
|
|
return (
|
|
<div
|
|
key={conn.id}
|
|
className="group relative flex flex-col rounded-lg border bg-card p-3.5 transition-all hover:shadow-md"
|
|
>
|
|
{/* 상단: 타입 뱃지 + 상태 */}
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[10px] font-semibold ${typeColor}`}>
|
|
{DB_TYPE_LABELS[conn.db_type] || conn.db_type}
|
|
</span>
|
|
<Badge variant={conn.is_active === "Y" ? "default" : "secondary"} className="h-5 text-[10px]">
|
|
{conn.is_active === "Y" ? "활성" : "비활성"}
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* 연결명 */}
|
|
<h3 className="mb-0.5 truncate text-xs font-semibold" title={conn.connection_name}>
|
|
{conn.connection_name}
|
|
</h3>
|
|
|
|
{/* 회사 */}
|
|
<p className="mb-2 truncate text-[11px] text-muted-foreground">
|
|
{(conn as any).company_name || conn.company_code || "공통"}
|
|
</p>
|
|
|
|
{/* 호스트 정보 */}
|
|
<div className="mb-3 space-y-0.5 rounded-md bg-muted/50 px-2 py-1.5">
|
|
<p className="truncate font-mono text-[10px] text-muted-foreground" title={`${conn.host}:${conn.port}`}>
|
|
{conn.host}:{conn.port}
|
|
</p>
|
|
<p className="truncate font-mono text-[10px] font-medium" title={conn.database_name}>
|
|
{conn.database_name}
|
|
<span className="ml-1 text-muted-foreground">({conn.username})</span>
|
|
</p>
|
|
</div>
|
|
|
|
{/* 하단 버튼 */}
|
|
<div className="mt-auto flex items-center gap-1">
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => handleTestConnection(conn)}
|
|
disabled={isTesting}
|
|
className="h-6 flex-1 text-[10px]"
|
|
>
|
|
{isTesting ? "테스트 중..." : testResult !== undefined ? (testResult ? "성공" : "실패") : "테스트"}
|
|
</Button>
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => { setSelectedConnection(conn); setSqlModalOpen(true); }}
|
|
className="h-6 px-2 text-[10px]"
|
|
>
|
|
<Terminal className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => handleEditConnection(conn)}
|
|
className="h-6 px-2 text-[10px]"
|
|
>
|
|
<Pencil className="h-3 w-3" />
|
|
</Button>
|
|
<Button
|
|
variant="outline" size="sm"
|
|
onClick={() => handleDeleteConnection(conn)}
|
|
className="h-6 px-2 text-[10px] text-destructive hover:bg-destructive/10"
|
|
>
|
|
<Trash2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* 모달들 */}
|
|
{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-[420px]">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-sm">연결 삭제</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-xs">
|
|
“{connectionToDelete?.connection_name}” 연결을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter className="gap-2 sm:gap-0">
|
|
<AlertDialogCancel onClick={cancelDeleteConnection} className="h-8 text-xs">취소</AlertDialogCancel>
|
|
<AlertDialogAction onClick={confirmDeleteConnection} className="h-8 bg-destructive text-xs hover:bg-destructive/90">삭제</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{selectedConnection && (
|
|
<SqlQueryModal
|
|
isOpen={sqlModalOpen}
|
|
onClose={() => { setSqlModalOpen(false); setSelectedConnection(null); }}
|
|
connectionId={selectedConnection.id!}
|
|
connectionName={selectedConnection.connection_name}
|
|
/>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* REST API 탭 */}
|
|
<TabsContent value="rest-api" className="mt-4 space-y-3">
|
|
<RestApiConnectionList />
|
|
</TabsContent>
|
|
|
|
{/* 장비연결 탭 */}
|
|
<TabsContent value="device" className="mt-4">
|
|
<PipelineDevicePage />
|
|
</TabsContent>
|
|
|
|
{/* 파일 리더 탭 */}
|
|
<TabsContent value="file-reader" className="mt-4">
|
|
<ComingSoon icon={FileText} title="파일 리더" desc="CSV, TXT, Excel 등 파일 데이터 연결 기능이 준비 중입니다" />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
<ScrollToTop />
|
|
</div>
|
|
);
|
|
}
|