commit
This commit is contained in:
@@ -20,12 +20,28 @@
|
||||
<maven.compiler.release>11</maven.compiler.release>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-dependencies</artifactId>
|
||||
<version>2021.0.7</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
|
||||
@@ -7,14 +7,13 @@ import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.spring.domain.post.repository.PostRepository;
|
||||
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(
|
||||
|
||||
@@ -31,9 +31,7 @@ public class PostCreateBatch extends AbstractBatchTask {
|
||||
|
||||
@Autowired
|
||||
@Override
|
||||
public void setTransactionManager(
|
||||
@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager
|
||||
) {
|
||||
public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) {
|
||||
super.setTransactionManager(transactionManager);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import org.springframework.batch.core.job.builder.JobBuilder;
|
||||
import org.springframework.batch.core.job.flow.FlowExecutionStatus;
|
||||
import org.springframework.batch.core.job.flow.JobExecutionDecider;
|
||||
import org.springframework.batch.core.launch.support.RunIdIncrementer;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.batch.core.step.builder.StepBuilder;
|
||||
import org.springframework.batch.core.step.tasklet.Tasklet;
|
||||
import org.springframework.batch.item.ItemProcessor;
|
||||
@@ -21,15 +20,16 @@ import org.springframework.batch.item.ItemWriter;
|
||||
import org.springframework.batch.item.database.JpaPagingItemReader;
|
||||
import org.springframework.batch.item.database.builder.JpaPagingItemReaderBuilder;
|
||||
import org.springframework.batch.repeat.RepeatStatus;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import com.spring.domain.post.entity.Post;
|
||||
import com.spring.domain.post.entity.PostBackUp;
|
||||
import com.spring.domain.post.repository.PostBackUpRepository;
|
||||
import com.spring.domain.post.repository.PostRepository;
|
||||
import com.spring.infra.batch.AbstractBatchChunk;
|
||||
import com.spring.infra.batch.BatchJobInfo;
|
||||
import com.spring.infra.db.orm.jpa.SecondaryJpaConfig;
|
||||
|
||||
@@ -37,14 +37,21 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@Component
|
||||
@BatchJobInfo(
|
||||
group = "${batch-info.post-create-batch.group}",
|
||||
jobName = "${batch-info.post-create-batch.job-name}",
|
||||
cronExpression = "${batch-info.post-create-batch.cron-expression}",
|
||||
description = "${batch-info.post-create-batch.description}"
|
||||
)
|
||||
@RequiredArgsConstructor
|
||||
public class PostCreateBatchChunk {
|
||||
public class PostCreateBatchChunk extends AbstractBatchChunk {
|
||||
|
||||
private final JobRepository jobRepository;
|
||||
|
||||
@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER)
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
@Autowired
|
||||
@Override
|
||||
public void setTransactionManager(@Qualifier(SecondaryJpaConfig.TRANSACTION_MANAGER) PlatformTransactionManager transactionManager) {
|
||||
super.setTransactionManager(transactionManager);
|
||||
}
|
||||
|
||||
@Qualifier(SecondaryJpaConfig.ENTITY_MANAGER_FACTORY)
|
||||
private final EntityManagerFactory entityManagerFactory;
|
||||
@@ -54,15 +61,9 @@ public class PostCreateBatchChunk {
|
||||
|
||||
private List<Post> list = new ArrayList<>();
|
||||
|
||||
@Bean
|
||||
@BatchJobInfo(
|
||||
group = "${batch-info.post-create-batch.group}",
|
||||
jobName = "${batch-info.post-create-batch.job-name}",
|
||||
cronExpression = "${batch-info.post-create-batch.cron-expression}",
|
||||
description = "${batch-info.post-create-batch.description}"
|
||||
)
|
||||
Job testPostJob() {
|
||||
return new JobBuilder("testPostJob")
|
||||
@Override
|
||||
public Job createJob() {
|
||||
return new JobBuilder(batchJobInfoData.getJobName())
|
||||
.repository(jobRepository)
|
||||
.incrementer(new RunIdIncrementer())
|
||||
.start(readListStep())
|
||||
@@ -136,5 +137,5 @@ public class PostCreateBatchChunk {
|
||||
return RepeatStatus.FINISHED;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.spring.infra.batch;
|
||||
|
||||
import org.springframework.batch.core.Job;
|
||||
|
||||
public interface AbstractBatch {
|
||||
|
||||
void initializeBatchJobInfo();
|
||||
|
||||
void registerJobBean();
|
||||
|
||||
Job createJob();
|
||||
|
||||
default String removeScopedTargetPrefix(String beanName) {
|
||||
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.spring.infra.batch;
|
||||
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.repository.JobRepository;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
|
||||
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
@Configuration
|
||||
public abstract class AbstractBatchChunk implements AbstractBatch, ApplicationContextAware, InitializingBean {
|
||||
|
||||
private final BatchJobInfo batchJobInfo;
|
||||
private BatchJobInfoService batchJobInfoService;
|
||||
private ApplicationContext applicationContext;
|
||||
protected BatchJobInfoData batchJobInfoData;
|
||||
protected JobRepository jobRepository;
|
||||
protected PlatformTransactionManager transactionManager;
|
||||
|
||||
/**
|
||||
* 기본 생성자입니다.
|
||||
* <p>
|
||||
* BatchJobInfo 어노테이션을 찾고, 없으면 예외를 발생시킵니다.
|
||||
* </p>
|
||||
*/
|
||||
protected AbstractBatchChunk() {
|
||||
this.batchJobInfo = AnnotationUtils.findAnnotation(getClass(), BatchJobInfo.class);
|
||||
if (this.batchJobInfo == null) {
|
||||
throw new IllegalStateException("BatchJobInfo annotation is missing");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
|
||||
this.applicationContext = applicationContext;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
this.batchJobInfoService = applicationContext.getBean(BatchJobInfoService.class);
|
||||
initializeBatchJobInfo();
|
||||
registerJobBean();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initializeBatchJobInfo() {
|
||||
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
|
||||
this.batchJobInfoData = batchJobInfoService.getBatchJobInfo(removeScopedTargetPrefix(beanName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업을 Spring의 Bean으로 등록합니다.
|
||||
*/
|
||||
@Override
|
||||
public void registerJobBean() {
|
||||
var beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory();
|
||||
var registry = (BeanDefinitionRegistry) beanFactory;
|
||||
String jobBeanName = batchJobInfoData.getJobName();
|
||||
if (!registry.containsBeanDefinition(jobBeanName)) {
|
||||
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
|
||||
registry.registerBeanDefinition(jobBeanName, beanDefinitionBuilder.getBeanDefinition());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JobRepository를 설정합니다.
|
||||
*
|
||||
* @param jobRepository 설정할 JobRepository
|
||||
*/
|
||||
@Autowired
|
||||
public void setJobRepository(JobRepository jobRepository) {
|
||||
this.jobRepository = jobRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* PlatformTransactionManager를 설정합니다.
|
||||
*
|
||||
* @param transactionManager 설정할 PlatformTransactionManager
|
||||
*/
|
||||
@Autowired
|
||||
public void setTransactionManager(PlatformTransactionManager transactionManager) {
|
||||
this.transactionManager = transactionManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public abstract Job createJob();
|
||||
|
||||
}
|
||||
@@ -23,8 +23,6 @@ import org.springframework.core.annotation.AnnotationUtils;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
|
||||
|
||||
/**
|
||||
* 배치 작업을 정의하는 추상 클래스입니다.
|
||||
* <p>
|
||||
@@ -35,10 +33,10 @@ import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
|
||||
* @version 1.0
|
||||
*/
|
||||
@Configuration
|
||||
public abstract class AbstractBatchTask implements ApplicationContextAware, InitializingBean {
|
||||
public abstract class AbstractBatchTask implements AbstractBatch, ApplicationContextAware, InitializingBean {
|
||||
|
||||
private final BatchJobInfo batchJobInfo;
|
||||
private BatchJobInfoBeanPostProcessor batchJobInfoProcessor;
|
||||
private BatchJobInfoService batchJobInfoService;
|
||||
private BatchJobInfoData batchJobInfoData;
|
||||
private ApplicationContext applicationContext;
|
||||
private JobRepository jobRepository;
|
||||
@@ -60,28 +58,33 @@ public abstract class AbstractBatchTask implements ApplicationContextAware, Init
|
||||
@Override
|
||||
public void setApplicationContext(@NonNull ApplicationContext applicationContext) throws BeansException {
|
||||
this.applicationContext = applicationContext;
|
||||
this.batchJobInfoService = applicationContext.getBean(BatchJobInfoService.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
this.batchJobInfoProcessor = applicationContext.getBean(BatchJobInfoBeanPostProcessor.class);
|
||||
initializeBatchJobInfo();
|
||||
registerJobBean();
|
||||
}
|
||||
|
||||
private void initializeBatchJobInfo() {
|
||||
@Override
|
||||
public void initializeBatchJobInfo() {
|
||||
String beanName = applicationContext.getBeanNamesForType(this.getClass())[0];
|
||||
this.batchJobInfoData = batchJobInfoProcessor.getBatchJobInfo(beanName);
|
||||
this.batchJobInfoData = batchJobInfoService.getBatchJobInfo(removeScopedTargetPrefix(beanName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 작업을 Spring의 Bean으로 등록합니다.
|
||||
*/
|
||||
private void registerJobBean() {
|
||||
@Override
|
||||
public void registerJobBean() {
|
||||
var beanFactory = ((ConfigurableApplicationContext) applicationContext).getBeanFactory();
|
||||
var registry = (BeanDefinitionRegistry) beanFactory;
|
||||
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
|
||||
registry.registerBeanDefinition(batchJobInfoData.getJobName(), beanDefinitionBuilder.getBeanDefinition());
|
||||
String jobBeanName = batchJobInfoData.getJobName();
|
||||
if (!registry.containsBeanDefinition(jobBeanName)) {
|
||||
var beanDefinitionBuilder = BeanDefinitionBuilder.genericBeanDefinition(Job.class, this::createJob);
|
||||
registry.registerBeanDefinition(jobBeanName, beanDefinitionBuilder.getBeanDefinition());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,7 +113,8 @@ public abstract class AbstractBatchTask implements ApplicationContextAware, Init
|
||||
* @return 생성된 Job 객체
|
||||
* @throws IllegalStateException STEP이 정의되지 않은 경우 예외 발생
|
||||
*/
|
||||
private Job createJob() {
|
||||
@Override
|
||||
public Job createJob() {
|
||||
List<Step> steps = createSteps();
|
||||
if (steps.isEmpty()) {
|
||||
throw new IllegalStateException("No steps defined for job: " + batchJobInfoData.getJobName());
|
||||
|
||||
@@ -32,9 +32,9 @@ public class BatchConfig {
|
||||
* @param jobRegistry JobRegistry 객체
|
||||
* @return 설정된 JobRegistryBeanPostProcessor 객체
|
||||
*/
|
||||
@Bean
|
||||
@Bean
|
||||
JobRegistryBeanPostProcessor jobRegistryBeanPostProcessor(JobRegistry jobRegistry) {
|
||||
final var processor = new JobRegistryBeanPostProcessor();
|
||||
var processor = new JobRegistryBeanPostProcessor();
|
||||
processor.setJobRegistry(jobRegistry);
|
||||
return processor;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
|
||||
/**
|
||||
* 배치 작업에 대한 메타데이터를 정의하는 어노테이션입니다.
|
||||
* <p>
|
||||
* 이 어노테이션은 배치 작업의 그룹, 이름 및 cron 표현식을 설정하는 데 사용됩니다.
|
||||
* </p>
|
||||
*/
|
||||
@Target({ElementType.TYPE, ElementType.METHOD})
|
||||
@RefreshScope
|
||||
@Target({ElementType.TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface BatchJobInfo {
|
||||
/**
|
||||
|
||||
@@ -1,79 +1,60 @@
|
||||
package com.spring.infra.batch;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.configuration.DuplicateJobException;
|
||||
import org.springframework.batch.core.configuration.JobRegistry;
|
||||
import org.springframework.batch.core.configuration.support.ReferenceJobFactory;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.InstantiationAwareBeanPostProcessor;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class BatchJobInfoBeanPostProcessor implements InstantiationAwareBeanPostProcessor {
|
||||
public class BatchJobInfoBeanPostProcessor implements BeanPostProcessor {
|
||||
|
||||
private final Environment environment;
|
||||
private final Map<String, BatchJobInfoData> batchJobInfoMap = new ConcurrentHashMap<>();
|
||||
|
||||
private static final String BASE_PACKAGE = "com.spring";
|
||||
private final BatchJobInfoService batchJobInfoService;
|
||||
private final JobRegistry jobRegistry;
|
||||
|
||||
@Override
|
||||
@Nullable
|
||||
public Object postProcessBeforeInstantiation(@NonNull Class<?> beanClass, @NonNull String beanName) throws BeansException {
|
||||
if (beanClass.getPackage().getName().startsWith(BASE_PACKAGE)) {
|
||||
processAnnotations(beanClass, beanName);
|
||||
}
|
||||
return null;
|
||||
public Object postProcessBeforeInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
|
||||
processBatchJobInfoAnnotations(bean, beanName);
|
||||
return bean;
|
||||
}
|
||||
|
||||
private void processAnnotations(Class<?> beanClass, String beanName) {
|
||||
if (beanClass.isAnnotationPresent(BatchJobInfo.class)) {
|
||||
BatchJobInfo batchJobInfo = beanClass.getAnnotation(BatchJobInfo.class);
|
||||
setBatchJobInfoData(batchJobInfo, beanName);
|
||||
}
|
||||
|
||||
ReflectionUtils.doWithMethods(beanClass, method -> {
|
||||
if (method.isAnnotationPresent(BatchJobInfo.class)) {
|
||||
BatchJobInfo batchJobInfo = method.getAnnotation(BatchJobInfo.class);
|
||||
String jobBeanName = method.getName();
|
||||
setBatchJobInfoData(batchJobInfo, jobBeanName);
|
||||
@Override
|
||||
@Nullable
|
||||
public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
|
||||
if (bean instanceof AbstractBatch) {
|
||||
AbstractBatch abstractBatch = (AbstractBatch) bean;
|
||||
Job job = abstractBatch.createJob();
|
||||
String jobName = job.getName();
|
||||
if (!jobRegistry.getJobNames().contains(jobName)) {
|
||||
try {
|
||||
jobRegistry.register(new ReferenceJobFactory(job));
|
||||
} catch (DuplicateJobException e) {
|
||||
log.warn("Unexpected DuplicateJobException for job: {}", jobName, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
|
||||
private void setBatchJobInfoData(BatchJobInfo batchJobInfo, String beanName) {
|
||||
batchJobInfoMap.put(beanName, processBatchJobInfo(batchJobInfo));
|
||||
private void processBatchJobInfoAnnotations(Object bean, String beanName) {
|
||||
if (bean.getClass().isAnnotationPresent(BatchJobInfo.class)) {
|
||||
BatchJobInfo batchJobInfo = bean.getClass().getAnnotation(BatchJobInfo.class);
|
||||
batchJobInfoService.setBatchJobInfoData(removeScopedTargetPrefix(beanName), batchJobInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private BatchJobInfoData processBatchJobInfo(BatchJobInfo batchJobInfo) {
|
||||
return new BatchJobInfoData(
|
||||
resolvePlaceHolder(batchJobInfo.group()),
|
||||
resolvePlaceHolder(batchJobInfo.jobName()),
|
||||
resolvePlaceHolder(batchJobInfo.cronExpression()),
|
||||
resolvePlaceHolder(batchJobInfo.description())
|
||||
);
|
||||
private String removeScopedTargetPrefix(String beanName) {
|
||||
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
|
||||
}
|
||||
|
||||
private String resolvePlaceHolder(String value) {
|
||||
return environment.resolvePlaceholders(value);
|
||||
}
|
||||
|
||||
public BatchJobInfoData getBatchJobInfo(String beanName) {
|
||||
return batchJobInfoMap.get(beanName);
|
||||
}
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public static class BatchJobInfoData {
|
||||
private final String jobGroup;
|
||||
private final String jobName;
|
||||
private final String cronExpression;
|
||||
private final String description;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.spring.infra.batch;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class BatchJobInfoData {
|
||||
private final String jobGroup;
|
||||
private final String jobName;
|
||||
private final String cronExpression;
|
||||
private final String description;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.spring.infra.batch;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BatchJobInfoService {
|
||||
|
||||
private final Environment environment;
|
||||
private final Map<String, BatchJobInfoData> batchJobInfoMap = new ConcurrentHashMap<>();
|
||||
|
||||
public void setBatchJobInfoData(String beanName, BatchJobInfo batchJobInfo) {
|
||||
batchJobInfoMap.put(beanName, processBatchJobInfo(batchJobInfo));
|
||||
}
|
||||
|
||||
public BatchJobInfoData getBatchJobInfo(String beanName) {
|
||||
return batchJobInfoMap.get(beanName);
|
||||
}
|
||||
|
||||
private BatchJobInfoData processBatchJobInfo(BatchJobInfo batchJobInfo) {
|
||||
return new BatchJobInfoData(
|
||||
resolvePlaceHolder(batchJobInfo.group()),
|
||||
resolvePlaceHolder(batchJobInfo.jobName()),
|
||||
resolvePlaceHolder(batchJobInfo.cronExpression()),
|
||||
resolvePlaceHolder(batchJobInfo.description())
|
||||
);
|
||||
}
|
||||
|
||||
private String resolvePlaceHolder(String value) {
|
||||
return environment.resolvePlaceholders(value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import org.springframework.batch.core.configuration.JobRegistry;
|
||||
import org.springframework.batch.core.launch.JobLauncher;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.scheduling.quartz.QuartzJobBean;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -34,7 +33,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
* @see JobRegistry
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class QuartzJobLauncher extends QuartzJobBean {
|
||||
|
||||
|
||||
@@ -2,25 +2,16 @@ package com.spring.infra.quartz;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.quartz.CronScheduleBuilder;
|
||||
import org.quartz.CronTrigger;
|
||||
import org.quartz.JobBuilder;
|
||||
import org.quartz.JobDataMap;
|
||||
import org.quartz.JobDetail;
|
||||
import org.quartz.Scheduler;
|
||||
import org.quartz.SchedulerException;
|
||||
import org.quartz.TriggerBuilder;
|
||||
import org.springframework.aop.support.AopUtils;
|
||||
import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.event.ContextRefreshedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
|
||||
import com.spring.infra.batch.AbstractBatchTask;
|
||||
import com.spring.infra.batch.BatchJobInfo;
|
||||
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor;
|
||||
import com.spring.infra.batch.BatchJobInfoBeanPostProcessor.BatchJobInfoData;
|
||||
import com.spring.infra.batch.AbstractBatch;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@@ -46,9 +37,8 @@ import lombok.RequiredArgsConstructor;
|
||||
@RequiredArgsConstructor
|
||||
public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedEvent> {
|
||||
|
||||
private final Scheduler scheduler;
|
||||
private final ApplicationContext applicationContext;
|
||||
private final BatchJobInfoBeanPostProcessor batchJobInfoProcessor;
|
||||
private final QuartzScheduleService quartzScheduleService;
|
||||
|
||||
/**
|
||||
* 애플리케이션 컨텍스트가 리프레시될 때 호출되는 메소드입니다.
|
||||
@@ -58,96 +48,34 @@ public class QuartzJobRegistrar implements ApplicationListener<ContextRefreshedE
|
||||
*/
|
||||
@Override
|
||||
public void onApplicationEvent(@NonNull ContextRefreshedEvent event) {
|
||||
clearJobs();
|
||||
refreshJobs();
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public void onRefreshScopeRefreshed(@NonNull RefreshScopeRefreshedEvent event) {
|
||||
refreshJobs();
|
||||
}
|
||||
|
||||
private void refreshJobs() {
|
||||
quartzScheduleService.clearJobs();
|
||||
registerBatchJobs();
|
||||
registerAbstractBatchJobs();
|
||||
}
|
||||
|
||||
private void clearJobs() {
|
||||
try {
|
||||
scheduler.clear();
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* BatchJobInfo 어노테이션이 붙은 모든 메소드를 찾아 Quartz 스케줄러에 등록합니다.
|
||||
*/
|
||||
private void registerBatchJobs() {
|
||||
for (String beanName : applicationContext.getBeanDefinitionNames()) {
|
||||
Object bean = applicationContext.getBean(beanName);
|
||||
Class<?> beanClass = bean.getClass();
|
||||
|
||||
ReflectionUtils.doWithMethods(beanClass, method -> {
|
||||
if (method.isAnnotationPresent(BatchJobInfo.class)) {
|
||||
BatchJobInfoData batchJobInfoData = batchJobInfoProcessor.getBatchJobInfo(beanName);
|
||||
if (batchJobInfoData != null) {
|
||||
scheduleQuartzJob(batchJobInfoData);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleQuartzJob(BatchJobInfoData batchJobInfoData) {
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("jobName", batchJobInfoData.getJobName());
|
||||
|
||||
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
|
||||
.setJobData(jobDataMap)
|
||||
.storeDurably(true)
|
||||
.withDescription(batchJobInfoData.getDescription())
|
||||
.build();
|
||||
|
||||
CronTrigger trigger = TriggerBuilder.newTrigger()
|
||||
.forJob(jobDetail)
|
||||
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
|
||||
.withDescription(batchJobInfoData.getDescription())
|
||||
.build();
|
||||
|
||||
try {
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling Quartz job: " + batchJobInfoData.getJobName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AbstractBatchJob을 상속받은 모든 클래스를 찾아 Quartz 스케줄러에 등록합니다.
|
||||
*/
|
||||
private void registerAbstractBatchJobs() {
|
||||
Map<String, AbstractBatchTask> batchJobs = applicationContext.getBeansOfType(AbstractBatchTask.class);
|
||||
for (String beanName : batchJobs.keySet()) {
|
||||
scheduleJob(batchJobInfoProcessor.getBatchJobInfo(beanName));
|
||||
private void registerBatchJobs() {
|
||||
Map<String, AbstractBatch> batchJobs = applicationContext.getBeansOfType(AbstractBatch.class);
|
||||
for (Map.Entry<String, AbstractBatch> entry : batchJobs.entrySet()) {
|
||||
AbstractBatch batch = entry.getValue();
|
||||
if (!AopUtils.isAopProxy(batch)) {
|
||||
quartzScheduleService.createJobTrigger(removeScopedTargetPrefix(entry.getKey()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void scheduleJob(BatchJobInfoData batchJobInfoData) {
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("jobName", batchJobInfoData.getJobName());
|
||||
|
||||
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
|
||||
.setJobData(jobDataMap)
|
||||
.storeDurably(true)
|
||||
.withDescription(batchJobInfoData.getDescription())
|
||||
.build();
|
||||
|
||||
CronTrigger trigger = TriggerBuilder.newTrigger()
|
||||
.forJob(jobDetail)
|
||||
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
|
||||
.withDescription(batchJobInfoData.getDescription())
|
||||
.build();
|
||||
|
||||
try {
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling AbstractBatchJob: " + batchJobInfoData.getJobName(), e);
|
||||
}
|
||||
private String removeScopedTargetPrefix(String beanName) {
|
||||
return beanName.startsWith("scopedTarget.") ? beanName.substring("scopedTarget.".length()) : beanName;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.spring.infra.quartz;
|
||||
|
||||
import org.quartz.CronScheduleBuilder;
|
||||
import org.quartz.CronTrigger;
|
||||
import org.quartz.JobBuilder;
|
||||
import org.quartz.JobDataMap;
|
||||
import org.quartz.JobDetail;
|
||||
import org.quartz.Scheduler;
|
||||
import org.quartz.SchedulerException;
|
||||
import org.quartz.TriggerBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.spring.infra.batch.BatchJobInfoData;
|
||||
import com.spring.infra.batch.BatchJobInfoService;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class QuartzScheduleService {
|
||||
|
||||
private final Scheduler scheduler;
|
||||
private final BatchJobInfoService batchJobInfoService;
|
||||
|
||||
public void clearJobs() {
|
||||
try {
|
||||
scheduler.clear();
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException();
|
||||
}
|
||||
}
|
||||
|
||||
public void createJobTrigger(String beanName) {
|
||||
BatchJobInfoData batchJobInfoData = batchJobInfoService.getBatchJobInfo(beanName);
|
||||
|
||||
JobDataMap jobDataMap = new JobDataMap();
|
||||
jobDataMap.put("jobName", batchJobInfoData.getJobName());
|
||||
|
||||
JobDetail jobDetail = JobBuilder.newJob(QuartzJobLauncher.class)
|
||||
.withIdentity(batchJobInfoData.getJobName(), batchJobInfoData.getJobGroup())
|
||||
.setJobData(jobDataMap)
|
||||
.storeDurably(true)
|
||||
.withDescription(batchJobInfoData.getDescription())
|
||||
.build();
|
||||
|
||||
CronTrigger trigger = TriggerBuilder.newTrigger()
|
||||
.forJob(jobDetail)
|
||||
.withIdentity(batchJobInfoData.getJobName() + "Trigger", batchJobInfoData.getJobGroup())
|
||||
.withSchedule(CronScheduleBuilder.cronSchedule(batchJobInfoData.getCronExpression()))
|
||||
.withDescription(batchJobInfoData.getDescription())
|
||||
.build();
|
||||
|
||||
try {
|
||||
scheduler.scheduleJob(jobDetail, trigger);
|
||||
} catch (SchedulerException e) {
|
||||
throw new IllegalStateException("Error scheduling Quartz job: " + batchJobInfoData.getJobName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -163,5 +163,65 @@
|
||||
"name": "front.base.timeout",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'front.base.timeout'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.email-send-batch.group",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.email-send-batch.group'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.email-send-batch.job-name",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.email-send-batch.job-name'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.email-send-batch.cron-expression",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.email-send-batch.cron-expression'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.email-send-batch.description",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.email-send-batch.description'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-batch.group",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-batch.group'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-batch.job-name",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-batch.job-name'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-batch.cron-expression",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-batch.cron-expression'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-batch.description",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-batch.description'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-create-batch.group",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-create-batch.group'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-create-batch.job-name",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-create-batch.job-name'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-create-batch.cron-expression",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-create-batch.cron-expression'"
|
||||
},
|
||||
{
|
||||
"name": "batch-info.post-create-batch.description",
|
||||
"type": "java.lang.String",
|
||||
"description": "A description for 'batch-info.post-create-batch.description'"
|
||||
}
|
||||
]}
|
||||
@@ -2,6 +2,13 @@ server:
|
||||
port: 8081
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
refresh:
|
||||
enabled: true
|
||||
devtools:
|
||||
restart:
|
||||
enabled: false
|
||||
add-properties: false
|
||||
application:
|
||||
name: spring-batch-quartz
|
||||
datasource:
|
||||
@@ -95,7 +102,7 @@ batch-info:
|
||||
email-send-batch:
|
||||
group: "EMAIL"
|
||||
job-name: "emailSendJob"
|
||||
cron-expression: "*/10 * * * * ?"
|
||||
cron-expression: "*/20 * * * * ?"
|
||||
description: "이메일배치작업"
|
||||
post-batch:
|
||||
group: "POST"
|
||||
@@ -105,7 +112,7 @@ batch-info:
|
||||
post-create-batch:
|
||||
group: "POST"
|
||||
job-name: "postCreateJob"
|
||||
cron-expression: "0/50 * * * * ?"
|
||||
cron-expression: "0/30 * * * * ?"
|
||||
description: "테스트배치작업"
|
||||
|
||||
jwt:
|
||||
@@ -116,6 +123,12 @@ jwt:
|
||||
secret: bnhjdXMyLjAtcGxhdGZvcm0tcHJvamVjdC13aXRoLXNwcmluZy1ib290bnhjdF9zdHJvbmdfY29tcGxleF9zdHJvbmc=
|
||||
expiration: 10080
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: refresh
|
||||
|
||||
logging:
|
||||
level:
|
||||
org:
|
||||
|
||||
@@ -34,6 +34,11 @@ const scheduleService = {
|
||||
cronExpression
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
refreshJob: async () => {
|
||||
await apiClient.post('/actuator/refresh');
|
||||
return true;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -29,6 +29,11 @@ apiClient.interceptors.request.use(
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
const authHeader = response.headers['authorization'];
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
saveAccessToken(token);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
@@ -49,6 +54,7 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
removeTokens();
|
||||
location.href = "/";
|
||||
}
|
||||
return new Promise(() => {});
|
||||
|
||||
@@ -9,6 +9,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
e.preventDefault();
|
||||
fetchDataAndRender();
|
||||
});
|
||||
|
||||
const refreshJobBtn = document.getElementById('refreshJobBtn');
|
||||
refreshJobBtn.addEventListener('click', refreshJobs);
|
||||
|
||||
manageTooltips.init();
|
||||
});
|
||||
|
||||
const fetchDataAndRender = async () => {
|
||||
@@ -19,6 +24,15 @@ const fetchDataAndRender = async () => {
|
||||
updateTable(response);
|
||||
};
|
||||
|
||||
const refreshJobs = async () => {
|
||||
const result = await scheduleService.refreshJob();
|
||||
if (result) {
|
||||
alert('스케줄이 재적용 되었습니다.');
|
||||
manageTooltips.hideAll();
|
||||
fetchDataAndRender();
|
||||
}
|
||||
};
|
||||
|
||||
const updateTable = (jobs) => {
|
||||
const tableBody = document.querySelector('tbody');
|
||||
tableBody.innerHTML = jobs.map(job => `
|
||||
@@ -64,10 +78,10 @@ const showJobDetail = async (e) => {
|
||||
updateJobControlButtons(jobDetail.status);
|
||||
|
||||
document.getElementById('pauseJobBtn').onclick = () => {
|
||||
pauseJob(group, name).then(() => updateJobStatus(group, name, 'PAUSED'));
|
||||
scheduleService.pauseJob(group, name).then(() => updateJobStatus(group, name, 'PAUSED'));
|
||||
};
|
||||
document.getElementById('resumeJobBtn').onclick = () => {
|
||||
resumeJob(group, name).then(() => updateJobStatus(group, name, 'NORMAL'));
|
||||
scheduleService.resumeJob(group, name).then(() => updateJobStatus(group, name, 'NORMAL'));
|
||||
};
|
||||
document.getElementById('updateCronBtn').onclick = () => {
|
||||
updateCronExpression(group, name);
|
||||
@@ -167,4 +181,15 @@ const updateTableJobStatus = (group, name, newStatus) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const manageTooltips = {
|
||||
init: () => {
|
||||
const tooltipElements = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipElements.map(el => new bootstrap.Tooltip(el));
|
||||
},
|
||||
hideAll: () => {
|
||||
const tooltip = bootstrap.Tooltip.getInstance('#refreshJobBtn');
|
||||
setTimeout(() => { tooltip.hide(); }, 100);
|
||||
}
|
||||
};
|
||||
@@ -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="/" class="logo d-flex align-items-center">
|
||||
<span class="d-none d-lg-block">NXCUS - Agent2.0</span>
|
||||
<span class="d-none d-lg-block">NXCUS - Agent</span>
|
||||
</a>
|
||||
<button class="btn toggle-sidebar-btn" id="toggleSidebar" aria-label="Toggle Sidebar">
|
||||
<i class="bi bi-list"></i>
|
||||
|
||||
@@ -82,14 +82,14 @@
|
||||
<i class="bi bi-clock-history me-2"></i>최근 실행된 작업
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<div class="table-responsive" style="max-height: 300px; overflow-y: auto;">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-people"></i> 그룹명</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-briefcase"></i> 잡 이름</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-calendar-event me-2"></i> 실행 시간</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-activity"></i> 상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="recentJobsTable">
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="col-lg-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<h5 class="card-title fs-6 text-dark">
|
||||
<i class="bi bi-search"></i> 스케줄 검색
|
||||
</h5>
|
||||
<form id="searchForm" class="row g-3">
|
||||
@@ -54,18 +54,21 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<i class="bi bi-list-ul"></i> 스케줄 목록
|
||||
<h5 class="card-title d-flex justify-content-between align-items-center">
|
||||
<span class="fs-6 text-dark"><i class="bi bi-list-ul"></i> 스케줄 목록</span>
|
||||
<button id="refreshJobBtn" class="btn btn-sm btn-outline-primary" data-bs-toggle="tooltip" data-bs-placement="left" title="스케줄 재적용">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</h5>
|
||||
<div class="table-responsive">
|
||||
<div class="table-responsive" style="max-height: 450px; overflow-y: auto;">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-1 text-nowrap"><i class="bi bi-people"></i> 그룹</th>
|
||||
<th class="col-1 text-nowrap"><i class="bi bi-briefcase"></i> 잡 이름</th>
|
||||
<th class="col-1 text-nowrap"><i class="bi bi-calendar-event"></i> 스케줄</th>
|
||||
<th class="col-1 text-nowrap"><i class="bi bi-activity"></i> 상태</th>
|
||||
<th class="col-1 text-nowrap"><i class="bi bi-gear"></i> 액션</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-people"></i> 그룹</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-briefcase"></i> 잡 이름</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-calendar-event"></i> 스케줄</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-activity"></i> 상태</th>
|
||||
<th class="col-1 text-nowrap sticky-top bg-light"><i class="bi bi-gear"></i> 액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<div class="card-body p-5">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-person-circle text-primary" style="font-size: 3rem;"></i>
|
||||
<h2 class="card-title mt-3">로그인</h2>
|
||||
<h2 class="card-title mt-3">Sign in to NXCUS-Agent</h2>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="input-group">
|
||||
@@ -34,10 +34,10 @@
|
||||
</div>
|
||||
<div class="d-grid gap-2">
|
||||
<button id="signIn" type="button" class="btn btn-primary">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>로그인
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Sign in
|
||||
</button>
|
||||
<button id="signUp" type="button" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-person-plus-fill me-2"></i>회원가입
|
||||
<i class="bi bi-person-plus-fill me-2"></i>Sign up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-dark text-white">
|
||||
<h5 class="modal-title" id="signupModalLabel">회원가입</h5>
|
||||
<h5 class="modal-title" id="signupModalLabel">Join Membership</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<form id="signupForm">
|
||||
@@ -85,10 +85,10 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">
|
||||
<i class="bi bi-x-circle me-2"></i>취소
|
||||
<i class="bi bi-x-circle me-2"></i>Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-outline-primary" id="signupSubmit">
|
||||
<i class="bi bi-check-circle me-2"></i>가입하기
|
||||
<i class="bi bi-check-circle me-2"></i>Join
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user