2025-09-30 10:30:26 +09:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
import React, { useState, useEffect, useCallback } from "react";
|
2025-09-30 10:30:26 +09:00
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-18 14:37:57 +09:00
|
|
|
import { Play, Pencil, Trash2, ChevronDown, ChevronRight, Clock } from "lucide-react";
|
2025-09-30 10:30:26 +09:00
|
|
|
import { BatchConfig } from "@/lib/api/batch";
|
2026-03-18 14:37:57 +09:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
2025-09-30 10:30:26 +09:00
|
|
|
|
|
|
|
|
interface BatchCardProps {
|
|
|
|
|
batch: BatchConfig;
|
2026-03-18 14:37:57 +09:00
|
|
|
expanded: boolean;
|
|
|
|
|
onToggleExpand: () => void;
|
2025-09-30 10:30:26 +09:00
|
|
|
executingBatch: number | null;
|
|
|
|
|
onExecute: (batchId: number) => void;
|
|
|
|
|
onToggleStatus: (batchId: number, currentStatus: string) => void;
|
|
|
|
|
onEdit: (batchId: number) => void;
|
|
|
|
|
onDelete: (batchId: number, batchName: string) => void;
|
2026-03-18 14:37:57 +09:00
|
|
|
cronToKorean: (cron: string) => string;
|
2025-09-30 10:30:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function BatchCard({
|
|
|
|
|
batch,
|
2026-03-18 14:37:57 +09:00
|
|
|
expanded,
|
|
|
|
|
onToggleExpand,
|
2025-09-30 10:30:26 +09:00
|
|
|
executingBatch,
|
|
|
|
|
onExecute,
|
|
|
|
|
onToggleStatus,
|
|
|
|
|
onEdit,
|
|
|
|
|
onDelete,
|
2026-03-18 14:37:57 +09:00
|
|
|
cronToKorean,
|
2025-09-30 10:30:26 +09:00
|
|
|
}: BatchCardProps) {
|
2026-03-18 14:37:57 +09:00
|
|
|
const [sparkline, setSparkline] = useState<SparklineSlot[]>([]);
|
|
|
|
|
const [recentLogs, setRecentLogs] = useState<BatchRecentLog[]>([]);
|
|
|
|
|
const [detailLoading, setDetailLoading] = useState(false);
|
|
|
|
|
|
2025-10-22 14:52:13 +09:00
|
|
|
const isExecuting = executingBatch === batch.id;
|
2026-03-18 14:37:57 +09:00
|
|
|
const isActive = batch.is_active === "Y";
|
|
|
|
|
const ledStatus: LedStatus = isExecuting ? "run" : isActive ? "on" : "off";
|
2025-09-30 10:30:26 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
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";
|
2025-09-30 10:30:26 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
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]);
|
2025-09-30 10:30:26 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (expanded) loadDetail();
|
|
|
|
|
}, [expanded, loadDetail]);
|
2025-09-30 10:30:26 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
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)}시간 전`;
|
|
|
|
|
})();
|
2025-10-22 14:52:13 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
const handleRowClick = (e: React.MouseEvent) => {
|
|
|
|
|
if ((e.target as HTMLElement).closest("button")) return;
|
|
|
|
|
onToggleExpand();
|
|
|
|
|
};
|
2025-09-30 10:30:26 +09:00
|
|
|
|
2026-03-18 14:37:57 +09:00
|
|
|
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" />
|
2025-09-30 10:30:26 +09:00
|
|
|
) : (
|
2026-03-18 14:37:57 +09:00
|
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
2025-09-30 10:30:26 +09:00
|
|
|
)}
|
2026-03-18 14:37:57 +09:00
|
|
|
<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}
|
|
|
|
|
</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%" }}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</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>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
2025-09-30 10:30:26 +09:00
|
|
|
);
|
|
|
|
|
}
|