commit
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
package com.spring.common.validation;
|
||||
|
||||
import java.text.ParseException;
|
||||
import java.util.Date;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
|
||||
import org.quartz.CronExpression;
|
||||
|
||||
public class CronExpressionValidator implements ConstraintValidator<ValidCronExpression, String> {
|
||||
|
||||
@Override
|
||||
public boolean isValid(String cronExpression, ConstraintValidatorContext context) {
|
||||
if (cronExpression == null || cronExpression.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
boolean result = false;
|
||||
if(CronExpression.isValidExpression(cronExpression)){
|
||||
try {
|
||||
CronExpression targetExpression = new CronExpression(cronExpression);
|
||||
if (targetExpression.getNextValidTimeAfter(new Date(System.currentTimeMillis())) != null) {
|
||||
result = true;
|
||||
}
|
||||
} catch (ParseException e) {
|
||||
result = false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.spring.common.validation;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import javax.validation.Constraint;
|
||||
import javax.validation.Payload;
|
||||
|
||||
@Documented
|
||||
@Constraint(validatedBy = CronExpressionValidator.class)
|
||||
@Target({ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ValidCronExpression {
|
||||
String message() default "Invalid cron expression";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
@@ -10,19 +10,27 @@ import org.springframework.stereotype.Component;
|
||||
import com.spring.infra.batch.AbstractBatchTask;
|
||||
import com.spring.infra.batch.BatchJobInfo;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import com.spring.domain.post.repository.PostRepository;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@BatchJobInfo(
|
||||
group = "EMAIL",
|
||||
jobName = "emailSendJob",
|
||||
cronExpression = "*/10 * * * * ?"
|
||||
cronExpression = "*/10 * * * * ?",
|
||||
description = "이메일배치작업"
|
||||
)
|
||||
@RequiredArgsConstructor
|
||||
public class EmailSendBatch extends AbstractBatchTask {
|
||||
|
||||
private final PostRepository postRepository;
|
||||
|
||||
@Override
|
||||
protected List<Step> createSteps() {
|
||||
log.info("EmailSendBatch -> createSteps");
|
||||
return List.of(
|
||||
addStep("emailSendJobStep1", createTasklet()),
|
||||
addStep("emailSendJobStep2", createSendTasklet())
|
||||
@@ -31,10 +39,15 @@ public class EmailSendBatch extends AbstractBatchTask {
|
||||
|
||||
@Override
|
||||
protected Tasklet createTasklet() {
|
||||
return ((contribution, chunkContext) -> RepeatStatus.FINISHED);
|
||||
log.info("EmailSendBatch -> createTasklet");
|
||||
return ((contribution, chunkContext) -> {
|
||||
postRepository.findAll();
|
||||
return RepeatStatus.FINISHED;
|
||||
});
|
||||
}
|
||||
|
||||
private Tasklet createSendTasklet() {
|
||||
log.info("EmailSendBatch -> createSendTasklet");
|
||||
return ((contribution, chunkContext) -> RepeatStatus.FINISHED);
|
||||
}
|
||||
|
||||
|
||||
@@ -28,11 +28,11 @@ public class PostCreateBatch extends AbstractBatchTask {
|
||||
|
||||
private final PostMapper postMapper;
|
||||
|
||||
// @Autowired
|
||||
// @Override
|
||||
// public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) {
|
||||
// super.setTransactionManager(transactionManager);
|
||||
// }
|
||||
@Autowired
|
||||
@Override
|
||||
public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) {
|
||||
super.setTransactionManager(transactionManager);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Tasklet createTasklet() {
|
||||
|
||||
@@ -42,15 +42,17 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class PostCreateBatchChunk {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER)
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
@Qualifier(SecondaryJpaConfig.ENTITY_MANAGER_FACTORY)
|
||||
private final EntityManagerFactory entityManagerFactory;
|
||||
private final PostRepository postRepository;
|
||||
private final PostBackUpRepository postBackUpRepository;
|
||||
|
||||
private List<Post> list = new ArrayList<>();
|
||||
|
||||
@QuartzJob(group = "POST", jobName = "testPostJob", cronExpression = "0/30 * * * * ?")
|
||||
@Bean
|
||||
@QuartzJob(group = "POST", jobName = "testPostJob", cronExpression = "0/50 * * * * ?", description = "테스트배치작업")
|
||||
Job testPostJob() {
|
||||
return new JobBuilder("testPostJob")
|
||||
.repository(jobRepository)
|
||||
@@ -63,8 +65,7 @@ public class PostCreateBatchChunk {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Bean
|
||||
JobExecutionDecider decider() {
|
||||
private JobExecutionDecider decider() {
|
||||
return (JobExecution jobExecution, StepExecution stepExecution) ->
|
||||
!list.isEmpty() ? new FlowExecutionStatus("PROCESS") : new FlowExecutionStatus("TERMINATE");
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
// @DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
@DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
@Entity
|
||||
@Table(name = "APP_POST")
|
||||
@Getter
|
||||
|
||||
@@ -12,7 +12,7 @@ import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
// @DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
@DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
@Entity
|
||||
@Table(name = "APP_POST_BACKUP")
|
||||
@Getter
|
||||
|
||||
@@ -5,10 +5,9 @@ import java.util.List;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import com.spring.domain.post.entity.Post;
|
||||
import com.spring.infra.db.orm.mybatis.annotation.PrimaryMapper;
|
||||
// import com.spring.infra.db.orm.mybatis.annotation.SecondaryMapper;
|
||||
import com.spring.infra.db.orm.mybatis.annotation.SecondaryMapper;
|
||||
|
||||
@PrimaryMapper
|
||||
@SecondaryMapper
|
||||
public interface PostMapper {
|
||||
List<Post> findAll();
|
||||
void save(@Param("post") Post post);
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.spring.domain.post.entity.PostBackUp;
|
||||
import com.spring.infra.db.SecondaryDataSourceConfig;
|
||||
import com.spring.infra.db.orm.jpa.annotation.DatabaseSelector;
|
||||
|
||||
// @DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
@DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
public interface PostBackUpRepository extends JpaRepository<PostBackUp, Long> {
|
||||
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.spring.domain.post.entity.Post;
|
||||
import com.spring.infra.db.SecondaryDataSourceConfig;
|
||||
import com.spring.infra.db.orm.jpa.annotation.DatabaseSelector;
|
||||
|
||||
// @DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
@DatabaseSelector(SecondaryDataSourceConfig.DATABASE)
|
||||
public interface PostRepository extends JpaRepository<Post, Long> {
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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.BatchChartResponse;
|
||||
@@ -21,9 +22,9 @@ public class DashBoardApi {
|
||||
private final BatchChartService batchChartService;
|
||||
private final DashBoardJobService dashBoardJobService;
|
||||
|
||||
@GetMapping("/chart/batch")
|
||||
public BatchChartResponse getBatchJobExecutionData() {
|
||||
return batchChartService.getBatchJobExecutionData();
|
||||
@GetMapping("/chart")
|
||||
public BatchChartResponse getBatchJobExecutionData(@RequestParam int year, @RequestParam int month) {
|
||||
return batchChartService.getBatchJobExecutionData(year, month);
|
||||
}
|
||||
|
||||
@GetMapping("/recent-job")
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.spring.domain.schedule.api;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import javax.validation.Valid;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -53,7 +55,7 @@ public class ScheduleJobApi {
|
||||
}
|
||||
|
||||
@PostMapping("/reschedule")
|
||||
public boolean rescheduleJob(@RequestBody ReScheduleJobRequest request) {
|
||||
public boolean rescheduleJob(@Valid @RequestBody ReScheduleJobRequest request) {
|
||||
return reScheduleJobService.rescheduleJob(request);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package com.spring.domain.schedule.dto;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.batch.core.BatchStatus;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -13,7 +10,7 @@ import lombok.RequiredArgsConstructor;
|
||||
public class BatchChartResponse {
|
||||
|
||||
private final List<BatchJobAverageDurationProjection> jobAvgSummary;
|
||||
private final Map<BatchStatus, Long> statusCounts;
|
||||
private final List<BatchJobStatusCountProjection> statusCounts;
|
||||
private final List<BatchJobHourProjection> jobHourSummary;
|
||||
private final List<BatchJobExecutionProjection> jobExecutionSummary;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.spring.domain.schedule.dto;
|
||||
import org.springframework.batch.core.BatchStatus;
|
||||
|
||||
public interface BatchJobStatusCountProjection {
|
||||
String getJobName();
|
||||
BatchStatus getStatus();
|
||||
Long getCount();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.spring.domain.schedule.dto;
|
||||
|
||||
import javax.validation.constraints.NotBlank;
|
||||
|
||||
import com.spring.common.validation.ValidCronExpression;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -7,8 +11,11 @@ import lombok.RequiredArgsConstructor;
|
||||
@RequiredArgsConstructor
|
||||
public class ReScheduleJobRequest {
|
||||
|
||||
@NotBlank(message = "잡 그룹을 입력해 주십시오.")
|
||||
private final String jobGroup;
|
||||
@NotBlank(message = "잡 이름을 입력해 주십시오.")
|
||||
private final String jobName;
|
||||
@ValidCronExpression(message = "잘못된 크론 표현식 입니다.")
|
||||
private final String cronExpression;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.spring.domain.schedule.repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
@@ -14,39 +13,47 @@ 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 " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE bje.status = 'COMPLETED' AND bje.startTime >= :startDate " +
|
||||
"ORDER BY bje.startTime")
|
||||
List<BatchJobAverageDurationProjection> findJobAverageDurations(@Param("startDate") LocalDateTime startDate);
|
||||
|
||||
@Query("SELECT bji.jobName AS jobName, " +
|
||||
"bje.startTime AS startTime, " +
|
||||
"bje.endTime AS endTime " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE bje.status = 'COMPLETED' " +
|
||||
"AND FUNCTION('YEAR', bje.startTime) = :year " +
|
||||
"AND FUNCTION('MONTH', bje.startTime) = :month " +
|
||||
"ORDER BY bje.startTime")
|
||||
List<BatchJobAverageDurationProjection> findJobAverageDurations(@Param("year") int year, @Param("month") int month);
|
||||
|
||||
@Query("SELECT bje.status AS status, COUNT(bje) AS count " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"GROUP BY bje.status")
|
||||
List<BatchJobStatusCountProjection> findJobStatusCounts();
|
||||
@Query("SELECT bji.jobName AS jobName, bje.status AS status, COUNT(bje) AS count " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE FUNCTION('YEAR', bje.startTime) = :year " +
|
||||
"AND FUNCTION('MONTH', bje.startTime) = :month " +
|
||||
"GROUP BY bji.jobName, bje.status " +
|
||||
"ORDER BY bji.jobName, bje.status")
|
||||
List<BatchJobStatusCountProjection> findJobStatusCounts(@Param("year") int year, @Param("month") int month);
|
||||
|
||||
@Query("SELECT bje.startTime AS executionDate, " +
|
||||
"bji.jobName AS jobName, " +
|
||||
"COUNT(bje) AS executionCount " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE bje.startTime >= :startDate " +
|
||||
"GROUP BY bje.startTime, bji.jobName " +
|
||||
"ORDER BY bje.startTime, bji.jobName")
|
||||
List<BatchJobExecutionProjection> findJobExecutionSummaryDays(@Param("startDate") LocalDateTime startDate);
|
||||
@Query("SELECT bje.startTime AS executionDate, " +
|
||||
"bji.jobName AS jobName, " +
|
||||
"COUNT(bje) AS executionCount " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE FUNCTION('YEAR', bje.startTime) = :year " +
|
||||
"AND FUNCTION('MONTH', bje.startTime) = :month " +
|
||||
"GROUP BY bje.startTime, bji.jobName " +
|
||||
"ORDER BY bje.startTime, bji.jobName")
|
||||
List<BatchJobExecutionProjection> findJobExecutionSummaryDays(@Param("year") int year, @Param("month") int month);
|
||||
|
||||
@Query("SELECT bji.jobName AS jobName, " +
|
||||
"FUNCTION('HOUR', bje.startTime) AS hour, " +
|
||||
"COUNT(bje) AS count " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE bje.startTime >= :startDate " +
|
||||
"GROUP BY bji.jobName, FUNCTION('HOUR', bje.startTime) " +
|
||||
"ORDER BY bji.jobName, hour")
|
||||
List<BatchJobHourProjection> findHourlyJobExecutionDistribution(@Param("startDate") LocalDateTime startDate);
|
||||
@Query("SELECT bji.jobName AS jobName, " +
|
||||
"FUNCTION('HOUR', bje.startTime) AS hour, " +
|
||||
"COUNT(bje) AS count " +
|
||||
"FROM BatchJobExecution bje " +
|
||||
"JOIN bje.jobInstance bji " +
|
||||
"WHERE FUNCTION('YEAR', bje.startTime) = :year " +
|
||||
"AND FUNCTION('MONTH', bje.startTime) = :month " +
|
||||
"GROUP BY bji.jobName, FUNCTION('HOUR', bje.startTime) " +
|
||||
"ORDER BY bji.jobName, hour")
|
||||
List<BatchJobHourProjection> findHourlyJobExecutionDistribution(@Param("year") int year, @Param("month") int month);
|
||||
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.spring.domain.schedule.service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.batch.core.BatchStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.spring.domain.schedule.dto.BatchChartResponse;
|
||||
import com.spring.domain.schedule.dto.BatchJobAverageDurationProjection;
|
||||
@@ -23,36 +20,30 @@ public class BatchChartService {
|
||||
|
||||
private final BatchJobExecutionRepository batchJobExecutionRepository;
|
||||
|
||||
public BatchChartResponse getBatchJobExecutionData() {
|
||||
@Transactional(readOnly = true)
|
||||
public BatchChartResponse getBatchJobExecutionData(int year, int month) {
|
||||
return new BatchChartResponse(
|
||||
getJobAverageDurations(),
|
||||
getJobStatusCounts(),
|
||||
getHourlyJobExecutionDistribution(),
|
||||
getJobExecutionSummary()
|
||||
getJobAverageDurations(year, month),
|
||||
getJobStatusCounts(year, month),
|
||||
getHourlyJobExecutionDistribution(year, month),
|
||||
getJobExecutionSummary(year, month)
|
||||
);
|
||||
}
|
||||
|
||||
private List<BatchJobAverageDurationProjection> getJobAverageDurations() {
|
||||
LocalDateTime startDate = LocalDateTime.now().minusDays(30);
|
||||
return batchJobExecutionRepository.findJobAverageDurations(startDate);
|
||||
private List<BatchJobAverageDurationProjection> getJobAverageDurations(int year, int month) {
|
||||
return batchJobExecutionRepository.findJobAverageDurations(year, month);
|
||||
}
|
||||
|
||||
private Map<BatchStatus, Long> getJobStatusCounts() {
|
||||
return batchJobExecutionRepository.findJobStatusCounts().stream()
|
||||
.collect(Collectors.toMap(
|
||||
BatchJobStatusCountProjection::getStatus,
|
||||
BatchJobStatusCountProjection::getCount
|
||||
));
|
||||
private List<BatchJobStatusCountProjection> getJobStatusCounts(int year, int month) {
|
||||
return batchJobExecutionRepository.findJobStatusCounts(year, month);
|
||||
}
|
||||
|
||||
private List<BatchJobHourProjection> getHourlyJobExecutionDistribution() {
|
||||
LocalDateTime startDate = LocalDateTime.now().minusDays(30);
|
||||
return batchJobExecutionRepository.findHourlyJobExecutionDistribution(startDate);
|
||||
private List<BatchJobHourProjection> getHourlyJobExecutionDistribution(int year, int month) {
|
||||
return batchJobExecutionRepository.findHourlyJobExecutionDistribution(year, month);
|
||||
}
|
||||
|
||||
private List<BatchJobExecutionProjection> getJobExecutionSummary() {
|
||||
LocalDateTime startDate = LocalDateTime.now().minusDays(30);
|
||||
return batchJobExecutionRepository.findJobExecutionSummaryDays(startDate);
|
||||
private List<BatchJobExecutionProjection> getJobExecutionSummary(int year, int month) {
|
||||
return batchJobExecutionRepository.findJobExecutionSummaryDays(year, month);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ public class FindScheduleJobService {
|
||||
}
|
||||
|
||||
private boolean isGroupMatched(final String group, final String groupName) {
|
||||
return groupName == null || groupName.isEmpty() || group.equals(groupName);
|
||||
return groupName == null || groupName.isEmpty() ||
|
||||
group.toLowerCase().contains(groupName.toLowerCase());
|
||||
}
|
||||
|
||||
private Stream<JobKey> getJobKeysForGroup(final String group) {
|
||||
@@ -74,7 +75,8 @@ public class FindScheduleJobService {
|
||||
}
|
||||
|
||||
private boolean isJobMatched(final JobKey jobKey, final String jobName) {
|
||||
return jobName == null || jobName.isEmpty() || jobKey.getName().equals(jobName);
|
||||
return jobName == null || jobName.isEmpty() ||
|
||||
jobKey.getName().toLowerCase().contains(jobName.toLowerCase());
|
||||
}
|
||||
|
||||
private ScheduleJobResponse createScheduleSafely(final JobKey jobKey) {
|
||||
@@ -105,11 +107,12 @@ public class FindScheduleJobService {
|
||||
private String getTriggerState(final Optional<Trigger> trigger) {
|
||||
return trigger.map(t -> {
|
||||
try {
|
||||
return scheduler.getTriggerState(t.getKey()).name();
|
||||
Trigger.TriggerState state = scheduler.getTriggerState(t.getKey());
|
||||
return state.name();
|
||||
} catch (SchedulerException e) {
|
||||
return "";
|
||||
return Trigger.TriggerState.ERROR.name();
|
||||
}
|
||||
}).orElse("");
|
||||
}).orElse(Trigger.TriggerState.NONE.name());
|
||||
}
|
||||
|
||||
private String getCronExpression(final Optional<Trigger> trigger) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.spring.domain.schedule.service;
|
||||
|
||||
import org.quartz.CronScheduleBuilder;
|
||||
import org.quartz.CronTrigger;
|
||||
import org.quartz.Scheduler;
|
||||
import org.quartz.SchedulerException;
|
||||
import org.quartz.Trigger;
|
||||
@@ -20,13 +21,17 @@ public class ReScheduleJobService {
|
||||
|
||||
public boolean rescheduleJob(ReScheduleJobRequest request) {
|
||||
try {
|
||||
TriggerKey triggerKey = TriggerKey.triggerKey(request.getJobName(), request.getJobGroup());
|
||||
TriggerKey triggerKey = TriggerKey.triggerKey(request.getJobName() + "Trigger", request.getJobGroup());
|
||||
Trigger oldTrigger = scheduler.getTrigger(triggerKey);
|
||||
if (oldTrigger != null) {
|
||||
Trigger newTrigger = TriggerBuilder.newTrigger()
|
||||
.withIdentity(triggerKey)
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(request.getCronExpression()))
|
||||
.build();
|
||||
CronTrigger newTrigger = TriggerBuilder.newTrigger()
|
||||
.withIdentity(oldTrigger.getKey())
|
||||
.withDescription(oldTrigger.getDescription())
|
||||
.withPriority(oldTrigger.getPriority())
|
||||
.forJob(oldTrigger.getJobKey())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(request.getCronExpression().trim())
|
||||
.withMisfireHandlingInstructionDoNothing())
|
||||
.build();
|
||||
scheduler.rescheduleJob(oldTrigger.getKey(), newTrigger);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -175,4 +175,13 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
|
||||
return batchJobInfo.cronExpression();
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업의 설명을 반환합니다.
|
||||
*
|
||||
* @return 배치 작업의 설명
|
||||
*/
|
||||
public String description() {
|
||||
return batchJobInfo.description();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -43,4 +43,10 @@ public @interface BatchJobInfo {
|
||||
* @return cron 표현식
|
||||
*/
|
||||
String cronExpression() default "";
|
||||
/**
|
||||
* 배치 작업의 설명을 지정합니다.
|
||||
*
|
||||
* @return 작업의 설명
|
||||
*/
|
||||
String description() default "";
|
||||
}
|
||||
|
||||
@@ -51,4 +51,10 @@ public @interface QuartzJob {
|
||||
* @return Cron 표현식
|
||||
*/
|
||||
String cronExpression() default "";
|
||||
/**
|
||||
* Quartz 작업의 설명을 지정합니다.
|
||||
*
|
||||
* @return 작업의 설명
|
||||
*/
|
||||
String description() default "";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.spring.infra.quartz;
|
||||
|
||||
import org.quartz.JobDataMap;
|
||||
import org.quartz.JobExecutionContext;
|
||||
import org.quartz.JobExecutionException;
|
||||
import org.springframework.batch.core.Job;
|
||||
@@ -42,11 +43,6 @@ public class QuartzJobLauncher extends QuartzJobBean {
|
||||
|
||||
private final JobLauncher jobLauncher;
|
||||
private final JobRegistry jobRegistry;
|
||||
private String jobName;
|
||||
|
||||
public void setJobName(String jobName) {
|
||||
this.jobName = jobName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quartz 스케줄러에 의해 호출되는 메소드로, 실제 Job을 실행합니다.
|
||||
@@ -62,6 +58,9 @@ public class QuartzJobLauncher extends QuartzJobBean {
|
||||
*/
|
||||
@Override
|
||||
protected void executeInternal(@NonNull JobExecutionContext context) throws JobExecutionException {
|
||||
JobDataMap jobDataMap = context.getJobDetail().getJobDataMap();
|
||||
String jobName = jobDataMap.getString("jobName");
|
||||
|
||||
try {
|
||||
Job job = jobRegistry.getJob(jobName);
|
||||
JobParameters params = new JobParametersBuilder()
|
||||
@@ -70,7 +69,7 @@ public class QuartzJobLauncher extends QuartzJobBean {
|
||||
.toJobParameters();
|
||||
jobLauncher.run(job, params);
|
||||
} catch (Exception e) {
|
||||
log.error("job execution exception! - {}", e.getCause());
|
||||
log.error("Job execution exception! - {}", e.getMessage(), e);
|
||||
throw new JobExecutionException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import org.quartz.JobDataMap;
|
||||
import org.quartz.JobDetail;
|
||||
import org.quartz.Scheduler;
|
||||
import org.quartz.SchedulerException;
|
||||
import org.quartz.Trigger;
|
||||
import org.quartz.TriggerBuilder;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
@@ -56,10 +55,19 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
|
||||
*/
|
||||
@Override
|
||||
public void onApplicationEvent(@NonNull ContextRefreshedEvent event) {
|
||||
clearJobs();
|
||||
registerQuartzJobs();
|
||||
registerAbstractTasks();
|
||||
}
|
||||
|
||||
private void clearJobs() {
|
||||
try {
|
||||
scheduler.clear();
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QuartzJob 어노테이션이 붙은 모든 메소드를 찾아 Quartz 스케줄러에 등록합니다.
|
||||
*/
|
||||
@@ -70,72 +78,69 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
|
||||
for (Method method : beanClass.getDeclaredMethods()) {
|
||||
QuartzJob quartzJobAnnotation = AnnotationUtils.findAnnotation(method, QuartzJob.class);
|
||||
if (quartzJobAnnotation != null) {
|
||||
try {
|
||||
JobDetail jobDetail = createJobDetail(quartzJobAnnotation);
|
||||
Trigger trigger = createTrigger(quartzJobAnnotation, jobDetail);
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling Quartz job", e);
|
||||
}
|
||||
scheduleQuartzJob(quartzJobAnnotation);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* QuartzJob 어노테이션 정보를 바탕으로 JobDetail 객체를 생성합니다.
|
||||
*
|
||||
* @param quartzJobAnnotation QuartzJob 어노테이션 객체
|
||||
* @return 생성된 JobDetail 객체
|
||||
*/
|
||||
private JobDetail createJobDetail(QuartzJob quartzJobAnnotation) {
|
||||
private void scheduleQuartzJob(QuartzJob quartzJobAnnotation) {
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("jobName", quartzJobAnnotation.jobName());
|
||||
return JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
|
||||
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
.withIdentity(quartzJobAnnotation.jobName(), quartzJobAnnotation.group())
|
||||
.setJobData(jobDataMap)
|
||||
.storeDurably(true)
|
||||
.withDescription(quartzJobAnnotation.description())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* QuartzJob 어노테이션 정보를 바탕으로 Trigger 객체를 생성합니다.
|
||||
*
|
||||
* @param quartzJobAnnotation QuartzJob 어노테이션 객체
|
||||
* @param jobDetail 연관된 JobDetail 객체
|
||||
* @return 생성된 Trigger 객체
|
||||
*/
|
||||
private Trigger createTrigger(QuartzJob quartzJobAnnotation, JobDetail jobDetail) {
|
||||
return TriggerBuilder.newTrigger()
|
||||
CronTrigger trigger = TriggerBuilder.newTrigger()
|
||||
.forJob(jobDetail)
|
||||
.withIdentity(quartzJobAnnotation.jobName() + "Trigger", quartzJobAnnotation.group())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(quartzJobAnnotation.cronExpression()))
|
||||
.withDescription(quartzJobAnnotation.description())
|
||||
.build();
|
||||
|
||||
try {
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling Quartz job: " + quartzJobAnnotation.jobName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AbstractBatchJob을 상속받은 모든 클래스를 찾아 Quartz 스케줄러에 등록합니다.
|
||||
*/
|
||||
public void registerAbstractTasks() {
|
||||
private void registerAbstractTasks() {
|
||||
Map<String, AbstractBatchTask> batchJobs = applicationContext.getBeansOfType(AbstractBatchTask.class);
|
||||
for (AbstractBatchTask batchJob : batchJobs.values()) {
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("jobName", batchJob.jobName());
|
||||
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
.withIdentity(batchJob.jobName(), batchJob.group())
|
||||
.setJobData(jobDataMap)
|
||||
.storeDurably(true)
|
||||
.build();
|
||||
CronTrigger trigger = TriggerBuilder.newTrigger()
|
||||
.forJob(jobDetail)
|
||||
.withIdentity(batchJob.jobName() + "Trigger", batchJob.group())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(batchJob.cronExpression()))
|
||||
.build();
|
||||
try {
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling AbstractBatchJob: " + batchJob.jobName(), e);
|
||||
}
|
||||
scheduleJob(batchJob);
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleJob(AbstractBatchTask batchJob) {
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("jobName", batchJob.jobName());
|
||||
|
||||
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
.withIdentity(batchJob.jobName(), batchJob.group())
|
||||
.setJobData(jobDataMap)
|
||||
.storeDurably(true)
|
||||
.withDescription(batchJob.description())
|
||||
.build();
|
||||
|
||||
CronTrigger trigger = TriggerBuilder.newTrigger()
|
||||
.forJob(jobDetail)
|
||||
.withIdentity(batchJob.jobName() + "Trigger", batchJob.group())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(batchJob.cronExpression()))
|
||||
.withDescription(batchJob.description())
|
||||
.build();
|
||||
|
||||
try {
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling AbstractBatchJob: " + batchJob.jobName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,14 +41,16 @@ spring:
|
||||
hibernate:
|
||||
dialect: org.hibernate.dialect.H2Dialect
|
||||
"[format_sql]": true # 쿼리 로그 포맷 (정렬)
|
||||
"[show_sql]": true # 쿼리 로그 출력
|
||||
"[show_sql]": false # 쿼리 로그 출력
|
||||
"[highlight_sql]": true # 쿼리 하이라이트
|
||||
"[use_sql_comments]": true # SQL 주석 사용
|
||||
naming:
|
||||
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
|
||||
show-sql: true
|
||||
|
||||
batch:
|
||||
job:
|
||||
enabled: true
|
||||
enabled: false
|
||||
jdbc:
|
||||
initialize-schema: always
|
||||
|
||||
@@ -100,4 +102,12 @@ jwt:
|
||||
front:
|
||||
base:
|
||||
url: http://localhost:8081
|
||||
timeout: 100000
|
||||
timeout: 100000
|
||||
|
||||
logging:
|
||||
level:
|
||||
org:
|
||||
hibernate:
|
||||
SQL: DEBUG
|
||||
type:
|
||||
descriptor: TRACE
|
||||
@@ -1,7 +1,9 @@
|
||||
import apiClient from '../common/axios-instance.js';
|
||||
|
||||
export const getBatchJobExecutionData = async () => {
|
||||
const response = await apiClient.get('/api/dashboard/chart/batch');
|
||||
export const getBatchJobExecutionData = async (year, month) => {
|
||||
const response = await apiClient.get('/api/dashboard/chart', {
|
||||
params: { year, month }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,25 @@ export const getJobDetail = async (groupName, jobName) => {
|
||||
}
|
||||
|
||||
export const pauseJob = async (groupName, jobName) => {
|
||||
const response = await apiClient.post(`/api/schedule/pause/${groupName}/${jobName}`);
|
||||
const response = await apiClient.get(`/api/schedule/pause/${groupName}/${jobName}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const resumeJob = async (groupName, jobName) => {
|
||||
const response = await apiClient.post(`/api/schedule/resume/${groupName}/${jobName}`);
|
||||
const response = await apiClient.get(`/api/schedule/resume/${groupName}/${jobName}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const triggerJob = async (groupName, jobName) => {
|
||||
const response = await apiClient.post(`/api/schedule/trigger/${groupName}/${jobName}`);
|
||||
const response = await apiClient.get(`/api/schedule/trigger/${groupName}/${jobName}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const rescheduleJob = async (jobGroup, jobName, cronExpression) => {
|
||||
const response = await apiClient.post('/api/schedule/reschedule', {
|
||||
jobGroup,
|
||||
jobName,
|
||||
cronExpression
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
@@ -1 +1,7 @@
|
||||
dayjs.locale('ko');
|
||||
dayjs.locale('ko');
|
||||
|
||||
export const formatDateTime = (dateTimeString) => {
|
||||
if (!dateTimeString) return '-';
|
||||
const date = new Date(dateTimeString);
|
||||
return dayjs(date).format("YYYY-MM-DD ddd A HH:mm:ss");
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { formatDateTime } from '../../common/common.js';
|
||||
import { getBatchJobExecutionData, getRecentJobs } from '../../apis/dashboard-api.js';
|
||||
|
||||
let selectedMonth;
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initMonthPicker();
|
||||
fetchDataAndRender();
|
||||
});
|
||||
|
||||
const fetchDataAndRender = async () => {
|
||||
const batchData = await getBatchJobExecutionData();
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
const batchData = await getBatchJobExecutionData(year, month);
|
||||
const recentJobs = await getRecentJobs();
|
||||
|
||||
renderBatchExecutionTimeChart(batchData.jobAvgSummary);
|
||||
@@ -15,6 +19,23 @@ const fetchDataAndRender = async () => {
|
||||
renderRecentJobsTable(recentJobs);
|
||||
};
|
||||
|
||||
const initMonthPicker = () => {
|
||||
const monthPicker = document.getElementById('monthPicker');
|
||||
const currentDate = dayjs();
|
||||
selectedMonth = currentDate.format('YYYY-MM');
|
||||
monthPicker.value = selectedMonth;
|
||||
|
||||
monthPicker.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
monthPicker.showPicker();
|
||||
});
|
||||
|
||||
monthPicker.addEventListener('change', (event) => {
|
||||
selectedMonth = event.target.value;
|
||||
if(selectedMonth) fetchDataAndRender();
|
||||
});
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
@@ -32,6 +53,7 @@ const chartOptions = {
|
||||
|
||||
let batchExecutionTimeChart;
|
||||
const renderBatchExecutionTimeChart = (data) => {
|
||||
if (batchExecutionTimeChart) batchExecutionTimeChart.destroy();
|
||||
const jobExecutionTimes = {};
|
||||
data.forEach(job => {
|
||||
if (job.endTime && job.startTime) {
|
||||
@@ -57,9 +79,9 @@ const renderBatchExecutionTimeChart = (data) => {
|
||||
datasets: [{
|
||||
label: '평균 실행 시간 (초)',
|
||||
data: Object.values(averageExecutionTimes),
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.6)',
|
||||
borderColor: 'rgba(75, 192, 192, 1)',
|
||||
borderWidth: 1
|
||||
backgroundColor: 'rgba(54, 162, 235, 0.6)',
|
||||
borderColor: 'rgba(54, 162, 235, 3)',
|
||||
borderWidth: 3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
@@ -91,25 +113,58 @@ const renderBatchExecutionTimeChart = (data) => {
|
||||
|
||||
let batchStatusChart;
|
||||
const renderBatchStatusChart = (data) => {
|
||||
if (batchStatusChart) batchStatusChart.destroy();
|
||||
const statusTotals = data.reduce((acc, item) => {
|
||||
if (!acc[item.status]) {
|
||||
acc[item.status] = 0;
|
||||
}
|
||||
acc[item.status] += item.count;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const labels = Object.keys(statusTotals);
|
||||
const counts = Object.values(statusTotals);
|
||||
const statusColors = {
|
||||
'COMPLETED': 'rgba(75, 192, 192, 0.6)',
|
||||
'FAILED': 'rgba(255, 99, 132, 0.6)',
|
||||
'STARTING': 'rgba(255, 206, 86, 0.6)',
|
||||
'STARTED': 'rgba(54, 162, 235, 0.6)',
|
||||
'STOPPING': 'rgba(153, 102, 255, 0.6)',
|
||||
'STOPPED': 'rgba(255, 159, 64, 0.6)',
|
||||
'ABANDONED': 'rgba(201, 203, 207, 0.6)'
|
||||
};
|
||||
|
||||
const ctx = document.getElementById('batchStatusChart').getContext('2d');
|
||||
batchStatusChart = new Chart(ctx, {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: Object.keys(data),
|
||||
labels: labels,
|
||||
datasets: [{
|
||||
data: Object.values(data),
|
||||
backgroundColor: [
|
||||
'rgba(75, 192, 192, 0.6)',
|
||||
'rgba(255, 99, 132, 0.6)',
|
||||
'rgba(255, 206, 86, 0.6)',
|
||||
'rgba(54, 162, 235, 0.6)'
|
||||
]
|
||||
data: counts,
|
||||
backgroundColor: Object.values(statusColors)
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...chartOptions,
|
||||
plugins: {
|
||||
...chartOptions.plugins
|
||||
...chartOptions.plugins,
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: () => null,
|
||||
label: (context) => {
|
||||
const label = context.label || '';
|
||||
const value = context.parsed || 0;
|
||||
return `${label}: ${value}`;
|
||||
},
|
||||
afterLabel: (context) => {
|
||||
const status = context.label;
|
||||
return data
|
||||
.filter(item => item.status === status)
|
||||
.map(item => ` ${item.jobName}: ${item.count}`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -117,6 +172,7 @@ const renderBatchStatusChart = (data) => {
|
||||
|
||||
let hourlyJobExecutionChart;
|
||||
const renderHourlyJobExecutionChart = (data) => {
|
||||
if (hourlyJobExecutionChart) hourlyJobExecutionChart.destroy();
|
||||
const ctx = document.getElementById('hourlyJobExecutionChart').getContext('2d');
|
||||
const hours = Array.from({length: 24}, (_, i) => i);
|
||||
const jobNames = [...new Set(data.map(item => item.jobName))];
|
||||
@@ -174,12 +230,16 @@ const renderHourlyJobExecutionChart = (data) => {
|
||||
|
||||
let dailyJobExecutionsChart;
|
||||
const renderDailyJobExecutionsChart = (data) => {
|
||||
if (dailyJobExecutionsChart) dailyJobExecutionsChart.destroy();
|
||||
const ctx = document.getElementById('dailyJobExecutionsChart').getContext('2d');
|
||||
const now = new Date();
|
||||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
const firstDay = new Date(year, month - 1, 1);
|
||||
const lastDay = new Date(year, month, 0);
|
||||
const endDate = new Date(lastDay.getTime());
|
||||
|
||||
const dates = [];
|
||||
for (let d = new Date(firstDay); d <= lastDay; d.setDate(d.getDate() + 1)) {
|
||||
for (let d = new Date(firstDay); d <= endDate; d = new Date(d.setDate(d.getDate() + 1))) {
|
||||
dates.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
@@ -203,7 +263,7 @@ const renderDailyJobExecutionsChart = (data) => {
|
||||
label: jobName,
|
||||
data: dates.map(date => ({
|
||||
x: luxon.DateTime.fromISO(date).toJSDate(),
|
||||
y: groupedData[date]?.[jobName] || null
|
||||
y: groupedData[date]?.[jobName] || 0
|
||||
})),
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
@@ -220,7 +280,14 @@ const renderDailyJobExecutionsChart = (data) => {
|
||||
options: {
|
||||
...chartOptions,
|
||||
plugins: {
|
||||
...chartOptions.plugins
|
||||
...chartOptions.plugins,
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (context) => {
|
||||
return luxon.DateTime.fromJSDate(context[0].parsed.x).toFormat('yyyy-MM-dd');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
@@ -229,13 +296,14 @@ const renderDailyJobExecutionsChart = (data) => {
|
||||
unit: 'day',
|
||||
displayFormats: {
|
||||
day: 'MM-dd'
|
||||
},
|
||||
tooltipFormat: 'yyyy-MM-dd'
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: '날짜'
|
||||
}
|
||||
},
|
||||
min: firstDay,
|
||||
max: lastDay
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
@@ -253,9 +321,9 @@ const renderRecentJobsTable = (recentJobs) => {
|
||||
const tableBody = document.getElementById('recentJobsTable');
|
||||
tableBody.innerHTML = recentJobs.map(job => `
|
||||
<tr>
|
||||
<td>${job.jobName}</td>
|
||||
<td>${job.jobGroup}</td>
|
||||
<td>${new Date(job.firedTime).toLocaleString()}</td>
|
||||
<td>${job.jobName}</td>
|
||||
<td>${formatDateTime(job.firedTime)}</td>
|
||||
<td>${job.state}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
@@ -1,90 +1,169 @@
|
||||
import { getAllJobs, getJobDetail, triggerJob, pauseJob, resumeJob } from '../../apis/schedule-api.js';
|
||||
import { formatDateTime } from '../../common/common.js';
|
||||
import { getAllJobs, getJobDetail, pauseJob, resumeJob, rescheduleJob } from '../../apis/schedule-api.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const searchForm = document.getElementById('searchForm');
|
||||
const tableBody = document.querySelector('tbody');
|
||||
fetchDataAndRender();
|
||||
|
||||
searchForm.addEventListener('submit', async (e) => {
|
||||
document.getElementById('searchForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(searchForm);
|
||||
const searchParams = new URLSearchParams(formData);
|
||||
const response = await getAllJobs(searchParams);
|
||||
updateTable(response);
|
||||
fetchDataAndRender();
|
||||
});
|
||||
});
|
||||
|
||||
const updateTable = (jobs) => {
|
||||
tableBody.innerHTML = jobs.map(job => `
|
||||
<tr>
|
||||
<td>${job.group}</td>
|
||||
<td>${job.name}</td>
|
||||
<td>${job.cronExpression}</td>
|
||||
<td><span class="badge ${getStatusBadgeClass(job.status)}">${job.status}</span></td>
|
||||
<td>
|
||||
<button class="badge btn btn-sm btn-secondary detail-btn" data-group="${job.group}" data-name="${job.name}">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
const fetchDataAndRender = async () => {
|
||||
const searchForm = document.getElementById('searchForm');
|
||||
const formData = new FormData(searchForm);
|
||||
const searchParams = new URLSearchParams(formData);
|
||||
const response = await getAllJobs(searchParams);
|
||||
updateTable(response);
|
||||
};
|
||||
|
||||
document.querySelectorAll('.detail-btn').forEach(btn => {
|
||||
btn.addEventListener('click', showJobDetail);
|
||||
});
|
||||
};
|
||||
const updateTable = (jobs) => {
|
||||
const tableBody = document.querySelector('tbody');
|
||||
tableBody.innerHTML = jobs.map(job => `
|
||||
<tr>
|
||||
<td>${job.group}</td>
|
||||
<td>${job.name}</td>
|
||||
<td>${job.cronExpression}</td>
|
||||
<td><span class="badge ${getStatusBadgeClass(job.status)}">${job.status}</span></td>
|
||||
<td>
|
||||
<button class="badge btn btn-sm btn-secondary detail-btn" data-group="${job.group}" data-name="${job.name}">
|
||||
<i class="bi bi-eye"></i> 상세
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
const showJobDetail = async (e) => {
|
||||
const { group, name } = e.target.closest('button').dataset;
|
||||
const jobDetail = await getJobDetail(group, name);
|
||||
const detailContent = document.getElementById('scheduleDetailContent');
|
||||
detailContent.innerHTML = `
|
||||
<div class="card">
|
||||
<ul class="list-group list-group-flush">
|
||||
${createDetailItem('그룹', jobDetail.group, 'bi-people')}
|
||||
${createDetailItem('잡 이름', jobDetail.name, 'bi-briefcase')}
|
||||
${createDetailItem('설명', jobDetail.description || '-', 'bi-card-text')}
|
||||
${createDetailItem('스케줄', `<input type="text" class="form-control form-control-sm" id="cronExpression" value="${jobDetail.cronExpression}">`, 'bi-calendar-event')}
|
||||
${createDetailItem('다음 실행', formatDateTime(jobDetail.nextFireTime), 'bi-clock')}
|
||||
${createDetailItem('이전 실행', formatDateTime(jobDetail.previousFireTime), 'bi-clock-history')}
|
||||
${createDetailItem('상태', `<span class="badge ${getStatusBadgeClass(jobDetail.status)}">${jobDetail.status}</span>`, 'bi-activity')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
document.querySelectorAll('.detail-btn').forEach(btn => {
|
||||
btn.addEventListener('click', showJobDetail);
|
||||
});
|
||||
};
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById('scheduleDetailModal'));
|
||||
modal.show();
|
||||
|
||||
document.getElementById('startJobBtn').onclick = () => triggerJob(group, name);
|
||||
document.getElementById('pauseJobBtn').onclick = () => pauseJob(group, name);
|
||||
document.getElementById('resumeJobBtn').onclick = () => resumeJob(group, name);
|
||||
};
|
||||
|
||||
const createDetailItem = (label, value, iconClass) => `
|
||||
<li class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-4">
|
||||
<i class="bi ${iconClass} text-primary me-2"></i>
|
||||
<strong>${label}</strong>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
${value}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
const showJobDetail = async (e) => {
|
||||
const { group, name } = e.target.closest('button').dataset;
|
||||
const jobDetail = await getJobDetail(group, name);
|
||||
const detailContent = document.getElementById('scheduleDetailContent');
|
||||
detailContent.innerHTML = `
|
||||
<div class="card">
|
||||
<ul class="list-group list-group-flush">
|
||||
${createDetailItem('그룹', jobDetail.group, 'bi-people')}
|
||||
${createDetailItem('잡 이름', jobDetail.name, 'bi-briefcase')}
|
||||
${createDetailItem('설명', jobDetail.description || '-', 'bi-card-text')}
|
||||
${createDetailItem('스케줄', `<input type="text" class="form-control form-control-sm" id="cronExpression" value="${jobDetail.cronExpression}">`, 'bi-calendar-event')}
|
||||
${createDetailItem('다음 실행', formatDateTime(jobDetail.nextFireTime), 'bi-clock')}
|
||||
${createDetailItem('이전 실행', formatDateTime(jobDetail.previousFireTime), 'bi-clock-history')}
|
||||
${createDetailItem('상태', `<span class="badge ${getStatusBadgeClass(jobDetail.status)}">${jobDetail.status}</span>`, 'bi-activity')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const formatDateTime = (dateTimeString) => {
|
||||
if (!dateTimeString) return '-';
|
||||
const date = new Date(dateTimeString);
|
||||
return dayjs(date).format("YYYY-MM-DD ddd A HH:mm:ss");
|
||||
const modal = new bootstrap.Modal(document.getElementById('scheduleDetailModal'));
|
||||
modal.show();
|
||||
|
||||
updateJobControlButtons(jobDetail.status);
|
||||
|
||||
document.getElementById('pauseJobBtn').onclick = () => {
|
||||
pauseJob(group, name).then(() => updateJobStatus(group, name, 'PAUSED'));
|
||||
};
|
||||
document.getElementById('resumeJobBtn').onclick = () => {
|
||||
resumeJob(group, name).then(() => updateJobStatus(group, name, 'NORMAL'));
|
||||
};
|
||||
document.getElementById('updateCronBtn').onclick = () => {
|
||||
updateCronExpression(group, name);
|
||||
};
|
||||
};
|
||||
|
||||
const createDetailItem = (label, value, iconClass) => `
|
||||
<li class="list-group-item">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-4">
|
||||
<i class="bi ${iconClass} text-primary me-2"></i>
|
||||
<strong>${label}</strong>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
${value}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
const statusClasses = {
|
||||
'NONE': 'bg-secondary',
|
||||
'NORMAL': 'bg-success',
|
||||
'PAUSED': 'bg-warning',
|
||||
'COMPLETE': 'bg-info',
|
||||
'ERROR': 'bg-danger',
|
||||
'BLOCKED': 'bg-dark'
|
||||
};
|
||||
return statusClasses[status] || 'bg-secondary';
|
||||
};
|
||||
|
||||
const updateCronExpression = async (group, name) => {
|
||||
const cronExpressionInput = document.getElementById('cronExpression');
|
||||
const newCronExpression = cronExpressionInput.value;
|
||||
const result = await rescheduleJob(group, name, newCronExpression);
|
||||
if (result) {
|
||||
alert('스케쥴이 수정 되었습니다.');
|
||||
fetchDataAndRender();
|
||||
} else {
|
||||
alert('스케쥴 수정이 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const updateJobControlButtons = (status) => {
|
||||
const pauseJobBtn = document.getElementById('pauseJobBtn');
|
||||
const resumeJobBtn = document.getElementById('resumeJobBtn');
|
||||
|
||||
const updateButtonState = (button, isEnabled) => {
|
||||
button.disabled = !isEnabled;
|
||||
button.classList.toggle(button.dataset.enabledClass, isEnabled);
|
||||
button.classList.toggle('btn-outline-secondary', !isEnabled);
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
const statusClasses = {
|
||||
'NORMAL': 'bg-success',
|
||||
'PAUSED': 'bg-warning',
|
||||
'COMPLETE': 'bg-info',
|
||||
'ERROR': 'bg-danger'
|
||||
};
|
||||
return statusClasses[status] || 'bg-secondary';
|
||||
const updateBothButtons = (pauseEnabled, resumeEnabled) => {
|
||||
updateButtonState(pauseJobBtn, pauseEnabled);
|
||||
updateButtonState(resumeJobBtn, resumeEnabled);
|
||||
};
|
||||
});
|
||||
|
||||
switch (status) {
|
||||
case 'PAUSED':
|
||||
updateBothButtons(false, true);
|
||||
break;
|
||||
case 'COMPLETE':
|
||||
case 'ERROR':
|
||||
updateBothButtons(false, false);
|
||||
break;
|
||||
case 'NORMAL':
|
||||
case 'BLOCKED':
|
||||
case 'NONE':
|
||||
default:
|
||||
updateBothButtons(true, false);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const updateJobStatus = async (group, name, newStatus) => {
|
||||
const jobDetail = await getJobDetail(group, name);
|
||||
jobDetail.status = newStatus;
|
||||
|
||||
const statusElement = document.querySelector('#scheduleDetailContent .badge');
|
||||
statusElement.className = `badge ${getStatusBadgeClass(newStatus)}`;
|
||||
statusElement.textContent = newStatus;
|
||||
|
||||
updateJobControlButtons(newStatus);
|
||||
updateTableJobStatus(group, name, newStatus);
|
||||
};
|
||||
|
||||
const updateTableJobStatus = (group, name, newStatus) => {
|
||||
const tableRows = document.querySelectorAll('tbody tr');
|
||||
for (let row of tableRows) {
|
||||
const rowGroup = row.cells[0].textContent;
|
||||
const rowName = row.cells[1].textContent;
|
||||
if (rowGroup === group && rowName === name) {
|
||||
const statusCell = row.cells[3];
|
||||
statusCell.innerHTML = `<span class="badge ${getStatusBadgeClass(newStatus)}">${newStatus}</span>`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -17,5 +17,5 @@
|
||||
<script th:src="@{/js/lib/bootstrap/chartjs-adapter-luxon.umd.min.js}"></script>
|
||||
<script th:src="@{/js/lib/dayjs/dayjs.min.js}"></script>
|
||||
<script th:src="@{/js/lib/dayjs/locale/ko.js}"></script>
|
||||
<script th:src="@{/js/common/common.js}"></script>
|
||||
<script th:src="@{/js/common/common.js}" type="module"></script>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<header id="header" class="header fixed-top d-flex align-items-center">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<a href="index.html" class="logo d-flex align-items-center">
|
||||
<span class="d-none d-lg-block">NiceAdmin</span>
|
||||
<span class="d-none d-lg-block">NXCUS - Agent2.0</span>
|
||||
</a>
|
||||
<i class="bi bi-list toggle-sidebar-btn"></i>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="/schedule"><i class="bi bi-menu-button-wide"></i><span>Schedule</span></a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="index.html"><i class="bi bi-bar-chart"></i><span>Charts</span></a>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="col-auto">
|
||||
<nav>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="index.html">홈</a></li>
|
||||
<li class="breadcrumb-item">홈</li>
|
||||
<li class="breadcrumb-item active">대시보드</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -24,6 +24,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<section class="section">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-calendar-month"></i></span>
|
||||
<input type="month" id="monthPicker" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
@@ -60,7 +68,7 @@
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i>일별 작업 실행 횟수
|
||||
<i class="bi bi-graph-up me-2"></i>일별 작업 실행 횟수
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<canvas id="dailyJobExecutionsChart" style="height: 250px;"></canvas>
|
||||
@@ -75,13 +83,13 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><i class="bi bi-briefcase me-2"></i>작업 이름</th>
|
||||
<th><i class="bi bi-folder me-2"></i>그룹</th>
|
||||
<th><i class="bi bi-calendar-event me-2"></i>실행 시간</th>
|
||||
<th><i class="bi bi-flag me-2"></i>상태</th>
|
||||
<th><i class="bi bi-people"></i> 그룹명</th>
|
||||
<th><i class="bi bi-briefcase"></i> 잡 이름</th>
|
||||
<th><i class="bi bi-calendar-event me-2"></i> 실행 시간</th>
|
||||
<th><i class="bi bi-activity"></i> 상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentJobsTable">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
layout:decorate="~{layouts/layout}"
|
||||
layout:fragment="content" lang="ko" xml:lang="ko">
|
||||
<head>
|
||||
<title>Schedule - List</title>
|
||||
<title>Schedule</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="main" class="main">
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="col-auto">
|
||||
<nav>
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="index.html">홈</a></li>
|
||||
<li class="breadcrumb-item">홈</li>
|
||||
<li class="breadcrumb-item active">스케줄</li>
|
||||
</ol>
|
||||
</nav>
|
||||
@@ -93,13 +93,10 @@
|
||||
<fieldset>
|
||||
<legend class="visually-hidden">작업 제어 버튼</legend>
|
||||
<div class="d-flex justify-content-between" style="width: 300px;">
|
||||
<button type="button" class="btn btn-outline-success rounded-circle" id="startJobBtn" data-bs-toggle="tooltip" data-bs-placement="top" title="시작">
|
||||
<i class="bi bi-play-fill"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger rounded-circle" id="pauseJobBtn" data-bs-toggle="tooltip" data-bs-placement="top" title="정지">
|
||||
<button type="button" class="btn btn-outline-danger rounded-circle" id="pauseJobBtn" data-bs-toggle="tooltip" data-bs-placement="top" title="정지" data-enabled-class="btn-outline-danger">
|
||||
<i class="bi bi-pause-fill"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-info rounded-circle" id="resumeJobBtn" data-bs-toggle="tooltip" data-bs-placement="top" title="재시작">
|
||||
<button type="button" class="btn btn-outline-info rounded-circle" id="resumeJobBtn" data-bs-toggle="tooltip" data-bs-placement="top" title="재시작" data-enabled-class="btn-outline-info">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary rounded-circle" id="updateCronBtn" data-bs-toggle="tooltip" data-bs-placement="top" title="저장">
|
||||
|
||||
Reference in New Issue
Block a user