diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index a384b645..64e725e8 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -1,57 +1,102 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { - Plus, - Search, +import { + Plus, + Search, RefreshCw, - Database + Database, + LayoutGrid, + CheckCircle, + Activity, + AlertTriangle, } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { useRouter } from "next/navigation"; -import { - BatchAPI, - BatchConfig, - BatchMapping, -} from "@/lib/api/batch"; -import BatchCard from "@/components/admin/BatchCard"; +import { BatchAPI, type BatchConfig } from "@/lib/api/batch"; +import apiClient from "@/lib/api/client"; import { ScrollToTop } from "@/components/common/ScrollToTop"; +import { cn } from "@/lib/utils"; +import BatchCard from "@/components/admin/BatchCard"; + +// 대시보드 통계 타입 (백엔드 GET /batch-management/stats) +interface BatchStats { + totalBatches: number; + activeBatches: number; + todayExecutions: number; + todayFailures: number; + prevDayExecutions?: number; + prevDayFailures?: number; +} + +// 스파크라인/최근 로그 타입은 BatchCard 내부 정의 사용 + +/** Cron 표현식 → 한글 설명 */ +function cronToKorean(cron: string): string { + if (!cron || !cron.trim()) return "-"; + const parts = cron.trim().split(/\s+/); + if (parts.length < 5) return cron; + const [min, hour, dayOfMonth, month, dayOfWeek] = parts; + + // 매 30분: */30 * * * * + if (min.startsWith("*/") && hour === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { + const n = min.slice(2); + return `매 ${n}분`; + } + // 매 N시간: 0 */2 * * * + if (min === "0" && hour.startsWith("*/") && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { + const n = hour.slice(2); + return `매 ${n}시간`; + } + // 매일 HH:MM: 0 1 * * * 또는 0 6,18 * * * + if (min === "0" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") { + if (hour.includes(",")) { + const times = hour.split(",").map((h) => `${h.padStart(2, "0")}:00`); + return times.join(", "); + } + return `매일 ${hour.padStart(2, "0")}:00`; + } + // 매주 일 03:00: 0 3 * * 0 + if (min === "0" && dayOfMonth === "*" && month === "*" && dayOfWeek !== "*" && dayOfWeek !== "?") { + const dayNames = ["일", "월", "화", "수", "목", "금", "토"]; + const d = dayNames[parseInt(dayOfWeek, 10)] ?? dayOfWeek; + return `매주 ${d} ${hour.padStart(2, "0")}:00`; + } + // 매월 1일 00:00: 0 0 1 * * + if (min === "0" && hour === "0" && dayOfMonth !== "*" && month === "*" && dayOfWeek === "*") { + return `매월 ${dayOfMonth}일 00:00`; + } + return cron; +} + +type StatusFilter = "all" | "active" | "inactive"; +type TypeFilter = "all" | "mapping" | "restapi" | "node_flow"; export default function BatchManagementPage() { const router = useRouter(); - - // 상태 관리 const [batchConfigs, setBatchConfigs] = useState([]); const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); const [searchTerm, setSearchTerm] = useState(""); - const [currentPage, setCurrentPage] = useState(1); - const [totalPages, setTotalPages] = useState(1); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); const [executingBatch, setExecutingBatch] = useState(null); + const [expandedId, setExpandedId] = useState(null); const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false); - // 페이지 로드 시 배치 목록 조회 - useEffect(() => { - loadBatchConfigs(); - }, [currentPage, searchTerm]); - - // 배치 설정 목록 조회 - const loadBatchConfigs = async () => { + const loadBatchConfigs = useCallback(async () => { setLoading(true); try { const response = await BatchAPI.getBatchConfigs({ - page: currentPage, - limit: 10, + limit: 500, search: searchTerm || undefined, }); - if (response.success && response.data) { setBatchConfigs(response.data); - if (response.pagination) { - setTotalPages(response.pagination.totalPages); - } } else { setBatchConfigs([]); } @@ -62,20 +107,46 @@ export default function BatchManagementPage() { } finally { setLoading(false); } - }; + }, [searchTerm]); + + const loadStats = useCallback(async () => { + setStatsLoading(true); + try { + const res = await apiClient.get<{ success: boolean; data?: BatchStats }>("/batch-management/stats"); + if (res.data?.success && res.data.data) { + setStats(res.data.data); + } else { + setStats(null); + } + } catch { + setStats(null); + } finally { + setStatsLoading(false); + } + }, []); + + useEffect(() => { + loadBatchConfigs(); + }, [loadBatchConfigs]); + + useEffect(() => { + loadStats(); + }, [loadStats]); - // 배치 수동 실행 const executeBatch = async (batchId: number) => { setExecutingBatch(batchId); try { const response = await BatchAPI.executeBatchConfig(batchId); if (response.success) { - toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`); + toast.success( + `배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)` + ); + loadBatchConfigs(); + loadStats(); } else { toast.error("배치 실행에 실패했습니다."); } } catch (error) { - console.error("배치 실행 실패:", error); showErrorToast("배치 실행에 실패했습니다", error, { guidance: "배치 설정을 확인하고 다시 시도해 주세요.", }); @@ -84,226 +155,230 @@ export default function BatchManagementPage() { } }; - // 배치 활성화/비활성화 토글 const toggleBatchStatus = async (batchId: number, currentStatus: string) => { - console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus }); - try { - const newStatus = currentStatus === 'Y' ? 'N' : 'Y'; - console.log("📝 새로운 상태:", newStatus); - - const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus }); - console.log("✅ API 호출 성공:", result); - - toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`); - loadBatchConfigs(); // 목록 새로고침 + const newStatus = currentStatus === "Y" ? "N" : "Y"; + await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" }); + toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`); + loadBatchConfigs(); + loadStats(); } catch (error) { - console.error("❌ 배치 상태 변경 실패:", error); + console.error("배치 상태 변경 실패:", error); toast.error("배치 상태 변경에 실패했습니다."); } }; - // 배치 삭제 const deleteBatch = async (batchId: number, batchName: string) => { - if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) { - return; - } - + if (!confirm(`'${batchName}' 배치를 삭제하시겠습니까?`)) return; try { await BatchAPI.deleteBatchConfig(batchId); toast.success("배치가 삭제되었습니다."); - loadBatchConfigs(); // 목록 새로고침 + loadBatchConfigs(); + loadStats(); } catch (error) { console.error("배치 삭제 실패:", error); toast.error("배치 삭제에 실패했습니다."); } }; - // 검색 처리 - const handleSearch = (value: string) => { - setSearchTerm(value); - setCurrentPage(1); // 검색 시 첫 페이지로 이동 - }; + const handleCreateBatch = () => setIsBatchTypeModalOpen(true); - // 매핑 정보 요약 생성 - const getMappingSummary = (mappings: BatchMapping[]) => { - if (!mappings || mappings.length === 0) { - return "매핑 없음"; - } - - const tableGroups = new Map(); - mappings.forEach(mapping => { - const key = `${mapping.from_table_name} → ${mapping.to_table_name}`; - tableGroups.set(key, (tableGroups.get(key) || 0) + 1); - }); - - const summaries = Array.from(tableGroups.entries()).map(([key, count]) => - `${key} (${count}개 컬럼)` - ); - - return summaries.join(", "); - }; - - // 배치 추가 버튼 클릭 핸들러 - const handleCreateBatch = () => { - setIsBatchTypeModalOpen(true); - }; - - // 배치 타입 선택 핸들러 - const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => { - console.log("배치 타입 선택:", type); + const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => { setIsBatchTypeModalOpen(false); - - if (type === 'db-to-db') { - // 기존 DB → DB 배치 생성 페이지로 이동 - console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create'); - router.push('/admin/batchmng/create'); - } else if (type === 'restapi-to-db') { - // 새로운 REST API 배치 페이지로 이동 - console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new'); - try { - router.push('/admin/batch-management-new'); - console.log("라우터 push 실행 완료"); - } catch (error) { - console.error("라우터 push 오류:", error); - // 대안: window.location 사용 - window.location.href = '/admin/batch-management-new'; - } + if (type === "db-to-db") { + router.push("/admin/batchmng/create"); + } else { + router.push("/admin/batch-management-new"); } }; + const filteredBatches = useMemo(() => { + let list = batchConfigs; + if (statusFilter === "active") list = list.filter((b) => b.is_active === "Y"); + else if (statusFilter === "inactive") list = list.filter((b) => b.is_active !== "Y"); + const et = (b: BatchConfig) => (b as { execution_type?: string }).execution_type; + if (typeFilter === "mapping") list = list.filter((b) => !et(b) || et(b) === "mapping"); + else if (typeFilter === "restapi") list = list.filter((b) => et(b) === "restapi"); + else if (typeFilter === "node_flow") list = list.filter((b) => et(b) === "node_flow"); + return list; + }, [batchConfigs, statusFilter, typeFilter]); + return (
- {/* 페이지 헤더 */} -
-

배치 관리

-

데이터베이스 간 배치 작업을 관리합니다.

-
- - {/* 검색 및 액션 영역 */} -
- {/* 검색 영역 */} -
-
-
- - handleSearch(e.target.value)} - className="h-10 pl-10 text-sm" - /> -
-
- - -
- - {/* 액션 버튼 영역 */} -
-
- 총{" "} - - {batchConfigs.length.toLocaleString()} - {" "} - 건 -
-
- {/* 배치 목록 */} - {batchConfigs.length === 0 ? ( -
-
- -
-

배치가 없습니다

-

- {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."} -

-
- {!searchTerm && ( - - )} + {/* 통계 카드 4개 */} +
+
+
+

전체 배치

+

+ {statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()} +

+
+ +
+
+
+
+

활성 배치

+

+ {statsLoading ? "-" : (stats?.activeBatches ?? 0).toLocaleString()} +

+
+
+ +
+
+
+
+

오늘 실행

+

+ {statsLoading ? "-" : (stats?.todayExecutions ?? 0).toLocaleString()} +

+
+
+ +
+
+
+
+

오늘 실패

+

+ {statsLoading ? "-" : (stats?.todayFailures ?? 0).toLocaleString()} +

+
+
+ +
+
+
+ + {/* 툴바 */} +
+
+ + setSearchTerm(e.target.value)} + className="h-9 rounded-lg border bg-card pl-9 text-xs" + /> +
+
+
+ {(["all", "active", "inactive"] as const).map((s) => ( + + ))} +
+
+ {(["all", "mapping", "restapi", "node_flow"] as const).map((t) => ( + + ))} +
+
+
+ 총 {filteredBatches.length}건 +
+
+ + {/* 배치 테이블 */} + {filteredBatches.length === 0 ? ( +
+ +

+ {searchTerm || statusFilter !== "all" || typeFilter !== "all" ? "검색 결과가 없습니다." : "배치가 없습니다."} +

+ {!searchTerm && statusFilter === "all" && typeFilter === "all" && ( + + )}
) : ( -
- {batchConfigs.map((batch) => ( - { - toggleBatchStatus(batchId, currentStatus); - }} - onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)} - onDelete={deleteBatch} - getMappingSummary={getMappingSummary} - /> - ))} -
- )} - - {/* 페이지네이션 */} - {totalPages > 1 && ( -
- - -
- {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { - const pageNum = i + 1; - return ( - - ); - })} -
- - +
+ + + + + + + + + + + + + {filteredBatches.map((batch) => ( + setExpandedId((id) => (id === batch.id ? null : batch.id ?? null))} + executingBatch={executingBatch} + onExecute={executeBatch} + onToggleStatus={toggleBatchStatus} + onEdit={(id) => router.push(`/admin/batchmng/edit/${id}`)} + onDelete={deleteBatch} + cronToKorean={cronToKorean} + /> + ))} + +
+ + 배치 + + 타입 + + 스케줄 + + 최근 24h + + 마지막 실행 + + 액션 +
)} @@ -311,60 +386,49 @@ export default function BatchManagementPage() { {isBatchTypeModalOpen && (
-
-

배치 타입 선택

- -
- {/* DB → DB */} - - - {/* REST API → DB */} - -
- -
- -
+

배치 타입 선택

+
+ + +
+
+
)}
- - {/* Scroll to Top 버튼 */}
); -} \ No newline at end of file +} diff --git a/frontend/components/admin/BatchCard.tsx b/frontend/components/admin/BatchCard.tsx index 374c81a2..13cf9927 100644 --- a/frontend/components/admin/BatchCard.tsx +++ b/frontend/components/admin/BatchCard.tsx @@ -1,173 +1,377 @@ "use client"; -import React from "react"; -import { Card, CardContent } from "@/components/ui/card"; +import React, { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { - Play, - Pause, - Edit, - Trash2, - RefreshCw, - Clock, - Database, - Calendar, - Activity, - Settings -} from "lucide-react"; +import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react"; import { BatchConfig } from "@/lib/api/batch"; +import apiClient from "@/lib/api/client"; +import { cn } from "@/lib/utils"; + +interface SparklineSlot { + hour: string; + success: number; + failed: number; +} + +interface BatchRecentLog { + id?: number; + started_at?: string; + finished_at?: string; + status?: string; + total_records?: number; + success_records?: number; + failed_records?: number; + error_message?: string | null; + duration_ms?: number; +} + +type LedStatus = "on" | "run" | "off" | "err"; + +function BatchLED({ status }: { status: LedStatus }) { + return ( +
+ ); +} + +/** 스파크라인 24바 (div 기반, 높이 24px) */ +function SparklineBars({ slots }: { slots: SparklineSlot[] }) { + if (!slots || slots.length === 0) { + return ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))} +
+ ); + } + return ( +
+ {slots.slice(0, 24).map((slot, i) => { + const hasRun = slot.success + slot.failed > 0; + const isFail = slot.failed > 0; + const height = + !hasRun ? 5 : isFail ? Math.min(40, 20 + slot.failed * 5) : Math.max(80, Math.min(95, 80 + slot.success)); + return ( +
+ ); + })} +
+ ); +} interface BatchCardProps { batch: BatchConfig; + expanded: boolean; + onToggleExpand: () => void; executingBatch: number | null; onExecute: (batchId: number) => void; onToggleStatus: (batchId: number, currentStatus: string) => void; onEdit: (batchId: number) => void; onDelete: (batchId: number, batchName: string) => void; - getMappingSummary: (mappings: any[]) => string; + cronToKorean: (cron: string) => string; } export default function BatchCard({ batch, + expanded, + onToggleExpand, executingBatch, onExecute, onToggleStatus, onEdit, onDelete, - getMappingSummary + cronToKorean, }: BatchCardProps) { - // 상태에 따른 스타일 결정 + const [sparkline, setSparkline] = useState([]); + const [recentLogs, setRecentLogs] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + const isExecuting = executingBatch === batch.id; - const isActive = batch.is_active === 'Y'; + const isActive = batch.is_active === "Y"; + const ledStatus: LedStatus = isExecuting ? "run" : isActive ? "on" : "off"; + + const executionType = (batch as { execution_type?: string }).execution_type; + const typeLabel = + executionType === "node_flow" + ? "노드 플로우" + : executionType === "restapi" || (batch.batch_mappings?.some((m) => m.from_connection_type === "external")) + ? "API→DB" + : "DB→DB"; + const typeBadgeClass = + executionType === "node_flow" + ? "bg-indigo-500/10 text-indigo-600" + : executionType === "restapi" || batch.batch_mappings?.some((m) => m.from_connection_type === "external") + ? "bg-violet-500/10 text-violet-600" + : "bg-cyan-500/10 text-cyan-600"; + + const loadDetail = useCallback(async () => { + if (!batch.id) return; + setDetailLoading(true); + try { + const [sparkRes, logsRes] = await Promise.all([ + apiClient.get<{ success: boolean; data?: SparklineSlot[] }>( + `/batch-management/batch-configs/${batch.id}/sparkline` + ), + apiClient.get<{ success: boolean; data?: BatchRecentLog[] }>( + `/batch-management/batch-configs/${batch.id}/recent-logs?limit=5` + ), + ]); + if (sparkRes.data?.success && Array.isArray(sparkRes.data.data)) { + setSparkline(sparkRes.data.data); + } + if (logsRes.data?.success && Array.isArray(logsRes.data.data)) { + setRecentLogs(logsRes.data.data); + } + } catch { + setSparkline([]); + setRecentLogs([]); + } finally { + setDetailLoading(false); + } + }, [batch.id]); + + useEffect(() => { + if (expanded) loadDetail(); + }, [expanded, loadDetail]); + + const lastLog = recentLogs[0]; + const lastRunText = isExecuting + ? "실행 중..." + : !isActive + ? "-" + : lastLog?.started_at + ? new Date(lastLog.started_at).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + : "-"; + const lastRunSub = + !isActive || !lastLog?.started_at + ? "비활성" + : isExecuting + ? "" + : (() => { + const min = Math.floor((Date.now() - new Date(lastLog.started_at).getTime()) / 60000); + if (min < 1) return "방금 전"; + if (min < 60) return `${min}분 전`; + return `${Math.floor(min / 60)}시간 전`; + })(); + + const handleRowClick = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest("button")) return; + onToggleExpand(); + }; return ( - - - {/* 헤더 */} -
-
-
- -

{batch.batch_name}

-
-

- {batch.description || '설명 없음'} + <> + (e.key === "Enter" || e.key === " ") && onToggleExpand()} + className={cn( + "min-h-[60px] border-b transition-colors hover:bg-card/80", + expanded && "bg-primary/5 shadow-[inset_3px_0_0_0_hsl(var(--primary))]" + )} + > + +

