fix(배치관리): 대시보드 NaN 제거 + 24시간 차트 더미데이터 → 실데이터

- 백엔드: getBatchManagementGlobalSparklineData 쿼리 추가 (generate_series 로
  24개 슬롯 고정, 회사 전체 배치 LEFT JOIN 집계)
- 백엔드: GET /api/batch-management/sparkline 엔드포인트 추가
- 프론트: BatchStats/SparklineData 타입을 백엔드 mapper 의 snake_case 응답키와
  일치시킴 (today_count, today_failed_count, hour_slot, success_count, ...).
  키 미스매치로 stats 카드가 NaN 으로 표시되던 버그 해소
- 프론트: GlobalSparkline 컴포넌트의 Math.random() 더미 막대를 실데이터 prop 으로
  교체. row-level Sparkline 도 동일 키 정렬로 정상 렌더되도록 수정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-20 09:59:52 +09:00
parent 8a10edd8e1
commit 067193efa9
5 changed files with 109 additions and 28 deletions
@@ -136,6 +136,15 @@ public class BatchManagementController {
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
}
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
@GetMapping("/sparkline")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
@RequestAttribute("company_code") String companyCode) {
Map<String, Object> params = new HashMap<>();
params.put("company_code", companyCode);
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
}
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
@GetMapping("/batch-configs/{id}/recent-logs")
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
@@ -296,6 +296,11 @@ public class BatchManagementService extends BaseService {
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
}
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
commonService.applyCompanyCodeFilter(params);
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
}
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
}
@@ -87,6 +87,32 @@
ORDER BY hour_slot
</select>
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
WITH hours AS (
SELECT generate_series(
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
DATE_TRUNC('hour', NOW()),
INTERVAL '1 hour'
) AS hour_slot
),
filtered_logs AS (
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
execution_status
FROM batch_execution_logs
WHERE start_time >= NOW() - INTERVAL '24 hours'
<include refid="common.companyCodeFilter"/>
)
SELECT h.hour_slot,
COUNT(l.execution_status) AS total_count,
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
FROM hours h
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
GROUP BY h.hour_slot
ORDER BY h.hour_slot
</select>
<!-- 최근 실행 로그 목록 (최대 20건) -->
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
SELECT id,
@@ -128,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
return (
<div className="flex h-8 items-end gap-[2px]">
{data.map((slot, i) => {
const hasFail = slot.failed > 0;
const hasSuccess = slot.success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
const failed = Number(slot.failed_count) || 0;
const success = Number(slot.success_count) || 0;
const hasFail = failed > 0;
const hasSuccess = success > 0;
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
const colorClass = hasFail
? "bg-destructive/70 hover:bg-destructive"
: hasSuccess
@@ -141,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
key={i}
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
style={{ height }}
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
/>
);
})}
@@ -278,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
);
}
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
if (!stats) return null;
function GlobalSparkline({ data }: { data: SparklineData[] }) {
if (!data || data.length === 0) return null;
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center justify-between">
@@ -294,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
</div>
</div>
<div className="flex h-10 items-end gap-[3px]">
{Array.from({ length: 24 }).map((_, i) => {
const hasExec = Math.random() > 0.3;
const hasFail = hasExec && Math.random() < 0.08;
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
{data.map((slot, i) => {
const success = Number(slot.success_count) || 0;
const failed = Number(slot.failed_count) || 0;
const hasFail = failed > 0;
const hasExec = success > 0 || hasFail;
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
const h = hasFail
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
: hasExec
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
: 6;
const hour = slot.hour_slot?.slice(11, 16) || "";
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
style={{ height: `${h}%` }}
title={`${hour} | 성공 ${success} 실패 ${failed}`}
/>
);
})}
</div>
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
<span>24 </span>
<span>12 </span>
<span>6 </span>
<span></span>
</div>
</div>
@@ -327,6 +340,7 @@ export default function BatchManagementPage() {
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
const [stats, setStats] = useState<BatchStats | null>(null);
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
@@ -339,10 +353,12 @@ export default function BatchManagementPage() {
const loadBatchConfigs = useCallback(async () => {
setLoading(true);
try {
const [configsResponse, statsData] = await Promise.all([
const [configsResponse, statsData, globalSpark] = await Promise.all([
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
BatchAPI.getBatchStats(),
BatchAPI.getGlobalSparkline(),
]);
setGlobalSparkline(globalSpark);
// cross-tenant 메타 (단일 모드면 undefined → null)
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
if (configsResponse.success && configsResponse.data) {
@@ -461,8 +477,12 @@ export default function BatchManagementPage() {
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
const inactiveBatches = batchConfigs.length - activeBatches;
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
const todayExec = Number(stats?.today_count) || 0;
const todayFail = Number(stats?.today_failed_count) || 0;
const yestExec = Number(stats?.yesterday_count) || 0;
const yestFail = Number(stats?.yesterday_failed_count) || 0;
const execDiff = todayExec - yestExec;
const failDiff = todayFail - yestFail;
return (
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
@@ -502,7 +522,7 @@ export default function BatchManagementPage() {
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"> </span>
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
{execDiff !== 0 && (
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
{execDiff > 0 ? "+" : ""}{execDiff}
@@ -512,8 +532,8 @@ export default function BatchManagementPage() {
<div className="h-8 w-px bg-border" />
<div className="flex flex-1 flex-col px-4 py-3">
<span className="text-[11px] text-muted-foreground"></span>
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{stats.todayFailures}
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
{todayFail}
</span>
{failDiff !== 0 && (
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
@@ -525,7 +545,7 @@ export default function BatchManagementPage() {
)}
{/* 24시간 차트 */}
<GlobalSparkline stats={stats} />
<GlobalSparkline data={globalSparkline} />
{/* 검색 + 필터 */}
<div className="flex flex-wrap items-center gap-3">
+30 -9
View File
@@ -37,19 +37,22 @@ export interface NodeFlowInfo {
node_count: number;
}
// 백엔드 mapper (batchManagement.xml) 가 snake_case 로 응답하므로 그대로 사용한다.
// 프로젝트 컨벤션: Map 키 = snake_case (CLAUDE.md 백엔드 규칙 참조)
export interface BatchStats {
totalBatches: number;
activeBatches: number;
todayExecutions: number;
todayFailures: number;
prevDayExecutions: number;
prevDayFailures: number;
total_count: number;
active_count: number;
today_count: number;
today_failed_count: number;
yesterday_count: number;
yesterday_failed_count: number;
}
export interface SparklineData {
hour: string;
success: number;
failed: number;
hour_slot: string;
total_count: number;
success_count: number;
failed_count: number;
}
export interface RecentLog {
@@ -621,6 +624,24 @@ export class BatchAPI {
}
}
/**
* 회사 전체 배치의 최근 24시간 스파크라인 데이터 (24개 슬롯 고정)
*/
static async getGlobalSparkline(): Promise<SparklineData[]> {
try {
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
`/batch-management/sparkline`
);
if (response.data.success) {
return response.data.data || [];
}
return [];
} catch (error) {
console.error("글로벌 스파크라인 조회 오류:", error);
return [];
}
}
/**
* 배치별 최근 24시간 스파크라인 데이터
*/