feat: 배치 관리 시스템 구현
✨ 주요 기능: - 배치 설정 관리 (생성/수정/삭제/실행) - 배치 실행 로그 관리 및 모니터링 - 배치 스케줄러 자동 실행 (cron 기반) - 외부 DB 연결을 통한 데이터 동기화 - Oracle, MSSQL, MariaDB 커넥터 지원 🔧 백엔드 구현: - BatchManagementController: 배치 설정 CRUD - BatchExecutionLogController: 실행 로그 관리 - BatchSchedulerService: 자동 스케줄링 - BatchExternalDbService: 외부 DB 연동 - 배치 관련 테이블 스키마 추가 🎨 프론트엔드 구현: - 배치 관리 대시보드 UI - 배치 생성/수정 폼 - 실행 로그 모니터링 화면 - 수동 실행 및 상태 관리 🛡️ 안전성: - 기존 시스템과 독립적 구현 - 트랜잭션 기반 안전한 데이터 처리 - 에러 핸들링 및 로깅 강화
This commit is contained in:
544
frontend/app/(main)/admin/batchmng/create/page.tsx
Normal file
544
frontend/app/(main)/admin/batchmng/create/page.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
ColumnInfo,
|
||||
BatchMappingRequest,
|
||||
} from "@/lib/api/batch";
|
||||
|
||||
export default function BatchCreatePage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 기본 정보
|
||||
const [batchName, setBatchName] = useState("");
|
||||
const [cronSchedule, setCronSchedule] = useState("0 12 * * *");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// 커넥션 및 데이터
|
||||
const [connections, setConnections] = useState<ConnectionInfo[]>([]);
|
||||
const [fromConnection, setFromConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [toConnection, setToConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [fromTables, setFromTables] = useState<string[]>([]);
|
||||
const [toTables, setToTables] = useState<string[]>([]);
|
||||
const [fromTable, setFromTable] = useState("");
|
||||
const [toTable, setToTable] = useState("");
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
|
||||
// 매핑 상태
|
||||
const [selectedFromColumn, setSelectedFromColumn] = useState<ColumnInfo | null>(null);
|
||||
const [mappings, setMappings] = useState<BatchMapping[]>([]);
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
|
||||
// 커넥션 목록 로드
|
||||
useEffect(() => {
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
const loadConnections = async () => {
|
||||
setLoadingConnections(true);
|
||||
try {
|
||||
const data = await BatchAPI.getConnections();
|
||||
setConnections(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error("커넥션 로드 실패:", error);
|
||||
toast.error("커넥션 목록을 불러오는데 실패했습니다.");
|
||||
setConnections([]);
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 커넥션 변경
|
||||
const handleFromConnectionChange = async (connectionId: string) => {
|
||||
if (connectionId === 'unknown') return;
|
||||
|
||||
const connection = connections.find(conn => {
|
||||
if (conn.type === 'internal') {
|
||||
return connectionId === 'internal';
|
||||
}
|
||||
return conn.id ? conn.id.toString() === connectionId : false;
|
||||
});
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromTables([]);
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||
setFromTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 커넥션 변경
|
||||
const handleToConnectionChange = async (connectionId: string) => {
|
||||
if (connectionId === 'unknown') return;
|
||||
|
||||
const connection = connections.find(conn => {
|
||||
if (conn.type === 'internal') {
|
||||
return connectionId === 'internal';
|
||||
}
|
||||
return conn.id ? conn.id.toString() === connectionId : false;
|
||||
});
|
||||
|
||||
if (!connection) return;
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToTables([]);
|
||||
setToColumns([]);
|
||||
|
||||
try {
|
||||
const tables = await BatchAPI.getTablesFromConnection(connection.type, connection.id);
|
||||
setToTables(Array.isArray(tables) ? tables : []);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 테이블 변경
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
setFromTable(tableName);
|
||||
setFromColumns([]);
|
||||
setSelectedFromColumn(null);
|
||||
|
||||
if (!fromConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(fromConnection.type, fromConnection.id, tableName);
|
||||
setFromColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("FROM 컬럼 목록 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// TO 테이블 변경
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
setToTable(tableName);
|
||||
setToColumns([]);
|
||||
|
||||
if (!toConnection || !tableName) return;
|
||||
|
||||
try {
|
||||
const columns = await BatchAPI.getTableColumns(toConnection.type, toConnection.id, tableName);
|
||||
setToColumns(Array.isArray(columns) ? columns : []);
|
||||
} catch (error) {
|
||||
console.error("TO 컬럼 목록 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// FROM 컬럼 선택
|
||||
const handleFromColumnClick = (column: ColumnInfo) => {
|
||||
setSelectedFromColumn(column);
|
||||
toast.info(`FROM 컬럼 선택됨: ${column.column_name}`);
|
||||
};
|
||||
|
||||
// TO 컬럼 선택 (매핑 생성)
|
||||
const handleToColumnClick = (toColumn: ColumnInfo) => {
|
||||
if (!selectedFromColumn || !fromConnection || !toConnection) {
|
||||
toast.error("먼저 FROM 컬럼을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// n:1 매핑 검사
|
||||
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`;
|
||||
const existingMapping = mappings.find(mapping => {
|
||||
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`;
|
||||
return existingToKey === toKey;
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
toast.error("동일한 TO 컬럼에 중복 매핑할 수 없습니다. (n:1 매핑 방지)");
|
||||
return;
|
||||
}
|
||||
|
||||
const newMapping: BatchMapping = {
|
||||
from_connection_type: fromConnection.type,
|
||||
from_connection_id: fromConnection.id || null,
|
||||
from_table_name: fromTable,
|
||||
from_column_name: selectedFromColumn.column_name,
|
||||
from_column_type: selectedFromColumn.data_type || '',
|
||||
to_connection_type: toConnection.type,
|
||||
to_connection_id: toConnection.id || null,
|
||||
to_table_name: toTable,
|
||||
to_column_name: toColumn.column_name,
|
||||
to_column_type: toColumn.data_type || '',
|
||||
mapping_order: mappings.length + 1,
|
||||
};
|
||||
|
||||
setMappings([...mappings, newMapping]);
|
||||
setSelectedFromColumn(null);
|
||||
toast.success(`매핑 생성: ${selectedFromColumn.column_name} → ${toColumn.column_name}`);
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const newMappings = mappings.filter((_, i) => i !== index);
|
||||
const reorderedMappings = newMappings.map((mapping, i) => ({
|
||||
...mapping,
|
||||
mapping_order: i + 1
|
||||
}));
|
||||
setMappings(reorderedMappings);
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
};
|
||||
|
||||
// 배치 설정 저장
|
||||
const saveBatchConfig = async () => {
|
||||
if (!batchName.trim()) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cronSchedule.trim()) {
|
||||
toast.error("실행 스케줄을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mappings.length === 0) {
|
||||
toast.error("최소 하나 이상의 매핑을 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const request = {
|
||||
batchName: batchName,
|
||||
description: description || undefined,
|
||||
cronSchedule: cronSchedule,
|
||||
mappings: mappings,
|
||||
isActive: true
|
||||
};
|
||||
|
||||
await BatchAPI.createBatchConfig(request);
|
||||
toast.success("배치 설정이 성공적으로 저장되었습니다!");
|
||||
|
||||
// 목록 페이지로 이동
|
||||
router.push("/admin/batchmng");
|
||||
} catch (error) {
|
||||
console.error("배치 설정 저장 실패:", error);
|
||||
toast.error("배치 설정 저장에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span>목록으로</span>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">배치관리 매핑 시스템</h1>
|
||||
<p className="text-muted-foreground">새로운 배치 매핑을 생성합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="batchName">배치명 *</Label>
|
||||
<Input
|
||||
id="batchName"
|
||||
value={batchName}
|
||||
onChange={(e) => setBatchName(e.target.value)}
|
||||
placeholder="배치명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cronSchedule">실행 스케줄 (Cron) *</Label>
|
||||
<Input
|
||||
id="cronSchedule"
|
||||
value={cronSchedule}
|
||||
onChange={(e) => setCronSchedule(e.target.value)}
|
||||
placeholder="0 12 * * * (매일 12시)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 매핑 설정 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* FROM 섹션 */}
|
||||
<Card className="border-green-200">
|
||||
<CardHeader className="bg-green-50">
|
||||
<CardTitle className="text-green-700">FROM (원본 데이터베이스)</CardTitle>
|
||||
<p className="text-sm text-green-600">
|
||||
1단계: 커넥션을 선택하세요 → 2단계: 테이블을 선택하세요 → 3단계: 컬럼을 클릭해서 매핑하세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select
|
||||
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""}
|
||||
onValueChange={handleFromConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.isArray(connections) && connections.map((conn) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||
>
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={fromTable}
|
||||
onValueChange={handleFromTableChange}
|
||||
disabled={!fromConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* FROM 컬럼 목록 */}
|
||||
{fromTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{fromTable} 테이블</Label>
|
||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||
{fromColumns.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
onClick={() => handleFromColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn?.column_name === column.column_name
|
||||
? 'bg-green-100 border-green-300'
|
||||
: 'hover:bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{column.column_name}</div>
|
||||
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||
</div>
|
||||
))}
|
||||
{fromColumns.length === 0 && fromTable && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* TO 섹션 */}
|
||||
<Card className="border-red-200">
|
||||
<CardHeader className="bg-red-50">
|
||||
<CardTitle className="text-red-700">TO (대상 데이터베이스)</CardTitle>
|
||||
<p className="text-sm text-red-600">
|
||||
FROM에서 컬럼을 선택한 후, 여기서 대상 컬럼을 클릭하면 매핑됩니다
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>커넥션 선택</Label>
|
||||
<Select
|
||||
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""}
|
||||
onValueChange={handleToConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Array.isArray(connections) && connections.map((conn) => (
|
||||
<SelectItem
|
||||
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name}
|
||||
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'}
|
||||
>
|
||||
{conn.name} ({conn.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>테이블 선택</Label>
|
||||
<Select
|
||||
value={toTable}
|
||||
onValueChange={handleToTableChange}
|
||||
disabled={!toConnection}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTables.map((table) => (
|
||||
<SelectItem key={table} value={table}>
|
||||
{table}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* TO 컬럼 목록 */}
|
||||
{toTable && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-blue-600 font-semibold">{toTable} 테이블</Label>
|
||||
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2">
|
||||
{toColumns.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
onClick={() => handleToColumnClick(column)}
|
||||
className={`p-3 border rounded cursor-pointer transition-colors ${
|
||||
selectedFromColumn
|
||||
? 'hover:bg-red-50 border-gray-200'
|
||||
: 'bg-gray-100 border-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{column.column_name}</div>
|
||||
<div className="text-sm text-gray-500">{column.data_type}</div>
|
||||
</div>
|
||||
))}
|
||||
{toColumns.length === 0 && toTable && (
|
||||
<div className="text-center text-gray-500 py-4">
|
||||
컬럼을 불러오는 중...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 매핑 현황 */}
|
||||
{mappings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>컬럼 매핑 현황 ({mappings.length}개)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{mapping.from_table_name}.{mapping.from_column_name}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{mapping.from_column_type}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 text-gray-400" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">
|
||||
{mapping.to_table_name}.{mapping.to_column_name}
|
||||
</div>
|
||||
<div className="text-gray-500">
|
||||
{mapping.to_column_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/admin/batchmng")}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveBatchConfig}
|
||||
disabled={loading || mappings.length === 0}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user