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:
@@ -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">
|
||||
|
||||
@@ -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시간 스파크라인 데이터
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user