[agent-pipeline] rollback to 7f33b3fd
This commit is contained in:
parent
cbb8b24e70
commit
cd0f0df34d
|
|
@ -1,102 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Database,
|
||||
LayoutGrid,
|
||||
CheckCircle,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
Database
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
} from "@/lib/api/batch";
|
||||
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";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태 관리
|
||||
const [batchConfigs, setBatchConfigs] = useState<BatchConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
// 페이지 로드 시 배치 목록 조회
|
||||
useEffect(() => {
|
||||
loadBatchConfigs();
|
||||
}, [currentPage, searchTerm]);
|
||||
|
||||
// 배치 설정 목록 조회
|
||||
const loadBatchConfigs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await BatchAPI.getBatchConfigs({
|
||||
limit: 500,
|
||||
page: currentPage,
|
||||
limit: 10,
|
||||
search: searchTerm || undefined,
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
setBatchConfigs(response.data);
|
||||
if (response.pagination) {
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
} else {
|
||||
setBatchConfigs([]);
|
||||
}
|
||||
|
|
@ -107,46 +62,20 @@ 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 ?? 0}개, 성공: ${response.data?.successRecords ?? 0}개)`
|
||||
);
|
||||
loadBatchConfigs();
|
||||
loadStats();
|
||||
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`);
|
||||
} else {
|
||||
toast.error("배치 실행에 실패했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("배치 실행 실패:", error);
|
||||
showErrorToast("배치 실행에 실패했습니다", error, {
|
||||
guidance: "배치 설정을 확인하고 다시 시도해 주세요.",
|
||||
});
|
||||
|
|
@ -155,230 +84,226 @@ export default function BatchManagementPage() {
|
|||
}
|
||||
};
|
||||
|
||||
// 배치 활성화/비활성화 토글
|
||||
const toggleBatchStatus = async (batchId: number, currentStatus: string) => {
|
||||
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
|
||||
|
||||
try {
|
||||
const newStatus = currentStatus === "Y" ? "N" : "Y";
|
||||
await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus === "Y" });
|
||||
toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
|
||||
loadBatchConfigs();
|
||||
loadStats();
|
||||
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(); // 목록 새로고침
|
||||
} 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();
|
||||
loadStats();
|
||||
loadBatchConfigs(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("배치 삭제 실패:", error);
|
||||
toast.error("배치 삭제에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBatch = () => setIsBatchTypeModalOpen(true);
|
||||
|
||||
const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
|
||||
setIsBatchTypeModalOpen(false);
|
||||
if (type === "db-to-db") {
|
||||
router.push("/admin/batchmng/create");
|
||||
} else {
|
||||
router.push("/admin/batch-management-new");
|
||||
}
|
||||
// 검색 처리
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
setCurrentPage(1); // 검색 시 첫 페이지로 이동
|
||||
};
|
||||
|
||||
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]);
|
||||
// 매핑 정보 요약 생성
|
||||
const getMappingSummary = (mappings: BatchMapping[]) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return "매핑 없음";
|
||||
}
|
||||
|
||||
const tableGroups = new Map<string, number>();
|
||||
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);
|
||||
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';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex flex-col gap-4 border-b pb-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-extrabold tracking-tight sm:text-3xl">배치 관리</h1>
|
||||
<p className="text-xs text-muted-foreground sm:text-sm">
|
||||
데이터 동기화 배치 작업을 모니터링하고 관리합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="default" onClick={loadBatchConfigs} disabled={loading} className="gap-2">
|
||||
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
{/* 페이지 헤더 */}
|
||||
<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">데이터베이스 간 배치 작업을 관리합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 액션 영역 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
{/* 검색 영역 */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="w-full sm:w-[400px]">
|
||||
<div className="relative">
|
||||
<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) => handleSearch(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadBatchConfigs}
|
||||
disabled={loading}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button size="default" onClick={handleCreateBatch} className="gap-2">
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 영역 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
총{" "}
|
||||
<span className="font-semibold text-foreground">
|
||||
{batchConfigs.length.toLocaleString()}
|
||||
</span>{" "}
|
||||
건
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
새 배치
|
||||
배치 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 4개 */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex justify-between rounded-xl border bg-card p-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">전체 배치</p>
|
||||
<p className="mt-1 font-mono text-2xl font-extrabold sm:text-3xl">
|
||||
{statsLoading ? "-" : (stats?.totalBatches ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/10">
|
||||
<LayoutGrid className="h-5 w-5 text-indigo-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between rounded-xl border bg-card p-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">활성 배치</p>
|
||||
<p className="mt-1 font-mono text-2xl font-extrabold text-green-600 sm:text-3xl">
|
||||
{statsLoading ? "-" : (stats?.activeBatches ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-green-500/10">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between rounded-xl border bg-card p-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">오늘 실행</p>
|
||||
<p className="mt-1 font-mono text-2xl font-extrabold text-cyan-600 sm:text-3xl">
|
||||
{statsLoading ? "-" : (stats?.todayExecutions ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-cyan-500/10">
|
||||
<Activity className="h-5 w-5 text-cyan-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between rounded-xl border bg-card p-4">
|
||||
<div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">오늘 실패</p>
|
||||
<p className="mt-1 font-mono text-2xl font-extrabold text-destructive sm:text-3xl">
|
||||
{statsLoading ? "-" : (stats?.todayFailures ?? 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative max-w-[320px] flex-1">
|
||||
<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-9 rounded-lg border bg-card pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-0.5 rounded-lg border bg-card p-0.5">
|
||||
{(["all", "active", "inactive"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-[11px] font-semibold",
|
||||
statusFilter === s ? "bg-primary/10 text-primary" : "text-muted-foreground hover:opacity-80"
|
||||
)}
|
||||
{/* 배치 목록 */}
|
||||
{batchConfigs.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<Database className="h-12 w-12 text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">배치가 없습니다</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
|
||||
</p>
|
||||
</div>
|
||||
{!searchTerm && (
|
||||
<Button
|
||||
onClick={handleCreateBatch}
|
||||
className="h-10 gap-2 text-sm font-medium"
|
||||
>
|
||||
{s === "all" ? "전체" : s === "active" ? "활성" : "비활성"}
|
||||
</button>
|
||||
))}
|
||||
<Plus className="h-4 w-4" />
|
||||
첫 번째 배치 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 rounded-lg border bg-card p-0.5">
|
||||
{(["all", "mapping", "restapi", "node_flow"] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTypeFilter(t)}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-[11px] font-semibold",
|
||||
typeFilter === t ? "bg-primary/10 text-primary" : "text-muted-foreground hover:opacity-80"
|
||||
)}
|
||||
>
|
||||
{t === "all" ? "전체" : t === "mapping" ? "DB-DB" : t === "restapi" ? "API-DB" : "노드 플로우"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-[11px] text-muted-foreground">
|
||||
총 <span className="font-bold text-foreground">{filteredBatches.length}</span>건
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 테이블 */}
|
||||
{filteredBatches.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-xl border bg-card">
|
||||
<Database className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{searchTerm || statusFilter !== "all" || typeFilter !== "all" ? "검색 결과가 없습니다." : "배치가 없습니다."}
|
||||
</p>
|
||||
{!searchTerm && statusFilter === "all" && typeFilter === "all" && (
|
||||
<Button onClick={handleCreateBatch} className="mt-4 gap-2" size="sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
첫 배치 추가
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<table className="w-full table-fixed border-collapse" style={{ tableLayout: "fixed" }}>
|
||||
<thead>
|
||||
<tr className="h-10 bg-muted/50">
|
||||
<th className="w-[44px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground" />
|
||||
<th className="border-b px-3 text-left text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
배치
|
||||
</th>
|
||||
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
타입
|
||||
</th>
|
||||
<th className="w-[130px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
스케줄
|
||||
</th>
|
||||
<th className="w-[160px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
최근 24h
|
||||
</th>
|
||||
<th className="w-[100px] border-b px-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
마지막 실행
|
||||
</th>
|
||||
<th className="w-[120px] border-b px-2 text-right text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
액션
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredBatches.map((batch) => (
|
||||
<BatchCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
expanded={expandedId === batch.id}
|
||||
onToggleExpand={() => 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}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
{batchConfigs.map((batch) => (
|
||||
<BatchCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
executingBatch={executingBatch}
|
||||
onExecute={executeBatch}
|
||||
onToggleStatus={(batchId, currentStatus) => {
|
||||
toggleBatchStatus(batchId, currentStatus);
|
||||
}}
|
||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||
onDelete={deleteBatch}
|
||||
getMappingSummary={getMappingSummary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={pageNum}
|
||||
variant={currentPage === pageNum ? "default" : "outline"}
|
||||
onClick={() => setCurrentPage(pageNum)}
|
||||
className="h-10 min-w-[40px] text-sm"
|
||||
>
|
||||
{pageNum}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -386,49 +311,60 @@ export default function BatchManagementPage() {
|
|||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg">
|
||||
<h2 className="text-center text-xl font-semibold">배치 타입 선택</h2>
|
||||
<div className="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect("db-to-db")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">DB → DB</div>
|
||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect("restapi-to-db")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl text-muted-foreground">API</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">REST API → DB</div>
|
||||
<div className="text-sm text-muted-foreground">REST API에서 DB로 데이터 수집</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Button variant="outline" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-center">배치 타입 선택</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{/* DB → DB */}
|
||||
<button
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect('db-to-db')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">DB → DB</div>
|
||||
<div className="text-sm text-muted-foreground">데이터베이스 간 데이터 동기화</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* REST API → DB */}
|
||||
<button
|
||||
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent"
|
||||
onClick={() => handleBatchTypeSelect('restapi-to-db')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">🌐</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<Database className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-1 text-center">
|
||||
<div className="text-lg font-medium">REST API → DB</div>
|
||||
<div className="text-sm text-muted-foreground">REST API에서 데이터베이스로 데이터 수집</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsBatchTypeModalOpen(false)}
|
||||
className="h-10 text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to Top 버튼 */}
|
||||
<ScrollToTop />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,377 +1,173 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Edit,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Database,
|
||||
Calendar,
|
||||
Activity,
|
||||
Settings
|
||||
} 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 (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto h-2 w-2 rounded-full",
|
||||
status === "on" && "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.3)]",
|
||||
status === "run" &&
|
||||
"bg-amber-500 shadow-[0_0_8px_rgba(245,158,11,0.3)] animate-pulse",
|
||||
status === "off" && "bg-muted-foreground",
|
||||
status === "err" && "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.3)]"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 스파크라인 24바 (div 기반, 높이 24px) */
|
||||
function SparklineBars({ slots }: { slots: SparklineSlot[] }) {
|
||||
if (!slots || slots.length === 0) {
|
||||
return (
|
||||
<div className="flex h-6 items-end gap-[1px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[3px] flex-1 rounded-t-[1px] bg-muted-foreground opacity-15"
|
||||
style={{ height: "5%" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex h-6 items-end gap-[1px]">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"min-w-[3px] flex-1 rounded-t-[1px] transition-opacity hover:opacity-100",
|
||||
!hasRun && "bg-muted-foreground opacity-15",
|
||||
hasRun && isFail && "bg-red-500 opacity-80",
|
||||
hasRun && !isFail && "bg-green-500 opacity-60"
|
||||
)}
|
||||
style={{ height: `${height}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
cronToKorean: (cron: string) => string;
|
||||
getMappingSummary: (mappings: any[]) => string;
|
||||
}
|
||||
|
||||
export default function BatchCard({
|
||||
batch,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
executingBatch,
|
||||
onExecute,
|
||||
onToggleStatus,
|
||||
onEdit,
|
||||
onDelete,
|
||||
cronToKorean,
|
||||
getMappingSummary
|
||||
}: BatchCardProps) {
|
||||
const [sparkline, setSparkline] = useState<SparklineSlot[]>([]);
|
||||
const [recentLogs, setRecentLogs] = useState<BatchRecentLog[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
|
||||
// 상태에 따른 스타일 결정
|
||||
const isExecuting = executingBatch === batch.id;
|
||||
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();
|
||||
};
|
||||
const isActive = batch.is_active === 'Y';
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={(e) => (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))]"
|
||||
)}
|
||||
>
|
||||
<td className="w-[44px] border-b px-2 py-2 align-middle">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<BatchLED status={ledStatus} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-b px-3 py-2 align-middle">
|
||||
<div className="min-w-0">
|
||||
<p className={cn("truncate text-[13px] font-bold", !isActive && "text-muted-foreground")}>
|
||||
{batch.batch_name}
|
||||
<Card className="rounded-lg border bg-card shadow-sm transition-colors hover:bg-muted/50">
|
||||
<CardContent className="p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Settings className="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
||||
<h3 className="text-base font-semibold truncate">{batch.batch_name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">
|
||||
{batch.description || '설명 없음'}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-muted-foreground">{batch.description || "설명 없음"}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[100px] border-b px-2 py-2 align-middle">
|
||||
<span className={cn("inline-flex rounded-[5px] px-2 py-0.5 text-[10px] font-bold", typeBadgeClass)}>
|
||||
{typeLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td className="w-[130px] border-b px-2 py-2 align-middle">
|
||||
<p className={cn("font-mono text-[11px] font-medium", !isActive && "text-muted-foreground")}>
|
||||
{batch.cron_schedule}
|
||||
</p>
|
||||
<p className="text-[9px] text-muted-foreground">{cronToKorean(batch.cron_schedule)}</p>
|
||||
</td>
|
||||
<td className="w-[160px] border-b px-2 py-2 align-middle">
|
||||
{expanded ? (
|
||||
<SparklineBars slots={sparkline} />
|
||||
) : (
|
||||
<div className="flex h-6 items-end gap-[1px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[3px] flex-1 rounded-t-[1px] bg-muted-foreground opacity-15"
|
||||
style={{ height: "5%" }}
|
||||
/>
|
||||
))}
|
||||
<Badge variant={isActive ? 'default' : 'secondary'} className="ml-2 flex-shrink-0">
|
||||
{isExecuting ? '실행 중' : isActive ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
{/* 스케줄 정보 */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
스케줄
|
||||
</span>
|
||||
<span className="font-medium truncate ml-2">{batch.cron_schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 생성일 정보 */}
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Calendar className="h-4 w-4" />
|
||||
생성일
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 매핑 정보 */}
|
||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Database className="h-4 w-4" />
|
||||
매핑
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{batch.batch_mappings.length}개
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[100px] border-b px-2 py-2 align-middle">
|
||||
<p className={cn("font-mono text-[10px]", isExecuting && "text-amber-600")}>{lastRunText}</p>
|
||||
<p className="text-[9px] text-muted-foreground">{lastRunSub}</p>
|
||||
</td>
|
||||
<td className="w-[120px] border-b px-2 py-2 align-middle text-right">
|
||||
<div className="flex justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-green-500/20 hover:text-green-600"
|
||||
onClick={() => batch.id != null && onExecute(batch.id)}
|
||||
disabled={isExecuting}
|
||||
aria-label="수동 실행"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => batch.id != null && onEdit(batch.id)}
|
||||
aria-label="편집"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 hover:bg-destructive/20 hover:text-destructive"
|
||||
onClick={() => batch.id != null && onDelete(batch.id, batch.batch_name)}
|
||||
aria-label="삭제"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="bg-muted/30">
|
||||
<td colSpan={7} className="border-b px-6 py-5 align-top">
|
||||
<div className="rounded-b-xl border border-t-0">
|
||||
{detailLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">로딩 중...</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="rounded-lg border border-primary/10 bg-gradient-to-br from-primary/[0.06] to-cyan-500/5 px-4 py-3"
|
||||
style={{
|
||||
background: "linear-gradient(135deg, hsl(var(--primary) / 0.06), hsl(186 100% 50% / 0.04)",
|
||||
}}
|
||||
>
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">
|
||||
{batch.description || batch.batch_name} 배치입니다. 스케줄: {cronToKorean(batch.cron_schedule)}
|
||||
{recentLogs.length > 0 &&
|
||||
` · 최근 실행 ${recentLogs.length}건`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
최근 24시간
|
||||
</h4>
|
||||
<div className="h-10 w-full">
|
||||
<SparklineBars slots={sparkline} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
실행 이력 (최근 5건)
|
||||
</h4>
|
||||
<div className="overflow-x-auto rounded-md border bg-card">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-2 py-1.5 text-left font-semibold">시간</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">상태</th>
|
||||
<th className="px-2 py-1.5 text-right font-semibold">처리</th>
|
||||
<th className="max-w-[120px] truncate px-2 py-1.5 text-left font-semibold">에러</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentLogs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-2 py-3 text-center text-muted-foreground">
|
||||
이력 없음
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
recentLogs.map((log, i) => (
|
||||
<tr key={i} className="border-b last:border-0">
|
||||
<td className="whitespace-nowrap px-2 py-1.5 font-mono">
|
||||
{log.started_at
|
||||
? new Date(log.started_at).toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[9px] font-bold",
|
||||
log.status === "SUCCESS" || log.status === "success"
|
||||
? "bg-green-500/10 text-green-600"
|
||||
: "bg-red-500/10 text-red-600"
|
||||
)}
|
||||
>
|
||||
{log.status === "SUCCESS" || log.status === "success" ? "성공" : "실패"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right font-mono">
|
||||
{log.success_records ?? log.total_records ?? 0}건
|
||||
{log.duration_ms != null ? ` / ${(log.duration_ms / 1000).toFixed(1)}s` : ""}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate px-2 py-1.5 text-muted-foreground">
|
||||
{log.error_message || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 실행 중 프로그레스 */}
|
||||
{isExecuting && (
|
||||
<div className="mt-4 space-y-2 border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-primary">
|
||||
<Activity className="h-4 w-4 animate-pulse" />
|
||||
<span>실행 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary">
|
||||
<div
|
||||
className="h-full animate-pulse rounded-full bg-primary"
|
||||
style={{ width: '45%' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 border-t pt-4">
|
||||
{/* 실행 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onExecute(batch.id)}
|
||||
disabled={isExecuting}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
실행
|
||||
</Button>
|
||||
|
||||
{/* 활성화/비활성화 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(batch.id, batch.is_active)}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
{isActive ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
{isActive ? '비활성' : '활성'}
|
||||
</Button>
|
||||
|
||||
{/* 수정 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(batch.id)}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
수정
|
||||
</Button>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => onDelete(batch.id, batch.batch_name)}
|
||||
className="h-9 flex-1 gap-2 text-sm"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue