This commit is contained in:
mindol1004
2024-12-12 13:48:05 +09:00
parent 624a46def7
commit e3569a06b2
11 changed files with 300 additions and 23 deletions

View File

@@ -1,10 +1,14 @@
package com.spring.domain.schedule.api;
import java.time.LocalDateTime;
import java.util.List;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -13,8 +17,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.spring.domain.schedule.dto.BatchJobHistoryProjection;
import com.spring.domain.schedule.dto.ReScheduleJobRequest;
import com.spring.domain.schedule.dto.ScheduleJobResponse;
import com.spring.domain.schedule.service.FindJobHistoryService;
import com.spring.domain.schedule.service.FindScheduleJobService;
import com.spring.domain.schedule.service.ReScheduleJobService;
import com.spring.domain.schedule.service.ScheduleControlService;
@@ -24,11 +30,13 @@ import lombok.RequiredArgsConstructor;
@RequestMapping("/api/schedule")
@RestController
@RequiredArgsConstructor
@Validated
public class ScheduleJobApi {
private final FindScheduleJobService findScheduleJobService;
private final ReScheduleJobService reScheduleJobService;
private final ScheduleControlService scheduleControlService;
private final FindJobHistoryService findJobHistoryService;
@GetMapping
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
@@ -66,4 +74,16 @@ public class ScheduleJobApi {
return reScheduleJobService.rescheduleJob(request);
}
@GetMapping("/history")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public List<BatchJobHistoryProjection> getJobHistory(
@NotNull(message = "시작일자는 필수값 입니다.") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime fromDate,
@NotNull(message = "종료일자는 필수값 입니다.") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime toDate,
@RequestParam(required = false) String jobName,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return findJobHistoryService.getJobHistory(fromDate, toDate, jobName, page, size);
}
}

View File

@@ -0,0 +1,10 @@
package com.spring.domain.schedule.dto;
import java.time.LocalDateTime;
public interface BatchJobHistoryProjection {
String getJobName();
LocalDateTime getStartTime();
LocalDateTime getEndTime();
String getStatus();
}

View File

@@ -1,19 +1,32 @@
package com.spring.domain.schedule.repository;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import com.spring.domain.schedule.dto.BatchJobAverageDurationProjection;
import com.spring.domain.schedule.dto.BatchJobExecutionProjection;
import com.spring.domain.schedule.dto.BatchJobHistoryProjection;
import com.spring.domain.schedule.dto.BatchJobHourProjection;
import com.spring.domain.schedule.dto.BatchJobStatusCountProjection;
import com.spring.domain.schedule.entity.BatchJobExecution;
public interface BatchJobExecutionRepository extends JpaRepository<BatchJobExecution, Long> {
@Query("SELECT bji.jobName AS jobName, " +
"bje.startTime AS startTime, " +
"bje.endTime AS endTime, " +
"bje.status AS status " +
"FROM BatchJobExecution bje " +
"JOIN bje.jobInstance bji " +
"WHERE bje.createTime BETWEEN :fromDate AND :toDate " +
"ORDER BY bje.createTime DESC")
List<BatchJobHistoryProjection> findJobHistory(@Param("fromDate") LocalDateTime fromDate, @Param("toDate") LocalDateTime toDate, Pageable pageable);
@Query("SELECT bji.jobName AS jobName, " +
"bje.startTime AS startTime, " +
"bje.endTime AS endTime " +

View File

@@ -0,0 +1,30 @@
package com.spring.domain.schedule.service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import com.spring.domain.schedule.dto.BatchJobHistoryProjection;
import com.spring.domain.schedule.repository.BatchJobExecutionRepository;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class FindJobHistoryService {
private final BatchJobExecutionRepository batchJobExecutionRepository;
@Transactional(readOnly = true)
public List<BatchJobHistoryProjection> getJobHistory(LocalDateTime fromDate, LocalDateTime toDate, String jobName, int page, int size) {
return batchJobExecutionRepository.findJobHistory(fromDate, toDate, PageRequest.of(page, size)).stream()
.filter(jobHistory -> !StringUtils.hasText(jobName) || jobHistory.getJobName().equals(jobName))
.collect(Collectors.toList());
}
}

View File

@@ -94,7 +94,7 @@ public class QuartzConfig {
factory.setDataSource(dataSource);
factory.setTransactionManager(transactionManager);
factory.setJobFactory(jobFactory);
factory.setAutoStartup(true);
factory.setAutoStartup(false);
factory.setWaitForJobsToCompleteOnShutdown(true);
return factory;
}

View File

@@ -12,29 +12,35 @@ import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Menus {
DASHBOARD(
"/dashboard",
"Dashboard",
"bi bi-grid",
"/dashboard",
"Dashboard",
"bi bi-grid",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN, AgentUserRole.ROLE_USER)
),
SCHEDULE(
"/schedule",
"Schedule",
"bi bi-menu-button-wide",
"/schedule",
"Schedule",
"bi bi-menu-button-wide",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN)
),
JOB_HISTORY(
"/schedule/history",
"Job History",
"bi bi-clock-history",
List.of(AgentUserRole.ROLE_SUPER, AgentUserRole.ROLE_ADMIN)
),
USER_MANAGEMENT(
"/user/management",
"User Management",
"bi bi-person",
"/user/management",
"User Management",
"bi bi-person",
List.of(AgentUserRole.ROLE_SUPER)
),
END_POINT(
"/endpoint",
"API Explorer",
"bi bi-search",
"/endpoint",
"API Explorer",
"bi bi-search",
List.of(AgentUserRole.ROLE_SUPER)
);

View File

@@ -8,11 +8,17 @@ import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/schedule")
public class ScheduleController {
@GetMapping
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public String schedule() {
return "pages/schedule/schedule";
}
@GetMapping("/history")
@PreAuthorize("hasAnyRole('SUPER', 'ADMIN')")
public String history() {
return "pages/schedule/history";
}
}

View File

@@ -14,8 +14,8 @@ spring:
datasource:
primary:
driver-class-name: org.h2.Driver
url: 'jdbc:h2:mem:app'
username: mindol1004
url: 'jdbc:h2:file:D:/projects/h2-db/app'
username: test
password: 1111
hikari:
pool-name: HikariPool-1
@@ -24,8 +24,8 @@ spring:
idle-timeout: 60000
secondary:
driver-class-name: org.h2.Driver
url: 'jdbc:h2:mem:mob'
username: mindol1004
url: 'jdbc:h2:file:D:/projects/h2-db/mob'
username: test
password: 1111
hikari:
pool-name: HikariPool-2
@@ -44,7 +44,7 @@ spring:
database-platform: org.hibernate.dialect.H2Dialect
#show-sql: true
hibernate:
ddl-auto: create
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
@@ -168,6 +168,7 @@ management:
logging:
level:
root: info
org:
hibernate:
SQL: debug

View File

@@ -39,6 +39,13 @@ const scheduleService = {
refreshJob: async () => {
await apiClient.post('/actuator/refresh');
return true;
},
getJobHistory: async (fromDate, toDate, jobName, page, size) => {
const response = await apiClient.get('/api/schedule/history', {
params: { fromDate, toDate, jobName, page, size }
});
return response.data.data;
}
};

View File

@@ -0,0 +1,94 @@
import { addEvents, formatDateTime } from '../../common/common.js';
import scheduleService from '../../apis/schedule-api.js';
let currentPage = 0;
const pageSize = 50;
let isLoading = false;
document.addEventListener('DOMContentLoaded', () => {
setDefaultDates();
fetchDataAndRender();
setupEventListeners();
});
const setDefaultDates = () => {
const today = dayjs().format('YYYY-MM-DD');
document.getElementById('fromDate').value = today;
document.getElementById('toDate').value = today;
};
const setupEventListeners = () => {
const eventMap = {
'searchForm': { event: 'submit', handler: fetchData },
'fromDate': { event: 'change', handler: validateDates },
'toDate': { event: 'change', handler: validateDates }
};
addEvents(eventMap);
};
const validateDates = () => {
const fromDate = document.getElementById('fromDate');
const toDate = document.getElementById('toDate');
if (fromDate.value && toDate.value && fromDate.value > toDate.value) {
alert('시작일자는 종료일자보다 클 수 없습니다.');
fromDate.value = toDate.value;
}
};
const fetchData = () => {
isLoading = false;
currentPage = 0;
document.getElementById('jobList').scrollTop = 0;
document.querySelector('tbody').innerHTML = '';
fetchDataAndRender();
}
const fetchDataAndRender = async () => {
const fromDateValue = document.getElementById('fromDate').value;
const toDateValue = document.getElementById('toDate').value;
if (!fromDateValue || !toDateValue) {
alert('시작일자와 종료일자를 모두 입력해주세요.');
return;
}
if (isLoading) return;
isLoading = true;
const jobName = document.getElementById('jobName').value;
const fromDate = dayjs(document.getElementById('fromDate').value).format('YYYY-MM-DDTHH:mm:ss');
const toDate = dayjs(document.getElementById('toDate').value).endOf('day').format('YYYY-MM-DDTHH:mm:ss');
const jobHistories = await scheduleService.getJobHistory(fromDate, toDate, jobName, currentPage, pageSize);
if (jobHistories.length > 0) {
appendJobHistories(jobHistories);
currentPage++;
isLoading = false;
document.getElementById('jobList').addEventListener('scroll', handleScroll);
} else {
document.getElementById('jobList').removeEventListener('scroll', handleScroll);
}
};
const appendJobHistories = (jobHistories) => {
jobHistories.forEach(history => {
const row = document.createElement('tr');
const startTime = dayjs(history.startTime);
const endTime = dayjs(history.endTime);
const executionTime = Math.abs(endTime.diff(startTime, 'millisecond'));
row.innerHTML = `
<td>${history.jobName}</td>
<td>${formatDateTime(history.startTime)}</td>
<td>${formatDateTime(history.endTime)}</td>
<td>${executionTime} ms</td>
<td>${history.status}</td>
`;
document.querySelector('tbody').appendChild(row);
});
};
const handleScroll = () => {
const tableBody = document.getElementById('jobList');
if (tableBody.scrollTop + tableBody.clientHeight >= tableBody.scrollHeight) {
fetchDataAndRender();
}
};

View File

@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layouts/layout}"
layout:fragment="content" lang="ko" xml:lang="ko">
<head>
<title>잡 히스토리</title>
</head>
<body>
<main id="main" class="main">
<div class="pagetitle">
<div class="row align-items-center">
<div class="col">
<h1><i class="bi bi-clock-history"></i> 잡 히스토리</h1>
</div>
<div class="col-auto">
<nav>
<ol class="breadcrumb">
<li class="breadcrumb-item"></li>
<li class="breadcrumb-item active">잡 히스토리</li>
</ol>
</nav>
</div>
</div>
</div>
<section class="section">
<div class="row">
<div class="col-lg-12">
<div class="card">
<div class="card-body">
<h5 class="card-title fs-6 text-dark">
<i class="bi bi-search"></i> 잡 검색
</h5>
<form id="searchForm" class="row g-3">
<div class="col-md-3">
<div class="form-floating">
<input type="date" class="form-control" id="fromDate" placeholder="시작일자">
<label for="fromDate"><i class="bi bi-calendar"></i> 시작일자</label>
</div>
</div>
<div class="col-md-3">
<div class="form-floating">
<input type="date" class="form-control" id="toDate" placeholder="종료일자">
<label for="toDate"><i class="bi bi-calendar"></i> 종료일자</label>
</div>
</div>
<div class="col-md-3">
<div class="form-floating">
<input type="text" class="form-control" id="jobName" placeholder="잡 이름">
<label for="jobName"><i class="bi bi-briefcase"></i> 잡 이름</label>
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary h-100 w-100">
<i class="bi bi-search"></i> 검색
</button>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title d-flex justify-content-between align-items-center">
<span class="fs-6 text-dark"><i class="bi bi-list-ul"></i> 잡 목록</span>
</h5>
<div id="jobList" class="table-responsive" style="max-height: 480px; overflow-y: auto;">
<table class="table table-hover">
<thead style="position: sticky; top: 0; z-index: 1;">
<tr>
<th class="col-2 text-nowrap bg-light"><i class="bi bi-briefcase"></i> 잡 이름</th>
<th class="col-2 text-nowrap bg-light"><i class="bi bi-calendar-event"></i> 시작 시간</th>
<th class="col-2 text-nowrap bg-light"><i class="bi bi-calendar-event"></i> 종료 시간</th>
<th class="col-2 text-nowrap bg-light"><i class="bi bi-clock"></i> 수행 시간</th>
<th class="col-2 text-nowrap bg-light"><i class="bi bi-activity"></i> 상태</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</section>
<script type="module" th:src="@{/js/pages/schedule/history.js}" defer></script>
</main>
</body>
</html>