This commit is contained in:
mindol1004
2024-09-25 10:32:37 +09:00
parent 9d1079fcbd
commit 4f3d7e659b
35 changed files with 549 additions and 269 deletions

View File

@@ -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;
}
}

View File

@@ -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 {};
}

View File

@@ -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);
}

View File

@@ -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() {

View File

@@ -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");
}

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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")

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -175,4 +175,13 @@ public abstract class AbstractBatchTask implements ApplicationContextAware {
return batchJobInfo.cronExpression();
}
/**
* 배치 작업의 설명을 반환합니다.
*
* @return 배치 작업의 설명
*/
public String description() {
return batchJobInfo.description();
}
}

View File

@@ -43,4 +43,10 @@ public @interface BatchJobInfo {
* @return cron 표현식
*/
String cronExpression() default "";
/**
* 배치 작업의 설명을 지정합니다.
*
* @return 작업의 설명
*/
String description() default "";
}

View File

@@ -51,4 +51,10 @@ public @interface QuartzJob {
* @return Cron 표현식
*/
String cronExpression() default "";
/**
* Quartz 작업의 설명을 지정합니다.
*
* @return 작업의 설명
*/
String description() default "";
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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");
}

View File

@@ -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('');

View File

@@ -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;
}
}
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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="저장">