+ {expanded ? ( + + ) : ( + + )} + +
+ + +
+

+ {batch.batch_name}

+

{batch.description || "설명 없음"}

- - {isExecuting ? '실행 중' : isActive ? '활성' : '비활성'} - -
- - {/* 정보 */} -
- {/* 스케줄 정보 */} -
- - - 스케줄 - - {batch.cron_schedule} -
- - {/* 생성일 정보 */} -
- - - 생성일 - - - {new Date(batch.created_date).toLocaleDateString('ko-KR')} - -
- - {/* 매핑 정보 */} - {batch.batch_mappings && batch.batch_mappings.length > 0 && ( -
- - - 매핑 - - - {batch.batch_mappings.length}개 - + + + + {typeLabel} + + + +

+ {batch.cron_schedule} +

+

{cronToKorean(batch.cron_schedule)}

+ + + {expanded ? ( + + ) : ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+ ))}
)} -
- - {/* 실행 중 프로그레스 */} - {isExecuting && ( -
-
- - 실행 중... -
-
-
-
+ + +

{lastRunText}

+

{lastRunSub}

+ + +
e.stopPropagation()}> + + +
- )} - - {/* 액션 버튼 */} -
- {/* 실행 버튼 */} - - - {/* 활성화/비활성화 버튼 */} - - - {/* 수정 버튼 */} - - - {/* 삭제 버튼 */} - -
- - + + + {expanded && ( + + +
+ {detailLoading ? ( +
로딩 중...
+ ) : ( +
+
+

+ {batch.description || batch.batch_name} 배치입니다. 스케줄: {cronToKorean(batch.cron_schedule)} + {recentLogs.length > 0 && + ` · 최근 실행 ${recentLogs.length}건`} +

+
+
+
+

+ + 최근 24시간 +

+
+ +
+
+
+

+ + 실행 이력 (최근 5건) +

+
+ + + + + + + + + + + {recentLogs.length === 0 ? ( + + + + ) : ( + recentLogs.map((log, i) => ( + + + + + + + )) + )} + +
시간상태처리에러
+ 이력 없음 +
+ {log.started_at + ? new Date(log.started_at).toLocaleString("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : "-"} + + + {log.status === "SUCCESS" || log.status === "success" ? "성공" : "실패"} + + + {log.success_records ?? log.total_records ?? 0}건 + {log.duration_ms != null ? ` / ${(log.duration_ms / 1000).toFixed(1)}s` : ""} + + {log.error_message || "-"} +
+
+
+
+
+ )} +
+ + + )} + ); }